ultra11y 2.22.0

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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
@@ -0,0 +1,866 @@
1
+ // `scan --runtime local` — the dynamic tier WITHOUT Docker. Resolves a host/target
2
+ // Playwright + @axe-core/playwright AT RUNTIME (createRequire, a node: builtin) from
3
+ // `--cwd`, so the zero-dep static engine bundle never gains a static browser import.
4
+ // It runs the SAME axe-core pass as the Docker RUNNER, then the residual-criteria
5
+ // probes axe cannot decide (focus visibility, 200% zoom, text spacing, content on
6
+ // hover, target size). The Docker RUNNER / docker/* files are untouched (docker-sync).
7
+ //
8
+ // Browser-context code is passed to page.evaluate AS STRINGS — like the Docker RUNNER —
9
+ // because the engine's tsconfig ships no DOM lib (it is a Node program); a typed
10
+ // arrow body referencing document/window would not type-check.
11
+ import { existsSync, statSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import { resolve } from "node:path";
14
+ import type { DynamicFinding, DynamicResult, Lang, SamplePage } from "./types.js";
15
+ import { type DiscoverOpts, discoverUrls, type ProbeHit, type RunnerOutput, tagSampleFindings, toDynamicResult } from "./scan.js";
16
+ import { sampleScope } from "./sample.js";
17
+ import { today } from "./util.js";
18
+
19
+ export const LOCAL_ENGINE = "axe-core@playwright (local)";
20
+
21
+ // The needs-rendering SCs the LOCAL runtime's probes actually MEASURE on every run: 200%
22
+ // zoom (1.4.4), 320px reflow (1.4.10), text spacing (1.4.12), focus visibility (2.4.7),
23
+ // content on hover (1.4.13). Live regions (4.1.3) are measured only when the STATEFUL
24
+ // interactions are on (`--no-interact` skips that probe). Stamped on every local
25
+ // DynamicResult so the partial-audit advisory reflects real coverage — the Docker subset
26
+ // lives in scan.ts (DOCKER_TESTED_SCS).
27
+ const LOCAL_TESTED_SCS: readonly string[] = ["1.4.4", "1.4.10", "1.4.12", "2.4.7", "1.4.13"];
28
+ export function localTestedScs(interact: boolean): string[] {
29
+ return interact ? [...LOCAL_TESTED_SCS, "4.1.3"] : [...LOCAL_TESTED_SCS];
30
+ }
31
+
32
+ // Playwright + AxeBuilder are resolved at runtime (never typed deps of this package),
33
+ // so they cross the boundary untyped. biome's noExplicitAny is off for this repo.
34
+ type Any = any;
35
+
36
+ interface LocalDeps {
37
+ chromium: Any;
38
+ AxeBuilder: Any;
39
+ }
40
+
41
+ const PW_SPEC = "@playwright/test";
42
+ const AXE_SPEC = "@axe-core/playwright";
43
+
44
+ /** Resolvable WITHOUT loading (cheap auto-detection): @playwright/test + @axe-core/playwright
45
+ * both resolve from `--cwd`. */
46
+ export function localAvailable(cwd: string): boolean {
47
+ try {
48
+ const req = createRequire(resolve(cwd, "package.json"));
49
+ req.resolve(PW_SPEC);
50
+ req.resolve(AXE_SPEC);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /** Load Playwright `chromium` + the `AxeBuilder` class from the target project. */
58
+ export function resolveLocalDeps(cwd: string): LocalDeps {
59
+ let chromium: Any;
60
+ let AxeBuilder: Any;
61
+ try {
62
+ const req = createRequire(resolve(cwd, "package.json"));
63
+ const pw = req(PW_SPEC);
64
+ const axeMod = req(AXE_SPEC);
65
+ chromium = pw.chromium;
66
+ AxeBuilder = axeMod.default ?? axeMod;
67
+ } catch (e) {
68
+ const msg = e instanceof Error ? e.message : String(e);
69
+ throw new Error(
70
+ `Playwright not resolvable from "${cwd}". Pass --cwd <dir> at a project with @playwright/test + @axe-core/playwright installed (e.g. --cwd packages/app), or use --runtime docker. (${msg})`,
71
+ );
72
+ }
73
+ if (!chromium || typeof AxeBuilder !== "function") {
74
+ throw new Error(
75
+ `Resolved Playwright/@axe-core/playwright from "${cwd}" but they did not expose chromium / AxeBuilder. Check the installed versions, or use --runtime docker.`,
76
+ );
77
+ }
78
+ return { chromium, AxeBuilder };
79
+ }
80
+
81
+ async function launchChromium(chromium: Any): Promise<Any> {
82
+ try {
83
+ return await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] });
84
+ } catch (e) {
85
+ const msg = e instanceof Error ? e.message : String(e);
86
+ if (/Executable doesn'?t exist|playwright install|browserType\.launch/i.test(msg)) {
87
+ throw new Error(
88
+ `Could not launch Chromium for the resolved Playwright. Install it from the --cwd project: \`npx playwright install chromium\`. (${msg})`,
89
+ );
90
+ }
91
+ throw e;
92
+ }
93
+ }
94
+
95
+ // ---- residual-criteria probes (browser-context source, evaluated as strings) ----
96
+
97
+ // Shared helpers injected at the top of every probe expression.
98
+ const PRELUDE = `
99
+ const __sel = (e) => {
100
+ if (!e || !e.tagName) return '—';
101
+ const t = e.tagName.toLowerCase();
102
+ if (e.id) return t + '#' + e.id;
103
+ const c = typeof e.className === 'string' ? e.className.trim().split(/\\s+/)[0] : '';
104
+ return c ? t + '.' + c : t;
105
+ };
106
+ const __vis = (e) => {
107
+ const r = e.getBoundingClientRect();
108
+ if (r.width <= 4 || r.height <= 4) return false; // tiny / 1px sr-only boxes
109
+ const s = getComputedStyle(e);
110
+ if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false;
111
+ // visually-hidden "screen-reader-only" pattern (clip rect / clip-path inset) — present in
112
+ // the a11y tree but not painted; must not be measured for clipping/target-size.
113
+ if (s.clip && s.clip !== 'auto' && s.clip !== 'rect(auto, auto, auto, auto)') return false;
114
+ if (s.clipPath && (s.clipPath.indexOf('inset(100%') >= 0 || s.clipPath.indexOf('inset(50%') >= 0)) return false;
115
+ return true;
116
+ };
117
+ const __html = (e) => (e.outerHTML || '').slice(0, 160);
118
+ `;
119
+
120
+ // The 320px reflow check (same semantics as the Docker RUNNER), mapped to 1.4.10.
121
+ const REFLOW_PROBE = `(() => {
122
+ const el = document.scrollingElement || document.documentElement;
123
+ return { horizontalScroll: el.scrollWidth > el.clientWidth + 2 };
124
+ })()`;
125
+
126
+ // Note: WCAG 2.5.8 Target Size is covered by axe-core's own `target-size` rule (which
127
+ // correctly applies the inline + 24px-spacing exceptions), so there is no bespoke probe
128
+ // for it — a hand-rolled one was strictly noisier than axe on real DSFR pages.
129
+
130
+ // 1.4.4 Resize Text: enlarge text to 200% and detect actual LOSS OF CONTENT — text
131
+ // clipped in an overflow:hidden / nowrap / ellipsis container. (A mere horizontal
132
+ // scrollbar at 200% text is NOT a 1.4.4 failure — 2D reflow is 1.4.10 — so we do not
133
+ // flag page-level horizontal scroll here; only content the user can no longer read.)
134
+ const REFLOW_ZOOM_PROBE = `(() => { ${PRELUDE}
135
+ const root = document.documentElement;
136
+ const prev = root.style.fontSize;
137
+ root.style.fontSize = '200%';
138
+ const hits = [];
139
+ for (const e of Array.from(document.querySelectorAll('p,li,h1,h2,h3,h4,h5,h6,td,th,button,a,label,span'))) {
140
+ if (!__vis(e)) continue;
141
+ if ((e.textContent || '').trim().length < 8) continue;
142
+ const s = getComputedStyle(e);
143
+ const clip = s.overflow === 'hidden' || s.overflowY === 'hidden' || s.overflowX === 'hidden';
144
+ const noWrap = s.whiteSpace === 'nowrap' || s.textOverflow === 'ellipsis';
145
+ if ((clip || noWrap) && (e.scrollHeight > e.clientHeight + 6 || e.scrollWidth > e.clientWidth + 6)) {
146
+ hits.push({ selector: __sel(e), html: __html(e), detail: 'Texte tronqué/masqué à 200% (conteneur overflow:hidden / nowrap) — perte de contenu au zoom (1.4.4).' });
147
+ }
148
+ if (hits.length >= 12) break;
149
+ }
150
+ root.style.fontSize = prev;
151
+ return hits;
152
+ })()`;
153
+
154
+ // 1.4.12 Text Spacing override (line-height 1.5, letter 0.12em, word 0.16em, p 2em).
155
+ const TEXT_SPACING_CSS =
156
+ "* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }";
157
+ const TEXT_SPACING_PROBE = `(() => { ${PRELUDE}
158
+ const hits = [];
159
+ for (const e of Array.from(document.querySelectorAll('p,li,span,a,button,h1,h2,h3,h4,h5,h6,td,th,label,div'))) {
160
+ if (!__vis(e)) continue;
161
+ if ((e.textContent || '').trim().length < 8) continue;
162
+ const s = getComputedStyle(e);
163
+ const clipped = (s.overflowX === 'hidden' || s.overflowY === 'hidden' || s.overflow === 'hidden') && (e.scrollHeight > e.clientHeight + 2 || e.scrollWidth > e.clientWidth + 2);
164
+ const ellipsis = s.textOverflow === 'ellipsis' && e.scrollWidth > e.clientWidth + 2;
165
+ if (clipped || ellipsis) {
166
+ hits.push({ selector: __sel(e), html: __html(e), detail: 'Texte tronqué/masqué sous l\\'espacement de texte WCAG 1.4.12 — perte de contenu.' });
167
+ }
168
+ if (hits.length >= 20) break;
169
+ }
170
+ return hits;
171
+ })()`;
172
+
173
+ // 2.4.7 Focus Visible — pass 1: tag each focusable + snapshot its unfocused style.
174
+ // `scope` (a CSS selector or "" for the whole document) restricts the pass — used to
175
+ // re-run it INSIDE an opened dialog whose focusables the pristine pass could not see.
176
+ //
177
+ // Custom radios/checkboxes (RGAA 10.7): the DSFR-style pattern hides the native input
178
+ // (sr-only) and paints the focus ring on its LABEL. Measuring the hidden input would
179
+ // always report "no visible change" as a false pass — so for a visually-hidden but
180
+ // focusable radio/checkbox we measure the PROXY (label[for], wrapping label, or the
181
+ // aria-labelledby target) instead, keyed by the input (which is what Tab focuses).
182
+ function focusSetupExpr(scope = ""): string {
183
+ const rootExpr = scope ? `document.querySelectorAll(${JSON.stringify(scope)})` : `[document.documentElement]`;
184
+ return `(() => { ${PRELUDE}
185
+ const sel = 'a[href],button:not([disabled]),input:not([type=hidden]):not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"]),[role=button]:not([disabled])';
186
+ const snap = (e) => { const s = getComputedStyle(e); return [s.outlineStyle, s.outlineWidth, s.outlineColor, s.boxShadow, s.borderColor, s.borderTopWidth, s.borderBottomWidth, s.backgroundColor, s.color, s.textDecorationLine].join('|'); };
187
+ // Visually-hidden radio/checkbox → measure its visible label/proxy, not the input.
188
+ const proxyFor = (e) => {
189
+ const type = (e.getAttribute('type') || '').toLowerCase();
190
+ const custom = e.tagName === 'INPUT' && (type === 'radio' || type === 'checkbox') && !__vis(e);
191
+ if (!custom) return __vis(e) ? e : null;
192
+ let p = null;
193
+ if (e.id) { try { p = document.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(e.id) : e.id) + '"]'); } catch (_) {} }
194
+ if (!p) p = e.closest('label');
195
+ if (!p) { const lb = (e.getAttribute('aria-labelledby') || '').split(/\\s+/)[0]; if (lb) p = document.getElementById(lb); }
196
+ return (p && __vis(p)) ? p : null;
197
+ };
198
+ // Fresh authoritative pass: drop any tags a previous (whole-document or dialog) pass left.
199
+ for (const el of Array.from(document.querySelectorAll('[data-u11y-f],[data-u11y-fp]'))) { el.removeAttribute('data-u11y-f'); el.removeAttribute('data-u11y-fp'); }
200
+ const roots = ${rootExpr};
201
+ const focusables = [];
202
+ for (const root of Array.from(roots)) {
203
+ if (root.matches && root.matches(sel)) focusables.push(root);
204
+ for (const e of Array.from(root.querySelectorAll(sel))) focusables.push(e);
205
+ }
206
+ window.__u11yF = {};
207
+ let n = 0;
208
+ for (const e of focusables) {
209
+ const proxy = proxyFor(e);
210
+ if (!proxy) continue;
211
+ const key = 'k' + n;
212
+ e.setAttribute('data-u11y-f', key);
213
+ proxy.setAttribute('data-u11y-fp', key);
214
+ window.__u11yF[key] = { rest: snap(proxy), sel: __sel(proxy), html: __html(proxy) };
215
+ n++;
216
+ if (n >= 120) break;
217
+ }
218
+ return n;
219
+ })()`;
220
+ }
221
+
222
+ // Pass 2 (after each Tab): is the active element a tagged focusable whose focus PROXY
223
+ // (itself, or its label for a custom control) is UNCHANGED vs the unfocused snapshot?
224
+ // If so, focus produced no visible indicator.
225
+ const FOCUS_CHECK_PROBE = `(() => {
226
+ const e = document.activeElement;
227
+ if (!e || e === document.body || e === document.documentElement) return null;
228
+ const key = e.getAttribute && e.getAttribute('data-u11y-f');
229
+ if (!key || !window.__u11yF || !window.__u11yF[key]) return null;
230
+ const rec = window.__u11yF[key];
231
+ const proxy = document.querySelector('[data-u11y-fp="' + key + '"]') || e;
232
+ const s = getComputedStyle(proxy);
233
+ const now = [s.outlineStyle, s.outlineWidth, s.outlineColor, s.boxShadow, s.borderColor, s.borderTopWidth, s.borderBottomWidth, s.backgroundColor, s.color, s.textDecorationLine].join('|');
234
+ return { key: key, changed: now !== rec.rest, selector: rec.sel, html: rec.html };
235
+ })()`;
236
+
237
+ // 1.4.13 Content on Hover — find triggers whose aria-describedby target is hidden, so
238
+ // hovering can reveal it. probeHover then checks it is dismissible (Escape).
239
+ const HOVER_SETUP_PROBE = `(() => { ${PRELUDE}
240
+ const out = [];
241
+ let n = 0;
242
+ for (const e of Array.from(document.querySelectorAll('[aria-describedby]'))) {
243
+ const id = (e.getAttribute('aria-describedby') || '').split(/\\s+/)[0];
244
+ if (!id) continue;
245
+ const t = document.getElementById(id);
246
+ if (!t) continue;
247
+ const s = getComputedStyle(t);
248
+ const hidden = s.display === 'none' || s.visibility === 'hidden' || t.getBoundingClientRect().height === 0;
249
+ if (!hidden) continue;
250
+ const key = 'h' + n;
251
+ e.setAttribute('data-u11y-h', key);
252
+ out.push({ key: key, target: id, selector: __sel(e) });
253
+ n++;
254
+ if (n >= 10) break;
255
+ }
256
+ return out;
257
+ })()`;
258
+
259
+ function hoverVisibleExpr(id: string, wantHidden = false): string {
260
+ const j = JSON.stringify(id);
261
+ return `(() => { const t = document.getElementById(${j}); if (!t) return ${wantHidden ? "true" : "false"}; const s = getComputedStyle(t); const shown = s.display !== 'none' && s.visibility !== 'hidden' && t.getBoundingClientRect().height > 0; return ${wantHidden ? "!shown" : "shown"}; })()`;
262
+ }
263
+
264
+ async function probeFocusVisible(page: Any, scope = ""): Promise<ProbeHit[]> {
265
+ const count = (await page.evaluate(focusSetupExpr(scope))) as number;
266
+ if (!count) return [];
267
+ const hits: ProbeHit[] = [];
268
+ const seen = new Set<string>();
269
+ const limit = Math.min(count + 2, 130);
270
+ for (let i = 0; i < limit; i++) {
271
+ await page.keyboard.press("Tab");
272
+ const r = (await page.evaluate(FOCUS_CHECK_PROBE)) as { key: string; changed: boolean; selector: string; html: string } | null;
273
+ if (!r) continue;
274
+ if (seen.has(r.key)) break; // wrapped around the tab ring
275
+ seen.add(r.key);
276
+ if (!r.changed) {
277
+ hits.push({
278
+ selector: r.selector,
279
+ html: r.html,
280
+ detail: "Le focus clavier ne produit aucun changement visible (outline/box-shadow/bordure/fond) — focus non visible (2.4.7).",
281
+ });
282
+ }
283
+ if (hits.length >= 20) break;
284
+ }
285
+ return hits;
286
+ }
287
+
288
+ // ---- stateful probes (local runtime, interactions ON) --------------------------------
289
+ // SAFETY CONTRACT (also stated in `scan --no-interact` help): these probes drive the page
290
+ // but perform ONLY non-navigating actions — fill text inputs, toggle checkbox/radio, click
291
+ // `button[type="button"]`. NEVER a link, NEVER a submit button, NEVER a form submit. Every
292
+ // interaction records `location.href` before and aborts + restores if it changed, and every
293
+ // loop is bounded, exactly like the read-only probes above. Original state is restored.
294
+
295
+ // (1) FILL_INPUTS_STEP — set a long representative value on each visible text-like input
296
+ // (text/search/email/tel/number/date + textarea), fire `input`, tag whether an ancestor is
297
+ // a td/th, and stash the original value so RESTORE_INPUTS_STEP reverts every field. The
298
+ // stress probes then measure a page that carries the real typed content the auditor
299
+ // confirmed is required to reproduce the reflow/zoom/text-spacing NCs.
300
+ const FILL_INPUTS_STEP = `(() => { ${PRELUDE}
301
+ const LONG = 'Établissement Général des Très Longues Valeurs Saisies 0123456789 exemple';
302
+ const textLike = new Set(['text','search','email','tel','number','date']);
303
+ let n = 0;
304
+ for (const e of Array.from(document.querySelectorAll('input, textarea'))) {
305
+ const tag = e.tagName.toLowerCase();
306
+ const type = (e.getAttribute('type') || 'text').toLowerCase();
307
+ const isTextarea = tag === 'textarea';
308
+ if (!isTextarea && !textLike.has(type)) continue;
309
+ if (e.disabled || e.readOnly) continue;
310
+ if (!__vis(e)) continue;
311
+ let val = LONG;
312
+ if (type === 'number') val = '01234567890123456789';
313
+ else if (type === 'date') val = '2026-12-31';
314
+ else if (type === 'email') val = 'utilisateur.au.nom.tres.long@sous-domaine.exemple.fr';
315
+ else if (type === 'tel') val = '+33 6 12 34 56 78 90 12 34';
316
+ // Respect maxlength: a user can never enter more than that, so a short-code field
317
+ // (maxlength=4) must not be judged as if it held a 70-char value (false overflow).
318
+ const ml = parseInt(e.getAttribute('maxlength') || '0', 10);
319
+ if (ml > 0 && val.length > ml && type !== 'date') val = val.slice(0, ml);
320
+ const key = 'fi' + n;
321
+ e.setAttribute('data-u11y-fill', key);
322
+ e.setAttribute('data-u11y-orig', e.value == null ? '' : String(e.value));
323
+ if (e.closest('td, th')) e.setAttribute('data-u11y-cell', '1');
324
+ try { e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}
325
+ n++;
326
+ if (n >= 60) break;
327
+ }
328
+ return n;
329
+ })()`;
330
+
331
+ const RESTORE_INPUTS_STEP = `(() => {
332
+ for (const e of Array.from(document.querySelectorAll('[data-u11y-fill]'))) {
333
+ const orig = e.getAttribute('data-u11y-orig');
334
+ try { if (orig !== null) { e.value = orig; e.dispatchEvent(new Event('input', { bubbles: true })); } } catch (_) {}
335
+ e.removeAttribute('data-u11y-fill');
336
+ e.removeAttribute('data-u11y-orig');
337
+ e.removeAttribute('data-u11y-cell');
338
+ }
339
+ return true;
340
+ })()`;
341
+
342
+ // (2) INPUT_OVERFLOW — a FILLED input whose typed value has become UNREADABLE under the
343
+ // active stress. A single-line <input> never wraps, so a long value always makes
344
+ // scrollWidth > clientWidth — that alone is normal (the field scrolls), NOT a failure. The
345
+ // real defect the auditor confirmed is a field squeezed so NARROW (a fixed-width or
346
+ // table-cell input at 320px / 200% zoom / text-spacing) that only a few characters of the
347
+ // typed value are visible. So we require clipping AND an unusably narrow visible box: fewer
348
+ // than MIN_VISIBLE_CHARS characters fit at the current font size (font-relative, so the
349
+ // 200%-zoom case is caught naturally as the character width doubles), or the box collapsed
350
+ // near zero. Calibrated on the fixtures: the clip case shows 3.3 chars, the clean one 22. The SC +
351
+ // wording differ per stress; the "input inside a table cell" note is appended for a td/th.
352
+ const MIN_VISIBLE_CHARS = 6;
353
+ function inputOverflowScan(detail: string, cellSuffix: string): string {
354
+ const d = JSON.stringify(detail);
355
+ const cs = JSON.stringify(cellSuffix);
356
+ return `
357
+ for (const e of Array.from(document.querySelectorAll('[data-u11y-fill]'))) {
358
+ if (!__vis(e)) continue;
359
+ const clientW = e.clientWidth;
360
+ const clipping = e.scrollWidth > clientW + 8;
361
+ if (!clipping) continue;
362
+ const fs = parseFloat(getComputedStyle(e).fontSize) || 16;
363
+ const charW = Math.max(1, fs * 0.5); // ~ average glyph advance
364
+ const charsVisible = clientW / charW;
365
+ if (charsVisible < ${MIN_VISIBLE_CHARS} || clientW <= 24) {
366
+ const inCell = e.getAttribute('data-u11y-cell') === '1';
367
+ hits.push({ selector: __sel(e), html: __html(e), detail: ${d} + (inCell ? ${cs} : '') });
368
+ }
369
+ if (hits.length >= 12) break;
370
+ }`;
371
+ }
372
+ // Viewport / text-spacing stress: the caller already set the 320px viewport or added the
373
+ // text-spacing stylesheet; we just measure the filled inputs.
374
+ function inputOverflowExpr(detail: string, cellSuffix: string): string {
375
+ return `(() => { ${PRELUDE} const hits = [];${inputOverflowScan(detail, cellSuffix)} return hits; })()`;
376
+ }
377
+ // Zoom stress: self-contained — enlarge text to 200%, measure, restore.
378
+ function inputOverflowZoomExpr(detail: string, cellSuffix: string): string {
379
+ return `(() => { ${PRELUDE}
380
+ const root = document.documentElement;
381
+ const prev = root.style.fontSize;
382
+ root.style.fontSize = '200%';
383
+ const hits = [];${inputOverflowScan(detail, cellSuffix)}
384
+ root.style.fontSize = prev;
385
+ return hits;
386
+ })()`;
387
+ }
388
+
389
+ const CELL_SUFFIX = { fr: " (champ situé dans une cellule de tableau)", en: " (input inside a table cell)" };
390
+ const INPUT_OVERFLOW_DETAIL = {
391
+ reflow: {
392
+ fr: "Champ rempli dont la valeur saisie est tronquée/illisible à 320px de large — perte de contenu (1.4.10).",
393
+ en: "Filled input whose typed value is clipped/unreadable at 320px width — loss of content (1.4.10).",
394
+ },
395
+ zoom: {
396
+ fr: "Champ rempli dont la valeur saisie est tronquée/illisible au zoom 200% — perte de contenu (1.4.4).",
397
+ en: "Filled input whose typed value is clipped/unreadable at 200% zoom — loss of content (1.4.4).",
398
+ },
399
+ spacing: {
400
+ fr: "Champ rempli dont la valeur saisie est tronquée/illisible sous l'espacement de texte WCAG — perte de contenu (1.4.12).",
401
+ en: "Filled input whose typed value is clipped/unreadable under the WCAG text-spacing override — loss of content (1.4.12).",
402
+ },
403
+ };
404
+
405
+ // (3) OPEN_DIALOGS_STEP — best effort: open the first 3 closed <dialog> (showModal, else
406
+ // the `open` attribute) and reveal currently-hidden [role=dialog]/[role=alertdialog]
407
+ // wrappers, tagging each so the focus pass can be re-run scoped to it. Original state is
408
+ // captured for CLOSE_DIALOGS_STEP. No navigation is possible from opening a dialog.
409
+ const OPEN_DIALOGS_STEP = `(() => { ${PRELUDE}
410
+ const out = [];
411
+ let n = 0;
412
+ for (const d of Array.from(document.querySelectorAll('dialog:not([open])'))) {
413
+ if (n >= 3) break;
414
+ let opened = false;
415
+ try { if (typeof d.showModal === 'function') { d.showModal(); opened = d.open === true; } } catch (_) {}
416
+ if (!opened) { try { d.setAttribute('open', ''); opened = true; } catch (_) {} }
417
+ if (!opened) continue;
418
+ const key = 'dl' + n;
419
+ d.setAttribute('data-u11y-dialog', key);
420
+ out.push(key);
421
+ n++;
422
+ }
423
+ for (const d of Array.from(document.querySelectorAll('[role=dialog],[role=alertdialog]'))) {
424
+ if (n >= 3) break;
425
+ if (__vis(d)) continue; // already open — its focusables were covered by the main pass
426
+ const prevStyle = d.getAttribute('style') || '';
427
+ const hadHidden = d.hasAttribute('hidden');
428
+ d.removeAttribute('hidden');
429
+ if (d.style.display === 'none') d.style.display = 'block';
430
+ d.style.visibility = 'visible';
431
+ d.style.opacity = '1';
432
+ if (!__vis(d)) { if (hadHidden) d.setAttribute('hidden', ''); d.setAttribute('style', prevStyle); continue; }
433
+ const key = 'dl' + n;
434
+ d.setAttribute('data-u11y-dialog', key);
435
+ d.setAttribute('data-u11y-dlg-style', prevStyle);
436
+ if (hadHidden) d.setAttribute('data-u11y-dlg-hidden', '1');
437
+ out.push(key);
438
+ n++;
439
+ }
440
+ return out;
441
+ })()`;
442
+
443
+ const CLOSE_DIALOGS_STEP = `(() => {
444
+ for (const d of Array.from(document.querySelectorAll('[data-u11y-dialog]'))) {
445
+ if (d.tagName.toLowerCase() === 'dialog') {
446
+ try { if (typeof d.close === 'function' && d.open) d.close(); } catch (_) {}
447
+ d.removeAttribute('open');
448
+ } else {
449
+ const prev = d.getAttribute('data-u11y-dlg-style');
450
+ if (prev) d.setAttribute('style', prev); else d.removeAttribute('style');
451
+ if (d.getAttribute('data-u11y-dlg-hidden') === '1') d.setAttribute('hidden', '');
452
+ }
453
+ d.removeAttribute('data-u11y-dialog');
454
+ d.removeAttribute('data-u11y-dlg-style');
455
+ d.removeAttribute('data-u11y-dlg-hidden');
456
+ }
457
+ return true;
458
+ })()`;
459
+
460
+ async function probeDialogs(page: Any): Promise<ProbeHit[]> {
461
+ const keys = (await page.evaluate(OPEN_DIALOGS_STEP).catch(() => [])) as string[];
462
+ if (!Array.isArray(keys) || keys.length === 0) return [];
463
+ const hits: ProbeHit[] = [];
464
+ for (const key of keys) {
465
+ const scoped = await probeFocusVisible(page, `[data-u11y-dialog="${key}"]`).catch(() => [] as ProbeHit[]);
466
+ hits.push(...scoped);
467
+ if (hits.length >= 12) break;
468
+ }
469
+ await page.evaluate(CLOSE_DIALOGS_STEP).catch(() => {});
470
+ return hits.slice(0, 12);
471
+ }
472
+
473
+ // (5) LIVE_REGION_PROBE — WCAG 4.1.3 Status Messages (honest heuristic, severity mineur).
474
+ // Install a MutationObserver on <body>, perform ONLY the safe interactions above, and flag
475
+ // a text update whose nearest ancestor is NOT a live region (aria-live / role=status|alert
476
+ // |log) — it was likely never announced to assistive tech. location.href is asserted after
477
+ // each interaction (abort + restore on any change). Interactions and hits are bounded.
478
+ //
479
+ // HEURISTIC HONESTY: an EXPECTED context change (a dialog opening, an accordion panel
480
+ // expanding after its toggle) also mutates non-live text and can fire this probe — such
481
+ // updates don't necessarily need a live region. That is why the finding is `mineur` with
482
+ // "likely/probablement" framing, deliberately: it points the auditor at the update, it does
483
+ // not claim certainty.
484
+ //
485
+ // CLICK SAFETY (authenticated scans): even a `button[type="button"]` click can trigger a
486
+ // server MUTATION (delete a row, send a message) that the location.href assertion cannot
487
+ // see. So the click loop is emitted ONLY when `allowClicks` is true — the caller disables
488
+ // it by default whenever a storageState (authenticated session) is in use, re-enabled via
489
+ // `scan --interact-clicks`; unauthenticated scans keep clicks on. Defense-in-depth on top:
490
+ // even when clicks are on, a button whose accessible name matches a destructive/submitting
491
+ // verb (fr/en: supprimer, retirer, effacer, envoyer, valider, confirmer, payer, delete,
492
+ // remove, send, submit, confirm, pay, …) is never clicked. Fill/toggle interactions always
493
+ // run (they are the confirmed real-world need) and are restored.
494
+ const DESTRUCTIVE_NAME_RE = "\\b(supprim|retir|effac|envoy|valid|confirm|pay|achet|command|delete|remove|eras|clear|send|submit|buy|order)";
495
+ function liveRegionExpr(detail: string, allowClicks: boolean): string {
496
+ const d = JSON.stringify(detail);
497
+ const clickLoop = allowClicks
498
+ ? `
499
+ // click button[type=button] only (never a submit/link), skipping destructive names
500
+ const dangerous = new RegExp(${JSON.stringify(DESTRUCTIVE_NAME_RE)}, 'i');
501
+ const nameOf = (b) => {
502
+ let n = (b.getAttribute('aria-label') || '') + ' ' + (b.textContent || '') + ' ' + (b.getAttribute('title') || '');
503
+ // ALL aria-labelledby ids (attribute trimmed): a destructive verb may sit in ANY
504
+ // referenced id, and the value may carry stray leading/trailing whitespace.
505
+ for (const id of (b.getAttribute('aria-labelledby') || '').trim().split(/\\s+/)) {
506
+ if (!id) continue;
507
+ const t = document.getElementById(id);
508
+ if (t) n += ' ' + (t.textContent || '');
509
+ }
510
+ // Icon-only buttons: the name lives in img[alt] (an attribute — invisible to
511
+ // textContent) or an svg <title> (belt-and-braces; textContent usually includes it).
512
+ for (const im of Array.from(b.querySelectorAll('img[alt]'))) n += ' ' + (im.getAttribute('alt') || '');
513
+ for (const ti of Array.from(b.querySelectorAll('svg title'))) n += ' ' + (ti.textContent || '');
514
+ return n;
515
+ };
516
+ for (const b of Array.from(document.querySelectorAll('button[type="button"]'))) {
517
+ if (count >= 20 || hits.length >= 10) break;
518
+ if (b.disabled || !__vis(b)) continue;
519
+ if (dangerous.test(nameOf(b))) continue; // defense-in-depth: never click a destructive-named button
520
+ const before = location.href;
521
+ try { b.click(); } catch (_) {}
522
+ await settle();
523
+ if (location.href !== before) { obs.disconnect(); return hits; }
524
+ drain();
525
+ count++;
526
+ }`
527
+ : `
528
+ // click interactions disabled (authenticated scan without --interact-clicks)`;
529
+ return `(async () => { ${PRELUDE}
530
+ const isLive = (node) => {
531
+ let el = node && node.nodeType === 1 ? node : (node ? node.parentElement : null);
532
+ while (el && el !== document.documentElement) {
533
+ const live = (el.getAttribute && el.getAttribute('aria-live')) || '';
534
+ const role = (el.getAttribute && el.getAttribute('role')) || '';
535
+ if (live === 'polite' || live === 'assertive') return true;
536
+ if (role === 'status' || role === 'alert' || role === 'log') return true;
537
+ el = el.parentElement;
538
+ }
539
+ return false;
540
+ };
541
+ const hits = [];
542
+ const seen = new Set();
543
+ const records = [];
544
+ const obs = new MutationObserver((muts) => { for (const m of muts) records.push(m); });
545
+ obs.observe(document.body, { subtree: true, childList: true, characterData: true });
546
+ const settle = () => new Promise((r) => setTimeout(r, 40));
547
+ const drain = () => {
548
+ for (const m of records.splice(0)) {
549
+ const targets = m.type === 'characterData' ? [m.target] : Array.from(m.addedNodes);
550
+ for (const t of targets) {
551
+ if (!t || (t.textContent || '').trim().length === 0) continue;
552
+ if (isLive(t)) continue;
553
+ const host = t.nodeType === 1 ? t : t.parentElement;
554
+ if (!host || !__vis(host)) continue;
555
+ const key = __sel(host);
556
+ if (seen.has(key)) continue;
557
+ seen.add(key);
558
+ hits.push({ selector: key, html: __html(host), detail: ${d} });
559
+ }
560
+ }
561
+ };
562
+ let count = 0;${clickLoop}
563
+ // toggle checkbox/radio, then restore
564
+ for (const t of Array.from(document.querySelectorAll('input[type="checkbox"], input[type="radio"]'))) {
565
+ if (count >= 40 || hits.length >= 10) break;
566
+ if (t.disabled || !__vis(t)) continue;
567
+ const before = location.href;
568
+ const prev = t.checked;
569
+ try { t.click(); } catch (_) {}
570
+ await settle();
571
+ if (location.href !== before) { obs.disconnect(); return hits; }
572
+ drain();
573
+ try { if (t.checked !== prev) { t.checked = prev; t.dispatchEvent(new Event('change', { bubbles: true })); } } catch (_) {}
574
+ count++;
575
+ }
576
+ // fill text inputs, then restore
577
+ for (const inp of Array.from(document.querySelectorAll('input[type="text"], input[type="email"], input[type="search"], textarea'))) {
578
+ if (count >= 60 || hits.length >= 10) break;
579
+ if (inp.disabled || inp.readOnly || !__vis(inp)) continue;
580
+ const before = location.href;
581
+ const prev = inp.value == null ? '' : String(inp.value);
582
+ try { inp.value = 'test 123'; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) {}
583
+ await settle();
584
+ if (location.href !== before) { obs.disconnect(); return hits; }
585
+ drain();
586
+ try { inp.value = prev; inp.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}
587
+ count++;
588
+ }
589
+ obs.disconnect();
590
+ return hits.slice(0, 10);
591
+ })()`;
592
+ }
593
+ const LIVE_REGION_DETAIL = {
594
+ fr: "Mise à jour de contenu déclenchée par une interaction hors d'une région live (aria-live / role=status|alert|log) — probablement non restituée aux technologies d'assistance (4.1.3).",
595
+ en: "Content update triggered by an interaction outside any live region (aria-live / role=status|alert|log) — likely not announced to assistive technology (4.1.3).",
596
+ };
597
+
598
+ /** Should the live-region probe CLICK buttons? Never by default on an authenticated scan
599
+ * (a storageState session is loaded — a click could trigger a server mutation the
600
+ * location.href assertion cannot see); `scan --interact-clicks` re-enables explicitly.
601
+ * Unauthenticated scans keep clicks on. Exported for the browser-free policy test. */
602
+ export function clicksAllowed(storageState: string | undefined, interactClicks: boolean | undefined): boolean {
603
+ return interactClicks === true || !storageState;
604
+ }
605
+
606
+ async function probeLiveRegion(page: Any, lang: Lang, allowClicks: boolean): Promise<ProbeHit[]> {
607
+ const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
608
+ return (await page.evaluate(liveRegionExpr(detail, allowClicks)).catch(() => [])) as ProbeHit[];
609
+ }
610
+
611
+ async function probeHover(page: Any): Promise<ProbeHit[]> {
612
+ const triggers = (await page.evaluate(HOVER_SETUP_PROBE)) as { key: string; target: string; selector: string }[];
613
+ const hits: ProbeHit[] = [];
614
+ for (const tr of triggers) {
615
+ try {
616
+ await page.hover(`[data-u11y-h="${tr.key}"]`);
617
+ } catch {
618
+ continue;
619
+ }
620
+ await page.waitForTimeout(150);
621
+ const shown = (await page.evaluate(hoverVisibleExpr(tr.target))) as boolean;
622
+ if (!shown) continue; // not actually a hover-revealed tooltip
623
+ await page.keyboard.press("Escape");
624
+ await page.waitForTimeout(100);
625
+ const dismissed = (await page.evaluate(hoverVisibleExpr(tr.target, true))) as boolean;
626
+ await page.mouse.move(2, 2).catch(() => {});
627
+ if (!dismissed) {
628
+ hits.push({
629
+ selector: tr.selector,
630
+ html: "",
631
+ detail: `Le contenu révélé au survol (aria-describedby #${tr.target}) ne se masque pas avec Échap — Contenu au survol ou au focus (1.4.13).`,
632
+ });
633
+ }
634
+ if (hits.length >= 8) break;
635
+ }
636
+ return hits;
637
+ }
638
+
639
+ // ---- CI probe-string guard ------------------------------------------------------------
640
+ /** EVERY string-evaluated browser expression this runtime ships — the constants plus the
641
+ * parameterized builders instantiated with representative arguments. Exported ONLY for
642
+ * the browser-free CI smoke test (tests/probe-syntax.test.ts), which syntax-validates
643
+ * each via `new Function` (compiled, never called) — a probe-string typo fails CI
644
+ * instead of exploding (and being swallowed by a `.catch`) at scan time.
645
+ * Add every NEW probe/step/builder here when extending the runtime. */
646
+ export function probeSources(): Record<string, string> {
647
+ return {
648
+ PRELUDE: `(() => { ${PRELUDE} return true; })()`,
649
+ REFLOW_PROBE,
650
+ REFLOW_ZOOM_PROBE,
651
+ TEXT_SPACING_PROBE,
652
+ "focusSetupExpr(document)": focusSetupExpr(),
653
+ "focusSetupExpr(scoped)": focusSetupExpr('[data-u11y-dialog="dl0"]'),
654
+ FOCUS_CHECK_PROBE,
655
+ HOVER_SETUP_PROBE,
656
+ "hoverVisibleExpr(shown)": hoverVisibleExpr("tip-1"),
657
+ "hoverVisibleExpr(hidden)": hoverVisibleExpr("tip-1", true),
658
+ FILL_INPUTS_STEP,
659
+ RESTORE_INPUTS_STEP,
660
+ "inputOverflowExpr(reflow,en)": inputOverflowExpr(INPUT_OVERFLOW_DETAIL.reflow.en, CELL_SUFFIX.en),
661
+ "inputOverflowExpr(spacing,fr)": inputOverflowExpr(INPUT_OVERFLOW_DETAIL.spacing.fr, CELL_SUFFIX.fr),
662
+ "inputOverflowZoomExpr(zoom,en)": inputOverflowZoomExpr(INPUT_OVERFLOW_DETAIL.zoom.en, CELL_SUFFIX.en),
663
+ OPEN_DIALOGS_STEP,
664
+ CLOSE_DIALOGS_STEP,
665
+ "liveRegionExpr(clicks)": liveRegionExpr(LIVE_REGION_DETAIL.en, true),
666
+ "liveRegionExpr(noClicks)": liveRegionExpr(LIVE_REGION_DETAIL.fr, false),
667
+ };
668
+ }
669
+
670
+ const AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"];
671
+
672
+ /** Drive one page through axe-core + every probe, returning a RunnerOutput. */
673
+ async function runOnPage(
674
+ browser: Any,
675
+ AxeBuilder: Any,
676
+ target: string,
677
+ isFile: boolean,
678
+ opts: { storageState?: string; interact: boolean; allowClicks: boolean; lang: Lang },
679
+ ): Promise<RunnerOutput> {
680
+ const context = await browser.newContext(opts.storageState ? { storageState: opts.storageState } : {});
681
+ const page = await context.newPage();
682
+ const empty: ProbeHit[] = [];
683
+ try {
684
+ const url = isFile ? "file://" + resolve(target) : target;
685
+ await page.goto(url, { waitUntil: "load", timeout: 45000 });
686
+ // Let the client hydrate and any framework JS inject content/landmarks (SPA routes,
687
+ // DSFR JS) before measuring — otherwise axe/probes see the pre-hydration DOM and
688
+ // report false "no h1 / not in a landmark". Bounded networkidle + a short settle.
689
+ await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
690
+ await page.waitForTimeout(1200);
691
+ const axeRes = await new AxeBuilder({ page }).withTags(AXE_TAGS).analyze();
692
+ const violations = (axeRes.violations as Any[]).map((v: Any) => ({
693
+ id: v.id as string,
694
+ impact: (v.impact ?? null) as string | null,
695
+ help: v.help as string,
696
+ tags: v.tags as string[],
697
+ nodes: (v.nodes as Any[]).slice(0, 10).map((n: Any) => ({ target: (n.target as unknown[]).map(String), html: ((n.html as string) || "").slice(0, 200) })),
698
+ }));
699
+ // clean-DOM probes first (normal viewport), then — with inputs FILLED when interactions
700
+ // are on — viewport/zoom, then the text-spacing override. Each probe is guarded: a single
701
+ // probe failing degrades to no findings for that criterion rather than zeroing the whole
702
+ // page (axe results are already captured). The stateful interaction probes (dialogs,
703
+ // live-region) run LAST so their DOM side effects can never leak into the stress
704
+ // measurements; `--no-interact` (opts.interact === false) skips fill/dialogs/live-region
705
+ // entirely — the exact pre-stateful behaviour.
706
+ const focusVisible = await probeFocusVisible(page).catch(() => empty);
707
+ const hover = await probeHover(page).catch(() => empty);
708
+ const l = opts.lang;
709
+ if (opts.interact) await page.evaluate(FILL_INPUTS_STEP).catch(() => {});
710
+ const reflowZoom = (await page.evaluate(REFLOW_ZOOM_PROBE).catch(() => [])) as ProbeHit[];
711
+ const inputOverflowZoom = opts.interact
712
+ ? ((await page.evaluate(inputOverflowZoomExpr(INPUT_OVERFLOW_DETAIL.zoom[l], CELL_SUFFIX[l])).catch(() => [])) as ProbeHit[])
713
+ : [];
714
+ await page.setViewportSize({ width: 320, height: 800 }).catch(() => {});
715
+ const reflow = (await page.evaluate(REFLOW_PROBE).catch(() => ({ horizontalScroll: false }))) as { horizontalScroll: boolean };
716
+ const inputOverflowReflow = opts.interact
717
+ ? ((await page.evaluate(inputOverflowExpr(INPUT_OVERFLOW_DETAIL.reflow[l], CELL_SUFFIX[l])).catch(() => [])) as ProbeHit[])
718
+ : [];
719
+ await page.setViewportSize({ width: 1280, height: 900 }).catch(() => {});
720
+ await page.addStyleTag({ content: TEXT_SPACING_CSS }).catch(() => {});
721
+ const textSpacing = (await page.evaluate(TEXT_SPACING_PROBE).catch(() => [])) as ProbeHit[];
722
+ const inputOverflowSpacing = opts.interact
723
+ ? ((await page.evaluate(inputOverflowExpr(INPUT_OVERFLOW_DETAIL.spacing[l], CELL_SUFFIX[l])).catch(() => [])) as ProbeHit[])
724
+ : [];
725
+ if (opts.interact) await page.evaluate(RESTORE_INPUTS_STEP).catch(() => {});
726
+ // Stateful interaction probes last, and LIVE-REGION IS THE TERMINAL PROBE: unlike the
727
+ // fill/toggle interactions (restored) its button-click DOM mutations are NOT restored
728
+ // (a page's own click handler can change anything), so reordering it before any
729
+ // measurement probe would leak that state into the measurements. Dialog focus issues
730
+ // fold into the same 2.4.7 focus-visible bucket.
731
+ const dialogFocus = opts.interact ? await probeDialogs(page).catch(() => empty) : [];
732
+ const liveRegion = opts.interact ? await probeLiveRegion(page, l, opts.allowClicks).catch(() => empty) : [];
733
+ return {
734
+ url: (page.url() as string) || target,
735
+ violations,
736
+ reflow,
737
+ focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
738
+ hover,
739
+ reflowZoom,
740
+ textSpacing,
741
+ inputOverflowReflow,
742
+ inputOverflowZoom,
743
+ inputOverflowSpacing,
744
+ liveRegion,
745
+ };
746
+ } finally {
747
+ await context.close();
748
+ }
749
+ }
750
+
751
+ export interface LocalScanOpts {
752
+ target: string;
753
+ cwd: string;
754
+ storageState?: string;
755
+ lang?: Lang;
756
+ // Stateful probes (fill inputs → input-overflow, open dialogs, live-region) ON by default;
757
+ // `scan --no-interact` sets this false to fall back to the pristine-page probes only.
758
+ interact?: boolean;
759
+ // Allow the live-region probe to CLICK button[type=button] on an AUTHENTICATED scan
760
+ // (storageState in use). Off by default there — a click can trigger a server mutation
761
+ // invisible to the href assertion; `scan --interact-clicks` opts in. Ignored (clicks
762
+ // always on) when no storageState is loaded. See clicksAllowed().
763
+ interactClicks?: boolean;
764
+ }
765
+
766
+ /** Run the dynamic tier locally over a single URL/file (no Docker). */
767
+ export async function runScanLocal(opts: LocalScanOpts): Promise<DynamicResult> {
768
+ const isUrl = /^https?:\/\//i.test(opts.target);
769
+ if (!isUrl && !existsSync(opts.target)) {
770
+ throw new Error(`File not found: ${opts.target}. Pass an http(s):// URL or an existing HTML file.`);
771
+ }
772
+ const isFile = !isUrl && statSync(opts.target).isFile();
773
+ const lang = opts.lang ?? "en";
774
+ const interact = opts.interact !== false;
775
+ const { chromium, AxeBuilder } = resolveLocalDeps(opts.cwd);
776
+ const browser = await launchChromium(chromium);
777
+ try {
778
+ const out = await runOnPage(browser, AxeBuilder, opts.target, isFile, {
779
+ storageState: opts.storageState,
780
+ interact,
781
+ allowClicks: clicksAllowed(opts.storageState, opts.interactClicks),
782
+ lang,
783
+ });
784
+ return { ...toDynamicResult(out, opts.target, lang, LOCAL_ENGINE), testedScs: localTestedScs(interact) };
785
+ } finally {
786
+ await browser.close();
787
+ }
788
+ }
789
+
790
+ export interface LocalManyOpts {
791
+ cwd: string;
792
+ storageState?: string;
793
+ lang?: Lang;
794
+ interact?: boolean;
795
+ interactClicks?: boolean; // see LocalScanOpts.interactClicks
796
+ }
797
+
798
+ /** Run the local dynamic tier over many URLs (one browser, one context per page). */
799
+ export async function runScanManyLocal(urls: string[], opts: LocalManyOpts): Promise<DynamicResult> {
800
+ const lang = opts.lang ?? "en";
801
+ const interact = opts.interact !== false;
802
+ const { chromium, AxeBuilder } = resolveLocalDeps(opts.cwd);
803
+ const browser = await launchChromium(chromium);
804
+ const findings: DynamicFinding[] = [];
805
+ try {
806
+ for (const url of urls) {
807
+ const out = await runOnPage(browser, AxeBuilder, url, false, {
808
+ storageState: opts.storageState,
809
+ interact,
810
+ allowClicks: clicksAllowed(opts.storageState, opts.interactClicks),
811
+ lang,
812
+ });
813
+ findings.push(...toDynamicResult(out, url, lang, LOCAL_ENGINE).findings);
814
+ }
815
+ } finally {
816
+ await browser.close();
817
+ }
818
+ return { tool: "ultra11y", engine: LOCAL_ENGINE, target: `${urls.length} page(s)`, date: today(), findings, testedScs: localTestedScs(interact) };
819
+ }
820
+
821
+ /** Run the local dynamic tier over a NORMATIVE page SAMPLE (one browser, one context per
822
+ * page). Each page's own `storageState` OVERRIDES the run-wide one, so a mixed sample
823
+ * (some pages public, some authenticated) is scanned with the right session per page; the
824
+ * clicks-off policy (clicksAllowed) is re-evaluated per page from its effective
825
+ * storageState. Findings keep their sample-page provenance; the sample is recorded on the
826
+ * result. SECURITY: storageState is only ever passed to Playwright as a path — never read. */
827
+ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpts): Promise<DynamicResult> {
828
+ const lang = opts.lang ?? "en";
829
+ const interact = opts.interact !== false;
830
+ const { chromium, AxeBuilder } = resolveLocalDeps(opts.cwd);
831
+ const browser = await launchChromium(chromium);
832
+ const findings: DynamicFinding[] = [];
833
+ try {
834
+ for (const page of pages) {
835
+ const storageState = page.storageState ?? opts.storageState; // per-page override
836
+ const isFile = !/^https?:\/\//i.test(page.url);
837
+ const out = await runOnPage(browser, AxeBuilder, page.url, isFile, {
838
+ storageState,
839
+ interact,
840
+ allowClicks: clicksAllowed(storageState, opts.interactClicks),
841
+ lang,
842
+ });
843
+ findings.push(...tagSampleFindings(toDynamicResult(out, page.url, lang, LOCAL_ENGINE).findings, page));
844
+ }
845
+ } finally {
846
+ await browser.close();
847
+ }
848
+ return {
849
+ tool: "ultra11y",
850
+ engine: LOCAL_ENGINE,
851
+ target: `${pages.length} page(s) (échantillon)`,
852
+ date: today(),
853
+ findings,
854
+ sample: sampleScope({ pages }),
855
+ testedScs: localTestedScs(interact),
856
+ };
857
+ }
858
+
859
+ /** Discover URLs (sitemap/crawl) then scan them all through the local dynamic tier. */
860
+ export async function runCrawlScanLocal(opts: DiscoverOpts & LocalManyOpts): Promise<DynamicResult> {
861
+ const urls = await discoverUrls(opts);
862
+ if (urls.length === 0) {
863
+ throw new Error("No URL to scan (empty/unreachable sitemap, or entry page with no same-origin link).");
864
+ }
865
+ return runScanManyLocal(urls, opts);
866
+ }