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.8.2

Published

Your Playwright, Cypress or Selenium tests break when a selector moves — this finds the element again and rewrites the test. Deterministic: no LLM decides whether your build passes. Runs entirely on your machine, with a local MCP server for agents.

Readme

ia-qa-heal — self-healing selectors for E2E tests

npm license node

Your E2E tests break because a selector moved, not because the app is wrong. This finds the element again and rewrites the test.

The verdict is deterministic — no model decides whether your build passes. When the repair is not provable, the tool refuses to guess and tells you why, rather than quietly rewriting a test into a green lie.

It runs entirely on your machine. It brings its own headless Chromium, so it sees localhost, staging, or an app behind a VPN, and nothing ever leaves the machine.

Framework-agnostic where it reads, narrower where it writes — see what is supported before you judge it on the half you happen to hit. Ships a CLI, a local MCP server for AI agents, and an accessibility-tree page mapper.

New here? The step-by-step tutorial walks the whole thing with no jargon (same text as TUTORIAL.md, shipped in this package). This README is the short reference.

See it work

ia-qa-heal repairing a broken Playwright suite: 3 tests failed, FIX verdict, selectors rewritten to getByRole, 3 passed, verified

Watch the full 48-second video — the loop above starts one step earlier, from a red build: explain tells you which failures are drift and which one is not, then covers whether the app you mapped is sound at all, the MCP server answering an agent, and the HTML report --report writes for a PR.

ia-qa-heal run runs your suite, watches it break, diffs the live app against your baseline, rewrites the dead selectors, then re-runs the suite to verify and exits on that verdict. Nothing is committed. By default a selector is replaced by the element's new selector, in the style your suite already uses — CSS stays CSS. The demo adds --locators, which rewrites to getByRole(…, { exact: true }) where that is provably safe: a locator immune to the next layout change.

Prefer clicking to typing? ia-qa-heal ui opens a local console — the rows waiting on you, the verbs as buttons, the history as charts.

The ia-qa-heal console: a BLOCK verdict, the one element the tool refused to guess, the verbs as buttons, the history as charts

Watch the console (32s) — buttons pressed, results rendered, nothing written.

Which verb answers which question

| Your question | Verb | |---|---| | my tests broke — fix them | run (capture → diff → confirm → fix → re-run to verify) | | my app is behind a login | login — a visible browser, you log in, the session is reused | | record the app as it is now | map, then baseline to promote it as the reference | | apply the repairs to my test files | fix (--dry-run first; it edits the working tree, never commits) | | here is the failure — is it drift? | explain — paste the runner's output, get the verdict for the locator it names | | did anything drift? | diff → PASS/FIX/BLOCK, the CI gate | | my suite already ran with IAQA_CAPTURE=1 | diff, on its own — merges the capture, no browser, no second run | | day one, no baseline — is my suite still valid? | audit (advisory; never gate on it) | | is the app itself sound? | check — dead links, unnamed elements, name collisions, orphan pages | | which pages am I not testing? | discover (--sitemap, or --crawl behind the login) | | my menus, tabs and modals aren't in the contract | map --deep — opens them and puts what they reveal in the same contract | | what do my tests actually use? | ingest | | show me the app's structure | graph --format mermaid\|svg\|json\|markdown | | is this getting better or worse? | history | | let me look and decide myself | ui — a local console | | teach my AI agent to drive this | skill — installs the agent instructions into .claude/skills/ |

ia-qa-heal <verb> --help for flags. On a slow suite, note that run runs it twice (once to capture, once to verify the fix): run --no-verify keeps only the first, and IAQA_CAPTURE=1 on the run you already do in CI plus ia-qa-heal diff costs nothing extra.

Same operations for an AI agent — one contract served twice, never a simplified view for one of them. ia-qa-heal-mcp exposes map_app, diff_mappings, fix_tests, ingest_tests, audit_suite, explain_failure, check_app, heal_history and suggest_heal. login and ui are deliberately not tools: both wait for a person.

Optional, with your own keyia-qa-heal-ai (init, models, suggest) proposes a match for the rows deterministic matching gave up on. It is a suggestion a human confirms, and it never touches the verdict.

Install

npm i -D @ia-qa/self-healing

Four commands to your first verdict — no baseline needed, nothing is rewritten:

npx ia-qa-heal init      # interactive: writes .ia-qa/config.json
npx ia-qa-heal ingest    # inventory the selectors and names your tests use
npx ia-qa-heal map       # capture the app, and bind your selectors to it
npx ia-qa-heal audit     # does my suite still name things that exist?

audit is read-only and advisory (exit 0 unless --strict) — it answers on day one, before you have any history to compare against.

Then one command, for good:

npx ia-qa-heal baseline  # lock today's UI as the reference — once
# … someone changes the UI …
npx ia-qa-heal run       # runs your suite → diffs → fixes → re-runs to verify

run is the whole loop in one verb: it runs your suite, watches it break, diffs the live app against the baseline, rewrites the dead selectors, then re-runs the suite to verify and exits on that verdict. Nothing is committed.

npx ia-qa-heal map
npx ia-qa-heal diff        # PASS / FIX / BLOCK — this is the CI gate
npx ia-qa-heal fix --dry-run
npx ia-qa-heal fix

Use this when your suite is slow (run runs it twice), or when CI already captures with IAQA_CAPTURE=1 — then diff alone costs nothing extra.

ingest comes first, and it is not optional if your tests locate by CSS: it is what lets map bind the strings your tests write to real elements. Re-run it when your test files change — map says so when they have. Commit .ia-qa/ (except session.json): the baseline is what CI compares against.

Three binaries ship together: ia-qa-heal (the loop), ia-qa-heal-mcp (stdio MCP server for agents), ia-qa-heal-ai (optional BYOK suggestions — never a gate).

Behind a login

Most apps worth mapping are behind one, and only some logins are a form.

npx ia-qa-heal login     # opens a real browser; you log in however the app asks
npx ia-qa-heal map       # reuses that session

It models nothing about your login, which is why SSO, MFA, a consent screen and magic links all work — you perform them. What is saved is the browser session (.ia-qa/session.json): live cookies, so treat it like a password. It stays on your machine, login writes .ia-qa/.gitignore so a commit cannot carry it, and when it expires map says so instead of quietly mapping the login page under your dashboard's name.

Already have a session? An authenticated Playwright suite usually writes a storageState in globalSetup — point at it instead of logging in twice:

npx ia-qa-heal map --session playwright/.auth/user.json

Precedence: --sessionIAQA_SESSION"session" in config → what login wrote. A session you named and that cannot be read stops the run; it never falls through to "no session", which would capture the login page under your pages' names.

login needs a terminal and refuses under CI. There, use auth (a form fill, secrets from env/SSM — see Configuration) or capture-during-run, where your own suite logs itself in.

Start from the failure — ia-qa-heal explain

Every other verb starts from a contract. You start from a red build.

ia-qa-heal explain --junit results.xml            # the report your runner already writes
ia-qa-heal explain --message "Expected to find element: '.btn-primary'"
npx cypress run 2>&1 | ia-qa-heal explain          # or just pipe the log
🔧  FIX  ia-qa-heal explain — is this failure selector drift?
   1 failure read · 1 locator identified · 1 page compared

   🔧 FIX  selector ".btn-primary"
      page: checkout
      used at: tests/checkout.spec.js:1
      proposed: .btn-primary  →  #pay-now
      The string reaches nothing, but its element survived. `ia-qa-heal fix` can rewrite it.

PASS is the answer worth the most. It means the locators in that failure still reach their elements — the build is red for another reason, so stop reading selectors and go look at timing, test data, or the app. It re-runs nothing, so it never claims your test is flaky; it only rules drift out.

It works the same for every runner in every language because it does not parse your runner's error messages. It intersects the failure text with the locator literals ingest read from your own sources — so pytest, Cypress, Playwright, Selenium, WebdriverIO and JUnit all work, and the answer comes back with the file:line the error never carried.

Read-only, offline, opens no browser, applies nothing. Needs a baseline (it is about drift — a locator that worked and stopped); with no baseline the day-one verb is audit. Exits 0 on PASS, 1 on BLOCK, and on FIX only with --strict.

What is supported

Three different questions, three different answers — and conflating them is how a tool earns "it half worked". Inventoried is what ingest can find in your sources. Judged is what diff / audit / explain can put a verdict on. Rewritten is what fix will edit in your files.

| | inventoried | judged | rewritten | |---|---|---|---| | CSS selectors — any language, any framework (.js .ts .py .java .cs .rb .php .go .kt .feature) | ✅ | ✅ | ✅ | | Role-anchored names — getByRole, getByLabel, getByPlaceholder, findByRole, By.linkText | ✅ | ✅ | ✅ | | Text-anchored — getByText, cy.contains, getByTitle, getByAltText | ✅ | ⚠️ reported, never judged | ❌ by design | | --locators upgrade (rewrite a selector into getByRole) | — | — | Playwright, .js/.ts only | | Selectors built by concatenation or a template | ❌ | ❌ | ❌ |

Three of those rows are deliberate refusals, not gaps:

  • Text-anchored locators name a string, not an element. The contract holds interactive elements only, so nothing can prove getByText('Save') meant the renamed button rather than a heading that never moved. Editing one would break a passing test — the single outcome worse than the drift. They are reported with file:line and hold the verdict at BLOCK. Cypress reaches role-anchored rewriting only through cy.findByRole; that is a property of Cypress's API, not a hole here.
  • --locators is Playwright-only because getByRole(...) is Playwright syntax. A .py or .java suite still gets the plain selector rewrite — the upgrade is an extra, not the feature.
  • A concatenated selector is invisible to a static scan, and equally invisible to the literal rewrite fix performs. Nothing is lost by admitting it.

Honest limit of the "rewritten" column outside JS. The rewrite path accepts those file extensions and the code paths are exercised, but the test suite's rewrite cases are written against .js/.ts files. Treat non-JS rewriting as working-but-less-travelled: run fix --dry-run first, which you should be doing anyway.

One more axis: how much of the page is under contract at all

map records the state a page loads in. Everything behind a dropdown, a tab, an accordion or a modal is in no contract — so when it drifts, nothing says so. It is a coverage hole and never a false alarm, which is what makes it survive: the gate stays green over a surface it never looked at.

ia-qa-heal map --deep     # open them, and contract what appears

Measured on ia-qa.com: one page 105 → 133 elements, another 126 → 264. Across eight unrelated sites (MDN, Bootstrap, Material UI, GOV.UK, Wikipedia, Docusaurus…), seven gained elements — the gain is largest on component-framework apps and near zero on content sites, so it is worth saying which one you have before reaching for it.

  • It cannot mutate your app. Every non-GET request is aborted for the walk, and controls named like actions (log out, delete, pay…) are never clicked — a logout handler that only clears local storage sends no request for the network guard to catch.
  • What it reaches but cannot contract is reported, not hidden (↩ N left out): a selector must name exactly one element, and a positional one captured inside a menu can collide with the page behind it.
  • A deep baseline is only comparable to a deep capture. Mixing the two reports everything behind a menu as lost; diff and the MCP both warn before the verdict.
  • ~1 minute per page against seconds for a plain map. It is for building a baseline, not for a per-commit gate.

The verdict

🔧  FIX   2 ok · 1 renamed · 3 healable · 0 ambiguous · 0 lost
  • PASS — nothing moved.
  • FIX — every break has a deterministic old→new rewrite. fix applies it.
  • BLOCK — a human decides. Nothing is rewritten.

What fix refuses is the point of the tool:

| | | |---|---| | ambiguous | several candidates — any choice is a coin flip | | lost | the element is gone; there is nothing to rewrite towards | | rebound | the selector now finds a different element — the test passes and acts on the wrong thing | | unattributable | getByText, cy.contains, getByTitle… name a string, not an element. Nothing proves the test meant the renamed button rather than a heading that never moved, so editing one would break a passing test |

--locators rewrites to getByRole(role, { name }) instead of a fresher CSS path, where that is provably safe — a selector that survives the next layout change. Without it, a CSS selector is replaced by a CSS selector: the tool does not impose a locator style on your suite.

At runtime, if you want it — aiClick / aiFill

import { aiClick } from '@ia-qa/self-healing';
await aiClick(page, 'button#login');

If the normal action times out, these read the page contract, re-scan the live page, retry on the element that semantically matches, and log a loud warning that the test needs updating. Useful to keep a suite green while you triage — but it is a stopgap, not the loop: healing in memory hides the drift instead of putting it in a pull request. The verbs above edit your files so a human reviews the change.

Page Objects written in CSS

Your Page Object says .btn-primary. The contract says #pay-now. Both name the same button, and until they are bound every drift row reads not referenced by your tests — technically true, useless in practice.

So map binds them (with usage.json present — run ingest first) and records the result in .ia-qa/mapping/_resolved/. What that buys, on a diff where the element contract did not move at all:

🔧  FIX
   2 ok · 0 renamed · 0 healable · 0 lost      ← the contract is spotless

   🔗 1 selector your tests write no longer reaches what it used to:
      🔧 .btn-primary  →  #pay-now
         the element is still there, only its address moved.
         tests/checkout.spec.js:1, tests/checkout.spec.js:4

Somebody renamed a CSS class. The element diff is clean, the suite is broken, and fix rewrites the literal in your files. Four outcomes, one repaired automatically:

| | | |---|---| | reaches nothing, and its element is still there | 🔧 rewritten | | reaches a different element | ⛔ never rewritten — your test passes and acts on the wrong thing | | reaches several elements | ⛔ a coin flip | | its element is gone | ⛔ nothing to rewrite towards |

Your app may already carry the answer. The contract holds interactive elements only, so a selector on a status div, a cell or an alert is not in it. Usually that stays quiet — such an element has no identity independent of what drifts, so nothing could be repaired anyway. But when it does carry one (data-testid, id, aria-label), the repair is exact:

🔧 .infoBlockContainer > .alert  →  [data-testid="cart-alert"]

That is the common shape of a legacy suite: the front end added test ids years after the Page Objects were written and nobody propagated them. It cannot be noisy — an element with a test id has a stable identity by construction, and an anchor matching two elements is refused.

How much of your suite is in reach? map says so at the end, over the union of the pages it just mapped — the number nobody can add up by hand, because the same string appears on several pages:

🔗 Your 181 inventoried selectors, against the pages just mapped:
   34 repairable   reach a contracted element, or an identity this tool can rewrite towards
   52 working      reach something real with no stable identity — fine today, nothing to repair towards
   95 not seen     on any page mapped this run — which is about your coverage, not your tests

Read the composition, not the ratio. working selectors are fine. And not seen is a statement about how many pages you map: a selector on a page absent from config.pages, or in a state no capture reached, looks exactly like one that is gone. ia-qa-heal discover lists what you are missing.

No usage.json ⇒ no bindings ⇒ nothing changes. Same rule as name drift: no inventory, no escalation.

Capture during your own test run

map walks URLs. Your suite walks states — post-login pages, modals, wizard steps — and already handles whatever auth your app demands. Arm it with IAQA_CAPTURE=1 and the contract is captured during the real run. This is the intended path for a multi-domain / federated-SSO app, and it never touches your credentials.

Playwright — one import at the top of a spec (or wrap your own extended test with withCapture):

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

Cypress — one import in cypress/support/e2e.js, nothing to wire in setupNodeEvents:

import '@ia-qa/self-healing/cypress';

Selenium (Node) — you call it yourself, since there is nothing to hook:

const { capture } = require('@ia-qa/self-healing/selenium');
await driver.get('https://app.example.com/checkout');
await capture(driver);          // inert unless IAQA_CAPTURE=1

All three are inert without IAQA_CAPTURE=1, so they can live in the suite permanently, and they never fail your tests. Each worker writes its own shard to .ia-qa/mapping/.capture/; the next verb (diff, run, baseline) merges them into the same page contracts map produces — byte-compatible, named from config.pages so they pair with your baseline.

One suite, several applications. A page name is a filename, so two URLs sharing one name claim to be the same page — and five products each with a /login are not. A URL on a host other than your baseUrl is therefore named <host>--<path> (partner.example.com--login), never login. Single-host suites are unaffected: the prefix only appears where two applications would otherwise have overwritten each other's contract, and www./apex and http/https count as the same host. To choose the name yourself, declare the page with its absolute URL:

{ "name": "partner-login", "url": "https://partner.example.com/login" }

Any other framework or language. The shard format is the interface; the helpers above are sugar over it. Run the extractor in the page (extract.js in this package, also a copy-paste DevTools snippet on ia-qa.com) and drop a JSON file into .ia-qa/mapping/.capture/:

{ "pages": [ { "page": "checkout", "url": "/checkout",
              "elements": [ { "role": "button", "name": "Pay", "selector": "button#pay" } ] } ] }

No import, no Node required. Coverage is whatever the suite visits — it is not a crawler, and a page it never reached is stale in the next diff, never a pass.

What the run records — mapping/_exercised.json

run writes down which pages your suite actually loaded, alongside the ones it did not, the suite's exit code and the commit. It is the only artifact here that answers "what does this suite test?" — a contract only proves somebody configured a page, and usage.json only proves the suite mentions a string. Small, deterministic, and safe to commit.

It is written only by the capture path. When run falls back to map (no testCommand), pages are visited because they were declared, which is a different fact and must not be written to this file. And an empty capture writes nothing at all: no shard means the hook never fired — an uninstrumented suite, a crash before the first page — and recording visited: [] there would put "this suite tests nothing" on disk as an observation.

@ia-qa/qa-discovery's coverage verb reads it to state the gap between what your app has and what your suite reaches. Nothing here depends on that package.

Test depth

diff and run also state how much of each contract your tests actually name — a selector literal ingest inventoried, resolved through _resolved/, or an accessible name. Named is not asserted: a test that clicks a button names it without checking anything, and nothing on disk can tell the two apart, so the line never says "tested". Locators that name a string rather than an element (getByText, cy.contains, getByTitle, getByAltText) are counted apart and credited neither way — the same reasoning that stops fix rewriting them. Silent when ingest has never run: that is not measured, not zero.

In CI

diff exits 0 on PASS/FIX, 1 on BLOCK, 2 on 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 ingest  # inventory what your tests use
      - 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 captured — which is why the baseline belongs in git. --strict fails on FIX too; --report drift.html attaches a branded report to the build.

Trend charts for free. diff --junit reports/drift.xml writes the verdict as JUnit XML, which Jenkins, GitLab, CircleCI, Azure DevOps and the GitHub Actions reporters ingest natively, per-build history included. It describes your contract, not your suite, so every language produces identically shaped XML. healable and BLOCK-class rows are failures typed by status, renamed passes with the label change in system-out, and a page that was not compared is skipped — never a pass.

⚠️ Publish it under its own file pattern, separate from your suite's results. Merged into one trend, both sets of numbers stop meaning anything.

--json on diff, run, audit, check and history gives the same verdict as one parseable document. Locally, ia-qa-heal history charts what no single run can reconstruct — and holds out runs captured a different way rather than averaging them in.

Configuration

.ia-qa/config.json, written by init. The minimum:

{
  "baseUrl": "https://staging.myapp.com",
  "pages": [
    { "name": "checkout", "url": "/checkout" },
    // a view with no URL of its own — reach it by naming the control to click
    { "name": "billing", "url": "/account",
      "steps": [ { "click": { "role": "tab", "name": "Billing" } } ] }
  ],
  "testPaths": ["tests/", "pages/"],
  "testCommand": "npx playwright test"
}

Your password never goes in this file. For a form login, secrets holds the name of an env var (or an SSM parameter path) and auth points at it by name:

"secrets": { "user": { "source": "env", "key": "APP_USER" },
             "pass": { "source": "env", "key": "APP_PASS" } },
"auth": { "loginUrl": "/login", "usernameSelector": "#email", "passwordSelector": "#password",
          "submitSelector": "button[type=submit]", "usernameSecret": "user", "passwordSecret": "pass",
          "successSelector": "nav.dashboard" }

Loading the env var is your job — the CLI reads process.env and never opens a .env file, so run it under npx dotenv-cli -- or Node's --env-file. If a variable is missing it stops and names it.

Everything else is optional:

| field | what it does | |---|---| | session | a Playwright storageState to reuse instead of logging in | | locale | accessible names are language-dependent — set it if your app is not the system default | | volatile | content that rotates by design (feed links, promos). Never enters a contract, so churn cannot read as drift | | nameMask | labels carrying a counter or a clock ("Cart (*)"). Only the label stops being compared; role, selector and href still gate | | layouts / autoLayout | shared shell extracted to _layouts/, diffed once instead of per page | | ai | the BYOK add-on (see below) |

--config <dir> (or IAQA_CONFIG_DIR) points at another .ia-qa/ — one per portal in a monorepo. Every field is documented in full in the tutorial (TUTORIAL.md, shipped in this package).

The console — ia-qa-heal ui

The ia-qa-heal console: a BLOCK verdict, the one element the tool refused to guess, the verbs as buttons, the history as charts

Watch the console (32s) — buttons pressed, results rendered, nothing written.

A local web console for the rows where the tool gave up: highlighted crops of each candidate, the file:line of every test that breaks, the verbs as plain-language buttons, and the history as charts. --shortcut writes a desktop launcher.

It is loopback only (127.0.0.1), every request carries a per-process token, the Host header is pinned, and its buttons spawn this package's own CLI with a fixed argv — never a string from the request. It refuses to start under CI (an interactive surface there is a hung build) and stops itself once the browser tab goes away.

Optional AI add-on — ia-qa-heal-ai (BYOK)

Deterministic healing leaves lost and ambiguous rows behind on purpose. The add-on hands only those to your own LLM and suggests a match with a confidence score:

npx ia-qa-heal-ai init            # pick a provider, name the env var holding your key
npx ia-qa-heal-ai suggest         # print suggestions
npx ia-qa-heal-ai suggest --apply # apply, after you confirm — refuses without a TTY

It never gates CI, and there is no --ai flag on ia-qa-heal: the verdict stays deterministic by design. Your key stays in your environment; the prompt carries the contract rows, nothing else.

What leaves your machine

Nothing, unless you enable the BYOK add-on above — and when it does, suggest says so before the first request: the host it is about to call, how many rows it is sending, and what those rows contain (role, accessible name, context and selector — never a screenshot, never page HTML, never your test files). Same rule check follows for link probing and login for the session file: a flow that leaves the machine announces itself rather than leaving you to find it in a README.

MCP server: path sandboxing. The CLI is driven by a human in their own shell; the MCP server receives arguments from an agent — potentially from third-party content via prompt injection. Every path parameter (out_dir, before_path, after_path, test_paths) is validated: the resolved absolute path must stay inside the project root. IAQA_ALLOW_OUTSIDE_PATHS=1 lifts it. browser_path is logged to stderr for auditability.

MCP server: URL allowlist. map_app verifies the target URL shares the origin of config.json's baseUrl, so an agent cannot be steered toward an internal-only endpoint reachable from your machine. IAQA_ALLOW_ANY_URL=1 for multi-origin apps.

Contracts are generated from live page content. The .md and .json files contain text extracted from the DOM — accessible names, headings, link destinations. The Markdown renderer escapes pipes, newlines and backticks to prevent table breakage and code-block injection. If you map an untrusted page (third-party widget, ad, XSS'd content), treat the output as untrusted before feeding it to an LLM: the tool does not detect or strip instruction-like text.

.ia-qa/session.json is a secret — live cookies. Gitignored on creation.

Don't have tests yet? — @ia-qa/qa-discovery

Everything here assumes a suite already exists: map records what your tests locate, diff tells you when it moved. If you have been handed an app and no tests at all, that loop has nothing to hold on to yet.

@ia-qa/qa-discovery is the step before: it crawls an app you can reach and writes down what is in it — every page, the headings that say what each is for, every input it takes, every API call the browser really made. No verdict, no LLM, same local-first promise as this package.

npx -p @ia-qa/qa-discovery ia-qa-discover scan https://your-app.example.com --save --report

It also answers a question this package cannot. run records which pages your suite really walked (mapping/_exercised.json); discovery knows which pages exist. Its coverage verb puts the two together — "your suite visits 4 of the 27 pages this scan found, and the 23 it misses include the one that takes a password". Neither side can state that alone, and no number is shown at all until a run has actually been watched.

⚠️ scan and map are not the same operation. map writes a page contract — every interactive element by role and accessible name, for repairing broken locators. scan writes a surface — pages, headings, inputs, API calls, for deciding what to test in the first place. Neither produces the other.

And scan is not ia-qa-heal discover either — this is the one people actually conflate. Both crawl, and the names hide the difference. discover answers "which routes are missing from my config.pages?" and hands you { name, url } candidates. scan answers "what does this app take as input?" and hands you what is behind each URL. Measured on the same four pages of ia-qa.com: discover returns 4 pairs; scan returns those pages plus 159 headings, 3 input fields with their captured labels, 9 observed API calls, and the finding that none of the three can be located durably.

So if you only want to fill config.pages, use discover and install nothing. That is what it is for, and it is already here.

They are built to hand off, though. config.pages has the same { name, url } shape in both, and both resolve page names through the same function, so a page called checkout in one is checkout in the other — the artifacts line up 1:1. Sessions are interchangeable too, since both consume a Playwright storageState: ia-qa-heal map --session .ia-qa-discovery/session.json.

And in the other direction: this package knows what your suite covers, discovery knows what exists. The gap between the two is your real coverage — and it is computed rather than described: run ia-qa-discover scan in a project that already has .ia-qa/mapping/ and it ends by naming the pages that exist with no contract. It is not a verdict (a page may be out of scope on purpose), and a contracted page the scan did not reach is reported as a limit of the scan, never as a page that vanished.

Going deeper

  • The tutorial — the whole thing, step by step, no jargon: ia-qa.com/devtools/selector-drift/tutorial, or TUTORIAL.md inside this package.
  • ia-qa-heal skill — installs this package's agent instructions into <project>/.claude/skills/, so an agent reads the contracts instead of opening a browser, and knows the rules it must not break. Agents read skills from .claude/skills/, never from node_modules, which is why this is a verb and not just a file in the tarball.
  • ia-qa-heal-mcp — the stdio MCP server, for agents that prefer tool calls to a shell.
  • SPEC-selector-resolution.md (shipped in the package) — how a Page Object's selectors are bound to the contract, and why each refusal exists.
  • Peer dependenciesplaywright is a peer: your project provides it, so two installs cannot drift to different browser revisions. @aws-sdk/client-ssm is an optional peer, needed only by the aws-ssm secret source (~5 MB, imported lazily; the CLI tells you if you pick it without).
  • Browser reuse — it uses whatever Playwright browser is already on the machine before asking you to download 150 MB. IAQA_BROWSER_CHANNEL=chrome (or msedge), or IAQA_BROWSER_PATH=/path/to/chrome, to pin one.

MIT.