@ia-qa/self-healing 1.7.11 → 1.7.14
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 +28 -0
- package/TUTORIAL.md +44 -1
- package/dist/aom.d.ts +54 -0
- package/dist/aom.js +32 -0
- package/dist/aom.js.map +1 -1
- package/dist/browser/openables.d.ts +45 -0
- package/dist/browser/openables.js +128 -0
- package/dist/browser/openables.js.map +1 -0
- package/dist/cli/args.js +1 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/diff.d.ts +25 -0
- package/dist/cli/diff.js +30 -2
- package/dist/cli/diff.js.map +1 -1
- package/dist/cli/index.js +44 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/map.d.ts +2 -0
- package/dist/cli/map.js +75 -4
- package/dist/cli/map.js.map +1 -1
- package/dist/explore.d.ts +138 -0
- package/dist/explore.js +224 -0
- package/dist/explore.js.map +1 -0
- package/dist/htmlReport.js +15 -0
- package/dist/htmlReport.js.map +1 -1
- package/dist/mcp/server.js +12 -0
- package/dist/mcp/server.js.map +1 -1
- package/package.json +1 -1
- package/skills/ia-qa-heal/SKILL.md +25 -0
package/README.md
CHANGED
|
@@ -130,6 +130,7 @@ env/SSM — see **Configuration**) or capture-during-run, where your own suite l
|
|
|
130
130
|
| day one, no baseline — is my suite still valid? | `audit` (advisory; never gate on it) |
|
|
131
131
|
| is the app itself sound? | `check` — dead links, unnamed elements, name collisions, orphan pages |
|
|
132
132
|
| which pages am I *not* testing? | `discover` (`--sitemap`, or `--crawl` behind the login) |
|
|
133
|
+
| **my menus, tabs and modals aren't in the contract** | **`map --deep`** — opens them and puts what they reveal in the same contract |
|
|
133
134
|
| what do my tests actually use? | `ingest` |
|
|
134
135
|
| show me the app's structure | `graph --format mermaid\|svg\|json\|markdown` |
|
|
135
136
|
| is this getting better or worse? | `history` |
|
|
@@ -208,6 +209,33 @@ extensions and the code paths are exercised, but the test suite's rewrite cases
|
|
|
208
209
|
against `.js`/`.ts` files. Treat non-JS rewriting as working-but-less-travelled: run
|
|
209
210
|
`fix --dry-run` first, which you should be doing anyway.
|
|
210
211
|
|
|
212
|
+
### One more axis: how much of the page is under contract at all
|
|
213
|
+
|
|
214
|
+
`map` records the state a page **loads in**. Everything behind a dropdown, a tab, an accordion
|
|
215
|
+
or a modal is in no contract — so when it drifts, nothing says so. It is a coverage hole and
|
|
216
|
+
never a false alarm, which is what makes it survive: the gate stays green over a surface it
|
|
217
|
+
never looked at.
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
ia-qa-heal map --deep # open them, and contract what appears
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Measured on ia-qa.com: one page 105 → 133 elements, another 126 → 264. Across eight unrelated
|
|
224
|
+
sites (MDN, Bootstrap, Material UI, GOV.UK, Wikipedia, Docusaurus…), seven gained elements —
|
|
225
|
+
the gain is largest on component-framework apps and near zero on content sites, so it is worth
|
|
226
|
+
saying which one you have before reaching for it.
|
|
227
|
+
|
|
228
|
+
- **It cannot mutate your app.** Every non-GET request is aborted for the walk, and controls
|
|
229
|
+
named like actions (*log out*, *delete*, *pay*…) are never clicked — a logout handler that
|
|
230
|
+
only clears local storage sends no request for the network guard to catch.
|
|
231
|
+
- **What it reaches but cannot contract is reported**, not hidden (`↩ N left out`): a selector
|
|
232
|
+
must name exactly one element, and a positional one captured inside a menu can collide with
|
|
233
|
+
the page behind it.
|
|
234
|
+
- **A deep baseline is only comparable to a deep capture.** Mixing the two reports everything
|
|
235
|
+
behind a menu as `lost`; `diff` and the MCP both warn before the verdict.
|
|
236
|
+
- **~1 minute per page** against seconds for a plain `map`. It is for building a baseline, not
|
|
237
|
+
for a per-commit gate.
|
|
238
|
+
|
|
211
239
|
## The verdict
|
|
212
240
|
|
|
213
241
|
```
|
package/TUTORIAL.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
2. [The 30-second mental model](#2-the-30-second-mental-model)
|
|
12
12
|
3. [Before you start](#3-before-you-start)
|
|
13
13
|
4. [Path A — I use an AI agent (easiest)](#4-path-a--i-use-an-ai-agent-easiest)
|
|
14
|
-
5. [Path B — I use a terminal](#5-path-b--i-use-a-terminal)
|
|
14
|
+
5. [Path B — I use a terminal](#5-path-b--i-use-a-terminal) — including [menus, tabs and modals: `map --deep`](#step-53b--what-about-my-menus-tabs-and-modals-map---deep)
|
|
15
15
|
6. [Your first real workflow](#6-your-first-real-workflow-the-whole-point) — including [a test just failed, is it drift?](#step-64b--you-didnt-run-diff-you-got-a-red-build--explain) · [the console, if you don't want a terminal](#step-68--no-terminal-open-the-console)
|
|
16
16
|
7. [Reading the verdict](#7-reading-the-verdict)
|
|
17
17
|
8. [Putting it in CI](#8-putting-it-in-ci) — [JUnit trends](#82-trend-charts-for-free--junit) · [the run history](#83-the-trend--ia-qa-heal-history)
|
|
@@ -344,6 +344,48 @@ Plus, for the whole app:
|
|
|
344
344
|
|
|
345
345
|
Commit them.
|
|
346
346
|
|
|
347
|
+
### Step 5.3b — What about my menus, tabs and modals? `map --deep`
|
|
348
|
+
|
|
349
|
+
A page has more than one state, and `map` records the one it loads in. Everything behind a
|
|
350
|
+
dropdown, a tab, an accordion or a dialog is simply not there — so if it drifts, nothing tells
|
|
351
|
+
you. It is a **coverage** hole rather than a false alarm, which is what makes it easy to miss:
|
|
352
|
+
the gate stays green over a surface it never looked at.
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
npx ia-qa-heal map --deep
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
It opens what can be opened and contracts what appears. On ia-qa.com: `/method` 105 → 133
|
|
359
|
+
elements, the home page 126 → 264.
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
✔ 264 interactive elements → .ia-qa/mapping/home.json + .ia-qa/mapping/home.md
|
|
363
|
+
🔍 deep: +140 behind interaction (124 on load) · 37 states · 54 clicks, 9 reloads
|
|
364
|
+
↩ 133 revealed elements left out — selector already names another element
|
|
365
|
+
🛡 34 non-GET requests blocked during exploration (nothing mutating left the browser)
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Four things worth knowing before you use it:
|
|
369
|
+
|
|
370
|
+
**It cannot break your app.** Every non-GET request is aborted for the whole walk, so a click
|
|
371
|
+
that would have deleted, paid or submitted anything never reaches your server — and controls
|
|
372
|
+
whose name reads like an action (*log out*, *delete*, *pay*…) are not clicked at all, because a
|
|
373
|
+
logout handler that just clears local storage sends no request for the guard to stop.
|
|
374
|
+
|
|
375
|
+
**Elements it reaches but cannot contract are reported, not hidden** (`↩ 133 left out`). A
|
|
376
|
+
selector must name exactly one element; a positional one captured inside a menu can collide with
|
|
377
|
+
an element on the page behind it, and a contract holding the same selector twice makes the diff
|
|
378
|
+
pair rows at random. Those are dropped and counted.
|
|
379
|
+
|
|
380
|
+
**A deep baseline may only be diffed against a deep capture.** Mixing them reports everything
|
|
381
|
+
behind a menu as `lost` — the tool warns you before the verdict, in the CLI and over MCP alike.
|
|
382
|
+
Decide once, per baseline.
|
|
383
|
+
|
|
384
|
+
**It costs about a minute per page** where a plain `map` costs seconds. Use it to build or
|
|
385
|
+
refresh a baseline, not on every commit. `--depth 2` goes one click deeper; it was measured at
|
|
386
|
+
three times the time for a contract that never finishes, so leave it alone unless you have a
|
|
387
|
+
reason.
|
|
388
|
+
|
|
347
389
|
### Step 5.4 — Day one, before any drift: `audit`
|
|
348
390
|
|
|
349
391
|
Everything after this point pays off at the *next* UI change. `audit` pays off now: it asks **does my test suite still name things that exist?**
|
|
@@ -858,6 +900,7 @@ npx ia-qa-heal init # one-time setup
|
|
|
858
900
|
npx ia-qa-heal discover [--crawl] [--apply] # optional: propose pages for config.json (sitemap or safe crawl)
|
|
859
901
|
npx ia-qa-heal map # scan → *.json + *.md + _overview.md + _navigation.md/.svg
|
|
860
902
|
npx ia-qa-heal map --screenshots # + one JPEG per page, shown in the --report HTML (gitignore _shots/)
|
|
903
|
+
npx ia-qa-heal map --deep # + open menus/tabs/dialogs and contract what they reveal (see below)
|
|
861
904
|
npx ia-qa-heal baseline # promote mapping/ → baseline/ (commit it)
|
|
862
905
|
# …change the app, then `map` again…
|
|
863
906
|
npx ia-qa-heal diff # verdict: PASS / FIX / BLOCK (baseline/ vs mapping/)
|
package/dist/aom.d.ts
CHANGED
|
@@ -34,6 +34,20 @@ export interface MappedElement {
|
|
|
34
34
|
*/
|
|
35
35
|
rawName?: string;
|
|
36
36
|
nameMasked?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* How to reach this element, when reaching it takes clicks: the accessible names of the
|
|
39
|
+
* controls opened on the way (`["☰", "RAG & Agents"]`). Written by `map --deep` only, and
|
|
40
|
+
* omitted on everything visible at load — so a contract captured without exploration stays
|
|
41
|
+
* byte-identical to one captured before `via` existed.
|
|
42
|
+
*
|
|
43
|
+
* Never part of identity. `diffMappings` matches on role + name + selector and must keep
|
|
44
|
+
* doing so: an element that moved from behind a menu into the open is the *same* element,
|
|
45
|
+
* and a diff that called that drift would report a UX improvement as a regression.
|
|
46
|
+
*
|
|
47
|
+
* Its reader is a person or an agent asking "how do I even get to this button" — which is
|
|
48
|
+
* the question a deep contract creates and owes an answer to.
|
|
49
|
+
*/
|
|
50
|
+
via?: string[];
|
|
37
51
|
}
|
|
38
52
|
export interface PageMapping {
|
|
39
53
|
page: string;
|
|
@@ -52,6 +66,17 @@ export interface PageMapping {
|
|
|
52
66
|
* means *unknown*, never *matching*.
|
|
53
67
|
*/
|
|
54
68
|
captureState?: CaptureState;
|
|
69
|
+
/**
|
|
70
|
+
* How many clicks deep this contract was explored (`map --deep`). Absent means the loaded
|
|
71
|
+
* state only — every contract written before exploration existed, which is why absent must
|
|
72
|
+
* never be read as `0`-and-therefore-comparable-to-a-deep-one on purpose.
|
|
73
|
+
*
|
|
74
|
+
* It is recorded for the same reason `source` is: a deep baseline diffed against a shallow
|
|
75
|
+
* capture reports every explored element as `lost`, which is drift manufactured out of the
|
|
76
|
+
* capture method rather than out of the app. Depth is a property of a *baseline*, and a
|
|
77
|
+
* mismatch has to be nameable.
|
|
78
|
+
*/
|
|
79
|
+
exploredDepth?: number;
|
|
55
80
|
}
|
|
56
81
|
/**
|
|
57
82
|
* Did these two mappings come out of the same `map` run?
|
|
@@ -77,6 +102,35 @@ export declare function isSameCapture(baseline: {
|
|
|
77
102
|
} | null | undefined, current: {
|
|
78
103
|
capturedAt?: string;
|
|
79
104
|
} | null | undefined): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Were these two contracts explored to the same depth?
|
|
107
|
+
*
|
|
108
|
+
* A deep contract holds the elements behind menus, tabs and dialogs; a shallow one cannot, so
|
|
109
|
+
* diffing one against the other reports every one of them as `lost`. That is not a handful of
|
|
110
|
+
* noisy rows — most of a page's surface can sit behind interaction, so the mismatch manufactures
|
|
111
|
+
* the whole BLOCK out of how the two captures were taken.
|
|
112
|
+
*
|
|
113
|
+
* Absent means depth 0 — the loaded state only. That is a *known* value, not an unknown one:
|
|
114
|
+
* every contract written before `--deep` existed describes exactly one state. (`captureState` is
|
|
115
|
+
* the opposite case, where absent genuinely means "cannot tell", and must never read as a match.)
|
|
116
|
+
*
|
|
117
|
+
* Lives here, beside `isSameCapture`, for the same reason: both doors into a diff — the CLI and
|
|
118
|
+
* the MCP server — have to agree on what is not a comparison. One warning and the other silent
|
|
119
|
+
* would just move the phantom verdict to whichever door the user knocked on.
|
|
120
|
+
*/
|
|
121
|
+
export declare function depthMismatch(baseline: {
|
|
122
|
+
exploredDepth?: number;
|
|
123
|
+
} | null | undefined, current: {
|
|
124
|
+
exploredDepth?: number;
|
|
125
|
+
} | null | undefined): {
|
|
126
|
+
baseline: number;
|
|
127
|
+
current: number;
|
|
128
|
+
} | null;
|
|
129
|
+
/** The sentence every door prints or attaches when `depthMismatch` returns non-null. */
|
|
130
|
+
export declare function depthMismatchMessage(m: {
|
|
131
|
+
baseline: number;
|
|
132
|
+
current: number;
|
|
133
|
+
}): string;
|
|
80
134
|
export declare function extractInteractiveElements(page: Page): Promise<MappedElement[]>;
|
|
81
135
|
export declare function saveMapping(mapping: PageMapping, dir?: string): string;
|
|
82
136
|
export declare function loadMapping(pageName: string, dir?: string): PageMapping;
|
package/dist/aom.js
CHANGED
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.isSameCapture = isSameCapture;
|
|
37
|
+
exports.depthMismatch = depthMismatch;
|
|
38
|
+
exports.depthMismatchMessage = depthMismatchMessage;
|
|
37
39
|
exports.extractInteractiveElements = extractInteractiveElements;
|
|
38
40
|
exports.saveMapping = saveMapping;
|
|
39
41
|
exports.loadMapping = loadMapping;
|
|
@@ -63,6 +65,36 @@ const extract_1 = require("./browser/extract");
|
|
|
63
65
|
function isSameCapture(baseline, current) {
|
|
64
66
|
return Boolean(baseline?.capturedAt) && baseline.capturedAt === current?.capturedAt;
|
|
65
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Were these two contracts explored to the same depth?
|
|
70
|
+
*
|
|
71
|
+
* A deep contract holds the elements behind menus, tabs and dialogs; a shallow one cannot, so
|
|
72
|
+
* diffing one against the other reports every one of them as `lost`. That is not a handful of
|
|
73
|
+
* noisy rows — most of a page's surface can sit behind interaction, so the mismatch manufactures
|
|
74
|
+
* the whole BLOCK out of how the two captures were taken.
|
|
75
|
+
*
|
|
76
|
+
* Absent means depth 0 — the loaded state only. That is a *known* value, not an unknown one:
|
|
77
|
+
* every contract written before `--deep` existed describes exactly one state. (`captureState` is
|
|
78
|
+
* the opposite case, where absent genuinely means "cannot tell", and must never read as a match.)
|
|
79
|
+
*
|
|
80
|
+
* Lives here, beside `isSameCapture`, for the same reason: both doors into a diff — the CLI and
|
|
81
|
+
* the MCP server — have to agree on what is not a comparison. One warning and the other silent
|
|
82
|
+
* would just move the phantom verdict to whichever door the user knocked on.
|
|
83
|
+
*/
|
|
84
|
+
function depthMismatch(baseline, current) {
|
|
85
|
+
const b = baseline?.exploredDepth ?? 0;
|
|
86
|
+
const c = current?.exploredDepth ?? 0;
|
|
87
|
+
return b === c ? null : { baseline: b, current: c };
|
|
88
|
+
}
|
|
89
|
+
/** The sentence every door prints or attaches when `depthMismatch` returns non-null. */
|
|
90
|
+
function depthMismatchMessage(m) {
|
|
91
|
+
const deeper = m.baseline > m.current ? 'baseline' : 'current capture';
|
|
92
|
+
const shallower = deeper === 'baseline' ? 'current capture' : 'baseline';
|
|
93
|
+
return (`Exploration depth mismatch — the ${deeper} was captured with \`map --deep\`, the ${shallower} ` +
|
|
94
|
+
`was not (depth ${m.baseline} vs ${m.current}). A deep contract holds the elements behind ` +
|
|
95
|
+
`menus, tabs and dialogs; a shallow one cannot, so those elements read as \`lost\` here. Most ` +
|
|
96
|
+
`of this verdict is probably that, not drift in your app. Capture both sides the same way.`);
|
|
97
|
+
}
|
|
66
98
|
async function extractInteractiveElements(page) {
|
|
67
99
|
return page.evaluate(extract_1.extractInPage);
|
|
68
100
|
}
|
package/dist/aom.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"aom.js","sourceRoot":"","sources":["../src/aom.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"aom.js","sourceRoot":"","sources":["../src/aom.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-page finder for controls that *reveal* elements — the widened half of discovery.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained by the same rule as `browser/extract.js` and `browser/discover.js`:
|
|
5
|
+
* Playwright serialises it with `toString()` into `page.evaluate()`, so no imports, no
|
|
6
|
+
* closures, no TypeScript. Unlike those two it is **npm-only** — the web tool imports
|
|
7
|
+
* `match.js`, `contract.js` and `extract.js` and nothing else, so a change here owes one
|
|
8
|
+
* publish, not two.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this exists next to `discover.js` rather than replacing it
|
|
11
|
+
*
|
|
12
|
+
* `findRevealTargets` answers "what is safe to open **while crawling for links**". It is
|
|
13
|
+
* deliberately ARIA-only: a crawler that clicks a bare `<button>` on someone's app to find
|
|
14
|
+
* an `href` is trading a large risk for a small reward. That restraint is correct there and
|
|
15
|
+
* must not be loosened — `crawl.ts` keeps using it.
|
|
16
|
+
*
|
|
17
|
+
* This function answers a different question: "what is safe to open **to complete the
|
|
18
|
+
* contract of this page**". The reward is the whole feature — a contract that describes one
|
|
19
|
+
* state of a page describes an application the way a screenshot describes a film. Measured
|
|
20
|
+
* on a React site with no ARIA disclosure attributes at all, the closed state held 105
|
|
21
|
+
* elements and the reachable surface held 303. `findRevealTargets` returns 0 there, because
|
|
22
|
+
* `aria-expanded` is documentation the app never wrote.
|
|
23
|
+
*
|
|
24
|
+
* So the candidate set is widened to bare `<button>`, `[role=tab]` and `[onclick]`, and the
|
|
25
|
+
* safety is moved from *what we are willing to click* to *what a click is allowed to do*:
|
|
26
|
+
* `explore.ts` blocks every non-GET request for the duration. That is a stronger guarantee
|
|
27
|
+
* than an attribute allowlist, because it holds against the app rather than against its
|
|
28
|
+
* markup, and it is the reason widening here is defensible.
|
|
29
|
+
*
|
|
30
|
+
* What is still refused, and why each one:
|
|
31
|
+
* - a navigating `<a href>` — that is navigation, and the page it reaches has a contract
|
|
32
|
+
* of its own. Following it here would fold two pages into one.
|
|
33
|
+
* - `type="submit"` / `type="reset"`, and a bare `<button>` inside a `<form>` (which
|
|
34
|
+
* submits by default) — a blocked POST still leaves the app showing an error state, and
|
|
35
|
+
* a contract captured over an error banner is a lie about the page.
|
|
36
|
+
* - an accessible name that reads like an action (log out, delete, pay…). The network
|
|
37
|
+
* guard already stops the server-side effect; this stops the *client-side* one — a
|
|
38
|
+
* `logout` handler that clears localStorage and re-renders needs no request at all.
|
|
39
|
+
* - `aria-expanded="true"` — already open. Clicking that collapses, which removes elements
|
|
40
|
+
* instead of revealing them.
|
|
41
|
+
*/
|
|
42
|
+
export function findOpenables(): {
|
|
43
|
+
handle: string;
|
|
44
|
+
name: any;
|
|
45
|
+
}[];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findOpenables = findOpenables;
|
|
4
|
+
/**
|
|
5
|
+
* In-page finder for controls that *reveal* elements — the widened half of discovery.
|
|
6
|
+
*
|
|
7
|
+
* Self-contained by the same rule as `browser/extract.js` and `browser/discover.js`:
|
|
8
|
+
* Playwright serialises it with `toString()` into `page.evaluate()`, so no imports, no
|
|
9
|
+
* closures, no TypeScript. Unlike those two it is **npm-only** — the web tool imports
|
|
10
|
+
* `match.js`, `contract.js` and `extract.js` and nothing else, so a change here owes one
|
|
11
|
+
* publish, not two.
|
|
12
|
+
*
|
|
13
|
+
* ## Why this exists next to `discover.js` rather than replacing it
|
|
14
|
+
*
|
|
15
|
+
* `findRevealTargets` answers "what is safe to open **while crawling for links**". It is
|
|
16
|
+
* deliberately ARIA-only: a crawler that clicks a bare `<button>` on someone's app to find
|
|
17
|
+
* an `href` is trading a large risk for a small reward. That restraint is correct there and
|
|
18
|
+
* must not be loosened — `crawl.ts` keeps using it.
|
|
19
|
+
*
|
|
20
|
+
* This function answers a different question: "what is safe to open **to complete the
|
|
21
|
+
* contract of this page**". The reward is the whole feature — a contract that describes one
|
|
22
|
+
* state of a page describes an application the way a screenshot describes a film. Measured
|
|
23
|
+
* on a React site with no ARIA disclosure attributes at all, the closed state held 105
|
|
24
|
+
* elements and the reachable surface held 303. `findRevealTargets` returns 0 there, because
|
|
25
|
+
* `aria-expanded` is documentation the app never wrote.
|
|
26
|
+
*
|
|
27
|
+
* So the candidate set is widened to bare `<button>`, `[role=tab]` and `[onclick]`, and the
|
|
28
|
+
* safety is moved from *what we are willing to click* to *what a click is allowed to do*:
|
|
29
|
+
* `explore.ts` blocks every non-GET request for the duration. That is a stronger guarantee
|
|
30
|
+
* than an attribute allowlist, because it holds against the app rather than against its
|
|
31
|
+
* markup, and it is the reason widening here is defensible.
|
|
32
|
+
*
|
|
33
|
+
* What is still refused, and why each one:
|
|
34
|
+
* - a navigating `<a href>` — that is navigation, and the page it reaches has a contract
|
|
35
|
+
* of its own. Following it here would fold two pages into one.
|
|
36
|
+
* - `type="submit"` / `type="reset"`, and a bare `<button>` inside a `<form>` (which
|
|
37
|
+
* submits by default) — a blocked POST still leaves the app showing an error state, and
|
|
38
|
+
* a contract captured over an error banner is a lie about the page.
|
|
39
|
+
* - an accessible name that reads like an action (log out, delete, pay…). The network
|
|
40
|
+
* guard already stops the server-side effect; this stops the *client-side* one — a
|
|
41
|
+
* `logout` handler that clears localStorage and re-renders needs no request at all.
|
|
42
|
+
* - `aria-expanded="true"` — already open. Clicking that collapses, which removes elements
|
|
43
|
+
* instead of revealing them.
|
|
44
|
+
*/
|
|
45
|
+
function findOpenables() {
|
|
46
|
+
// Matched against the accessible name, English + French. Word-bounded so "cancellation
|
|
47
|
+
// policy" is not read as "cancel" — but `\b` does not fire on accented letters in some
|
|
48
|
+
// engines, so the French verbs are also matched bare.
|
|
49
|
+
var ACTION_RE = new RegExp('\\b(log\\s?out|sign\\s?out|logout|signout|delete|remove|destroy|discard|deactivate' +
|
|
50
|
+
'|disable|unsubscribe|revoke|buy|pay|payment|checkout|purchase|order|submit|send' +
|
|
51
|
+
'|confirm|approve|reject|save|reset|publish|cancel|upgrade|subscribe|renew)\\b' +
|
|
52
|
+
'|supprimer|effacer|payer|acheter|commander|envoyer|valider|confirmer|annuler' +
|
|
53
|
+
'|d[ée]connexion|se d[ée]connecter|d[ée]sabonner|r[ée]initialiser|enregistrer', 'i');
|
|
54
|
+
var OPENABLE_SELECTOR = [
|
|
55
|
+
'button',
|
|
56
|
+
'[role="button"]',
|
|
57
|
+
'[role="tab"]',
|
|
58
|
+
'[role="menuitem"]',
|
|
59
|
+
'summary',
|
|
60
|
+
'[aria-haspopup]',
|
|
61
|
+
'[aria-expanded]',
|
|
62
|
+
'[aria-controls]',
|
|
63
|
+
'[onclick]',
|
|
64
|
+
].join(', ');
|
|
65
|
+
function isVisible(el) {
|
|
66
|
+
var rect = el.getBoundingClientRect();
|
|
67
|
+
if (rect.width === 0 || rect.height === 0)
|
|
68
|
+
return false;
|
|
69
|
+
var style = window.getComputedStyle(el);
|
|
70
|
+
return style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0';
|
|
71
|
+
}
|
|
72
|
+
function accName(el) {
|
|
73
|
+
var label = el.getAttribute('aria-label');
|
|
74
|
+
if (label)
|
|
75
|
+
return label.trim().replace(/\s+/g, ' ');
|
|
76
|
+
var labelledBy = el.getAttribute('aria-labelledby');
|
|
77
|
+
if (labelledBy) {
|
|
78
|
+
var joined = labelledBy
|
|
79
|
+
.split(/\s+/)
|
|
80
|
+
.map(function (id) {
|
|
81
|
+
var n = document.getElementById(id);
|
|
82
|
+
return n ? n.textContent : '';
|
|
83
|
+
})
|
|
84
|
+
.join(' ')
|
|
85
|
+
.trim();
|
|
86
|
+
if (joined)
|
|
87
|
+
return joined.replace(/\s+/g, ' ');
|
|
88
|
+
}
|
|
89
|
+
var text = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
90
|
+
return text || (el.getAttribute('title') || '').trim();
|
|
91
|
+
}
|
|
92
|
+
var found = document.querySelectorAll(OPENABLE_SELECTOR);
|
|
93
|
+
var out = [];
|
|
94
|
+
var i = 0;
|
|
95
|
+
for (var k = 0; k < found.length; k++) {
|
|
96
|
+
var el = found[k];
|
|
97
|
+
var tag = el.tagName.toLowerCase();
|
|
98
|
+
if (!isVisible(el))
|
|
99
|
+
continue;
|
|
100
|
+
if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true')
|
|
101
|
+
continue;
|
|
102
|
+
if (tag === 'a') {
|
|
103
|
+
var href = el.getAttribute('href');
|
|
104
|
+
if (href && href !== '#' && href.indexOf('javascript:') !== 0)
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (tag === 'input')
|
|
108
|
+
continue;
|
|
109
|
+
var type = (el.getAttribute('type') || '').toLowerCase();
|
|
110
|
+
if (type === 'submit' || type === 'reset')
|
|
111
|
+
continue;
|
|
112
|
+
if (tag === 'button' && el.closest('form') && type !== 'button')
|
|
113
|
+
continue;
|
|
114
|
+
if (el.getAttribute('aria-expanded') === 'true')
|
|
115
|
+
continue;
|
|
116
|
+
var name = accName(el);
|
|
117
|
+
if (ACTION_RE.test(name))
|
|
118
|
+
continue;
|
|
119
|
+
// A stamped attribute is the one selector guaranteed to re-find this exact element,
|
|
120
|
+
// and adding a data attribute changes nothing the app reacts to. The stamp does not
|
|
121
|
+
// survive a reload, which is why `explore.ts` re-stamps after every reset.
|
|
122
|
+
el.setAttribute('data-iaqa-open', String(i));
|
|
123
|
+
out.push({ handle: '[data-iaqa-open="' + i + '"]', name: name.slice(0, 80) });
|
|
124
|
+
i++;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=openables.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openables.js","sourceRoot":"","sources":["../../src/browser/openables.js"],"names":[],"mappings":"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"}
|
package/dist/cli/args.js
CHANGED
|
@@ -18,7 +18,7 @@ exports.KNOWN_FLAGS = {
|
|
|
18
18
|
init: ['--yes', '--base-url', '--pages', '--discover', '--tests', '--test-command', '--bootstrap'],
|
|
19
19
|
discover: ['--sitemap', '--crawl', '--no-reveal', '--strict-host', '--apply', '--max', '--depth', '--session'],
|
|
20
20
|
login: ['--url', '--session'],
|
|
21
|
-
map: ['--screenshots', '--session'],
|
|
21
|
+
map: ['--screenshots', '--session', '--deep', '--depth', '--deep-budget'],
|
|
22
22
|
baseline: ['--dry-run'],
|
|
23
23
|
diff: ['--strict', '--json', '--junit', '--dir', '--report', '--open'],
|
|
24
24
|
fix: ['--dry-run', '--locators', '--dir', '--json'],
|
package/dist/cli/args.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"args.js","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":";;;AAgCA,gDAeC;AA/CD;;;;;;;;;;;GAWG;AACU,QAAA,WAAW,GAAsC;IAC5D,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,gBAAgB,EAAE,aAAa,CAAC;IAClG,QAAQ,EAAE,CAAC,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC;IAC9G,KAAK,EAAE,CAAC,OAAO,EAAE,WAAW,CAAC;IAC7B,GAAG,EAAE,CAAC,eAAe,EAAE,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"args.js","sourceRoot":"","sources":["../../src/cli/args.ts"],"names":[],"mappings":";;;AAgCA,gDAeC;AA/CD;;;;;;;;;;;GAWG;AACU,QAAA,WAAW,GAAsC;IAC5D,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,gBAAgB,EAAE,aAAa,CAAC;IAClG,QAAQ,EAAE,CAAC,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC;IAC9G,KAAK,EAAE,CAAC,OAAO,EAAE,WAAW,CAAC;IAC7B,GAAG,EAAE,CAAC,eAAe,EAAE,WAAW,EAAE,QAAQ,EAAE,SAAS,EAAE,eAAe,CAAC;IACzE,QAAQ,EAAE,CAAC,WAAW,CAAC;IACvB,IAAI,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,CAAC;IACtE,GAAG,EAAE,CAAC,WAAW,EAAE,YAAY,EAAE,OAAO,EAAE,QAAQ,CAAC;IACnD,MAAM,EAAE,CAAC,QAAQ,CAAC;IAClB,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC7B,OAAO,EAAE,CAAC,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,QAAQ,CAAC;IACvD,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,eAAe,CAAC;IAC3D,KAAK,EAAE,CAAC,UAAU,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,CAAC;IAChD,GAAG,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC;IACtF,KAAK,EAAE,CAAC,WAAW,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,CAAC;IAC7D,OAAO,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC;IAC9B,YAAY,EAAE,CAAC,QAAQ,CAAC;IACxB,EAAE,EAAE,CAAC,QAAQ,EAAE,WAAW,EAAE,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,CAAC;CACnF,CAAC;AAEF,SAAgB,kBAAkB,CAAC,OAAe,EAAE,IAAc;IAChE,MAAM,KAAK,GAAG,mBAAW,CAAC,OAAO,CAAC,CAAC;IACnC,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO;IAChC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QACjC,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,SAAS;QAChC,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CACb,eAAe,OAAO,0FAA0F;gBAC9G,4GAA4G;gBAC5G,8FAA8F,CACjG,CAAC;QACJ,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,qBAAqB,OAAO,8CAA8C,CAAC,CAAC;IAChH,CAAC;AACH,CAAC"}
|
package/dist/cli/diff.d.ts
CHANGED
|
@@ -122,6 +122,21 @@ export interface DirDiffCollection {
|
|
|
122
122
|
* qualifies, which is the ordinary case and costs nothing.
|
|
123
123
|
*/
|
|
124
124
|
systemic: SystemicOutcome;
|
|
125
|
+
/**
|
|
126
|
+
* The two sides were explored to different depths (`map --deep`), when either declares one.
|
|
127
|
+
*
|
|
128
|
+
* A deep baseline diffed against a shallow capture reports every element behind a menu as
|
|
129
|
+
* `lost` — a BLOCK over an app where nothing changed, manufactured entirely by how the two
|
|
130
|
+
* contracts were captured. It is the same class as `run`'s source mismatch, and it is worse:
|
|
131
|
+
* a deep contract can hold more elements behind interaction than in front of it, so the
|
|
132
|
+
* false verdict is not a handful of rows but most of the page.
|
|
133
|
+
*
|
|
134
|
+
* `null` when both sides agree (including both absent — two shallow contracts).
|
|
135
|
+
*/
|
|
136
|
+
depthMismatch: {
|
|
137
|
+
baseline: number;
|
|
138
|
+
current: number;
|
|
139
|
+
} | null;
|
|
125
140
|
}
|
|
126
141
|
/**
|
|
127
142
|
* A broken binding whose element survived is repairable — FIX. Everything else is a
|
|
@@ -152,6 +167,16 @@ export interface DirReport {
|
|
|
152
167
|
totals: Report['counts'];
|
|
153
168
|
/** Pairs left out of the verdict: same capture on both sides, so incapable of showing drift. */
|
|
154
169
|
staleExcluded?: string[];
|
|
170
|
+
/**
|
|
171
|
+
* The two sides were explored to different depths (`map --deep`) — so much of this verdict is
|
|
172
|
+
* probably an artefact of how the contracts were captured rather than drift in the app.
|
|
173
|
+
* Carried on the aggregate so `--json` and the report state it, not only the terminal.
|
|
174
|
+
*/
|
|
175
|
+
depthMismatch?: {
|
|
176
|
+
baseline: number;
|
|
177
|
+
current: number;
|
|
178
|
+
why: string;
|
|
179
|
+
};
|
|
155
180
|
/**
|
|
156
181
|
* Renamed labels the suite locates by name, de-duped across pages — a shared
|
|
157
182
|
* header renames once, not once per page that carries it.
|
package/dist/cli/diff.js
CHANGED
|
@@ -365,6 +365,11 @@ async function runDiff(args) {
|
|
|
365
365
|
refuseToCompare('same-capture', sameCaptureMessage(baseline.capturedAt), 'Change the app and run `ia-qa-heal map` again, so the two sides are different moments.', json);
|
|
366
366
|
return;
|
|
367
367
|
}
|
|
368
|
+
// Before the verdict, never after: a depth mismatch does not colour a few rows, it can invent
|
|
369
|
+
// the entire BLOCK. Same check as `diff --dir` and the MCP — one shared function, three doors.
|
|
370
|
+
const depths = (0, aom_1.depthMismatch)(baseline, current);
|
|
371
|
+
if (depths && !json)
|
|
372
|
+
console.error(`\n⚠️ ${(0, aom_1.depthMismatchMessage)(depths)}`);
|
|
368
373
|
const usage = (0, ingest_1.loadUsage)();
|
|
369
374
|
const report = attachBaselineHrefs((0, nameMask_1.diffWithMasks)(baseline, current, (0, nameMask_1.loadNameMasks)(), usage), baseline);
|
|
370
375
|
// Same rule as the directory walk: the resolutions sit next to the mappings they
|
|
@@ -547,7 +552,7 @@ async function runDirDiff(baselineDir, currentDir, opts) {
|
|
|
547
552
|
console.error(`❌ "${currentDir}" is not a directory.`);
|
|
548
553
|
process.exit(2);
|
|
549
554
|
}
|
|
550
|
-
const { reports, stale, systemic } = collectDirDiff(baselineDir, currentDir);
|
|
555
|
+
const { reports, stale, systemic, depthMismatch: depths } = collectDirDiff(baselineDir, currentDir);
|
|
551
556
|
if (reports.length === 0 && stale.length > 0) {
|
|
552
557
|
refuseToCompare('same-capture', `❌ Nothing to compare: ${stale.length === 1 ? 'the only pair is' : `all ${stale.length} pairs are`} ` +
|
|
553
558
|
`the same capture on both sides.\n\n` +
|
|
@@ -562,10 +567,23 @@ async function runDirDiff(baselineDir, currentDir, opts) {
|
|
|
562
567
|
refuseToCompare('no-pairs', `❌ No matching mapping pairs found between ${rel(baselineDir)} and ${rel(currentDir)}.`, 'Mapping files are paired by filename — run `ia-qa-heal map` so both directories hold the same pages.', opts.json);
|
|
563
568
|
return;
|
|
564
569
|
}
|
|
570
|
+
// Printed BEFORE the verdict, not after it. A depth mismatch does not colour a few rows —
|
|
571
|
+
// it can invent the entire BLOCK, because everything the deep side found behind a menu is
|
|
572
|
+
// simply absent from the shallow side and reads as `lost`. Someone who meets the verdict
|
|
573
|
+
// first will start hand-repairing drift that never happened.
|
|
574
|
+
if (depths) {
|
|
575
|
+
console.error(`\n⚠️ ${(0, aom_1.depthMismatchMessage)(depths)}\n` +
|
|
576
|
+
` \`ia-qa-heal map --deep\` before diffing, or re-baseline without it.`);
|
|
577
|
+
}
|
|
565
578
|
// Aggregate verdict
|
|
566
579
|
const aggregate = aggregateReports(reports);
|
|
567
580
|
if (stale.length > 0)
|
|
568
581
|
aggregate.staleExcluded = stale;
|
|
582
|
+
// Onto the aggregate, not only the terminal: `--json` is what a pipeline reads, and a CI that
|
|
583
|
+
// sees BLOCK without knowing the two sides were captured differently is the same blind spot
|
|
584
|
+
// the MCP had. Every consumer of a verdict gets the caveat attached to it.
|
|
585
|
+
if (depths)
|
|
586
|
+
aggregate.depthMismatch = { ...depths, why: (0, aom_1.depthMismatchMessage)(depths) };
|
|
569
587
|
if (systemic.groups.length > 0)
|
|
570
588
|
aggregate.systemic = systemic;
|
|
571
589
|
Object.assign(aggregate, (0, nextStep_1.nextStepFor)(aggregate.verdict, {
|
|
@@ -665,6 +683,10 @@ function collectDirDiff(baselineDir, currentDir) {
|
|
|
665
683
|
// never as a match.
|
|
666
684
|
let baselineState;
|
|
667
685
|
let currentState;
|
|
686
|
+
// Exploration depth, settled the same way and for the same reason. Absent means the loaded
|
|
687
|
+
// state only, which is depth 0 — a real value here, not unknown: every contract written
|
|
688
|
+
// before `--deep` existed describes exactly one state, and that is knowable.
|
|
689
|
+
let deepest = null;
|
|
668
690
|
// Kept out of the verdict rather than counted as `ok` — N ok that were never
|
|
669
691
|
// compared is exactly the false green this guard exists to stop.
|
|
670
692
|
const stale = [];
|
|
@@ -675,6 +697,7 @@ function collectDirDiff(baselineDir, currentDir) {
|
|
|
675
697
|
const assess = (baseline, current, baselineFile, currentFile, page) => {
|
|
676
698
|
baselineState ??= baseline.captureState;
|
|
677
699
|
currentState ??= current.captureState;
|
|
700
|
+
deepest ??= (0, aom_1.depthMismatch)(baseline, current);
|
|
678
701
|
const report = attachBaselineHrefs((0, nameMask_1.diffWithMasks)(baseline, current, masks, usage), baseline);
|
|
679
702
|
report.nameDrift = (0, nameDrift_1.assessNameDrift)(report.rows, current.elements, usage);
|
|
680
703
|
report.verdict = (0, nameDrift_1.escalateVerdict)(report.verdict, report.nameDrift);
|
|
@@ -734,7 +757,12 @@ function collectDirDiff(baselineDir, currentDir) {
|
|
|
734
757
|
// gets exactly the reports it got before.
|
|
735
758
|
const systemic = (0, systemicDelta_1.assessSystemic)(reports, baselineState, currentState);
|
|
736
759
|
reports = (0, systemicDelta_1.applySystemic)(reports, systemic);
|
|
737
|
-
return {
|
|
760
|
+
return {
|
|
761
|
+
reports,
|
|
762
|
+
stale,
|
|
763
|
+
systemic,
|
|
764
|
+
depthMismatch: deepest,
|
|
765
|
+
};
|
|
738
766
|
}
|
|
739
767
|
function aggregateReports(reports) {
|
|
740
768
|
const layoutReports = reports.filter((r) => r.isLayout);
|