@ia-qa/self-healing 1.7.3 → 1.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,498 +1,159 @@
1
1
  # @ia-qa/self-healing
2
2
 
3
- 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…).
3
+ Your E2E tests break because a selector moved, not because the app is wrong. This finds the
4
+ element again and rewrites the test.
4
5
 
5
- > 👉 **New here? Read the [step-by-step tutorial](https://www.ia-qa.com/devtools/selector-drift/tutorial)** no-jargon, start to finish. (Same text as the `TUTORIAL.md` shipped in this package.) This README is the reference.
6
+ A **CLI**, a **local MCP server** for AI agents, and an accessibility-tree page mapper.
7
+ Framework-agnostic — Playwright, Cypress, Selenium; JS/TS, Python, Java. The verdict is
8
+ deterministic: no model decides whether your build passes.
6
9
 
7
- **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.
10
+ **It runs entirely on your machine.** It brings its own headless Chromium, so it sees
11
+ `localhost`, staging, or an app behind a VPN, and **nothing ever leaves the machine**.
12
+
13
+ > **New here?** The [step-by-step tutorial](https://www.ia-qa.com/devtools/selector-drift/tutorial)
14
+ > walks the whole thing with no jargon (same text as `TUTORIAL.md`, shipped in this package).
15
+ > This README is the short reference.
8
16
 
9
17
  ## See it work
10
18
 
11
19
  [![ia-qa-heal repairing a broken Playwright suite: 3 failed, FIX verdict, selectors rewritten to getByRole, 3 passed, verified](https://www.ia-qa.com/media/heal-demo.gif)](https://www.ia-qa.com/media/heal-demo.mp4)
12
20
 
13
- One command. `ia-qa-heal run` runs your suite, watches it break, diffs the live app against your baseline, rewrites the dead selectors as `getByRole(…, { exact: true })` — not as fresher CSS paths — then **re-runs the suite to verify** and exits on that verdict. Nothing is committed.
14
-
15
- **[Watch the full 49-second version](https://www.ia-qa.com/media/heal-demo.mp4)** it also covers what deterministic healing *refuses* to guess (and the optional BYOK AI add-on that suggests a match for it), the MCP server answering an agent, and the branded HTML report `--report` writes for a PR.
16
-
17
- *Real commands against a live site, real stdout only the typing speed is synthesised.*
18
-
19
- ## Easiest start — have your agent set it up
20
-
21
- If you already work with Claude Code, Cursor, Copilot or another coding agent, don't configure this
22
- by hand. Paste this to it:
23
-
24
- ```text
25
- Install and set up @ia-qa/self-healing in this project.
26
- 1. First run `npx -y -p @ia-qa/self-healing ia-qa-heal skill --print` and follow it.
27
- That output is the authoritative doc — do not guess any command or flag from memory.
28
- 2. Register the MCP server so you can drive it yourself:
29
- command `npx`, args ["-y", "-p", "@ia-qa/self-healing", "ia-qa-heal-mcp"].
30
- 3. Read my e2e tests and my router, propose the page list for .ia-qa/config.json,
31
- and wait for me to confirm before mapping anything.
32
- 4. Then explain to me, in five lines: what map / baseline / diff / fix each do,
33
- and which locators you will never rewrite without asking me.
34
- ```
35
-
36
- Step 1 matters more than it looks: this package is newer than most models' training data, so an agent
37
- left to improvise will invent flags that don't exist. `skill --print` hands it the real instructions —
38
- including [the four prohibitions](#working-with-an-ai-agent--ia-qa-heal-skill) — before it touches
39
- anything. Step 4 is how you check it actually read them instead of guessing.
40
-
41
- ## Quick start — by hand
21
+ `ia-qa-heal run` runs your suite, watches it break, diffs the live app against your baseline,
22
+ rewrites the dead selectors, then **re-runs the suite to verify** and exits on that verdict.
23
+ Nothing is committed. By default a selector is replaced by the element's new selector, in the
24
+ style your suite already uses — CSS stays CSS. The demo adds `--locators`, which rewrites to
25
+ `getByRole(…, { exact: true })` where that is provably safe: a locator immune to the *next*
26
+ layout change.
42
27
 
43
- ```bash
44
- npx -y -p @ia-qa/self-healing ia-qa-heal init # wizard .ia-qa/config.json
45
- npx -y -p @ia-qa/self-healing ia-qa-heal map # → .ia-qa/mapping/<page>.json + .md
46
- npx -y -p @ia-qa/self-healing ia-qa-heal baseline # → .ia-qa/baseline/ — the reference. Commit it.
47
- # …change the app, then `map` again…
48
- npx -y -p @ia-qa/self-healing ia-qa-heal diff # baseline/ vs mapping/ → PASS / FIX / BLOCK
49
- ```
28
+ ▶ **[The full 49-second version](https://www.ia-qa.com/media/heal-demo.mp4)** also covers what
29
+ deterministic healing *refuses* to guess, the MCP server answering an agent, and the HTML
30
+ report `--report` writes for a PR.
50
31
 
51
- 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:
32
+ ## Install and run the loop
52
33
 
53
34
  ```bash
54
35
  npm i -D @ia-qa/self-healing
55
- npx ia-qa-heal init
36
+ npx ia-qa-heal init # interactive: writes .ia-qa/config.json
56
37
  ```
57
38
 
58
- ## Working with an AI agent — `ia-qa-heal skill`
59
-
60
- An agent that asks a browser where a button went is doing by hand what `.ia-qa/mapping/*.json` already answers offline: every interactive element's **role, accessible name and selector**, captured. A `link` the suite still calls a `button` shows up in one `grep`.
61
-
62
- `ia-qa-heal skill` installs that reflex — plus the rules that must not be broken — as a skill your agent loads (this is what step 1 of the [paste-block above](#easiest-start--have-your-agent-set-it-up) makes it read):
39
+ Then, **in this order**:
63
40
 
64
41
  ```bash
65
- ia-qa-heal skill # where it would go, and whether it is current — writes nothing
66
- ia-qa-heal skill --install # ./.claude/skills/ia-qa-heal/SKILL.md
67
- ia-qa-heal skill --install --user # ~/.claude/skills/, for every project on the machine
68
- ia-qa-heal skill --print # to stdout, for a different agent's format
69
- ```
70
-
71
- It is short on purpose (which verb answers which question; the flags stay in `--help`) and its centre of gravity is the **prohibitions** — never hand-rewrite a locator reported `unattributable`, never gate on `audit`, never read a green `run` as a green app, never diff a `map` baseline against a `run` capture. Those are the mistakes only an agent makes, and it makes them while trying to help.
72
-
73
- Installing is a separate step because agents read skills from `.claude/skills/`, never from `node_modules` — shipped alone, the file would never be loaded. The command is non-interactive, idempotent, prints the absolute path before writing, and refuses to overwrite a copy you have edited unless you pass `--force`.
74
-
75
- ## The whole flow — one tool, or all three together
76
-
77
- This one package ships **three commands**. Use just the first, or chain all three — they read the same contract, so they never disagree.
78
-
42
+ npx ia-qa-heal ingest # inventory the selectors and names your tests use
43
+ npx ia-qa-heal map # capture the app, and bind your selectors to it
44
+ npx ia-qa-heal baseline # promote that capture to "this is how it should be"
45
+ # someone changes the UI
46
+ npx ia-qa-heal map
47
+ npx ia-qa-heal diff # PASS / FIX / BLOCK
48
+ npx ia-qa-heal fix --dry-run
49
+ npx ia-qa-heal fix
79
50
  ```
80
- ① ia-qa-heal deterministic core (humans & CI) — no AI, no key, nothing leaves your machine
81
- init → (discover) → ingest → map → baseline → (app changes) → map → diff → fix
82
- └ diff gives a PASS / FIX / BLOCK verdict you can gate CI on; fix applies only the safe rewrites
83
-
84
- ② ia-qa-heal-ai optional AI add-on (BYOK) — runs AFTER diff, only on what ① gave up on
85
- init → suggest → (you review) → suggest --apply
86
- └ suggests a match for lost/ambiguous rows; a suggestion you confirm, never a CI gate
87
-
88
- ③ ia-qa-heal-mcp the same engine for AI agents (Claude Code, Cursor, Copilot…)
89
- └ tools: map_app · diff_mappings · fix_tests · suggest_heal (nothing leaves the machine)
90
- ```
91
-
92
- **Pick your entry point:**
93
-
94
- | You are… | Use |
95
- |---|---|
96
- | a human in a terminal | `ia-qa-heal` — add `ia-qa-heal-ai` only if you want AI suggestions for the leftovers |
97
- | a CI pipeline | `ia-qa-heal diff` / `fix` — deterministic only, gate on the exit code |
98
- | an AI agent | `ia-qa-heal-mcp` — drive it for the human, explain the PASS/FIX/BLOCK verdict |
99
-
100
- The golden rule: **① is the judge.** ② and ③ never change what CI gates on — they only help a human decide on the rows ① deliberately refuses to guess.
101
-
102
- ## The one file you edit: `.ia-qa/config.json` (credentials, users, URLs)
103
51
 
104
- Everything the tool needs lives in **one JSON file**, written for you by `ia-qa-heal init` (an interactive wizard — you don't hand-write it). This is where you tell it **how to log in**, **which pages to visit**, and **which AI model to use**. Here it is, fully annotated:
105
-
106
- ```jsonc
107
- {
108
- "baseUrl": "https://staging.myapp.com", // your app's root URL
109
-
110
- // 🔑 CREDENTIALS — you store a REFERENCE, never the actual value.
111
- // The real secret lives in your .env or AWS SSM and is read at runtime.
112
- // It never touches this file, and never leaves your machine.
113
- "secrets": {
114
- "user": { "source": "env", "key": "APP_USER" }, // → reads process.env.APP_USER
115
- "pass": { "source": "env", "key": "APP_PASS" }
116
- // AWS SSM instead of env: { "source": "aws-ssm", "key": "/staging/app/pass", "region": "eu-west-1" }
117
- },
118
-
119
- // 🔐 LOGIN — ONE login sequence. Points at the secrets above by name.
120
- "auth": {
121
- "loginUrl": "/login",
122
- "usernameSelector": "#email",
123
- "passwordSelector": "#password",
124
- "submitSelector": "button[type=submit]",
125
- "usernameSecret": "user", // ← the key from "secrets"
126
- "passwordSecret": "pass",
127
- "successSelector": "nav.dashboard" // optional: must appear after login (sanity check)
128
- },
129
-
130
- // 🌐 PAGES / URLs to map. One entry per route.
131
- "pages": [
132
- { "name": "checkout", "url": "/checkout" },
133
- // A view with NO url of its own (a tab/modal an SPA swaps in) — reach it by clicking:
134
- { "name": "billing", "url": "/account", "steps": [
135
- { "click": { "role": "tab", "name": "Billing" } }
136
- ]}
137
- ],
138
-
139
- "locale": "en-US", // accessible names are language-dependent — set this if your app isn't the system default
140
- "testPaths": ["tests/"], // where your test files live (for `fix` / `ingest`)
141
-
142
- // 🌀 VOLATILE — content that rotates by design (feed links, article cards, live promos).
143
- // It reads as interactive UI, so without this it enters the contract like a button
144
- // does — then two captures of the same green page disagree about it and a handful
145
- // of vanished feed items escalates into a false BLOCK. Matching elements never
146
- // enter a contract (map and run capture both filter them).
147
- // Glob per pattern (`*` = anything, case-insensitive, whole-string), optional field
148
- // prefix href:/selector:/name:/role: — without one, tried against href, selector and name.
149
- "volatile": ["href:*source=rss*", "href:https://blog.example.com/*"],
150
-
151
- // 🎭 NAMEMASK — labels that carry a counter, a total or a clock ("Cart (3)",
152
- // "🔌 Smithery 3.2k calls"). The *label* rotates, the element does not — so
153
- // unlike `volatile`, the element stays fully under contract (role, selector and
154
- // href still gate, a selector break on it still BLOCKs) and only the name stops
155
- // being compared. Applied at comparison time: the contracts on disk keep the raw
156
- // label, so adding or removing a mask never needs a re-map.
157
- // Same glob dialect as `volatile`, no field prefix. A pattern with no stable text
158
- // ("*") is refused at load. At runtime a mask is also dropped, loudly, if it would
159
- // swallow a label your tests locate by name, or leave two elements of one role
160
- // sharing a name.
161
- "nameMask": ["Cart (*)", "🔌 Smithery * calls"],
162
-
163
- "ai": { "…": "optional AI add-on — see below" }
164
- }
165
- ```
52
+ `ingest` comes **first**, and it is not optional if your tests locate by CSS: it is what lets
53
+ `map` bind the strings your tests write to real elements. Re-run it when your test files
54
+ change — `map` says so when they have. Commit `.ia-qa/` (except `session.json`): the baseline
55
+ is what CI compares against.
166
56
 
167
- **The three things people ask about:**
57
+ Three binaries ship together: `ia-qa-heal` (the loop), `ia-qa-heal-mcp` (stdio MCP server for
58
+ agents), `ia-qa-heal-ai` (optional BYOK suggestions — never a gate).
168
59
 
169
- - **"Where do I put my password?"** → **Not here.** You put the *name* of an environment variable in `secrets` (or an SSM parameter path). The value stays in your `.env` / shell / AWS. **Loading it is your job:** the CLI reads `process.env` and never opens a `.env` file, so a key that lives only in `.env` is invisible to it — run it under `npx dotenv-cli --` (or Node's `--env-file-if-exists`, 20.12+) and it works everywhere, CI included. If a variable is missing, the tool stops and tells you exactly which one to set — it never guesses and never writes a secret to disk.
170
- - **"Can I test as several users (admin, then guest)?"** → `auth` holds **one** login. Two ways to do multi-user:
171
- 1. **Separate configs/runs** — one `.ia-qa/` per role, selected with `--config <dir>` (see **Monorepos** below), or
172
- 2. **Let your own test suite log in** — run `ia-qa-heal run` with your `testCommand`; it sets `IAQA_CAPTURE=1` and the contract is captured *during your suite*, so **whatever logins your tests already do (any number of users) are covered** — no `auth` block needed.
173
- - **"My diff BLOCKs on links that just… rotate (a news feed, a blog widget)?"** → That is content churn, not UI drift: the feed's links entered the contract as if they were buttons, and the next capture caught the feed mid-rotation. Name what rotates in `"volatile"` (see above) and it never enters a contract again — the diff even prints a hint pointing here when a BLOCK is made of lost external links.
174
- - **"The same rows come back as `renamed` every run — a cart counter, a call total, a clock."** → Only the *label* is moving, so only the label should stop being compared: put the pattern in `"nameMask"` (`"Cart (*)"`), not in `"volatile"`. `volatile` would delete the element from the contract — counter, button, href and coverage together — so a real selector break on it would go unnoticed forever; a mask keeps all of that gating and exempts the name alone. The diff prints the exact config line to paste when it spots the pattern. Two masks it refuses to apply, and says so: one that would hide a label your tests locate by name (that is a red suite behind a green gate), and one that would leave two elements of the same role wearing one name (a locator coin flip the mask itself created).
175
- - **"How do I add a page behind a click, not a URL?"** → add `steps` to that page (see `billing` above): a list of `{ "click": { "role": "...", "name": "..." } }`. Named by role + accessible name, exactly like the contract — never a CSS selector.
176
- - **"My app spans several domains behind a single sign-on (federated SSO) — does `map` cover it?"** → **Not with one `auth` block.** `auth` runs **one** login against **one** domain, before the first page. A federated IdP that re-challenges each project/domain with its own scope (`?projectName=…`, a different sub-domain per portal) is never satisfied by that single login — the second domain bounces back to a login the first one never covered, and the page maps empty (map now warns when an authenticated target returns 0 elements). Two ways through: **one `.ia-qa/` per domain/portal**, each selected with `--config <dir>` (see **Monorepos** below), or **`ia-qa-heal run` with `IAQA_CAPTURE=1`** so your own suite — which already handles the full SSO dance (cookies, redirects, per-project scope) — does the auth and the contract is captured during that real run. And since 1.7.0 there is a third, which is the general one: **`ia-qa-heal login`** — you log in once in a visible browser, whatever the app asks, and `map` reuses that session (see below). For a multi-portal app, capture-during-run is the intended path in CI; `map --auth` is for single-domain apps.
60
+ ## Behind a login
177
61
 
178
- ### Behind any login `ia-qa-heal login`
179
-
180
- `auth` fills a form: a username field, a password field, a submit button. That is *one shape of
181
- login*, and plenty of apps do not have it — federated SSO, MFA, a consent screen, a magic link, a
182
- device check. No list of selectors gets through any of those, so for those apps "add an `auth`
183
- block" is not an answer at all.
184
-
185
- ```bash
186
- ia-qa-heal login # opens a visible browser at your first configured page
187
- # you log in there, however your app asks
188
- ia-qa-heal map # reuses that session — no auth block needed
189
- ```
190
-
191
- It models nothing about your login, which is exactly why it works anywhere: the human does
192
- whatever the app requires, and the browser session is saved to `.ia-qa/session.json` in
193
- Playwright's `storageState` shape. `map` and `discover --crawl` pick it up automatically and say
194
- so; it takes precedence over `auth` when both exist (you ran `login` *because* the form fill
195
- could not get through — re-running it silently would be the tool arguing with you).
196
-
197
- - **That file is a secret.** It holds live cookies: whoever has it is logged in as you. It never
198
- leaves your machine — nothing here uploads it — and `login` writes `.ia-qa/.gitignore` so a
199
- commit cannot carry it.
200
- - **It needs a person.** It refuses without a TTY and under CI, and an AI agent driving the CLI
201
- cannot perform it: the agent hands the step to you by name.
202
- - **It expires.** When the session dies, `map` hits the login wall again and says to re-run
203
- `login` — it never silently maps a login form under the name of your dashboard.
204
- - **It is not a CI mechanism.** In a pipeline, use `auth` (reproducible, secrets from env/SSM) or
205
- capture-during-run (`IAQA_CAPTURE=1` + `testCommand`, your own suite logs itself in).
206
-
207
- `--url <path>` opens a page other than the first one in `config.pages`.
208
-
209
- #### You already have a session — use it
210
-
211
- An authenticated Playwright suite usually writes a `storageState` in `globalSetup`. Point at
212
- it and skip logging in a second time:
62
+ Most apps worth mapping are behind one, and only some logins are a form.
213
63
 
214
64
  ```bash
215
- ia-qa-heal map --session playwright/.auth/user.json # a flag
216
- IAQA_SESSION=playwright/.auth/user.json ia-qa-heal map # or an env var
65
+ npx ia-qa-heal login # opens a real browser; you log in however the app asks
66
+ npx ia-qa-heal map # reuses that session
217
67
  ```
218
68
 
219
- ```jsonc
220
- // …or in .ia-qa/config.json, so every verb picks it up:
221
- "session": "playwright/.auth/user.json"
222
- ```
223
-
224
- Precedence: `--session` → `IAQA_SESSION` → `config.session` → the file `login` wrote. Relative
225
- paths resolve from the project root, and `--session` works on `map`, `discover` and `login`
226
- (there it says *where to write*, so a refresh updates the file you already point at).
227
-
228
- **A session you named and that cannot be read stops the run** — it never falls through to
229
- "no session". Silently ignoring it would capture the login page under your pages' names, which
230
- is the one failure this tool treats as worse than stopping. Same for a file that is not a
231
- storageState: it says so, instead of failing later inside the browser.
232
-
69
+ It models **nothing** about your login, which is why SSO, MFA, a consent screen and magic
70
+ links all work — you perform them. What is saved is the browser session
71
+ (`.ia-qa/session.json`): live cookies, so treat it like a password. It stays on your machine,
72
+ `login` writes `.ia-qa/.gitignore` so a commit cannot carry it, and when it expires `map` says
73
+ so instead of quietly mapping the login page under your dashboard's name.
233
74
 
234
- ### Monorepos one config per portal (`--config <dir>`)
235
-
236
- By default everything lives in `.ia-qa/` under the current directory. A monorepo with several apps/portals keeps **one `.ia-qa/` per portal** and points the CLI at each:
75
+ **Already have a session?** An authenticated Playwright suite usually writes a `storageState`
76
+ in `globalSetup` — point at it instead of logging in twice:
237
77
 
238
78
  ```bash
239
- ia-qa-heal map --config apps/xsp # → apps/xsp/.ia-qa/mapping/…
240
- ia-qa-heal diff --config apps/xsp # baseline/ vs mapping/ under apps/xsp/.ia-qa
241
- ia-qa-heal map --config apps/xcp # a different portal, its own contract
79
+ npx ia-qa-heal map --session playwright/.auth/user.json
242
80
  ```
243
81
 
244
- `--config <dir>` moves **only where the contract lives** (config, mapping, baseline, layouts, captures). Output paths you pass — `--report out.html`, explicit `diff before.json after.json` args — still resolve against your real shell directory. No flag ⇒ the current directory, exactly as before.
245
-
246
- **For the capture path, use the env var instead:** `--config` is a CLI flag and cannot reach the capture, which runs inside your own test process. Set `IAQA_CONFIG_DIR=apps/xsp` there — the Playwright capture reads it directly; for Cypress, pass it as `--env IAQA_CONFIG_DIR=apps/xsp` (or a `CYPRESS_IAQA_CONFIG_DIR` env var) so the hook writes shards under that portal. Then merge with the matching `ia-qa-heal diff --config apps/xsp`.
247
-
248
- > Deliberately **not** "profiles inside one config.json": mapping/, baseline/ and _layouts/ are per-directory on disk, and one directory per portal is what keeps a contract stage-able, review-able and revert-able as a single unit in git.
249
-
250
- > **🤖 For AI agents helping a human set this up:** the config is the human's *secret zone* — treat it with care.
251
- > - **Never put a real secret value in `secrets`** — only `{ "source": "env", "key": "THE_VAR_NAME" }`. If the human pastes a key, tell them to `export` it and reference the var name instead.
252
- > - In a **non-interactive shell you can't run the `init` wizard** — write `.ia-qa/config.json` directly with the shape above. `baseUrl` and a non-empty `pages[]` (each with a non-empty `name`) are required; `auth` only if the app needs login.
253
- > - **Behind a login and no credentials to wire?** Prefer the `IAQA_CAPTURE=1` + `ia-qa-heal run` path so the human's own suite handles auth — you never touch their passwords.
254
- > - A view that shares a URL with others is reachable **by name only** (`steps`), not by URL — say so rather than mapping the wrong view.
82
+ Precedence: `--session` `IAQA_SESSION` `"session"` in config what `login` wrote. A
83
+ session you *named* and that cannot be read stops the run; it never falls through to "no
84
+ session", which would capture the login page under your pages' names.
255
85
 
256
- ## Discover pages to map `ia-qa-heal discover`
86
+ `login` needs a terminal and refuses under CI. There, use `auth` (a form fill, secrets from
87
+ env/SSM — see **Configuration**) or capture-during-run, where your own suite logs itself in.
257
88
 
258
- `map` only ever sees the pages in `config.pages` — and so does drift detection. The question `discover` answers is the one nothing else does: **which pages of your app are you *not* covering?** It reads your whole surface (the sitemap, or a safe crawl), compares it to `config.pages`, and leads with the gap:
89
+ ## Which verb answers which question
259
90
 
260
- ```
261
- 🗺 Coverage — 2 of 143 discovered pages in config.pages · 141 not yet covered:
262
- all-tools /all-tools (sitemap)
263
- resources /resources (sitemap)
264
-
265
- ```
266
-
267
- It only ever **proposes**: it prints the uncovered pages and writes nothing unless you pass `--apply` (which appends them to `config.pages`). You confirm — exactly like the AI add-on's `suggest`.
268
-
269
- > **Why not just capture?** A live suite (`ia-qa-heal run` with `IAQA_CAPTURE=1`) records the pages your tests *visit* — but that is precisely its blind spot: it cannot tell you about the pages your tests *don't* visit. The app overview has the same limit (it only sees links out of pages already mapped). `discover` is the **only** source that sees the whole declared surface, so it is what tells you what you are missing. Use capture to map what you touch; use `discover` to find what you don't. Within it: **`--sitemap`** reads the public surface instantly (no browser); **`--crawl`** reaches behind the login.
270
-
271
- Two sources, one output (`{ name, url }` pairs appended to `config.pages`):
272
-
273
- ```bash
274
- npx ia-qa-heal discover # default: read sitemap.xml (pure HTTP, no browser)
275
- npx ia-qa-heal discover --sitemap https://…/custom.xml # an explicit sitemap
276
- npx ia-qa-heal discover --crawl # follow links from inside the logged-in app
277
- npx ia-qa-heal discover --crawl --apply # …and write the new pages to config.json
278
- ```
279
-
280
- - **`--sitemap` (the default, zero-risk).** A single GET on `<baseUrl>/sitemap.xml` — falling back to the `Sitemap:` lines in `/robots.txt`, and following a sitemap *index* one level into its children. No browser, no login, nothing touched. Its blind spot is the mirror of the crawler's: a sitemap is the **public, SEO** surface and stops exactly where the authenticated app begins.
281
-
282
- It leads with what you'll map — `Found 140 pages.` — never a bare "0 URLs". **`www.` and the apex are treated as one site**, so an apex `baseUrl` (`https://ia-qa.com`) against a `www.` sitemap (or the reverse, or a `www.`→apex 301) just works; the kept URLs are stored as **relative paths**, so `map` loads them under whatever host `baseUrl` names. The only time it mentions a skip is the one you can act on: when URLs were dropped because they sit on a **different host**, it names that host and how to reconcile it — so the old silent "0 URLs" over an apex/`www.` mismatch can't happen. Pass **`--strict-host`** to require an exact host match instead.
283
-
284
- - **`--crawl` (higher reach, still ultra-safe).** Starts *inside* the app — reusing the very `auth` login `map` uses — and follows links outward, so it finds the authenticated pages a sitemap never lists. It is built to be side-effect-free:
285
- - **same-origin GET navigations only**, and it **never visits a URL that reads like an action** — `/logout` (which would end the session for every page after it), `/delete`, `/pay`, `/order`, `/unsubscribe`… A GET that mutates is bad REST but real, so the guard is a denylist on the URL itself.
286
- - to find links hidden behind a menu, dropdown, or dialog, it **opens** them — and does nothing else. Only pure **disclosure toggles** are clicked (an `aria-haspopup`, a collapsed `aria-expanded="false"`, a `<summary>`); never a link, never a submit, never a control whose name reads like an action, never one already open. If a click unexpectedly navigates, that was not a disclosure — it records where it went and steps back. After reading, it presses `Escape` to close. It never clicks the revealed items; it only harvests their `href`s for the crawl. Reveal goes **one level deep** — open it and that's all. Turn it off entirely with **`--no-reveal`** (crawl links only).
287
-
288
- ```
289
- --depth <n> crawl link-depth from the start pages (default 2)
290
- --max <n> cap candidates per source
291
- --strict-host require an exact host match (default: www./apex are one site)
292
- ```
293
-
294
- Both sources can run together (`discover --sitemap --crawl`) — the sitemap hits seed the crawl frontier. Pages already in `config.pages` count as covered (not re-listed); a newly discovered page whose name would collide with an existing one gets a numeric suffix (a page name is a contract's filename — a collision would overwrite it). It is idempotent: once you have added the uncovered pages, a re-run reports full coverage.
295
-
296
- ```
297
- 🗺 Coverage — 6 of 8 discovered pages in config.pages · 2 not yet covered:
298
-
299
- name url (source)
300
- dashboard /dashboard (crawl)
301
- settings /settings (crawl)
302
- Run again with --apply to add the uncovered pages to config.pages.
303
- ```
304
-
305
- Then `ia-qa-heal map` captures their contracts as usual. Discovery never maps, heals, or edits a test — it only ever edits `config.json`, and only with `--apply`.
91
+ | Your question | Verb |
92
+ |---|---|
93
+ | my tests broke — fix them | `run` (capture → diff → confirm → fix → re-run to verify) |
94
+ | did anything drift? | `diff` → PASS/FIX/BLOCK, the CI gate |
95
+ | **my suite already ran with `IAQA_CAPTURE=1`** | **`diff`, on its own** — merges the capture, no browser, no second run |
96
+ | day one, no baseline — is my suite still valid? | `audit` (advisory; never gate on it) |
97
+ | is the app itself sound? | `check` — dead links, unnamed elements, name collisions, orphan pages |
98
+ | which pages am I *not* testing? | `discover` (`--sitemap`, or `--crawl` behind the login) |
99
+ | what do my tests actually use? | `ingest` |
100
+ | show me the app's structure | `graph --format mermaid\|svg\|json\|markdown` |
101
+ | is this getting better or worse? | `history` |
102
+ | let me look and decide myself | `ui` a local console |
306
103
 
307
- ## The console — `ia-qa-heal ui`
104
+ `ia-qa-heal <verb> --help` for flags. On a slow suite, note that `run` runs it **twice** (once
105
+ to capture, once to verify the fix): `run --no-verify` keeps only the first, and
106
+ `IAQA_CAPTURE=1` on the run you already do in CI plus `ia-qa-heal diff` costs nothing extra.
308
107
 
309
- The report tells you what drifted. The console lets you **act on it**.
108
+ ## The verdict
310
109
 
311
- ```bash
312
- npx ia-qa-heal ui
313
110
  ```
314
-
315
- …or, for someone who should never have to open a terminal at all:
316
-
317
- ```bash
318
- npx ia-qa-heal ui --shortcut # once per project
111
+ 🔧 FIX 2 ok · 1 renamed · 3 healable · 0 ambiguous · 0 lost
319
112
  ```
320
113
 
321
- That puts an icon on the desktop — the ia-qa logo, wired to *this* project. Double-click it: the console starts and the browser opens on it. No URL is ever typed or copied, which is also how the session token stays out of a human's hands. Click it twice and it reuses the console it already opened. **Closing the browser tab stops the server**, so a click never leaves a process running behind your back (`--keep-alive` opts out).
322
-
323
- It opens a local page with three things the terminal cannot give you:
324
-
325
- - **The rows waiting on a human** — every `ambiguous`, `lost` and `rebound` row, each with a **highlighted crop** of the candidates as they look on the page right now (needs `map --screenshots`). When five identical "Copy" buttons exist, the row's selector tells you nothing; the picture tells you which one. Alongside them, the `file:line` of every test that will break.
326
- - **The verbs as buttons** — capture, ingest, diff, audit, check, preview/apply the rewrites — with the output streamed back.
327
- - **The run history as charts** — verdicts over time, mean coverage, and which pages actually churn.
114
+ - **PASS** nothing moved.
115
+ - **FIX** — every break has a deterministic old→new rewrite. `fix` applies it.
116
+ - **BLOCK** a human decides. Nothing is rewritten.
328
117
 
329
- **How it is kept safe**, because a server on a QA's machine is not free:
118
+ What `fix` **refuses** is the point of the tool:
330
119
 
331
120
  | | |
332
121
  |---|---|
333
- | **Loopback only** | bound to `127.0.0.1`, never `0.0.0.0` in a container that would be a published port |
334
- | **A one-time token** | in the URL, required on every request. Without it, *any* page open in your browser could `fetch` the console and read the contracts of your authenticated app, or trigger an action |
335
- | **Host pinning** | requests whose `Host` is not loopback are refused, which is the DNS-rebinding case a token alone does not cover |
336
- | **No new authority** | buttons spawn this package's own CLI with a fixed argv from an allowlist never a string from the request. The console can do **nothing the CLI would refuse**: an `ambiguous` locator is never rewritten from here either |
337
-
338
- **It draws the findings; it does not quote them.** The console runs `check --json` and `audit --json` and renders the report — broken links with the pages that link to them, name collisions with their role and count, unnamed elements with their selector, each card carrying the sentence that says why it matters, in a full-width panel. It used to paste the verb's terminal output into a black `<pre>`: aligned monospace, folded behind "Technical detail", in the one surface whose premise is that its reader does not want a terminal. The raw output is still there, one click away, for whoever wants it. The network announcement survives the change — the panel states the host and the number of links requested, because a flow that leaves the machine says so whatever renders it.
339
-
340
- **A decision it states, it offers.** A result that ends *"re-run with a lower `--concurrency`"* or *"use Repair when the plan looks right"* is a terminal instruction printed to the one person who is not in a terminal. Those sentences are buttons now, inside the result card you are already reading: a check that could not verify some links offers **Verify those links again, slowly** and **Check the app without requesting links**; a diff with repairs offers the preview, and the preview offers the repair; an audit finding offers **Check for changes** and **Look at the app again** — and never a repair, because `audit` has no baseline to rewrite from and is advisory by design. Each verb also names its own verdict: `audit` never says "Repairs are ready", because it does not repair. The slower rate is a *frozen argv in the allowlist*, not a number field the page posts — a discrete choice keeps "no new authority" literally true, where a settings field would have traded the invariant for one option.
341
-
342
- Nothing is uploaded, there is no telemetry and no external asset — the page is inline HTML/CSS/JS and every byte it shows came off your disk. It refuses to start under CI, where an interactive surface would just hang; use `--json`, `--junit` or the exit code there.
343
-
344
- **One request does leave, and it is stated rather than buried:** at startup, at most once a day, `ui` asks `registry.npmjs.org` whether a newer version of this package exists, so the banner can tell you. The desktop shortcut launches a fixed path on disk, and the person clicking it does not own a terminal — this is the only way they would ever find out. It sends a request for a public package's version number: nothing about your app, your tests, your contracts or your results, no telemetry, no identifier. The answer is cached in `~/.ia-qa/update-check.json`, the terminal names the host before the request happens, and **`ia-qa-heal ui --offline` removes it entirely**.
345
-
346
- The banner stops at the command to paste. It installs nothing, and there is no button that would: the console's authority is exactly the CLI's, and `npm install` is a different binary that runs whatever lifecycle scripts the registry serves — wiring that to a button would widen a leaked token from "edits my test files" to "runs arbitrary code as me". If you started through `npx`, it says so and tells you there is nothing to install.
347
-
348
- ### Several projects — the switcher
349
-
350
- The header carries a **Project** dropdown as soon as there is more than one. It is governed by a single rule:
351
-
352
- > **A project is reachable only if you opened a console in it.**
353
-
354
- Nothing scans your disk. `~/.ia-qa/projects.json` is a consent list, and there are exactly two ways to consent: run `ia-qa-heal ui` in a folder, or pick **+ Add another project…** in the dropdown and give the path. Switching between listed projects resolves the requested directory against that list *exactly* — a folder outside it cannot be named. Entries whose project has been deleted are dropped rather than offered, and actions are handed `--config <active project>` explicitly rather than inheriting anything.
355
-
356
- Adding from the console is a deliberate action by whoever holds the session token — the same token that already authorizes *Repair my test files*. What the list defends against is a **drive-by**: another page in your browser reaching localhost. That is stopped by the token and the `Host` check, not by making you walk to a terminal.
357
-
358
- ```bash
359
- ia-qa-heal ui --forget # clear the whole list
360
- ia-qa-heal ui --forget ../shop # drop one project
361
- ```
362
-
363
- Forgetting only removes a folder from the switcher — it deletes nothing in the project, and opening a console there again re-adds it. `IAQA_HOME` relocates the list (useful on a shared machine).
364
-
365
- ## Visualize the app — `ia-qa-heal graph`
366
-
367
- Turn the mappings you already have into a **navigation graph** (page → page, derived from the `href` on each page's links — the same engine `map` uses for `_overview.md`). Read-only: it never rewrites your mapping files, it just renders them.
368
-
369
- ```bash
370
- ia-qa-heal graph # Mermaid on stdout — paste into a PR comment or mermaid.live
371
- ia-qa-heal graph --format json # ia-qa-graph@1 intermediate: { nodes[], edges[], meta }
372
- ia-qa-heal graph --format markdown --out nav-kb.md # one card per page — a RAG chunk / Confluence page
373
- ia-qa-heal graph --format svg --out nav.svg # the same SVG map writes as _navigation.svg
374
- ia-qa-heal graph --format json | your-agent # hand the graph to an agent to analyze
375
- ```
376
-
377
- `--format` is one of `mermaid` (default) · `svg` · `json` · `markdown`; `--out <file>` writes to a file instead of stdout; `--open` opens it. `mermaid` and `svg` are the same views `map` already writes to `_navigation.md`/`_navigation.svg` — offered here for stdout/pipe.
378
-
379
- The **`json`** form is the portable `ia-qa-graph@1` IR — nodes carry `{ url, elementCount, entry, isolated }`, and `meta` carries the coverage gap (`unmapped`), collapsed hubs (`fans`), any `mesh`, and `entries`. It matches the node/edge shape the ia-qa.com Graph tool consumes, so it drops into other viewers (D3, Reagraph) without conversion.
380
-
381
- The **`markdown`** form linearizes the graph into one section per page — *what it is, where it links, what reaches it* — so a page is a self-contained chunk you can index in a **RAG knowledge base** (split on the `##` headings) or push to **Confluence** (`create_confluence_page` on the ia-qa MCP). It carries the navigation *relations* the per-page `map` contracts don't, and ends with a **Coverage gaps** list of linked-but-unmapped destinations.
382
-
383
- ## The one-verb loop: `ia-qa-heal run`
384
-
385
- `run` chains the whole loop and stops where a human belongs:
386
-
387
- ```
388
- capture → ingest → diff → dry-run of the fixes → your confirmation → fix → re-run to verify
389
- ```
390
-
391
- - 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`.
392
- - `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.
393
- - **After the fix, `run` re-runs your suite once more (without `IAQA_CAPTURE`) to confirm the rewrite is actually green** — a selector fix is only worth anything if the tests pass after it. The exit code follows that re-run: `0` if the suite passes, `1` if it still fails (the rewrite landed but something else is red). `--no-verify` applies the fix and skips the re-run; the re-run is also skipped, with a reason, when nothing was rewritten or there is no `testCommand`.
394
- - With `--report`, the HTML for a `run` additionally carries the **applied** `old → new` edits (file and line numbers) and the verify verdict — the file-level before/after the `diff` report only promised.
395
-
396
- ### Already ran your suite with `IAQA_CAPTURE=1`? Just `diff`
397
-
398
- `run` is a convenience, not the only door. If the capture already happened — your suite ran with
399
- `IAQA_CAPTURE=1`, in CI or by hand — the verdict is one offline command away:
400
-
401
- ```bash
402
- IAQA_CAPTURE=1 npx playwright test # your suite, as usual
403
- ia-qa-heal diff # merges .capture/ and compares — no browser, no re-run
404
- ```
405
-
406
- `diff`, `fix` and `baseline` all merge the staging directory (`.ia-qa/mapping/.capture/`) on the
407
- way in and say so. **Do not run `map` to "finish" a capture**: `map` opens its own browser and
408
- starts over, without whatever session your suite had.
409
-
410
- And on a slow suite, `run` runs it **twice** — once to capture, once to verify the fix. `run
411
- --no-verify` keeps the first only; the exit code then reflects the diff rather than a re-proved
412
- green suite.
122
+ | `ambiguous` | several candidatesany choice is a coin flip |
123
+ | `lost` | the element is gone; there is nothing to rewrite towards |
124
+ | `rebound` | the selector now finds a *different* element the test passes and acts on the wrong thing |
125
+ | `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 |
413
126
 
414
- ### Capture during your test run `@ia-qa/self-healing/capture`
127
+ `--locators` rewrites to `getByRole(role, { name })` instead of a fresher CSS path, where that
128
+ is provably safe — a selector that survives the next layout change. Without it, a CSS selector
129
+ is replaced by a CSS selector: the tool does not impose a locator style on your suite.
415
130
 
416
- One line at the top of a spec (or wrap your own extended `test` with `withCapture`):
131
+ ### At runtime, if you want it `aiClick` / `aiFill`
417
132
 
418
133
  ```ts
419
- import { test, expect } from '@ia-qa/self-healing/capture';
420
- ```
421
-
422
- 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.
423
-
424
- ### Capture during your test run — Cypress (`@ia-qa/self-healing/cypress`)
425
-
426
- Same idea, one import in `cypress/support/e2e.js`:
427
-
428
- ```js
429
- import '@ia-qa/self-healing/cypress';
430
- ```
431
-
432
- Inert unless `IAQA_CAPTURE=1` (set it as a real env var, or forward it in `cypress.config.js` → `env: { IAQA_CAPTURE: process.env.IAQA_CAPTURE }`). When armed, it records the interactive elements of the page **each test ends on** — post-login, modals, wizards, whatever your suite already walks — and writes them to `.ia-qa/mapping/.capture/` via `cy.writeFile`. **Nothing to wire in `setupNodeEvents`.** The next CLI verb (`diff` / `run` / `baseline`) merges the shards into the same page contracts as `map` and the Playwright capture — byte-compatible.
433
-
434
- **This is the intended path for a multi-domain / federated-SSO app.** `map` logs in once against one domain; a suite that already authenticates across several portals reaches states `map` structurally cannot. Capture rides that real run, so **whatever logins your tests already do — UI form, API token injection, `cy.session`, any number of domains — are covered, with no `auth` block and without self-healing ever touching your credentials.**
435
-
436
- Details and limits (same as the Playwright path, plus a couple Cypress-specific ones):
437
- - Coverage is whatever the suite visits — it is not a crawler. To get a page's contract, have a test that **ends** on it (v1 captures the final state of each test, not every intermediate navigation).
438
- - Only the app-under-test's top document is read (iframes/shadow DOM excluded), and the extractor runs in the AUT realm via `eval` — an app whose CSP forbids `unsafe-eval` will silently capture nothing (the capture never fails your suite).
439
- - Parallel runners (`cypress-parallel`, Cypress Cloud) are fine: each worker writes its own uniquely-named shard and the merge unions them.
440
- - If a `.ia-qa/config.json` exists, contracts are named from `config.pages` (so they pair with your baseline) and `steps`-only views are skipped; with no config, the page name falls back to a slug of the URL.
441
-
442
- ### Capture during your test run — Selenium, JS (`@ia-qa/self-healing/selenium`)
443
-
444
- A `selenium-webdriver` (Node) test runs in Node, so — unlike Cypress — there is nothing to hook and no `cy.writeFile`: you call it yourself after a navigation, and it writes the shard directly.
445
-
446
- ```js
447
- const { capture } = require('@ia-qa/self-healing/selenium');
448
-
449
- await driver.get('https://app.example.com/checkout');
450
- await capture(driver); // inert unless IAQA_CAPTURE=1
451
- ```
452
-
453
- Inert without `IAQA_CAPTURE=1`, so the calls can live in the suite permanently. It injects the extractor into the live page via `driver.executeScript` (the Selenium twin of `page.evaluate`), names the page (config-aware, `steps`-views skipped), unions repeats, and writes `.ia-qa/mapping/.capture/sel-<worker>.json` — the same shard the merge already reads. Parallel workers each write their own file. Errors are swallowed; the capture never fails your test. Honors `IAQA_CONFIG_DIR` for monorepos, like every other path.
454
-
455
- ### Any other framework / language — write a shard
456
-
457
- The shard format **is** the interface; the Playwright/Cypress/Selenium helpers are just sugar over it. Non-JS Selenium (Python, Java, C#…), WebdriverIO, or a home-grown harness can feed capture directly: run the extractor in the page (it ships as the copy-paste DevTools snippet on ia-qa.com, and as `extract.js` in this package), then drop a JSON file into `.ia-qa/mapping/.capture/` shaped like:
458
-
459
- ```json
460
- { "pages": [ { "page": "checkout", "url": "/checkout",
461
- "elements": [ { "role": "button", "name": "Pay", "selector": "button#pay" } ] } ] }
134
+ import { aiClick } from '@ia-qa/self-healing';
135
+ await aiClick(page, 'button#login');
462
136
  ```
463
137
 
464
- Any `*.json` in that directory is merged into contracts by the next `ia-qa-heal` verb — named to match `config.pages` so it pairs with your baseline. That is the whole contract: no import, no Node required.
465
-
466
- ### Locator inventory`ia-qa-heal ingest`
467
-
468
- ```bash
469
- npx ia-qa-heal ingest tests/ # or no args: reads config.testPaths
470
- ```
471
-
472
- Statically scans your tests/POMs → `.ia-qa/usage.json`. Two halves, because suites are written two ways:
473
-
474
- - **Selectors** — Playwright, Cypress, Selenium call shapes + page-object declarations (`loginButton = '#login .cta'`, any name, any language). `diff` annotates every drifted selector with *used N× in M test files*, and `fix` no longer needs the test paths on the command line.
475
- - **Names** — the `get|find|queryBy…` family (Playwright, Testing Library, `@testing-library/cypress`): `ByRole`, `ByLabel(Text)`, `ByPlaceholder(Text)`, `ByText`, `ByTitle`, `ByAltText` — plus `cy.contains(…)` (both forms) and Selenium's `By.linkText` / `By.LINK_TEXT` / `By.PARTIAL_LINK_TEXT`. These are what makes the rename gate below possible.
476
-
477
- Purely static: no execution, no network. A locator built by concatenation or holding `${…}` is invisible here — and invisible to `fix` too, which is why it is skipped rather than guessed at.
138
+ If the normal action times out, these read the page contract, re-scan the live page, retry on
139
+ the element that semantically matches, and log a loud warning that the test needs updating.
140
+ Useful to keep a suite green while you triage but it is a **stopgap, not the loop**: healing
141
+ in memory hides the drift instead of putting it in a pull request. The verbs above edit your
142
+ files so a human reviews the change.
478
143
 
479
- ### Page Objects written in CSS — `_resolved/`
144
+ ## Page Objects written in CSS
480
145
 
481
- Your Page Object says `.btn-primary`. The contract says `#pay-now`. Both name the same
482
- button, and until they are **bound** every drift row reads `not referenced by your tests`
483
- technically true, practically useless, and the reason a CSS-anchored suite used to get
484
- nothing out of a diff.
146
+ Your Page Object says `.btn-primary`. The contract says `#pay-now`. Both name the same button,
147
+ and until they are **bound** every drift row reads *not referenced by your tests*
148
+ technically true, useless in practice.
485
149
 
486
- So `map` binds them: with `usage.json` present (run `ia-qa-heal ingest` first), each
487
- selector your tests write is evaluated against the page and recorded in
488
- `.ia-qa/mapping/_resolved/<page>.json`. `baseline` promotes those alongside the contracts,
489
- and `diff` compares the two moments.
490
-
491
- What that buys you — an example that no other check in this package can catch:
150
+ So `map` binds them (with `usage.json` present run `ingest` first) and records the result in
151
+ `.ia-qa/mapping/_resolved/`. What that buys, on a diff where the element contract did not move
152
+ at all:
492
153
 
493
154
  ```
494
155
  🔧 FIX
495
- 2 ok · 0 renamed · 0 healable · 0 lost ← the contract did not drift at all
156
+ 2 ok · 0 renamed · 0 healable · 0 lost ← the contract is spotless
496
157
 
497
158
  🔗 1 selector your tests write no longer reaches what it used to:
498
159
  🔧 .btn-primary → #pay-now
@@ -500,38 +161,32 @@ What that buys you — an example that no other check in this package can catch:
500
161
  tests/checkout.spec.js:1, tests/checkout.spec.js:4
501
162
  ```
502
163
 
503
- The button never moved; someone renamed a CSS class. The element diff is spotless, the
504
- suite is broken, and `ia-qa-heal fix` rewrites the literal in your files.
505
-
506
- Four outcomes, and only one of them is repaired automatically:
164
+ Somebody renamed a CSS class. The element diff is clean, the suite is broken, and `fix`
165
+ rewrites the literal in your files. Four outcomes, one repaired automatically:
507
166
 
508
167
  | | |
509
168
  |---|---|
510
- | the string now reaches **nothing**, and the element is still there | 🔧 rewritten |
511
- | the string now reaches a **different** element | ⛔ never rewritten — your test still passes and acts on the wrong thing |
512
- | the string now reaches **several** elements | ⛔ a coin flip, a human decides |
513
- | the element it named is **gone** | ⛔ nothing to rewrite towards |
514
-
515
- **And the case worth its own line your app already carries the answer.** The contract
516
- holds interactive elements only, so a selector on a status div, a cell or an alert is not
517
- in it. Most of the time that stays quiet (it is proof the selector works, and such an
518
- element has no identity independent of what drifts, so nothing could be repaired anyway).
519
- But when it *does* carry one — a `data-testid`, an `id`, an `aria-label` — the repair is
520
- exact:
169
+ | reaches **nothing**, and its element is still there | 🔧 rewritten |
170
+ | reaches a **different** element | ⛔ never rewritten — your test passes and acts on the wrong thing |
171
+ | reaches **several** elements | ⛔ a coin flip |
172
+ | its element is **gone** | ⛔ nothing to rewrite towards |
173
+
174
+ **Your app may already carry the answer.** The contract holds interactive elements only, so a
175
+ selector on a status div, a cell or an alert is not in it. Usually that stays quiet — such an
176
+ element has no identity independent of what drifts, so nothing could be repaired anyway. But
177
+ when it *does* carry one (`data-testid`, `id`, `aria-label`), the repair is exact:
521
178
 
522
179
  ```
523
180
  🔧 .infoBlockContainer > .alert → [data-testid="cart-alert"]
524
- the element is still there, only its address moved.
525
- pom/CartPage.ts:2, pom/CartPage.ts:4
526
181
  ```
527
182
 
528
- That is the common shape of a legacy suite: the front end added test ids years after the
529
- Page Objects were written, and nobody propagated them. This turns that gap into rewrites,
530
- and it cannot be noisy an element with a test id has a stable identity by construction,
531
- and an anchor matching two elements is refused.
532
- **How much of your suite is in reach?** `map` ends by saying so, over the union of the
533
- pages it just mapped — because this is the number everyone needs and nobody can add up by
534
- hand (the same string appears on several pages):
183
+ That is the common shape of a legacy suite: the front end added test ids years after the Page
184
+ Objects were written and nobody propagated them. It cannot be noisy an element with a test
185
+ id has a stable identity by construction, and an anchor matching two elements is refused.
186
+
187
+ **How much of your suite is in reach?** `map` says so at the end, over the union of the pages
188
+ it just mapped — the number nobody can add up by hand, because the same string appears on
189
+ several pages:
535
190
 
536
191
  ```
537
192
  🔗 Your 181 inventoried selectors, against the pages just mapped:
@@ -540,299 +195,63 @@ hand (the same string appears on several pages):
540
195
  95 not seen on any page mapped this run — which is about your coverage, not your tests
541
196
  ```
542
197
 
543
- Read the composition, not the ratio. `working` selectors are **fine** they just have
544
- nothing to repair towards. And `not seen` is a statement about how many pages you map: a
545
- selector on a page absent from `config.pages`, or in a state no capture reached, looks
546
- exactly like one that is gone. `ia-qa-heal discover` lists what you are missing.
547
-
548
- No `usage.json` ⇒ no bindings ⇒ nothing changes. Same rule as name drift: **no inventory,
549
- no escalation.** Today the bindings are captured by `map`; capture-during-run is specified
550
- in `SPEC-selector-resolution.md` and not built yet.
551
-
552
- ### The rename your suite actually depends on
553
-
554
- A relabelled element is classified `renamed`: the selector still resolves, the click still lands on the right thing. **For a CSS-anchored suite that is harmless. For a name-anchored one it is fatal** — `getByRole('button', { name: 'Save' })` stops matching the moment the button says "Enregistrer", and a selector-only diff would exit 0 while your suite goes red.
555
-
556
- Once `ingest` has run, `diff` cross-references every rename against what your tests reference:
557
-
558
- | Situation | Verdict | What `fix` does |
559
- |---|---|---|
560
- | No test names that label | unchanged (`PASS`) | nothing — adopting this adds no noise |
561
- | One element carries the new label | **FIX** | rewrites the call: `getByRole('button', { name: 'Enregistrer' })` |
562
- | The **old** label now sits on another element | **BLOCK** | nothing — your locator would silently target the wrong element (the name-space twin of `rebound`) |
563
- | Several elements carry the new label | **BLOCK** | nothing — a human picks |
564
- | The reference is text-anchored (see below) | **BLOCK** | nothing — reported with its `file:line` |
565
-
566
- **Only calls that state a role are ever rewritten.** That is the line between the two halves of the inventory, and it is not arbitrary:
567
-
568
- | Rewritten | Reported, never rewritten |
569
- |---|---|
570
- | `getByRole` / `findByRole` / `queryByRole` (Playwright, Testing Library, `@testing-library/cypress`) | `getByText` / `findByText` |
571
- | `getByLabel` · `getByLabelText` · `getByPlaceholder(Text)` — the role is implied | `cy.contains(…)`, both forms |
572
- | `By.linkText` / `By.LINK_TEXT` / `By.PARTIAL_LINK_TEXT` (Selenium: role = link) | `getByTitle` · `getByAltText` |
573
-
574
- `getByText('Archive')` names a *string*, not an element, and the contract maps interactive elements only — so it cannot prove the test meant the renamed button rather than a heading that never moved. Rewriting it would break a test that was passing, which is worse than any drift this tool detects. Those references are printed with their `file:line` and hold the verdict at BLOCK for a human instead.
575
-
576
- Without `usage.json` nothing is escalated at all, so an un-ingested project behaves exactly as before.
577
-
578
- ### Is the app itself sound? — `ia-qa-heal check`
579
-
580
- ```bash
581
- npx ia-qa-heal check # dead links + static checks
582
- npx ia-qa-heal check --offline # skip the network half, fully local
583
- npx ia-qa-heal check --strict # fail the pipeline on warnings too
584
- npx ia-qa-heal check --concurrency 2 # gentler on a target with a strict rate limiter
585
- ```
586
-
587
- `diff` answers *what moved since the baseline?*. `check` answers what a diff structurally cannot: **is the current contract healthy at all?** A green diff over a page whose every link 404s is still a green diff — verified on a sample app where `diff` exits 0 and `check` exits 1 at the same instant.
588
-
589
- Every check is admitted under one rule: **it must be able to fail for a reason a contract diff would not already catch.** "The button still exists" is rejected by that rule — `diff` proves it better and without a browser. What survives:
590
-
591
- | Check | Fails when | Verdict |
592
- |---|---|---|
593
- | **dead links** | the app answered 4xx/5xx (401/403 excluded — that is a working link behind auth; 429 excluded — see below) | **FAIL** |
594
- | **unnamed** | an interactive element has no accessible name: no screen-reader announcement, no `getByRole`, no healing | WARN |
595
- | **ambiguous** | two elements share role + name — every name-anchored locator for them is a coin flip, and a strict-mode violation waiting | WARN |
596
- | **orphans** | a mapped page nothing links to | WARN |
597
- | **unverified** | the request was throttled (429) or never answered — no verdict was reached | WARN |
198
+ Read the composition, not the ratio. `working` selectors are **fine**. And `not seen` is a
199
+ statement about how many pages you map: a selector on a page absent from `config.pages`, or in
200
+ a state no capture reached, looks exactly like one that is gone. `ia-qa-heal discover` lists
201
+ what you are missing.
598
202
 
599
- FAIL is reserved for a fact the app itself stated. The rest are real but arguable — an unnamed icon button, a deep-link-only page — so they warn and exit 0 unless you ask for `--strict`. A gate nobody can leave on is a gate nobody turns on.
203
+ No `usage.json` no bindings nothing changes. Same rule as name drift: **no inventory, no
204
+ escalation.**
600
205
 
601
- **Findings are de-duplicated across pages.** A navbar repeated on 142 pages is one thing to fix, not 142 findings — same reasoning that puts shared layouts in `_layouts/` for the diff.
206
+ ## Capture during your own test run
602
207
 
603
- **Network.** The link check requests URLs on your own `baseUrl` and nothing else: same site only, GET only, and never a URL that *acts* — `/logout`, `/orders/1/delete` and friends are skipped using the same denylist as `discover --crawl`, because a GET on those is not a check, it is a logout or a deletion. The run announces the host and the count **before** the first request. `--offline` drops that half entirely.
208
+ `map` walks URLs. Your suite walks *states* — post-login pages, modals, wizard steps and
209
+ already handles whatever auth your app demands. Arm it with `IAQA_CAPTURE=1` and the contract
210
+ is captured during the real run. **This is the intended path for a multi-domain / federated-SSO
211
+ app**, and it never touches your credentials.
604
212
 
605
- **Its own traffic is not a finding.** Run straight after mapping a large site, `check` used to walk into that site's rate limiter and report ~180 dead links, 177 of which answered 200 when asked one at a time — a blocking verdict built entirely out of its own request rate, burying 3 genuine 404s. A **429 is not a dead link**: it honours `Retry-After` (bounded), collapses to one request at a time, and retries once. What still will not answer lands in **`unverified`** — not ok, not dead, named and counted in the summary (`FAIL · 3 dead · 177 unverified (rate-limited)`). It downgrades the coverage, it never speaks a verdict: a 401 means *checked, the link exists*; a 429 means *not checked at all*, and silently exempting it would buy quiet with a false green. `--concurrency <n>` (default 6) for a target with a strict limiter.
213
+ **Playwright** one import at the top of a spec (or wrap your own extended `test` with
214
+ `withCapture`):
606
215
 
607
- ### Day one, no baseline — `ia-qa-heal audit`
608
-
609
- ```bash
610
- npx ia-qa-heal audit tests/ # advisory, exit 0
611
- npx ia-qa-heal audit --strict # exit 1 on a finding
216
+ ```ts
217
+ import { test, expect } from '@ia-qa/self-healing/capture';
612
218
  ```
613
219
 
614
- Every other verb compares two moments, so its value arrives one drift *after* you install it. Nobody adopts a healing tool before their tests break. `audit` answers the question you have on the day you arrive — *does my suite still name things that exist?* — from a **single capture**: it cross-references the inventory against `mapping/` and flags locators that match no element, with the closest live label as a suggestion.
615
-
616
- Deliberately conservative, and it says so out loud:
617
-
618
- - Only **role-anchored** locators carry the verdict, and only for roles a contract can hold. `getByText('Terms of service')` may point at a paragraph `map` never captured, and `getByRole('heading', …)` at a heading it never captures — their absence proves nothing, so they are counted and not judged.
619
- - **Advisory by default** (exit 0). A "missing" locator may live on a page not in `config.pages`, or in a page *state* `map` never saw (a label behind a tab or a mode toggle). Both caveats are printed next to the findings. Add `--strict` once your coverage is real.
620
- - Read-only. `audit` never edits and never applies a suggestion.
220
+ **Cypress** one import in `cypress/support/e2e.js`, nothing to wire in `setupNodeEvents`:
621
221
 
622
- ### Elements with no accessible name
623
-
624
- 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`.
625
-
626
- ## Two front doors, one engine
627
-
628
- | | For | Entry point |
629
- |---|---|---|
630
- | **MCP server** (stdio) | AI agents — "map my app at \<url\>" | `ia-qa-heal-mcp` → tools `map_app`, `diff_mappings`, `fix_tests`, `suggest_heal` (optional AI) |
631
- | **CLI** | humans & CI | `ia-qa-heal` → `init`, `discover`, `map`, `baseline`, `diff`, `fix`, `ingest`, `audit`, `run` |
632
- | **AI add-on** (optional, BYOK) | semantic renames the deterministic engine gives up on | `ia-qa-heal-ai` → `suggest` — see [Optional AI add-on](#optional-ai-add-on--semantic-suggestions-ia-qa-heal-ai-byok) |
633
-
634
- ### Locale
635
-
636
- 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:
637
-
638
- ```bash
639
- # env var — works for both CLI and MCP paths
640
- export IAQA_LOCALE=fr-FR # or en-US, de-DE, ja-JP…
641
-
642
- # or in .ia-qa/config.json:
643
- { "locale": "fr-FR", … }
222
+ ```js
223
+ import '@ia-qa/self-healing/cypress';
644
224
  ```
645
225
 
646
- 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.
226
+ **Selenium (Node)** you call it yourself, since there is nothing to hook:
647
227
 
648
- Agent config (Claude Code / Desktop, Cursor…):
649
- ```json
650
- {
651
- "mcpServers": {
652
- "ia-qa-self-healing": {
653
- "command": "npx",
654
- "args": ["-y", "-p", "@ia-qa/self-healing", "ia-qa-heal-mcp"]
655
- }
656
- }
657
- }
228
+ ```js
229
+ const { capture } = require('@ia-qa/self-healing/selenium');
230
+ await driver.get('https://app.example.com/checkout');
231
+ await capture(driver); // inert unless IAQA_CAPTURE=1
658
232
  ```
659
- `-p` is required, not cosmetic: the package ships **three** bins (`ia-qa-heal`, `ia-qa-heal-mcp`, `ia-qa-heal-ai`), so `npx @ia-qa/self-healing …` cannot resolve which to run and silently starts nothing.
660
- 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.
661
-
662
- ### Browser reuse — no second Chromium (`src/launcher.ts`)
663
- 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:
664
-
665
- 1. explicit executable — `browserPath` / `$IAQA_BROWSER_PATH`
666
- 2. explicit channel — `browserChannel` / `$IAQA_BROWSER_CHANNEL` (`chrome`, `msedge`…) → **zero download**
667
- 3. Playwright's bundled Chromium (shared cache — usually already present)
668
- 4. automatic fallback to the system Chrome, then Edge
669
-
670
- 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.
671
-
672
- ## How it works
673
-
674
- 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 (**your environment, typically loaded from a 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](#the-one-verb-loop-ia-qa-heal-run).)
675
- 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):
676
- ```json
677
- {
678
- "page": "login",
679
- "url": "/login",
680
- "capturedAt": "2026-07-15T10:00:00.000Z",
681
- "elements": [
682
- { "role": "button", "name": "Se connecter", "selector": "button#login" }
683
- ]
684
- }
685
- ```
686
- 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.
687
-
688
- Alongside the page contracts, `map` rebuilds the app index and the navigation graph — see [Map artifacts](#map-artifacts).
689
- 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.
690
-
691
- 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.
692
-
693
- 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.
694
- 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:
695
- ```
696
- ⛔ BLOCK
697
- before.json → after.json · 3 ok · 2 healable · 1 lost · 2 new
698
- 🔧 healable button "Se connecter" button#login-btn → button.btn-primary (exact name)
699
- ⛔ lost button "Supprimer le compte" button#delete → (no match)
700
- ```
701
- 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`).
702
-
703
- **`--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`.
704
-
705
- 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`.
706
-
707
- 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).
708
-
709
- 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.
710
- 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. With **no arguments** it defaults to `baseline/` vs `mapping/`, exactly like `diff`. This is "prepare the ground, the human decides" as a command.
711
-
712
- **`--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.
713
- 6. **Runtime healing** — in your Playwright tests:
714
- ```ts
715
- import { aiClick, aiFill } from '@ia-qa/self-healing';
716
-
717
- await aiFill(page, 'input[name="email"]', user);
718
- await aiClick(page, 'button#login');
719
- ```
720
- 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.
721
-
722
- ## Optional AI add-on — semantic suggestions (`ia-qa-heal-ai`, BYOK)
723
-
724
- Everything above is deterministic and **refuses to guess**: a rename Dice can't see — `"Submit"` → `"Confirm order"` — comes back `lost`, and a human fixes it. That refusal is the point; it is what makes a green gate here mean something.
725
-
726
- `ia-qa-heal-ai` is an **optional, opt-in** third binary that hands **only** those `lost`/`ambiguous` rows to **your own** LLM (bring your own key) and proposes a match. It is a **suggestion you confirm** — never a CI auto-fix. The deterministic `ia-qa-heal` works fully without it; configure no key and nothing changes.
727
-
728
- > **There is no `--ai` flag on `ia-qa-heal`** — and there never will be: the loop and its CI verdict stay AI-free. The AI layer is always this separate binary. (`ia-qa-heal run --ai` errors and points you here.)
729
233
 
730
- **Why it stays safe** the same reason the deterministic engine is trusted:
731
- - The model **picks a live candidate by index** it cannot invent a selector, so the worst case is pointing at the wrong *existing* element, which you veto.
732
- - A **confidence floor** drops weak guesses; below it the row stays `lost`.
733
- - **Candidates are pre-filtered** to the same-role top-N nearest the original, so the model breaks a tie the heuristic couldn't — it does not free-search the DOM.
734
- - `--apply` **refuses without a TTY** — it can never run unattended in CI. The deterministic `fix` stays the gate.
735
- - **Zero new dependency** (a raw `fetch`), model pinned at temperature 0, key resolved at runtime from `env`/`aws-ssm` and **never written to disk** — same `SecretRef` model as every other secret here.
234
+ All three are **inert without `IAQA_CAPTURE=1`**, so they can live in the suite permanently,
235
+ and they never fail your tests. Each worker writes its own shard to
236
+ `.ia-qa/mapping/.capture/`; the next verb (`diff`, `run`, `baseline`) merges them into the same
237
+ page contracts `map` produces byte-compatible, named from `config.pages` so they pair with
238
+ your baseline.
736
239
 
737
- **Providers you pick from a list, the tool does the rest** (bring your own key):
240
+ **Any other framework or language.** The shard format *is* the interface; the helpers above are
241
+ sugar over it. Run the extractor in the page (`extract.js` in this package, also a copy-paste
242
+ DevTools snippet on ia-qa.com) and drop a JSON file into `.ia-qa/mapping/.capture/`:
738
243
 
739
- | Provider | `provider` | Example models | Default key env |
740
- |---|---|---|---|
741
- | OpenAI | `openai` | `gpt-4o-mini`, `gpt-4.1`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano` | `OPENAI_API_KEY` |
742
- | Anthropic (Claude) | `anthropic` | `claude-haiku-4-5`, `claude-sonnet-5`, `claude-opus-4-8` | `ANTHROPIC_API_KEY` |
743
- | Google (Gemini) | `google` | `gemini-2.0-flash`, `gemini-1.5-pro` | `GEMINI_API_KEY` |
744
-
745
- The model list is a convenience — **any model id your provider accepts works** (the picker has a "custom…" entry), so point releases like `gpt-5.1` or dated snapshots you just type in. The call runs at temperature 0 and **auto-retries without it** for models that reject sampling params (newest Claude/GPT reasoning models), so your choice always works. `ia-qa-heal-ai models` prints the full list.
746
-
747
- **Setup — the easy way:**
748
- ```bash
749
- npx ia-qa-heal-ai init # pick provider + model from a list → writes the "ai" block for you
750
- export OPENAI_API_KEY=sk-… # (or ANTHROPIC_API_KEY / GEMINI_API_KEY — whatever you chose)
751
- ```
752
- `init` only writes a *reference* to the env var into `.ia-qa/config.json` — never the key itself. Or add the block by hand:
753
244
  ```json
754
- {
755
- "ai": {
756
- "provider": "openai",
757
- "model": "gpt-4o-mini",
758
- "apiKey": { "source": "env", "key": "OPENAI_API_KEY" }
759
- }
760
- }
761
- ```
762
- `minConfidence` is optional (default `0.7`).
763
-
764
- **Use** — right after `diff`:
765
- ```bash
766
- npx ia-qa-heal-ai suggest # baseline/ vs mapping/, all pages
767
- npx ia-qa-heal-ai suggest before.json after.json # one pair
768
- npx ia-qa-heal-ai suggest --json # machine-readable, no writes
769
- npx ia-qa-heal-ai suggest --report r.html # branded HTML report to attach to a PR (add --open locally)
770
- npx ia-qa-heal-ai suggest --apply # rewrite accepted ones — interactive, TTY only
771
- ```
772
-
773
- `--report` writes the same self-contained, ia-qa.com-branded artefact as `ia-qa-heal diff --report` — but of the **AI proposals**, with a loud "these are suggestions, not applied changes" banner. Attach it to a PR for a reviewer to sign off on what the model proposed.
774
- ```
775
- 💡 lost button "Submit" (checkout)
776
- button#submit → button[data-testid="confirm"]
777
- proposes button "Confirm order" · confidence 91%
778
- ↳ same primary action of the checkout form, relabelled
779
- ```
780
-
781
- **For agents** — the same thing is the MCP tool `suggest_heal`: it reads its key from the server's own environment (`api_key_env`, default `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`) and **never accepts a raw key in the call**; it returns suggestions as JSON and writes nothing.
782
-
783
- **Runtime** — the same resolver behind the `aiClick`/`aiFill` seam, opt-in:
784
- ```ts
785
- import { aiClick, createAiResolver } from '@ia-qa/self-healing';
786
-
787
- await aiClick(page, 'button#login', {
788
- llmResolver: createAiResolver({ provider: 'anthropic', model: 'claude-…', apiKey: process.env.ANTHROPIC_API_KEY! }),
789
- });
790
- ```
791
- A low-confidence, malformed, or failed call returns `null` and falls through to the deterministic heuristic — the AI never breaks a run.
792
-
793
- See **[ROADMAP.md](ROADMAP.md)** for what comes next — and for why the vision/VLM phase was measured and dropped: no image ever leaves your machine, because none is ever sent anywhere.
794
-
795
- ## Page Object Models — one file to keep in sync
796
-
797
- 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.
798
-
799
- ```ts
800
- // login.page.ts — the only place a selector is written
801
- export class LoginPage {
802
- readonly emailSelector = '#email';
803
- readonly passwordSelector = '#password';
804
- readonly submitSelector = 'button#login';
805
- // used everywhere as page.fill(this.emailSelector, …)
806
- }
807
- ```
808
-
809
- A developer reworks the login form: `button#login` becomes `button.btn-primary`. The whole suite goes red through this one file. Heal it:
810
-
811
- ```bash
812
- npx ia-qa-heal map # capture the new contract
813
- npx ia-qa-heal diff # 🔧 healable button "Log in" button#login → button.btn-primary
814
- npx ia-qa-heal ingest # inventory the POM's selector literals (optional — lets fix find the file itself)
815
- npx ia-qa-heal fix .ia-qa/baseline/login.json .ia-qa/mapping/login.json login.page.ts
816
- ```
817
-
818
- ```diff
819
- export class LoginPage {
820
- readonly emailSelector = '#email';
821
- readonly passwordSelector = '#password';
822
- - readonly submitSelector = 'button#login';
823
- + readonly submitSelector = 'button.btn-primary';
824
- }
245
+ { "pages": [ { "page": "checkout", "url": "/checkout",
246
+ "elements": [ { "role": "button", "name": "Pay", "selector": "button#pay" } ] } ] }
825
247
  ```
826
248
 
827
- 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.
828
-
829
- **Naming does not matter, paths do.** `ingest` inventories a declaration whose value *reads* like a selector, whatever the property is called — `loginButton = '#login .cta'`, `submitSelector = '…'`, `LOGIN_LINK = "a[data-testid='login']"` (Python and Java page objects included) — as well as framework calls it already knows (`this.page.locator('#login')`). What it cannot do is find a file you never pointed it at: if your POMs live in `pages/` and `testPaths` says `tests/`, nothing there is inventoried, and `diff` will say *0 of N drifted selectors referenced by your tests* — which reads like good news and is not. It now says so; widen `testPaths` and re-run `ingest`.
830
-
831
- Neighbouring constants (`baseUrl`, a fixture path, a brand colour) are excluded, and a selector built by concatenation or holding `${…}` is invisible — to `fix` too, which is why it is skipped rather than guessed at.
249
+ No import, no Node required. Coverage is whatever the suite visits it is not a crawler, and a
250
+ page it never reached is `stale` in the next diff, never a pass.
832
251
 
833
252
  ## In CI
834
253
 
835
- `diff` returns an exit code, so a pipeline can gate on drift — `0` = PASS/FIX, `1` = BLOCK, `2` = bad input:
254
+ `diff` exits `0` on PASS/FIX, `1` on BLOCK, `2` on bad input:
836
255
 
837
256
  ```yaml
838
257
  # .github/workflows/selector-drift.yml
@@ -848,124 +267,143 @@ jobs:
848
267
  - run: npm ci
849
268
  - run: npx playwright install --with-deps chromium
850
269
  - run: npm start & # your app on its usual port
270
+ - run: npx ia-qa-heal ingest # inventory what your tests use
851
271
  - run: npx ia-qa-heal map # re-capture into .ia-qa/mapping/
852
- - run: npx ia-qa-heal ingest # inventory selectors AND names your tests use
853
272
  - run: npx ia-qa-heal diff # 0 = PASS/FIX · 1 = BLOCK · 2 = bad input
854
273
  ```
855
274
 
856
- `ingest` is what makes the gate see a rename your tests locate by name. Skip it and `diff` judges selectors only — which is what it did before 1.6.0, and what it still does for any project without a `usage.json`.
857
-
858
- `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](TUTORIAL.md#8-putting-it-in-ci)**.
859
-
860
- ### Trend charts for free — `--junit`
861
-
862
- `diff --junit` and `run --junit` write the verdict as **JUnit XML**, which Jenkins, GitLab, CircleCI, Azure DevOps and the GitHub Actions reporters all ingest natively — including the per-build history graph. You get the trend without this package rendering anything in CI.
863
-
864
- ```bash
865
- npx ia-qa-heal diff --junit reports/selector-drift.xml
866
- ```
867
-
868
- It is language-agnostic by construction: what it describes is your **contract**, not your suite, so a Cypress/JS, Selenium/Python or Playwright/.NET project produces identically shaped XML.
275
+ `diff` with no arguments compares the committed `.ia-qa/baseline/` against what `map` just
276
+ captured — which is why the baseline belongs in git. `--strict` fails on FIX too;
277
+ `--report drift.html` attaches a branded report to the build.
869
278
 
870
- | contract row | JUnit | why |
871
- |---|---|---|
872
- | `ok` | pass | the locator still finds the same element |
873
- | `renamed` | pass + `system-out` | the selector still works; the label moved |
874
- | `healable` | failure `type="healable"` | broken today auto-repairable, but broken |
875
- | `ambiguous` · `lost` · `rebound` | failure, typed | broken, and no deterministic repair exists |
876
- | name drift | failure `type="name-drift-*"` | a name-anchored locator names nothing now |
877
- | page not compared | **skipped** | not measured — reporting it as a pass would be the false green this tool exists to prevent |
279
+ **Trend charts for free.** `diff --junit reports/drift.xml` writes the verdict as JUnit XML,
280
+ which Jenkins, GitLab, CircleCI, Azure DevOps and the GitHub Actions reporters ingest natively,
281
+ per-build history included. It describes your *contract*, not your suite, so every language
282
+ produces identically shaped XML. `healable` and BLOCK-class rows are failures typed by status,
283
+ `renamed` passes with the label change in `system-out`, and a page that was not compared is
284
+ **`skipped` never a pass**.
878
285
 
879
- > ⚠️ **Publish it under its own file pattern**, separate from your suite's results. Merged into one trend, the two sets of numbers stop meaning anything.
286
+ > ⚠️ Publish it under its **own file pattern**, separate from your suite's results. Merged into
287
+ > one trend, both sets of numbers stop meaning anything.
880
288
 
881
- `run --json` (and `diff --json`) give the same verdict as one JSON document on stdout. For `run`, every other line moves to stderr so a pipe stays parseable.
289
+ `--json` on `diff`, `run`, `audit`, `check` and `history` gives the same verdict as one
290
+ parseable document. Locally, `ia-qa-heal history` charts what no single run can reconstruct —
291
+ and holds out runs captured a *different* way rather than averaging them in.
882
292
 
883
- ### The trend, locally — `ia-qa-heal history`
293
+ ## Configuration
884
294
 
885
- Every `diff` and `run` appends one line to `.ia-qa/history.jsonl`: verdict, counters, capture source, page coverage and the git commit. It is the one thing no single invocation can reconstruct afterwards, so it is recorded as it happens.
295
+ `.ia-qa/config.json`, written by `init`. The minimum:
886
296
 
297
+ ```jsonc
298
+ {
299
+ "baseUrl": "https://staging.myapp.com",
300
+ "pages": [
301
+ { "name": "checkout", "url": "/checkout" },
302
+ // a view with no URL of its own — reach it by naming the control to click
303
+ { "name": "billing", "url": "/account",
304
+ "steps": [ { "click": { "role": "tab", "name": "Billing" } } ] }
305
+ ],
306
+ "testPaths": ["tests/", "pages/"],
307
+ "testCommand": "npx playwright test"
308
+ }
887
309
  ```
888
- 📈 Run history · 6 recorded
889
- 6 comparable runs captured by `map` · 67% ended with drift · 100% mean coverage
890
-
891
- ⛔ BLOCK 8/9/26, 6:25 PM 3 drifted 2/2 pages
892
- ✅ PASS 8/9/26, 6:26 PM 0 drifted 2/2 pages
893
-
894
- 🔥 Where the churn lives
895
- checkout 13 drifted elements across 4 runs
896
- ```
897
-
898
- Runs captured **differently** — a `map` baseline versus a live suite capture — are held **out** of the trend and named, never averaged in: the two read a page differently, so charting both would invent a trend out of a capture artifact. Same for a run that covered far fewer pages than the rest.
899
-
900
- The file is small, deterministic text (unlike `_shots/`), so committing it is defensible if you want the trend to survive across machines and CI.
901
-
902
- Agents get the same numbers through the MCP tool **`heal_history`** — one `summarize()`, three renderings (terminal, console charts, MCP).
903
-
904
- ## Map artifacts
905
-
906
- 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.
907
310
 
908
- **`.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.
311
+ **Your password never goes in this file.** For a form login, `secrets` holds the *name* of an
312
+ env var (or an SSM parameter path) and `auth` points at it by name:
909
313
 
910
- **`_navigation.md` / `_navigation.svg`** — the navigation graph, written when pages link to each other, built from link `href`s. 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.
911
-
912
- 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:
913
- - **shell links** (navbar, footer) — in the overview, listed once, instead of an edge from every page to every navbar destination;
914
- - **isolated pages** a box with no arrow;
915
- - **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;
916
- - **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.
917
-
918
- 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.
919
-
920
- 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.
921
-
922
- **`.ia-qa/mapping/_shots/<page>.jpg`** — optional, written only by `ia-qa-heal map --screenshots`. One viewport JPEG per page, which `--report` then shows next to each page's name and URL, so a reader sees the view instead of only reading its path:
923
-
924
- ```bash
925
- ia-qa-heal map --screenshots # capture
926
- ia-qa-heal diff --report --open # the report now carries the pictures
314
+ ```jsonc
315
+ "secrets": { "user": { "source": "env", "key": "APP_USER" },
316
+ "pass": { "source": "env", "key": "APP_PASS" } },
317
+ "auth": { "loginUrl": "/login", "usernameSelector": "#email", "passwordSelector": "#password",
318
+ "submitSelector": "button[type=submit]", "usernameSecret": "user", "passwordSecret": "pass",
319
+ "successSelector": "nav.dashboard" }
927
320
  ```
928
321
 
929
- It is an **illustration, not evidence**: nothing diffs it, no verdict depends on it, and a report generated without it renders exactly as it did before. `diff` never opens a browser that is what keeps it offline and deterministic — so the picture has to be taken at capture time or not at all.
930
-
931
- Practicalities: viewport only, never `fullPage` (a 26 000-pixel page costs a lot to say little); JPEG rather than PNG because the report embeds it as a `data:` URI and stays a single self-contained file; the report stops embedding past a 6 MB budget rather than produce something no browser will open. **Gitignore `_shots/`** — it is a reproducible artefact, regenerated at different bytes on every run, and committing it is permanent binary churn.
932
-
933
- ## Design notes
322
+ Loading the env var is your job the CLI reads `process.env` and never opens a `.env` file, so
323
+ run it under `npx dotenv-cli --` or Node's `--env-file`. If a variable is missing it stops and
324
+ names it.
934
325
 
935
- - `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) → `name` → `aria-label` → `href` (links, skip `#`, `/`, `javascript:`) → `placeholder` (inputs) → structural path).
936
- - **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.
937
- - 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).
938
- - `createAiResolver` in `src/playwright/healer.ts` wires the BYOK AI resolver (`src/ai/resolver.ts`) into the `llmResolver` seam; `llmResolverStub` is kept as a no-op for backward compatibility. See [Optional AI add-on](#optional-ai-add-on--semantic-suggestions-ia-qa-heal-ai-byok).
939
- - **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.
940
- - **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.
326
+ Everything else is optional:
941
327
 
942
- ## Security
943
-
944
- ### MCP server: path sandboxing
945
-
946
- 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).
947
-
948
- ### MCP server: URL allowlist
949
-
950
- `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).
951
-
952
- ### Contract files are generated from live page content
328
+ | field | what it does |
329
+ |---|---|
330
+ | `session` | a Playwright `storageState` to reuse instead of logging in |
331
+ | `locale` | accessible names are language-dependent — set it if your app is not the system default |
332
+ | `volatile` | content that rotates by design (feed links, promos). Never enters a contract, so churn cannot read as drift |
333
+ | `nameMask` | labels carrying a counter or a clock (`"Cart (*)"`). Only the **label** stops being compared; role, selector and href still gate |
334
+ | `layouts` / `autoLayout` | shared shell extracted to `_layouts/`, diffed once instead of per page |
335
+ | `ai` | the BYOK add-on (see below) |
953
336
 
954
- 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.
337
+ `--config <dir>` (or `IAQA_CONFIG_DIR`) points at another `.ia-qa/`one per portal in a
338
+ monorepo. Every field is documented in full in the tutorial
339
+ (`TUTORIAL.md`, shipped in this package).
955
340
 
956
- ## Peer dependencies
341
+ ## The console — `ia-qa-heal ui`
957
342
 
958
- `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.
343
+ A local web console for the rows where the tool gave up: highlighted crops of each candidate,
344
+ the `file:line` of every test that breaks, the verbs as plain-language buttons, and the history
345
+ as charts. `--shortcut` writes a desktop launcher.
959
346
 
960
- `@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.
347
+ It is **loopback only** (`127.0.0.1`), every request carries a per-process token, the Host
348
+ header is pinned, and its buttons spawn this package's own CLI with a fixed argv — never a
349
+ string from the request. It **refuses to start under CI** (an interactive surface there is a
350
+ hung build) and stops itself once the browser tab goes away.
961
351
 
962
- ## Build from source
352
+ ## Optional AI add-on — `ia-qa-heal-ai` (BYOK)
963
353
 
964
- For work on the package itself installing from npm needs none of this.
354
+ Deterministic healing leaves `lost` and `ambiguous` rows behind on purpose. The add-on hands
355
+ *only those* to your own LLM and **suggests** a match with a confidence score:
965
356
 
966
357
  ```bash
967
- cd packages/self-healing
968
- npm run build # tsc → dist/
969
- node dist/cli/index.js init
970
- node dist/cli/index.js map
971
- ```
358
+ npx ia-qa-heal-ai init # pick a provider, name the env var holding your key
359
+ npx ia-qa-heal-ai suggest # print suggestions
360
+ npx ia-qa-heal-ai suggest --apply # apply, after you confirm — refuses without a TTY
361
+ ```
362
+
363
+ It never gates CI, and there is no `--ai` flag on `ia-qa-heal`: the verdict stays
364
+ deterministic by design. Your key stays in your environment; the prompt carries the contract
365
+ rows, nothing else.
366
+
367
+ ## What leaves your machine
368
+
369
+ Nothing, unless you enable the BYOK add-on above.
370
+
371
+ **MCP server: path sandboxing.** The CLI is driven by a human in their own shell; the MCP
372
+ server receives arguments from an agent — potentially from third-party content via prompt
373
+ injection. Every path parameter (`out_dir`, `before_path`, `after_path`, `test_paths`) is
374
+ validated: the resolved absolute path must stay inside the project root.
375
+ `IAQA_ALLOW_OUTSIDE_PATHS=1` lifts it. `browser_path` is logged to stderr for auditability.
376
+
377
+ **MCP server: URL allowlist.** `map_app` verifies the target URL shares the origin of
378
+ `config.json`'s `baseUrl`, so an agent cannot be steered toward an internal-only endpoint
379
+ reachable from your machine. `IAQA_ALLOW_ANY_URL=1` for multi-origin apps.
380
+
381
+ **Contracts are generated from live page content.** The `.md` and `.json` files contain text
382
+ extracted from the DOM — accessible names, headings, link destinations. The Markdown renderer
383
+ escapes pipes, newlines and backticks to prevent table breakage and code-block injection. If
384
+ you map an untrusted page (third-party widget, ad, XSS'd content), treat the output as
385
+ untrusted before feeding it to an LLM: the tool does not detect or strip instruction-like text.
386
+
387
+ **`.ia-qa/session.json` is a secret** — live cookies. Gitignored on creation.
388
+
389
+ ## Going deeper
390
+
391
+ - **The tutorial** — the whole thing, step by step, no jargon:
392
+ [ia-qa.com/devtools/selector-drift/tutorial](https://www.ia-qa.com/devtools/selector-drift/tutorial),
393
+ or `TUTORIAL.md` inside this package.
394
+ - **`ia-qa-heal skill`** — installs this package's agent instructions into
395
+ `<project>/.claude/skills/`, so an agent reads the contracts instead of opening a browser,
396
+ and knows the rules it must not break. Agents read skills from `.claude/skills/`, never from
397
+ `node_modules`, which is why this is a verb and not just a file in the tarball.
398
+ - **`ia-qa-heal-mcp`** — the stdio MCP server, for agents that prefer tool calls to a shell.
399
+ - **`SPEC-selector-resolution.md`** (shipped in the package) — how a Page Object's selectors are bound to the contract,
400
+ and why each refusal exists.
401
+ - **Peer dependencies** — `playwright` is a peer: your project provides it, so two installs
402
+ cannot drift to different browser revisions. `@aws-sdk/client-ssm` is an *optional* peer,
403
+ needed only by the `aws-ssm` secret source (~5 MB, imported lazily; the CLI tells you if you
404
+ pick it without).
405
+ - **Browser reuse** — it uses whatever Playwright browser is already on the machine before
406
+ asking you to download 150 MB. `IAQA_BROWSER_CHANNEL=chrome` (or `msedge`), or
407
+ `IAQA_BROWSER_PATH=/path/to/chrome`, to pin one.
408
+
409
+ MIT.