@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
Maintainers
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.mdshipped 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 / BLOCKInstall 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 initThe 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.testCommandis set (e.g.npx playwright test),runspawns it withIAQA_CAPTURE=1and the mapping is captured during your own suite — login, modals, wizards included, with the credentials your suite already manages. NotestCommand→ falls back tomap. BLOCK(lost / ambiguous / rebound) stops for your judgment. Healable drift is shown as a dry run first;--yesskips 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.testPathsStatically 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-heal → init, 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:
- explicit executable —
browserPath/$IAQA_BROWSER_PATH - explicit channel —
browserChannel/$IAQA_BROWSER_CHANNEL(chrome,msedge…) → zero download - Playwright's bundled Chromium (shared cache — usually already present)
- 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
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.envor 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 timemapreads 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? UserunwithIAQA_CAPTURE=1and let your own test suite handle the login — see the one-verb loop.)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>.mdgroups 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>.mdto 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,
maprebuilds the app index and the navigation graph — see Map artifacts.ia-qa-heal baseline— promote.ia-qa/mapping/to.ia-qa/baseline/, the referencediffcompares 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
maponly ever captures one. Promotion is the second half, and it is a separate verb on purpose — deliberately not a flag onmap, because promoting in the same breath as capturing would make the reference incapable of ever being older than what it is compared to.maprecords what the app is;baselinesays 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 printsPASS ✅ No drift. That verdict is green because nothing was compared, not because nothing changed.diffnow refuses it outright (exit2): every side carries acapturedAt, equal timestamps mean onemaprun, and one run cannot drift from itself. A gate that reports green without looking is worse than no gate, because it is trusted.ia-qa-heal diff [before.json] [after.json]— CI-gate verdict. With no arguments, diffsbaseline/againstmapping/— the everyday case. Diffs two mappings (frommapor 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--strictto fail on FIX too),2= bad input, no baseline, or nothing to compare. Use--jsonfor 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 itsold → newtransition and test-usage annotation — the artefact to attach to a PR instead of a console screenshot (defaultia-qa-heal-report.html). Add--opento open it in your browser (local use; leave it off in CI). Works ondiffandrun.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 asok— padding a gate with comparisons that never happened is the same lie in smaller print.--jsonreports them asstaleExcluded.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), orlost(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-typebelow it silently slides onto its neighbour. Any row where that happened is flaggedrebound— 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: aFIXverdict exits0, which is exactly how this ships unnoticed.ia-qa-heal fix <before.json> <after.json> <test-paths…>— the deterministic half. Applies only thehealablerewrites 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 withgit diff, then push or discard.--dry-runpreviews the plan. This is "prepare the ground, the human decides" as a command.--locatorsrewrites 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/.tsfiles 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: trueis emitted because the guard already verified exactly one element matches the exact name — without it,getByRoledoes 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--locatorsis 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.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. map → diff → fix 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 inputdiff 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 (
homefans 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#idotherwise) →name→aria-label→href(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 feedsextractInPageto Playwright'spage.evaluate()and the healer re-runs it live; ia-qa.com importsextract.jswith?rawto show it as a copy-paste DevTools snippet and importsmatch.js/contract.jsdirectly. Result: the zero-install web flow produces byte-identical mappings, verdicts and.mdcontracts toia-qa-heal(verified on the same page). Keep everything insrc/browser/dependency-free or all consumers break. - Secrets: never written to disk.
envreadsprocess.env;aws-ssmcallsGetParameterwith decryption using the AWS SDK default credential chain (needsssm:GetParameter, pluskms:Decryptfor SecureString). llmResolverStubinsrc/playwright/healer.tsis the extension point for a real LLM/embedding-based resolver (e.g. viaPOST https://www.ia-qa.com/mcp/call).- Fix dry-run shows line numbers. Each replacement in
fix --dry-runreports 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.
saveMappingwarns when overwriting a previously captured mapping.baselinedetects mapping files that don't match any page inconfig.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