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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- 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
|
+
}
|