npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ia-qa/self-healing

v1.0.1

Published

Local-first self-healing for UI tests: a local MCP server + CLI that map your app's pages to a role/name/selector contract, diff selector drift (PASS/FIX/BLOCK), and apply deterministic fixes to Cypress/Playwright/Selenium tests. Runs on your machine — no

Downloads

2,202

Readme

@ia-qa/self-healing

Self-healing E2E toolkit: a local MCP server for AI agents, a guided CLI, an accessibility-tree page mapper, and runtime helpers (aiClick / aiFill) that recover from broken selectors. Framework- and language-agnostic (Cypress, Playwright, Selenium…; JS/TS, Python, Java…).

👉 New here? Read the step-by-step tutorial — no-jargon, start to finish. (Same text as the TUTORIAL.md shipped in this package.) This README is the reference.

It runs entirely on the end user's machine. It brings its own headless Chromium, so it sees localhost, staging, or an app behind a VPN — and no data ever leaves the machine. Nothing is hosted.

Quick start

npx -y -p @ia-qa/self-healing ia-qa-heal init      # wizard → .ia-qa/config.json
npx -y -p @ia-qa/self-healing ia-qa-heal map      # → .ia-qa/mapping/<page>.json + .md
npx -y -p @ia-qa/self-healing ia-qa-heal baseline # → .ia-qa/baseline/ — the reference. Commit it.
#   …change the app, then `map` again…
npx -y -p @ia-qa/self-healing ia-qa-heal diff     # baseline/ vs mapping/ → PASS / FIX / BLOCK

Install it in the project that holds your tests to drop the -p dance — and to get the aiClick / aiFill runtime helpers, which are imported, not run:

npm i -D @ia-qa/self-healing
npx ia-qa-heal init

The one-verb loop: ia-qa-heal run

run chains the whole loop and stops where a human belongs:

capture → ingest → diff → dry-run of the fixes → your confirmation → fix
  • If config.testCommand is set (e.g. npx playwright test), run spawns it with IAQA_CAPTURE=1 and the mapping is captured during your own suite — login, modals, wizards included, with the credentials your suite already manages. No testCommand → falls back to map.
  • BLOCK (lost / ambiguous / rebound) stops for your judgment. Healable drift is shown as a dry run first; --yes skips the confirmation for CI. Nothing is ever committed.

Capture during your test run — @ia-qa/self-healing/capture

One line at the top of a spec (or wrap your own extended test with withCapture):

import { test, expect } from '@ia-qa/self-healing/capture';

Inert unless IAQA_CAPTURE=1 — a normal run pays nothing. When armed, each worker records the pages it visits (per navigation, debounced, plus the final state of each test) into .ia-qa/mapping/.capture/; the next CLI verb merges the shards into regular page contracts, named from config.pages so they pair with your baseline. Views reached through steps share their URL and are skipped — map remains their path.

Selector inventory — ia-qa-heal ingest

npx ia-qa-heal ingest tests/        # or no args: reads config.testPaths

Statically scans your tests/POMs for selector literals (Playwright, Cypress, Selenium call shapes + …Selector = '…' properties) → .ia-qa/usage.json. With it, diff annotates every drifted selector with used N× in M test files / not referenced by your tests, and fix no longer needs the test paths on the command line. Display-only: the verdict still judges the whole contract.

Elements with no accessible name

An icon button or image link with no aria-label/text has no role + name identity — self-healing can't recover it, a getByRole locator can't target it, and a screen reader can't announce it. A live capture of a dynamic app keeps catching a shifting set of them, which is the noisiest thing in a drift diff. Each page contract now counts them and lists them in a dedicated section, and diff flags them (👻) with a synthetic hint derived from the element's icon/image (icon: chevron-right, img: Logo) so you can find them. The hint is a locating aid, never an accessible name — the durable fix is an aria-label or a data-testid.

Two front doors, one engine

| | For | Entry point | |---|---|---| | MCP server (stdio) | AI agents — "map my app at <url>" | ia-qa-heal-mcp → tools map_app, diff_mappings, fix_tests | | CLI | humans & CI | ia-qa-healinit, map, baseline, diff, fix, ingest, run |

Locale

The page contract's accessible names are language-dependent. If your app or browser OS renders in a different locale than your test environment, every name match will fail. Set the locale explicitly:

# env var — works for both CLI and MCP paths
export IAQA_LOCALE=fr-FR   # or en-US, de-DE, ja-JP…

# or in .ia-qa/config.json:
{ "locale": "fr-FR", … }

The map_app MCP tool also accepts locale as a parameter. Without it, the browser's system default is used — which is fine as long as it matches what your tests expect.

Agent config (Claude Code / Desktop, Cursor…):

{
  "mcpServers": {
    "ia-qa-self-healing": {
      "command": "npx",
      "args": ["-y", "-p", "@ia-qa/self-healing", "ia-qa-heal-mcp"]
    }
  }
}

-p is required, not cosmetic: the package ships two bins (ia-qa-heal, ia-qa-heal-mcp), so npx @ia-qa/self-healing … cannot resolve which to run and silently starts nothing. The MCP server is a dependency-free JSON-RPC 2.0 stdio implementation (src/mcp/server.ts) — no SDK. It does not require the separate Playwright MCP: it drives its own browser. stdout is the protocol channel; logs go to stderr.

Browser reuse — no second Chromium (src/launcher.ts)

Playwright's browser cache is machine-wide and shared (%LOCALAPPDATA%\ms-playwright, ~/.cache/ms-playwright). If the user already has any Playwright install — their own tests or the Playwright MCP — the bundled Chromium is already there and we reuse it for free. There is no "second Chromium" to avoid; the only real cost is revision skew (a different Playwright version pins a different Chromium revision). For that case, and for machines that forbid the download, the launcher resolves in this order:

  1. explicit executable — browserPath / $IAQA_BROWSER_PATH
  2. explicit channel — browserChannel / $IAQA_BROWSER_CHANNEL (chrome, msedge…) → zero download
  3. Playwright's bundled Chromium (shared cache — usually already present)
  4. automatic fallback to the system Chrome, then Edge

Only if all four fail does it error, listing the three fixes. map_app exposes browser_channel / browser_path and always reports which browser it used. For DOM extraction the engine is irrelevant — Chrome, Edge and Chromium yield the same contract.

How it works

  1. ia-qa-heal init — interactive wizard. This is where you define your secret zone and grant the tool access to it: choose the framework, where credentials live (local .env or AWS SSM Parameter Store), the login flow, and the pages to map. It writes .ia-qa/config.json, which stores only a reference to each secret — the env-var name or the SSM parameter path, never the value. At run time map reads the credentials from that zone and logs in; if one is missing it stops with a message naming the exact variable to set — it never prompts, never guesses, and never writes a secret to disk. And they never reach ia-qa. The credentials only fill the login form in the local headless browser on your machine; self-healing makes no call to ia-qa.com or any third party — the only network traffic is your own browser loading your own app (plus your own AWS account, if you chose SSM). (Behind a login and don't want to wire this up? Use run with IAQA_CAPTURE=1 and let your own test suite handle the login — see the one-verb loop.)

  2. ia-qa-heal map [page] — resolves credentials at runtime (env or SSM via your local AWS credential chain), launches headless Chromium, logs in if configured, and extracts every visible interactive element (role + accessible name + stable selector) to two files per page: .ia-qa/mapping/<page>.json (for tooling / diff) and .ia-qa/mapping/<page>.md (the page contract — the framework-agnostic artifact you hand to an LLM to auto-heal tests):

    {
      "page": "login",
      "url": "/login",
      "capturedAt": "2026-07-15T10:00:00.000Z",
      "elements": [
        { "role": "button", "name": "Se connecter", "selector": "button#login" }
      ]
    }

    The <page>.md groups elements by role and carries a self-describing header telling a model exactly how to use it. The CI auto-heal loop: when a UI test fails, hand the failing test file + the current <page>.md to an LLM — it finds the element by role + accessible name, reads the current selector, and rewrites the test in whatever framework and language it is written in (Cypress, Playwright, Selenium; JS or Python). The contract is the source of truth; the model handles the syntax. The same contract is downloadable from the zero-install Selector Drift Detector page on ia-qa.com.

    Alongside the page contracts, map rebuilds the app index and the navigation graph — see Map artifacts.

  3. ia-qa-heal baseline — promote .ia-qa/mapping/ to .ia-qa/baseline/, the reference diff compares against. Commit it: a reference that is not in git cannot be reviewed or reverted, and accepting drift is a code-review decision like any other.

    Drift is a claim about two moments, and map only ever captures one. Promotion is the second half, and it is a separate verb on purpose — deliberately not a flag on map, because promoting in the same breath as capturing would make the reference incapable of ever being older than what it is compared to. map records what the app is; baseline says it is also what the app is supposed to be. That second claim is a human judgement.

    Skip it and the natural workaround is cp current.json baseline.json && diff — which compares a capture to a copy of itself and prints PASS ✅ No drift. That verdict is green because nothing was compared, not because nothing changed. diff now refuses it outright (exit 2): every side carries a capturedAt, equal timestamps mean one map run, and one run cannot drift from itself. A gate that reports green without looking is worse than no gate, because it is trusted.

  4. ia-qa-heal diff [before.json] [after.json] — CI-gate verdict. With no arguments, diffs baseline/ against mapping/ — the everyday case. Diffs two mappings (from map or the ia-qa.com browser snippet) and reports which selectors survived, which the runtime healer would recover, and which will hard-fail:

    ⛔  BLOCK
       before.json → after.json  ·  3 ok · 2 healable · 1 lost · 2 new
       🔧 healable  button "Se connecter"      button#login-btn → button.btn-primary (exact name)
       ⛔ lost      button "Supprimer le compte"  button#delete → (no match)

    Exit codes for pipelines: 0 = PASS or FIX, 1 = BLOCK — lost, ambiguous, or rebound (add --strict to fail on FIX too), 2 = bad input, no baseline, or nothing to compare. Use --json for machine-readable output. Same diff engine as the web tool (src/browser/match.js).

    --report [file.html] also writes a self-contained, ia-qa.com-branded HTML report of the run — a plain-language summary of what the run checked and found (how many elements across how many pages, what drifted, how many of your test files it touches, and the next action), the PASS/FIX/BLOCK verdict, totals, a per-page table and every drifted selector with its old → new transition and test-usage annotation — the artefact to attach to a PR instead of a console screenshot (default ia-qa-heal-report.html). Add --open to open it in your browser (local use; leave it off in CI). Works on diff and run.

    A contract that was not re-mapped since the baseline carries the baseline's own capturedAt, so it cannot show drift. Those are listed and left out of the verdict rather than counted as ok — padding a gate with comparisons that never happened is the same lie in smaller print. --json reports them as staleExcluded.

    Every element carries a context (nearest landmark + section heading) so two elements with the same role + accessible name (five "Delete" buttons) stay distinguishable. The diff classifies each baseline element as ok, renamed (same element, new accessible name — a content drift to eyeball), healable (deterministic old→new rewrite), ambiguous (several equal matches — the tool refuses to guess, a human decides), or lost (no match).

    Identity comes from the contract (role + accessible name), never from the selector string. A selector that still resolves only proves the position is still occupied: insert one button above a tab bar and every nth-of-type below it silently slides onto its neighbour. Any row where that happened is flagged rebound — the old selector resolves, but to a different element, so the suite is green while clicking the wrong thing. Rebound rows are rewritten like any other healable row, and they BLOCK: a FIX verdict exits 0, which is exactly how this ships unnoticed.

  5. ia-qa-heal fix <before.json> <after.json> <test-paths…> — the deterministic half. Applies only the healable rewrites to your test files, replacing the selector only where it appears as a quoted string literal ('…'/"…"/`…`, quote-safe for attribute selectors), across any framework/language. Every rewrite is applied in a single pass against the selector each occurrence had in the file, so a permuted set (nth-of-type(1)→(2), (2)→(3)) can never chain and drag (1) to (3). LOST / AMBIGUOUS / RENAMED are printed but never touched — they need judgment. It edits the working tree and never commits: review with git diff, then push or discard. --dry-run previews the plan. This is "prepare the ground, the human decides" as a command.

    --locators rewrites to a Playwright role locator — page.getByRole('button', { name: 'Validator', exact: true }) — instead of a selector, wherever role + accessible name identifies exactly one element. A repaired positional selector is only correct until the next insertion; a role locator is what the contract already means, so it does not drift at all. Applies to .js/.ts files and Playwright call shapes (page.click('…'), page.locator('…'), page.fill('…', v), …); every other file and every element without an unambiguous role + name keeps the plain selector rewrite. exact: true is emitted because the guard already verified exactly one element matches the exact name — without it, getByRole does substring matching and would match multiple elements with overlapping names (e.g. three "Wiki" buttons), causing the strict-mode throw the locator was supposed to avoid. When --locators is passed but no Playwright call shape is found in the scanned files, a note explains that Cypress (cy.get(sel).click()) and other frameworks silently keep the plain selector rewrite.

  6. Runtime healing — in your Playwright tests:

    import { aiClick, aiFill } from '@ia-qa/self-healing';
    
    await aiFill(page, 'input[name="email"]', user);
    await aiClick(page, 'button#login');

    If the selector fails, the helper re-scans the live page, matches the element by role + accessible name (exact, then fuzzy Dice ≥ 0.6 — or your own llmResolver), retries on the healed selector, and logs a warning telling you to update the test and re-map.

Page Object Models — one file to keep in sync

A POM already does what this tool wants: it lifts every selector out of the tests and into one file. That is also the one file drift keeps breaking. mapdifffix treats it like any other test file — the selector is a quoted string literal, so fix rewrites it in place.

// login.page.ts — the only place a selector is written
export class LoginPage {
  readonly emailSelector = '#email';
  readonly passwordSelector = '#password';
  readonly submitSelector = 'button#login';
  // used everywhere as page.fill(this.emailSelector, …)
}

A developer reworks the login form: button#login becomes button.btn-primary. The whole suite goes red through this one file. Heal it:

npx ia-qa-heal map      # capture the new contract
npx ia-qa-heal diff     # 🔧 healable  button "Log in"  button#login → button.btn-primary
npx ia-qa-heal ingest   # inventory the POM's selector literals (optional — lets fix find the file itself)
npx ia-qa-heal fix .ia-qa/baseline/login.json .ia-qa/mapping/login.json login.page.ts
  export class LoginPage {
    readonly emailSelector = '#email';
    readonly passwordSelector = '#password';
-   readonly submitSelector = 'button#login';
+   readonly submitSelector = 'button.btn-primary';
  }

One line changes and every test that goes through LoginPage is fixed at once — the POM is exactly the leverage fix is built for. Review with git diff, run your suite, commit. Nothing was committed for you.

One naming detail. ingest recognises a POM property as a selector when its name contains selector/Selector (submitSelector, above) or when the value is a framework call it already knows (this.page.locator('#login')). That is what lists the file in .ia-qa/usage.json, so diff can annotate it and fix can find it with no path given. A plainly-named property (loginButton = '#login') is invisible to that inventory — but fix still rewrites its literal when you pass the POM file on the command line, because the rewrite is keyed on the string, not the property name.

In CI

diff returns an exit code, so a pipeline can gate on drift — 0 = PASS/FIX, 1 = BLOCK, 2 = bad input:

# .github/workflows/selector-drift.yml
name: Selector drift
on: [pull_request]
jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm start &          # your app on its usual port
      - run: npx ia-qa-heal map   # re-capture into .ia-qa/mapping/
      - run: npx ia-qa-heal diff  # 0 = PASS/FIX · 1 = BLOCK · 2 = bad input

diff with no arguments compares the committed .ia-qa/baseline/ against what map just re-captured — which is why the baseline has to be in git. Add --report drift-report.html to attach a branded HTML report to the build, and --strict to fail on FIX too. The full workflow (report upload, artifact, --strict) is in TUTORIAL.md → Putting it in CI.

Map artifacts

Beyond one contract per page, every map run rebuilds two views of the whole app. Both are derived entirely from the mappings on disk (no extra page visit) and rebuilt on every run, including map <page>, so they never drift out of date.

.ia-qa/mapping/_overview.md — the app index, and the file to open first. Every page contract answers "what is on this page?"; none of them answers "what is this app, and which contract do I need?". The overview does: every page with its URL, element count and a link to its contract; the shared layout; and the surface by role. It also surfaces two things no single contract can — same-origin destinations your app links to but your config never maps (the coverage gap), and the external domains you link out to.

_navigation.md / _navigation.svg — the navigation graph, written when pages link to each other, built from link hrefs. The .md is a Mermaid fence (GitHub, GitLab and the VS Code preview render it natively); the .svg is the same graph, hand-rolled, opening in any browser offline — no mermaid.js, no CDN, nothing fetched.

The graph draws what a page table cannot tell you: pages that link to each other selectively. Four things are deliberately left out, on one principle — a picture should not spend ink on what a sentence already says:

  • shell links (navbar, footer) — in the overview, listed once, instead of an edge from every page to every navbar destination;
  • isolated pages — a box with no arrow;
  • hub fans — a page whose spokes link nowhere else is stated in one sentence (home fans out to 144 pages) instead of drawn as 144 identical arrows crossing every other line. That is the catalogue shape, and no node-link diagram survives it;
  • meshes — when nearly every mapped page links to nearly every other (the related-tools-widget shape: 16 links between 5 pages), the whole drawing carries one fact — "these pages cross-link" — so the overview states it and no graph is written.

Pages nothing links to are called out as entry points (orange border in the .svg and the Mermaid graph): the way in — a login, a home — or a page only the shell reaches. The map cannot tell which; you can.

What is left is the real structure: a checkout tunnel stays, its noise does not. If nothing survives, both files are removed rather than left stale — for that app, the page list genuinely is the whole story.

Design notes

  • page.accessibility.snapshot() is deprecated and returns no selectors, so mapping walks the DOM directly and computes ARIA role + accname + a stable selector (data-testid|data-test-id|data-test|data-cy|data-qa|data-e2e#id (bare when unique, tag#id otherwise) → namearia-labelhref (links, skip #, /, javascript:) → placeholder (inputs) → structural path).
  • Shared browser-safe core. src/browser/ holds plain, self-contained JS (no imports, no TS syntax) used by both the CLI and the ia-qa.com web tool: extract.js (DOM walk), match.js (semantic matching + mapping diff), contract.js (Markdown page-contract renderer). The CLI feeds extractInPage to Playwright's page.evaluate() and the healer re-runs it live; ia-qa.com imports extract.js with ?raw to show it as a copy-paste DevTools snippet and imports match.js/contract.js directly. Result: the zero-install web flow produces byte-identical mappings, verdicts and .md contracts to ia-qa-heal (verified on the same page). Keep everything in src/browser/ dependency-free or all consumers break.
  • Secrets: never written to disk. env reads process.env; aws-ssm calls GetParameter with decryption using the AWS SDK default credential chain (needs ssm:GetParameter, plus kms:Decrypt for SecureString).
  • llmResolverStub in src/playwright/healer.ts is the extension point for a real LLM/embedding-based resolver (e.g. via POST https://www.ia-qa.com/mcp/call).
  • Fix dry-run shows line numbers. Each replacement in fix --dry-run reports the source lines where the rewrite applies (3× #old → #new (lines 12, 47, 103)), so you can jump straight to the affected locations without opening the file.
  • Phantom contract detection. saveMapping warns when overwriting a previously captured mapping. baseline detects mapping files that don't match any page in config.json — left behind when a page is removed — and warns which files to delete.

Security

MCP server: path sandboxing

The CLI is driven by a human in their own shell; the MCP server receives arguments from an AI agent — potentially from third-party content via prompt injection. Every path parameter (out_dir, before_path, after_path, test_paths) is validated by assertInsideProject: the resolved absolute path must stay inside the project root. Set IAQA_ALLOW_OUTSIDE_PATHS=1 to lift this for legitimate use cases. browser_path is logged to stderr for auditability but not blocked (it points at an executable, not a project file).

MCP server: URL allowlist

map_app verifies that the target URL shares the same origin as config.json's baseUrl. This prevents an agent from being steered toward an internal-only endpoint reachable from the user's machine. Set IAQA_ALLOW_ANY_URL=1 for multi-origin apps (SSO redirects, etc.). The check is skipped silently when no config.json exists (first init from an agent).

Contract files are generated from live page content

The .md and .json contracts contain text extracted from the page's DOM — accessible names, section headings, link destinations. The Markdown renderer escapes pipe characters, newlines, and backticks in these fields to prevent table breakage and code-block injection. If you map an untrusted page (third-party widget, ad, XSS'd content), treat the resulting files as untrusted input before feeding them to an LLM — the tool does not detect or strip instruction-like text.

Peer dependencies

playwright is a peer dependency — the consuming test project provides it (npx playwright install chromium if needed). This is deliberate: bundling our own copy would let a project on a different Playwright version end up with two installs and a browser-revision skew, which is exactly what the shared launcher exists to avoid.

@aws-sdk/client-ssm is an optional peer, needed only by the aws-ssm secret source. It is ~5 MB and imported lazily, so it is not a tax on installs that use the env source: npm i @aws-sdk/client-ssm if you want SSM, and the CLI tells you so if you pick it without.

Build from source

For work on the package itself — installing from npm needs none of this.

cd packages/self-healing
npm run build          # tsc → dist/
node dist/cli/index.js init
node dist/cli/index.js map