@ia-qa/self-healing 1.7.2 → 1.7.4

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,822 +1,240 @@
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 as `getByRole(…, { exact: true })` — not as fresher CSS paths —
23
+ then **re-runs the suite to verify** and exits on that verdict. Nothing is committed.
42
24
 
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
- ```
25
+ ▶ **[The full 49-second version](https://www.ia-qa.com/media/heal-demo.mp4)** also covers what
26
+ deterministic healing *refuses* to guess, the MCP server answering an agent, and the HTML
27
+ report `--report` writes for a PR.
50
28
 
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:
29
+ ## Install and run the loop
52
30
 
53
31
  ```bash
54
32
  npm i -D @ia-qa/self-healing
55
- npx ia-qa-heal init
33
+ npx ia-qa-heal init # interactive: writes .ia-qa/config.json
56
34
  ```
57
35
 
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):
36
+ Then, **in this order**:
63
37
 
64
38
  ```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
39
+ npx ia-qa-heal ingest # inventory the selectors and names your tests use
40
+ npx ia-qa-heal map # capture the app, and bind your selectors to it
41
+ npx ia-qa-heal baseline # promote that capture to "this is how it should be"
42
+ # someone changes the UI
43
+ npx ia-qa-heal map
44
+ npx ia-qa-heal diff # PASS / FIX / BLOCK
45
+ npx ia-qa-heal fix --dry-run
46
+ npx ia-qa-heal fix
69
47
  ```
70
48
 
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
-
79
- ```
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
49
+ `ingest` comes **first**, and it is not optional if your tests locate by CSS: it is what lets
50
+ `map` bind the strings your tests write to real elements. Re-run it when your test files
51
+ change `map` says so when they have. Commit `.ia-qa/` (except `session.json`): the baseline
52
+ is what CI compares against.
83
53
 
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
-
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
- ```
54
+ Three binaries ship together: `ia-qa-heal` (the loop), `ia-qa-heal-mcp` (stdio MCP server for
55
+ agents), `ia-qa-heal-ai` (optional BYOK suggestions never a gate).
166
56
 
167
- **The three things people ask about:**
57
+ ## Behind a login
168
58
 
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.
177
-
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.
59
+ Most apps worth mapping are behind one, and only some logins are a form.
184
60
 
185
61
  ```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
62
+ npx ia-qa-heal login # opens a real browser; you log in however the app asks
63
+ npx ia-qa-heal map # reuses that session
189
64
  ```
190
65
 
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
66
+ It models **nothing** about your login, which is why SSO, MFA, a consent screen and magic
67
+ links all work you perform them. What is saved is the browser session
68
+ (`.ia-qa/session.json`): live cookies, so treat it like a password. It stays on your machine,
69
+ `login` writes `.ia-qa/.gitignore` so a commit cannot carry it, and when it expires `map` says
70
+ so instead of quietly mapping the login page under your dashboard's name.
210
71
 
211
- An authenticated Playwright suite usually writes a `storageState` in `globalSetup`. Point at
212
- it and skip logging in a second time:
72
+ **Already have a session?** An authenticated Playwright suite usually writes a `storageState`
73
+ in `globalSetup` — point at it instead of logging in twice:
213
74
 
214
75
  ```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
76
+ npx ia-qa-heal map --session playwright/.auth/user.json
217
77
  ```
218
78
 
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.
79
+ Precedence: `--session` → `IAQA_SESSION` → `"session"` in config → what `login` wrote. A
80
+ session you *named* and that cannot be read stops the run; it never falls through to "no
81
+ session", which would capture the login page under your pages' names.
232
82
 
83
+ `login` needs a terminal and refuses under CI. There, use `auth` (a form fill, secrets from
84
+ env/SSM — see **Configuration**) or capture-during-run, where your own suite logs itself in.
233
85
 
234
- ### Monorepos one config per portal (`--config <dir>`)
86
+ ## Which verb answers which question
235
87
 
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:
237
-
238
- ```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
242
- ```
243
-
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.
88
+ | Your question | Verb |
89
+ |---|---|
90
+ | my tests broke — fix them | `run` (capture → diff → confirm → fix → re-run to verify) |
91
+ | did anything drift? | `diff` PASS/FIX/BLOCK, the CI gate |
92
+ | **my suite already ran with `IAQA_CAPTURE=1`** | **`diff`, on its own** merges the capture, no browser, no second run |
93
+ | day one, no baseline is my suite still valid? | `audit` (advisory; never gate on it) |
94
+ | is the app itself sound? | `check` — dead links, unnamed elements, name collisions, orphan pages |
95
+ | which pages am I *not* testing? | `discover` (`--sitemap`, or `--crawl` behind the login) |
96
+ | what do my tests actually use? | `ingest` |
97
+ | show me the app's structure | `graph --format mermaid\|svg\|json\|markdown` |
98
+ | is this getting better or worse? | `history` |
99
+ | let me look and decide myself | `ui` — a local console |
255
100
 
256
- ## Discover pages to map `ia-qa-heal discover`
101
+ `ia-qa-heal <verb> --help` for flags. On a slow suite, note that `run` runs it **twice** (once
102
+ to capture, once to verify the fix): `run --no-verify` keeps only the first, and
103
+ `IAQA_CAPTURE=1` on the run you already do in CI plus `ia-qa-heal diff` costs nothing extra.
257
104
 
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:
105
+ ## The verdict
259
106
 
260
107
  ```
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
-
108
+ 🔧 FIX 2 ok · 1 renamed · 3 healable · 0 ambiguous · 0 lost
265
109
  ```
266
110
 
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`.
111
+ - **PASS** nothing moved.
112
+ - **FIX** — every break has a deterministic old→new rewrite. `fix` applies it.
113
+ - **BLOCK** — a human decides. Nothing is rewritten.
268
114
 
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.
115
+ What `fix` **refuses** is the point of the tool:
270
116
 
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:
117
+ | | |
118
+ |---|---|
119
+ | `ambiguous` | several candidates — any choice is a coin flip |
120
+ | `lost` | the element is gone; there is nothing to rewrite towards |
121
+ | `rebound` | the selector now finds a *different* element — the test passes and acts on the wrong thing |
122
+ | `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 |
298
123
 
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
- ```
124
+ `--locators` rewrites to `getByRole(role, { name })` instead of a fresher CSS path, where that
125
+ is provably safe — a selector that survives the next layout change.
304
126
 
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`.
127
+ ## Page Objects written in CSS
306
128
 
307
- ## The console `ia-qa-heal ui`
129
+ Your Page Object says `.btn-primary`. The contract says `#pay-now`. Both name the same button,
130
+ and until they are **bound** every drift row reads *not referenced by your tests* —
131
+ technically true, useless in practice.
308
132
 
309
- The report tells you what drifted. The console lets you **act on it**.
133
+ So `map` binds them (with `usage.json` present run `ingest` first) and records the result in
134
+ `.ia-qa/mapping/_resolved/`. What that buys, on a diff where the element contract did not move
135
+ at all:
310
136
 
311
- ```bash
312
- npx ia-qa-heal ui
313
137
  ```
138
+ 🔧 FIX
139
+ 2 ok · 0 renamed · 0 healable · 0 lost ← the contract is spotless
314
140
 
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
141
+ 🔗 1 selector your tests write no longer reaches what it used to:
142
+ 🔧 .btn-primary → #pay-now
143
+ the element is still there, only its address moved.
144
+ tests/checkout.spec.js:1, tests/checkout.spec.js:4
319
145
  ```
320
146
 
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.
328
-
329
- **How it is kept safe**, because a server on a QA's machine is not free:
147
+ Somebody renamed a CSS class. The element diff is clean, the suite is broken, and `fix`
148
+ rewrites the literal in your files. Four outcomes, one repaired automatically:
330
149
 
331
150
  | | |
332
151
  |---|---|
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.
152
+ | reaches **nothing**, and its element is still there | 🔧 rewritten |
153
+ | reaches a **different** element | never rewritten your test passes and acts on the wrong thing |
154
+ | reaches **several** elements | a coin flip |
155
+ | its element is **gone** | nothing to rewrite towards |
355
156
 
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.
157
+ **Your app may already carry the answer.** The contract holds interactive elements only, so a
158
+ selector on a status div, a cell or an alert is not in it. Usually that stays quiet — such an
159
+ element has no identity independent of what drifts, so nothing could be repaired anyway. But
160
+ when it *does* carry one (`data-testid`, `id`, `aria-label`), the repair is exact:
357
161
 
358
- ```bash
359
- ia-qa-heal ui --forget # clear the whole list
360
- ia-qa-heal ui --forget ../shop # drop one project
361
162
  ```
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
163
+ 🔧 .infoBlockContainer > .alert → [data-testid="cart-alert"]
375
164
  ```
376
165
 
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.
166
+ That is the common shape of a legacy suite: the front end added test ids years after the Page
167
+ Objects were written and nobody propagated them. It cannot be noisy — an element with a test
168
+ id has a stable identity by construction, and an anchor matching two elements is refused.
382
169
 
383
- ## The one-verb loop: `ia-qa-heal run`
384
-
385
- `run` chains the whole loop and stops where a human belongs:
170
+ **How much of your suite is in reach?** `map` says so at the end, over the union of the pages
171
+ it just mapped — the number nobody can add up by hand, because the same string appears on
172
+ several pages:
386
173
 
387
174
  ```
388
- capture ingest diff dry-run of the fixes your confirmation → fix → re-run to verify
175
+ 🔗 Your 181 inventoried selectors, against the pages just mapped:
176
+ 34 repairable reach a contracted element, or an identity this tool can rewrite towards
177
+ 52 working reach something real with no stable identity — fine today, nothing to repair towards
178
+ 95 not seen on any page mapped this run — which is about your coverage, not your tests
389
179
  ```
390
180
 
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
- ```
181
+ Read the composition, not the ratio. `working` selectors are **fine**. And `not seen` is a
182
+ statement about how many pages you map: a selector on a page absent from `config.pages`, or in
183
+ a state no capture reached, looks exactly like one that is gone. `ia-qa-heal discover` lists
184
+ what you are missing.
405
185
 
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.
186
+ No `usage.json` no bindings nothing changes. Same rule as name drift: **no inventory, no
187
+ escalation.**
409
188
 
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.
189
+ ## Capture during your own test run
413
190
 
414
- ### Capture during your test run`@ia-qa/self-healing/capture`
191
+ `map` walks URLs. Your suite walks *states* post-login pages, modals, wizard steps — and
192
+ already handles whatever auth your app demands. Arm it with `IAQA_CAPTURE=1` and the contract
193
+ is captured during the real run. **This is the intended path for a multi-domain / federated-SSO
194
+ app**, and it never touches your credentials.
415
195
 
416
- One line at the top of a spec (or wrap your own extended `test` with `withCapture`):
196
+ **Playwright** one import at the top of a spec (or wrap your own extended `test` with
197
+ `withCapture`):
417
198
 
418
199
  ```ts
419
200
  import { test, expect } from '@ia-qa/self-healing/capture';
420
201
  ```
421
202
 
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`:
203
+ **Cypress**one import in `cypress/support/e2e.js`, nothing to wire in `setupNodeEvents`:
427
204
 
428
205
  ```js
429
206
  import '@ia-qa/self-healing/cypress';
430
207
  ```
431
208
 
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.
209
+ **Selenium (Node)** you call it yourself, since there is nothing to hook:
445
210
 
446
211
  ```js
447
212
  const { capture } = require('@ia-qa/self-healing/selenium');
448
-
449
213
  await driver.get('https://app.example.com/checkout');
450
214
  await capture(driver); // inert unless IAQA_CAPTURE=1
451
215
  ```
452
216
 
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.
217
+ All three are **inert without `IAQA_CAPTURE=1`**, so they can live in the suite permanently,
218
+ and they never fail your tests. Each worker writes its own shard to
219
+ `.ia-qa/mapping/.capture/`; the next verb (`diff`, `run`, `baseline`) merges them into the same
220
+ page contracts `map` produces — byte-compatible, named from `config.pages` so they pair with
221
+ your baseline.
454
222
 
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:
223
+ **Any other framework or language.** The shard format *is* the interface; the helpers above are
224
+ sugar over it. Run the extractor in the page (`extract.js` in this package, also a copy-paste
225
+ DevTools snippet on ia-qa.com) and drop a JSON file into `.ia-qa/mapping/.capture/`:
458
226
 
459
227
  ```json
460
228
  { "pages": [ { "page": "checkout", "url": "/checkout",
461
229
  "elements": [ { "role": "button", "name": "Pay", "selector": "button#pay" } ] } ] }
462
230
  ```
463
231
 
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.
478
-
479
- ### Page Objects written in CSS — `_resolved/`
480
-
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.
485
-
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:
492
-
493
- ```
494
- 🔧 FIX
495
- 2 ok · 0 renamed · 0 healable · 0 lost ← the contract did not drift at all
496
-
497
- 🔗 1 selector your tests write no longer reaches what it used to:
498
- 🔧 .btn-primary → #pay-now
499
- the element is still there, only its address moved.
500
- tests/checkout.spec.js:1, tests/checkout.spec.js:4
501
- ```
502
-
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:
507
-
508
- | | |
509
- |---|---|
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:
521
-
522
- ```
523
- 🔧 .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
- ```
527
-
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
- No `usage.json` ⇒ no bindings ⇒ nothing changes. Same rule as name drift: **no inventory,
533
- no escalation.** Today the bindings are captured by `map`; capture-during-run is specified
534
- in `SPEC-selector-resolution.md` and not built yet.
535
-
536
- ### The rename your suite actually depends on
537
-
538
- 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.
539
-
540
- Once `ingest` has run, `diff` cross-references every rename against what your tests reference:
541
-
542
- | Situation | Verdict | What `fix` does |
543
- |---|---|---|
544
- | No test names that label | unchanged (`PASS`) | nothing — adopting this adds no noise |
545
- | One element carries the new label | **FIX** | rewrites the call: `getByRole('button', { name: 'Enregistrer' })` |
546
- | The **old** label now sits on another element | **BLOCK** | nothing — your locator would silently target the wrong element (the name-space twin of `rebound`) |
547
- | Several elements carry the new label | **BLOCK** | nothing — a human picks |
548
- | The reference is text-anchored (see below) | **BLOCK** | nothing — reported with its `file:line` |
549
-
550
- **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:
551
-
552
- | Rewritten | Reported, never rewritten |
553
- |---|---|
554
- | `getByRole` / `findByRole` / `queryByRole` (Playwright, Testing Library, `@testing-library/cypress`) | `getByText` / `findByText` |
555
- | `getByLabel` · `getByLabelText` · `getByPlaceholder(Text)` — the role is implied | `cy.contains(…)`, both forms |
556
- | `By.linkText` / `By.LINK_TEXT` / `By.PARTIAL_LINK_TEXT` (Selenium: role = link) | `getByTitle` · `getByAltText` |
557
-
558
- `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.
559
-
560
- Without `usage.json` nothing is escalated at all, so an un-ingested project behaves exactly as before.
561
-
562
- ### Is the app itself sound? — `ia-qa-heal check`
563
-
564
- ```bash
565
- npx ia-qa-heal check # dead links + static checks
566
- npx ia-qa-heal check --offline # skip the network half, fully local
567
- npx ia-qa-heal check --strict # fail the pipeline on warnings too
568
- npx ia-qa-heal check --concurrency 2 # gentler on a target with a strict rate limiter
569
- ```
570
-
571
- `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.
572
-
573
- 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:
574
-
575
- | Check | Fails when | Verdict |
576
- |---|---|---|
577
- | **dead links** | the app answered 4xx/5xx (401/403 excluded — that is a working link behind auth; 429 excluded — see below) | **FAIL** |
578
- | **unnamed** | an interactive element has no accessible name: no screen-reader announcement, no `getByRole`, no healing | WARN |
579
- | **ambiguous** | two elements share role + name — every name-anchored locator for them is a coin flip, and a strict-mode violation waiting | WARN |
580
- | **orphans** | a mapped page nothing links to | WARN |
581
- | **unverified** | the request was throttled (429) or never answered — no verdict was reached | WARN |
582
-
583
- 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.
584
-
585
- **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.
586
-
587
- **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.
588
-
589
- **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.
590
-
591
- ### Day one, no baseline — `ia-qa-heal audit`
592
-
593
- ```bash
594
- npx ia-qa-heal audit tests/ # advisory, exit 0
595
- npx ia-qa-heal audit --strict # exit 1 on a finding
596
- ```
597
-
598
- 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.
599
-
600
- Deliberately conservative, and it says so out loud:
601
-
602
- - 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.
603
- - **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.
604
- - Read-only. `audit` never edits and never applies a suggestion.
605
-
606
- ### Elements with no accessible name
607
-
608
- 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`.
609
-
610
- ## Two front doors, one engine
611
-
612
- | | For | Entry point |
613
- |---|---|---|
614
- | **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) |
615
- | **CLI** | humans & CI | `ia-qa-heal` → `init`, `discover`, `map`, `baseline`, `diff`, `fix`, `ingest`, `audit`, `run` |
616
- | **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) |
617
-
618
- ### Locale
619
-
620
- 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:
621
-
622
- ```bash
623
- # env var — works for both CLI and MCP paths
624
- export IAQA_LOCALE=fr-FR # or en-US, de-DE, ja-JP…
625
-
626
- # or in .ia-qa/config.json:
627
- { "locale": "fr-FR", … }
628
- ```
629
-
630
- 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.
631
-
632
- Agent config (Claude Code / Desktop, Cursor…):
633
- ```json
634
- {
635
- "mcpServers": {
636
- "ia-qa-self-healing": {
637
- "command": "npx",
638
- "args": ["-y", "-p", "@ia-qa/self-healing", "ia-qa-heal-mcp"]
639
- }
640
- }
641
- }
642
- ```
643
- `-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.
644
- 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.
645
-
646
- ### Browser reuse — no second Chromium (`src/launcher.ts`)
647
- 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:
648
-
649
- 1. explicit executable — `browserPath` / `$IAQA_BROWSER_PATH`
650
- 2. explicit channel — `browserChannel` / `$IAQA_BROWSER_CHANNEL` (`chrome`, `msedge`…) → **zero download**
651
- 3. Playwright's bundled Chromium (shared cache — usually already present)
652
- 4. automatic fallback to the system Chrome, then Edge
653
-
654
- 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.
655
-
656
- ## How it works
657
-
658
- 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).)
659
- 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):
660
- ```json
661
- {
662
- "page": "login",
663
- "url": "/login",
664
- "capturedAt": "2026-07-15T10:00:00.000Z",
665
- "elements": [
666
- { "role": "button", "name": "Se connecter", "selector": "button#login" }
667
- ]
668
- }
669
- ```
670
- 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.
671
-
672
- Alongside the page contracts, `map` rebuilds the app index and the navigation graph — see [Map artifacts](#map-artifacts).
673
- 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.
674
-
675
- 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.
676
-
677
- 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.
678
- 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:
679
- ```
680
- ⛔ BLOCK
681
- before.json → after.json · 3 ok · 2 healable · 1 lost · 2 new
682
- 🔧 healable button "Se connecter" button#login-btn → button.btn-primary (exact name)
683
- ⛔ lost button "Supprimer le compte" button#delete → (no match)
684
- ```
685
- 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`).
686
-
687
- **`--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`.
688
-
689
- 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`.
690
-
691
- 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).
692
-
693
- 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.
694
- 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.
695
-
696
- **`--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.
697
- 6. **Runtime healing** — in your Playwright tests:
698
- ```ts
699
- import { aiClick, aiFill } from '@ia-qa/self-healing';
700
-
701
- await aiFill(page, 'input[name="email"]', user);
702
- await aiClick(page, 'button#login');
703
- ```
704
- 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.
705
-
706
- ## Optional AI add-on — semantic suggestions (`ia-qa-heal-ai`, BYOK)
707
-
708
- 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.
709
-
710
- `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.
711
-
712
- > **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.)
713
-
714
- **Why it stays safe** — the same reason the deterministic engine is trusted:
715
- - 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.
716
- - A **confidence floor** drops weak guesses; below it the row stays `lost`.
717
- - **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.
718
- - `--apply` **refuses without a TTY** — it can never run unattended in CI. The deterministic `fix` stays the gate.
719
- - **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.
720
-
721
- **Providers — you pick from a list, the tool does the rest** (bring your own key):
722
-
723
- | Provider | `provider` | Example models | Default key env |
724
- |---|---|---|---|
725
- | OpenAI | `openai` | `gpt-4o-mini`, `gpt-4.1`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano` | `OPENAI_API_KEY` |
726
- | Anthropic (Claude) | `anthropic` | `claude-haiku-4-5`, `claude-sonnet-5`, `claude-opus-4-8` | `ANTHROPIC_API_KEY` |
727
- | Google (Gemini) | `google` | `gemini-2.0-flash`, `gemini-1.5-pro` | `GEMINI_API_KEY` |
728
-
729
- 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.
730
-
731
- **Setup — the easy way:**
732
- ```bash
733
- npx ia-qa-heal-ai init # pick provider + model from a list → writes the "ai" block for you
734
- export OPENAI_API_KEY=sk-… # (or ANTHROPIC_API_KEY / GEMINI_API_KEY — whatever you chose)
735
- ```
736
- `init` only writes a *reference* to the env var into `.ia-qa/config.json` — never the key itself. Or add the block by hand:
737
- ```json
738
- {
739
- "ai": {
740
- "provider": "openai",
741
- "model": "gpt-4o-mini",
742
- "apiKey": { "source": "env", "key": "OPENAI_API_KEY" }
743
- }
744
- }
745
- ```
746
- `minConfidence` is optional (default `0.7`).
747
-
748
- **Use** — right after `diff`:
749
- ```bash
750
- npx ia-qa-heal-ai suggest # baseline/ vs mapping/, all pages
751
- npx ia-qa-heal-ai suggest before.json after.json # one pair
752
- npx ia-qa-heal-ai suggest --json # machine-readable, no writes
753
- npx ia-qa-heal-ai suggest --report r.html # branded HTML report to attach to a PR (add --open locally)
754
- npx ia-qa-heal-ai suggest --apply # rewrite accepted ones — interactive, TTY only
755
- ```
756
-
757
- `--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.
758
- ```
759
- 💡 lost button "Submit" (checkout)
760
- button#submit → button[data-testid="confirm"]
761
- proposes button "Confirm order" · confidence 91%
762
- ↳ same primary action of the checkout form, relabelled
763
- ```
764
-
765
- **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.
766
-
767
- **Runtime** — the same resolver behind the `aiClick`/`aiFill` seam, opt-in:
768
- ```ts
769
- import { aiClick, createAiResolver } from '@ia-qa/self-healing';
770
-
771
- await aiClick(page, 'button#login', {
772
- llmResolver: createAiResolver({ provider: 'anthropic', model: 'claude-…', apiKey: process.env.ANTHROPIC_API_KEY! }),
773
- });
774
- ```
775
- A low-confidence, malformed, or failed call returns `null` and falls through to the deterministic heuristic — the AI never breaks a run.
776
-
777
- 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.
778
-
779
- ## Page Object Models — one file to keep in sync
780
-
781
- 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.
782
-
783
- ```ts
784
- // login.page.ts — the only place a selector is written
785
- export class LoginPage {
786
- readonly emailSelector = '#email';
787
- readonly passwordSelector = '#password';
788
- readonly submitSelector = 'button#login';
789
- // used everywhere as page.fill(this.emailSelector, …)
790
- }
791
- ```
792
-
793
- A developer reworks the login form: `button#login` becomes `button.btn-primary`. The whole suite goes red through this one file. Heal it:
794
-
795
- ```bash
796
- npx ia-qa-heal map # capture the new contract
797
- npx ia-qa-heal diff # 🔧 healable button "Log in" button#login → button.btn-primary
798
- npx ia-qa-heal ingest # inventory the POM's selector literals (optional — lets fix find the file itself)
799
- npx ia-qa-heal fix .ia-qa/baseline/login.json .ia-qa/mapping/login.json login.page.ts
800
- ```
801
-
802
- ```diff
803
- export class LoginPage {
804
- readonly emailSelector = '#email';
805
- readonly passwordSelector = '#password';
806
- - readonly submitSelector = 'button#login';
807
- + readonly submitSelector = 'button.btn-primary';
808
- }
809
- ```
810
-
811
- 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.
812
-
813
- **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`.
814
-
815
- 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.
232
+ No import, no Node required. Coverage is whatever the suite visits — it is not a crawler, and a
233
+ page it never reached is `stale` in the next diff, never a pass.
816
234
 
817
235
  ## In CI
818
236
 
819
- `diff` returns an exit code, so a pipeline can gate on drift — `0` = PASS/FIX, `1` = BLOCK, `2` = bad input:
237
+ `diff` exits `0` on PASS/FIX, `1` on BLOCK, `2` on bad input:
820
238
 
821
239
  ```yaml
822
240
  # .github/workflows/selector-drift.yml
@@ -832,124 +250,143 @@ jobs:
832
250
  - run: npm ci
833
251
  - run: npx playwright install --with-deps chromium
834
252
  - run: npm start & # your app on its usual port
253
+ - run: npx ia-qa-heal ingest # inventory what your tests use
835
254
  - run: npx ia-qa-heal map # re-capture into .ia-qa/mapping/
836
- - run: npx ia-qa-heal ingest # inventory selectors AND names your tests use
837
255
  - run: npx ia-qa-heal diff # 0 = PASS/FIX · 1 = BLOCK · 2 = bad input
838
256
  ```
839
257
 
840
- `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`.
841
-
842
- `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)**.
843
-
844
- ### Trend charts for free — `--junit`
845
-
846
- `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.
847
-
848
- ```bash
849
- npx ia-qa-heal diff --junit reports/selector-drift.xml
850
- ```
851
-
852
- 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.
258
+ `diff` with no arguments compares the committed `.ia-qa/baseline/` against what `map` just
259
+ captured — which is why the baseline belongs in git. `--strict` fails on FIX too;
260
+ `--report drift.html` attaches a branded report to the build.
853
261
 
854
- | contract row | JUnit | why |
855
- |---|---|---|
856
- | `ok` | pass | the locator still finds the same element |
857
- | `renamed` | pass + `system-out` | the selector still works; the label moved |
858
- | `healable` | failure `type="healable"` | broken today auto-repairable, but broken |
859
- | `ambiguous` · `lost` · `rebound` | failure, typed | broken, and no deterministic repair exists |
860
- | name drift | failure `type="name-drift-*"` | a name-anchored locator names nothing now |
861
- | page not compared | **skipped** | not measured — reporting it as a pass would be the false green this tool exists to prevent |
262
+ **Trend charts for free.** `diff --junit reports/drift.xml` writes the verdict as JUnit XML,
263
+ which Jenkins, GitLab, CircleCI, Azure DevOps and the GitHub Actions reporters ingest natively,
264
+ per-build history included. It describes your *contract*, not your suite, so every language
265
+ produces identically shaped XML. `healable` and BLOCK-class rows are failures typed by status,
266
+ `renamed` passes with the label change in `system-out`, and a page that was not compared is
267
+ **`skipped` never a pass**.
862
268
 
863
- > ⚠️ **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.
269
+ > ⚠️ Publish it under its **own file pattern**, separate from your suite's results. Merged into
270
+ > one trend, both sets of numbers stop meaning anything.
864
271
 
865
- `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.
272
+ `--json` on `diff`, `run`, `audit`, `check` and `history` gives the same verdict as one
273
+ parseable document. Locally, `ia-qa-heal history` charts what no single run can reconstruct —
274
+ and holds out runs captured a *different* way rather than averaging them in.
866
275
 
867
- ### The trend, locally — `ia-qa-heal history`
276
+ ## Configuration
868
277
 
869
- 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.
278
+ `.ia-qa/config.json`, written by `init`. The minimum:
870
279
 
280
+ ```jsonc
281
+ {
282
+ "baseUrl": "https://staging.myapp.com",
283
+ "pages": [
284
+ { "name": "checkout", "url": "/checkout" },
285
+ // a view with no URL of its own — reach it by naming the control to click
286
+ { "name": "billing", "url": "/account",
287
+ "steps": [ { "click": { "role": "tab", "name": "Billing" } } ] }
288
+ ],
289
+ "testPaths": ["tests/", "pages/"],
290
+ "testCommand": "npx playwright test"
291
+ }
871
292
  ```
872
- 📈 Run history · 6 recorded
873
- 6 comparable runs captured by `map` · 67% ended with drift · 100% mean coverage
874
-
875
- ⛔ BLOCK 8/9/26, 6:25 PM 3 drifted 2/2 pages
876
- ✅ PASS 8/9/26, 6:26 PM 0 drifted 2/2 pages
877
-
878
- 🔥 Where the churn lives
879
- checkout 13 drifted elements across 4 runs
880
- ```
881
-
882
- 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.
883
-
884
- The file is small, deterministic text (unlike `_shots/`), so committing it is defensible if you want the trend to survive across machines and CI.
885
-
886
- Agents get the same numbers through the MCP tool **`heal_history`** — one `summarize()`, three renderings (terminal, console charts, MCP).
887
-
888
- ## Map artifacts
889
-
890
- 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.
891
293
 
892
- **`.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.
294
+ **Your password never goes in this file.** For a form login, `secrets` holds the *name* of an
295
+ env var (or an SSM parameter path) and `auth` points at it by name:
893
296
 
894
- **`_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.
895
-
896
- 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:
897
- - **shell links** (navbar, footer) — in the overview, listed once, instead of an edge from every page to every navbar destination;
898
- - **isolated pages** a box with no arrow;
899
- - **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;
900
- - **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.
901
-
902
- 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.
903
-
904
- 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.
905
-
906
- **`.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:
907
-
908
- ```bash
909
- ia-qa-heal map --screenshots # capture
910
- ia-qa-heal diff --report --open # the report now carries the pictures
297
+ ```jsonc
298
+ "secrets": { "user": { "source": "env", "key": "APP_USER" },
299
+ "pass": { "source": "env", "key": "APP_PASS" } },
300
+ "auth": { "loginUrl": "/login", "usernameSelector": "#email", "passwordSelector": "#password",
301
+ "submitSelector": "button[type=submit]", "usernameSecret": "user", "passwordSecret": "pass",
302
+ "successSelector": "nav.dashboard" }
911
303
  ```
912
304
 
913
- 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.
914
-
915
- 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.
916
-
917
- ## Design notes
305
+ Loading the env var is your job the CLI reads `process.env` and never opens a `.env` file, so
306
+ run it under `npx dotenv-cli --` or Node's `--env-file`. If a variable is missing it stops and
307
+ names it.
918
308
 
919
- - `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).
920
- - **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.
921
- - 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).
922
- - `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).
923
- - **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.
924
- - **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.
309
+ Everything else is optional:
925
310
 
926
- ## Security
927
-
928
- ### MCP server: path sandboxing
929
-
930
- 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).
931
-
932
- ### MCP server: URL allowlist
933
-
934
- `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).
935
-
936
- ### Contract files are generated from live page content
311
+ | field | what it does |
312
+ |---|---|
313
+ | `session` | a Playwright `storageState` to reuse instead of logging in |
314
+ | `locale` | accessible names are language-dependent — set it if your app is not the system default |
315
+ | `volatile` | content that rotates by design (feed links, promos). Never enters a contract, so churn cannot read as drift |
316
+ | `nameMask` | labels carrying a counter or a clock (`"Cart (*)"`). Only the **label** stops being compared; role, selector and href still gate |
317
+ | `layouts` / `autoLayout` | shared shell extracted to `_layouts/`, diffed once instead of per page |
318
+ | `ai` | the BYOK add-on (see below) |
937
319
 
938
- 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.
320
+ `--config <dir>` (or `IAQA_CONFIG_DIR`) points at another `.ia-qa/`one per portal in a
321
+ monorepo. Every field is documented in full in the tutorial
322
+ (`TUTORIAL.md`, shipped in this package).
939
323
 
940
- ## Peer dependencies
324
+ ## The console — `ia-qa-heal ui`
941
325
 
942
- `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.
326
+ A local web console for the rows where the tool gave up: highlighted crops of each candidate,
327
+ the `file:line` of every test that breaks, the verbs as plain-language buttons, and the history
328
+ as charts. `--shortcut` writes a desktop launcher.
943
329
 
944
- `@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.
330
+ It is **loopback only** (`127.0.0.1`), every request carries a per-process token, the Host
331
+ header is pinned, and its buttons spawn this package's own CLI with a fixed argv — never a
332
+ string from the request. It **refuses to start under CI** (an interactive surface there is a
333
+ hung build) and stops itself once the browser tab goes away.
945
334
 
946
- ## Build from source
335
+ ## Optional AI add-on — `ia-qa-heal-ai` (BYOK)
947
336
 
948
- For work on the package itself installing from npm needs none of this.
337
+ Deterministic healing leaves `lost` and `ambiguous` rows behind on purpose. The add-on hands
338
+ *only those* to your own LLM and **suggests** a match with a confidence score:
949
339
 
950
340
  ```bash
951
- cd packages/self-healing
952
- npm run build # tsc → dist/
953
- node dist/cli/index.js init
954
- node dist/cli/index.js map
955
- ```
341
+ npx ia-qa-heal-ai init # pick a provider, name the env var holding your key
342
+ npx ia-qa-heal-ai suggest # print suggestions
343
+ npx ia-qa-heal-ai suggest --apply # apply, after you confirm — refuses without a TTY
344
+ ```
345
+
346
+ It never gates CI, and there is no `--ai` flag on `ia-qa-heal`: the verdict stays
347
+ deterministic by design. Your key stays in your environment; the prompt carries the contract
348
+ rows, nothing else.
349
+
350
+ ## What leaves your machine
351
+
352
+ Nothing, unless you enable the BYOK add-on above.
353
+
354
+ **MCP server: path sandboxing.** The CLI is driven by a human in their own shell; the MCP
355
+ server receives arguments from an agent — potentially from third-party content via prompt
356
+ injection. Every path parameter (`out_dir`, `before_path`, `after_path`, `test_paths`) is
357
+ validated: the resolved absolute path must stay inside the project root.
358
+ `IAQA_ALLOW_OUTSIDE_PATHS=1` lifts it. `browser_path` is logged to stderr for auditability.
359
+
360
+ **MCP server: URL allowlist.** `map_app` verifies the target URL shares the origin of
361
+ `config.json`'s `baseUrl`, so an agent cannot be steered toward an internal-only endpoint
362
+ reachable from your machine. `IAQA_ALLOW_ANY_URL=1` for multi-origin apps.
363
+
364
+ **Contracts are generated from live page content.** The `.md` and `.json` files contain text
365
+ extracted from the DOM — accessible names, headings, link destinations. The Markdown renderer
366
+ escapes pipes, newlines and backticks to prevent table breakage and code-block injection. If
367
+ you map an untrusted page (third-party widget, ad, XSS'd content), treat the output as
368
+ untrusted before feeding it to an LLM: the tool does not detect or strip instruction-like text.
369
+
370
+ **`.ia-qa/session.json` is a secret** — live cookies. Gitignored on creation.
371
+
372
+ ## Going deeper
373
+
374
+ - **The tutorial** — the whole thing, step by step, no jargon:
375
+ [ia-qa.com/devtools/selector-drift/tutorial](https://www.ia-qa.com/devtools/selector-drift/tutorial),
376
+ or `TUTORIAL.md` inside this package.
377
+ - **`ia-qa-heal skill`** — installs this package's agent instructions into
378
+ `<project>/.claude/skills/`, so an agent reads the contracts instead of opening a browser,
379
+ and knows the rules it must not break. Agents read skills from `.claude/skills/`, never from
380
+ `node_modules`, which is why this is a verb and not just a file in the tarball.
381
+ - **`ia-qa-heal-mcp`** — the stdio MCP server, for agents that prefer tool calls to a shell.
382
+ - **`SPEC-selector-resolution.md`** (shipped in the package) — how a Page Object's selectors are bound to the contract,
383
+ and why each refusal exists.
384
+ - **Peer dependencies** — `playwright` is a peer: your project provides it, so two installs
385
+ cannot drift to different browser revisions. `@aws-sdk/client-ssm` is an *optional* peer,
386
+ needed only by the `aws-ssm` secret source (~5 MB, imported lazily; the CLI tells you if you
387
+ pick it without).
388
+ - **Browser reuse** — it uses whatever Playwright browser is already on the machine before
389
+ asking you to download 150 MB. `IAQA_BROWSER_CHANNEL=chrome` (or `msedge`), or
390
+ `IAQA_BROWSER_PATH=/path/to/chrome`, to pin one.
391
+
392
+ MIT.