@wcag-audit/cli 1.0.0-alpha.3

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 (71) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +110 -0
  3. package/package.json +72 -0
  4. package/patches/@guidepup+guidepup+0.24.1.patch +30 -0
  5. package/src/__tests__/sanity.test.js +7 -0
  6. package/src/ai-fix-json.js +321 -0
  7. package/src/audit.js +199 -0
  8. package/src/cache/route-cache.js +46 -0
  9. package/src/cache/route-cache.test.js +96 -0
  10. package/src/checkers/ai-vision.js +102 -0
  11. package/src/checkers/auth.js +98 -0
  12. package/src/checkers/axe.js +65 -0
  13. package/src/checkers/consistency.js +222 -0
  14. package/src/checkers/forms.js +149 -0
  15. package/src/checkers/interaction.js +142 -0
  16. package/src/checkers/keyboard.js +347 -0
  17. package/src/checkers/media.js +102 -0
  18. package/src/checkers/motion.js +155 -0
  19. package/src/checkers/pointer.js +128 -0
  20. package/src/checkers/screen-reader.js +522 -0
  21. package/src/checkers/viewport.js +202 -0
  22. package/src/cli.js +156 -0
  23. package/src/commands/ci.js +63 -0
  24. package/src/commands/ci.test.js +55 -0
  25. package/src/commands/doctor.js +105 -0
  26. package/src/commands/doctor.test.js +81 -0
  27. package/src/commands/init.js +126 -0
  28. package/src/commands/init.test.js +83 -0
  29. package/src/commands/scan.js +322 -0
  30. package/src/commands/scan.test.js +139 -0
  31. package/src/config/global.js +60 -0
  32. package/src/config/global.test.js +58 -0
  33. package/src/config/project.js +35 -0
  34. package/src/config/project.test.js +44 -0
  35. package/src/devserver/spawn.js +82 -0
  36. package/src/devserver/spawn.test.js +58 -0
  37. package/src/discovery/astro.js +86 -0
  38. package/src/discovery/astro.test.js +76 -0
  39. package/src/discovery/crawl.js +93 -0
  40. package/src/discovery/crawl.test.js +93 -0
  41. package/src/discovery/dynamic-samples.js +44 -0
  42. package/src/discovery/dynamic-samples.test.js +66 -0
  43. package/src/discovery/manual.js +38 -0
  44. package/src/discovery/manual.test.js +52 -0
  45. package/src/discovery/nextjs.js +136 -0
  46. package/src/discovery/nextjs.test.js +141 -0
  47. package/src/discovery/registry.js +80 -0
  48. package/src/discovery/registry.test.js +33 -0
  49. package/src/discovery/remix.js +82 -0
  50. package/src/discovery/remix.test.js +77 -0
  51. package/src/discovery/sitemap.js +73 -0
  52. package/src/discovery/sitemap.test.js +69 -0
  53. package/src/discovery/sveltekit.js +85 -0
  54. package/src/discovery/sveltekit.test.js +76 -0
  55. package/src/discovery/vite.js +94 -0
  56. package/src/discovery/vite.test.js +144 -0
  57. package/src/license/log-usage.js +23 -0
  58. package/src/license/log-usage.test.js +45 -0
  59. package/src/license/validate.js +58 -0
  60. package/src/license/validate.test.js +58 -0
  61. package/src/output/agents-md.js +58 -0
  62. package/src/output/agents-md.test.js +62 -0
  63. package/src/output/cursor-rules.js +57 -0
  64. package/src/output/cursor-rules.test.js +62 -0
  65. package/src/output/markdown.js +119 -0
  66. package/src/output/markdown.test.js +95 -0
  67. package/src/report.js +235 -0
  68. package/src/util/anthropic.js +25 -0
  69. package/src/util/llm.js +159 -0
  70. package/src/util/screenshot.js +131 -0
  71. package/src/wcag-criteria.js +256 -0
@@ -0,0 +1,222 @@
1
+ // Multi-page consistency checker. Covers:
2
+ //
3
+ // 3.2.3 Consistent Navigation — repeated navigation in same relative order
4
+ // 3.2.4 Consistent Identification — components with same function get same name
5
+ // 3.2.6 Consistent Help — help mechanism in the same place across pages
6
+ //
7
+ // Strategy: starting from the audited URL, follow up to N internal links
8
+ // (same origin), extract a normalized "navigation fingerprint" and a
9
+ // "components fingerprint" from each page, then compare. Differences in
10
+ // link order, component naming, or help-link position become findings.
11
+
12
+ import { URL } from "node:url";
13
+
14
+ const HELP_REGEX = /help|support|contact|faq/i;
15
+
16
+ export async function runConsistency(ctx) {
17
+ const findings = [];
18
+ const max = Math.max(2, Math.min(ctx.maxPages || 5, 10));
19
+ const visited = new Set();
20
+ const fingerprints = [];
21
+ const queue = [ctx.url];
22
+ const startOrigin = new URL(ctx.url).origin;
23
+
24
+ while (queue.length && fingerprints.length < max) {
25
+ const next = queue.shift();
26
+ if (visited.has(next)) continue;
27
+ visited.add(next);
28
+
29
+ const sub = await ctx.context.newPage();
30
+ try {
31
+ await sub.goto(next, { waitUntil: "networkidle", timeout: 60_000 });
32
+ // Extra settle time for async client hydration (Clerk, Auth.js, etc.
33
+ // inject nav items after the initial networkidle event).
34
+ await sub.waitForTimeout(1500);
35
+ const fp = await sub.evaluate((helpRegexSource) => {
36
+ const helpRe = new RegExp(helpRegexSource, "i");
37
+ // Nav fingerprint: ordered list of link texts inside the first <nav>
38
+ const nav = document.querySelector("nav,[role='navigation']");
39
+ const navLinks = nav
40
+ ? Array.from(nav.querySelectorAll("a")).map((a) => (a.textContent || "").trim()).filter(Boolean).slice(0, 30)
41
+ : [];
42
+ // Components fingerprint: name (innerText) of every <button> + every link with role
43
+ const components = Array.from(document.querySelectorAll("button,a")).map((el) => {
44
+ const r = el.getBoundingClientRect();
45
+ return {
46
+ tag: el.tagName.toLowerCase(),
47
+ name: (el.innerText || el.getAttribute("aria-label") || "").trim().toLowerCase().slice(0, 50),
48
+ href: el.getAttribute("href") || "",
49
+ role: el.getAttribute("role") || "",
50
+ visible: r.width > 0 && r.height > 0,
51
+ };
52
+ }).filter((c) => c.visible && c.name).slice(0, 200);
53
+ // Help-link position: rect of the first link/button matching the regex
54
+ const helpEl = Array.from(document.querySelectorAll("a,button"))
55
+ .find((el) => helpRe.test(el.textContent || el.getAttribute("aria-label") || ""));
56
+ const helpRect = helpEl ? helpEl.getBoundingClientRect() : null;
57
+ return {
58
+ navLinks,
59
+ components,
60
+ helpRect: helpRect ? { x: Math.round(helpRect.x), y: Math.round(helpRect.y) } : null,
61
+ };
62
+ }, HELP_REGEX.source);
63
+
64
+ fingerprints.push({ url: next, ...fp });
65
+
66
+ // Enqueue same-origin internal links
67
+ const links = await sub.$$eval("a[href]", (els) => els.map((a) => a.href).filter(Boolean));
68
+ for (const l of links) {
69
+ try {
70
+ if (new URL(l).origin === startOrigin && !visited.has(l) && !l.includes("#")) queue.push(l);
71
+ } catch (_) {}
72
+ if (queue.length > 30) break;
73
+ }
74
+ } catch (_) {
75
+ // ignore page errors during crawl
76
+ } finally {
77
+ await sub.close().catch(() => {});
78
+ }
79
+ }
80
+
81
+ if (fingerprints.length < 2) {
82
+ return { findings }; // not enough pages crawled to compare
83
+ }
84
+
85
+ // ── 3.2.3 Consistent Navigation ──────────────────────────────────────
86
+ // Dedup each nav's links before comparing — many sites render the
87
+ // same nav twice (desktop + mobile dropdown) which produces duplicate
88
+ // entries. We only care about the RELATIVE order of unique link
89
+ // labels, not how many times each label appears.
90
+ function dedupPreserveOrder(arr) {
91
+ const seen = new Set();
92
+ const out = [];
93
+ for (const x of arr) {
94
+ if (!seen.has(x)) {
95
+ seen.add(x);
96
+ out.push(x);
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+ const baseNav = dedupPreserveOrder(fingerprints[0].navLinks);
102
+ for (let i = 1; i < fingerprints.length; i++) {
103
+ const otherNav = dedupPreserveOrder(fingerprints[i].navLinks);
104
+ if (baseNav.length === 0 || otherNav.length === 0) continue;
105
+ // Find common nav items and check whether their relative order matches
106
+ const common = baseNav.filter((x) => otherNav.includes(x));
107
+ const baseOrder = common.map((x) => baseNav.indexOf(x));
108
+ const otherOrder = common.map((x) => otherNav.indexOf(x));
109
+ const ordered = baseOrder.every((v, j) => j === 0 || v > baseOrder[j - 1]);
110
+ const otherOrdered = otherOrder.every((v, j) => j === 0 || v > otherOrder[j - 1]);
111
+ if (!ordered || !otherOrdered) {
112
+ findings.push({
113
+ source: "playwright",
114
+ ruleId: "consistency/nav-order",
115
+ criteria: "3.2.3",
116
+ level: "AA",
117
+ impact: "moderate",
118
+ description: `Navigation links appear in a different order between ${fingerprints[0].url} and ${fingerprints[i].url}.`,
119
+ help: "Repeated navigation must occur in the same relative order on every page (WCAG 3.2.3).",
120
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/consistent-navigation.html",
121
+ selector: "nav",
122
+ evidence: `Page 1 nav: ${JSON.stringify(baseNav)}\nPage ${i + 1} nav: ${JSON.stringify(otherNav)}`,
123
+ failureSummary: "Nav order differs",
124
+ screenshotFile: null,
125
+ });
126
+ break;
127
+ }
128
+ }
129
+
130
+ // ── 3.2.4 Consistent Identification ──────────────────────────────────
131
+ // For each href that appears on >=2 pages, check the names match.
132
+ //
133
+ // Soft-matching: WCAG 3.2.4 is about FUNCTIONAL consistency, not
134
+ // pixel-identical text. So we normalize (strip emoji, arrows,
135
+ // punctuation, collapse whitespace) and allow substring matches
136
+ // (e.g. "Enterprise" and "See enterprise plans" are compatible).
137
+ function normalize(s) {
138
+ return (s || "")
139
+ .toLowerCase()
140
+ .replace(/[→←▸▶️→↗↘🡒→]/g, "")
141
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
142
+ .replace(/\s+/g, " ")
143
+ .trim();
144
+ }
145
+ function compatible(a, b) {
146
+ const na = normalize(a);
147
+ const nb = normalize(b);
148
+ if (!na || !nb) return true;
149
+ if (na === nb) return true;
150
+ // Substring match (one extends the other — common CTA pattern)
151
+ if (na.includes(nb) || nb.includes(na)) return true;
152
+ // Same leading word (e.g. "Enterprise" and "Enterprise plans")
153
+ const firstA = na.split(" ")[0];
154
+ const firstB = nb.split(" ")[0];
155
+ if (firstA && firstA === firstB && firstA.length >= 4) return true;
156
+ return false;
157
+ }
158
+ const nameByHref = {};
159
+ for (const fp of fingerprints) {
160
+ for (const c of fp.components) {
161
+ if (!c.href) continue;
162
+ if (!nameByHref[c.href]) nameByHref[c.href] = new Set();
163
+ nameByHref[c.href].add(c.name);
164
+ }
165
+ }
166
+ const inconsistent = Object.entries(nameByHref)
167
+ .filter(([_, names]) => {
168
+ const arr = [...names];
169
+ if (arr.length < 2) return false;
170
+ // Flag only if at least one pair is NOT compatible
171
+ for (let i = 0; i < arr.length; i++) {
172
+ for (let j = i + 1; j < arr.length; j++) {
173
+ if (!compatible(arr[i], arr[j])) return true;
174
+ }
175
+ }
176
+ return false;
177
+ })
178
+ .slice(0, 10);
179
+ if (inconsistent.length) {
180
+ findings.push({
181
+ source: "playwright",
182
+ ruleId: "consistency/identification",
183
+ criteria: "3.2.4",
184
+ level: "AA",
185
+ impact: "moderate",
186
+ description: `${inconsistent.length} component(s) with the same destination have different visible names across pages.`,
187
+ help: "Components with the same function should be identified consistently. WCAG 3.2.4.",
188
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/consistent-identification.html",
189
+ selector: "",
190
+ evidence: inconsistent.map(([href, names]) => `${href} → ${Array.from(names).join(" / ")}`).join("\n"),
191
+ failureSummary: `${inconsistent.length} components`,
192
+ screenshotFile: null,
193
+ });
194
+ }
195
+
196
+ // ── 3.2.6 Consistent Help ───────────────────────────────────────────
197
+ const helpRects = fingerprints.filter((f) => f.helpRect).map((f) => f.helpRect);
198
+ if (helpRects.length >= 2) {
199
+ const xs = helpRects.map((r) => r.x);
200
+ const ys = helpRects.map((r) => r.y);
201
+ const xRange = Math.max(...xs) - Math.min(...xs);
202
+ const yRange = Math.max(...ys) - Math.min(...ys);
203
+ if (xRange > 100 || yRange > 100) {
204
+ findings.push({
205
+ source: "playwright",
206
+ ruleId: "consistency/help-position",
207
+ criteria: "3.2.6",
208
+ level: "A",
209
+ impact: "moderate",
210
+ description: `Help mechanism appears in a different location across pages (x range ${xRange}px, y range ${yRange}px).`,
211
+ help: "WCAG 3.2.6 (added in 2.2) requires that any help mechanism (contact info, form, FAQ link, chatbot, self-help) appear in the same relative order on every page where it occurs.",
212
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/consistent-help.html",
213
+ selector: "",
214
+ evidence: JSON.stringify(helpRects),
215
+ failureSummary: "Help link position varies",
216
+ screenshotFile: null,
217
+ });
218
+ }
219
+ }
220
+
221
+ return { findings };
222
+ }
@@ -0,0 +1,149 @@
1
+ // Forms / error handling checker. Covers (substantially):
2
+ //
3
+ // 3.3.1 Error Identification — submit a form invalid, check for visible errors
4
+ // 3.3.3 Error Suggestion — check that errors offer a fix (AI-evaluated)
5
+ // 3.3.4 Error Prevention — check legal/financial forms have a confirm step
6
+ // 3.3.7 Redundant Entry — multi-step form duplicate field detection
7
+ //
8
+ // Strategy: enumerate forms, fill required fields with deliberately invalid
9
+ // data (or leave them empty), submit, screenshot, capture aria-live /
10
+ // inline error messages, and report.
11
+
12
+ import { saveScreenshot, captureElement } from "../util/screenshot.js";
13
+ import { askClaudeForJsonArray } from "../util/anthropic.js";
14
+
15
+ export async function runForms(ctx) {
16
+ const { page } = ctx;
17
+ const findings = [];
18
+ const forms = await page.$$("form");
19
+ if (forms.length === 0) return { findings };
20
+
21
+ for (let fi = 0; fi < Math.min(forms.length, 5); fi++) {
22
+ const form = forms[fi];
23
+ let formInfo;
24
+ try {
25
+ formInfo = await form.evaluate((f) => {
26
+ const fields = Array.from(f.querySelectorAll("input,select,textarea"))
27
+ .filter((el) => !["submit", "button", "hidden"].includes(el.type))
28
+ .map((el) => ({
29
+ tag: el.tagName.toLowerCase(),
30
+ type: el.type || "",
31
+ name: el.name || "",
32
+ required: el.required,
33
+ id: el.id || "",
34
+ }));
35
+ const submit = f.querySelector("button[type=submit],input[type=submit],button:not([type])");
36
+ return {
37
+ action: f.action || "",
38
+ fields,
39
+ hasSubmit: !!submit,
40
+ html: f.outerHTML.slice(0, 1000),
41
+ };
42
+ });
43
+ } catch (_) { continue; }
44
+
45
+ if (!formInfo.hasSubmit || formInfo.fields.length === 0) continue;
46
+
47
+ // ── Pre-submission state ──
48
+ const beforeUrl = page.url();
49
+ const beforeText = await page.evaluate(() => document.body.innerText);
50
+
51
+ // Try a deliberately empty submission (worst case for required fields)
52
+ try {
53
+ await form.evaluate((f) => {
54
+ const btn = f.querySelector("button[type=submit],input[type=submit],button:not([type])");
55
+ btn?.click();
56
+ });
57
+ await page.waitForTimeout(800);
58
+ } catch (_) { continue; }
59
+
60
+ const afterText = await page.evaluate(() => document.body.innerText);
61
+ const navigated = page.url() !== beforeUrl;
62
+ const newText = afterText.slice(beforeText.length);
63
+ const errorPatterns = /(required|invalid|please|must|cannot be empty|enter|provide)/i;
64
+ const errorVisible = errorPatterns.test(newText) || (await page.evaluate(() => {
65
+ return Array.from(document.querySelectorAll("[role='alert'],[aria-live='assertive'],[aria-live='polite'],.error,.invalid,[aria-invalid='true']"))
66
+ .some((el) => (el.innerText || "").trim().length > 0);
67
+ }));
68
+
69
+ if (!navigated && !errorVisible) {
70
+ const buf = await captureElement(page, page.locator("form").nth(fi), { label: "Form: no error" });
71
+ const screenshotFile = await saveScreenshot(buf, ctx.screenshotsDir, "forms_no_error");
72
+ findings.push({
73
+ source: "playwright",
74
+ ruleId: "forms/no-error-on-invalid",
75
+ criteria: "3.3.1",
76
+ level: "A",
77
+ impact: "serious",
78
+ description: `Form #${fi + 1} accepted an empty submission silently — no visible error, no navigation, no aria-live announcement.`,
79
+ help: "When a user submits an invalid form, errors must be programmatically associated and visibly announced. Use aria-invalid + aria-describedby on the field, and a live region for the summary.",
80
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html",
81
+ selector: `form:nth-of-type(${fi + 1})`,
82
+ evidence: formInfo.html.slice(0, 500),
83
+ failureSummary: "No error feedback after invalid submit",
84
+ screenshotFile,
85
+ });
86
+ } else if (errorVisible && ctx.ai.enabled) {
87
+ // ── 3.3.3 Error Suggestion — ask AI whether the error tells the user how to fix it
88
+ try {
89
+ const errSnapshot = await page.screenshot({ fullPage: false, type: "png" });
90
+ const prompt = `You are a WCAG 3.3.3 (Error Suggestion) auditor. The screenshot shows a form after an invalid submission. Evaluate whether the displayed error messages tell the user HOW TO FIX the problem (not just that something is wrong). Return a JSON array with ONE element: [ { "criterion": "3.3.3", "status": "pass" | "fail" | "needs-review", "summary": "...", "findings": [ { "issue": "...", "evidence": "quoted error text", "selector": "", "severity": "moderate" } ] } ]. Use 'pass' if every visible error provides a concrete suggestion. Use 'fail' if any error is generic ("invalid", "error", "required") with no fix guidance. Return ONLY the JSON.`;
91
+ const { array } = await askClaudeForJsonArray({
92
+ provider: ctx.ai.provider,
93
+ apiKey: ctx.ai.apiKey,
94
+ model: ctx.ai.model,
95
+ prompt,
96
+ images: [{ buffer: errSnapshot, mimeType: "image/png" }],
97
+ });
98
+ for (const r of array) {
99
+ if (r.status !== "fail") continue;
100
+ const screenshotFile = await saveScreenshot(errSnapshot, ctx.screenshotsDir, "forms_error_quality");
101
+ findings.push({
102
+ source: "ai",
103
+ ruleId: "ai/3.3.3-error-suggestion",
104
+ criteria: "3.3.3",
105
+ level: "AA",
106
+ impact: "moderate",
107
+ description: r.summary || "Form errors do not suggest how to fix the problem.",
108
+ help: (r.findings || []).map((f) => f.issue).join("\n"),
109
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/error-suggestion.html",
110
+ selector: `form:nth-of-type(${fi + 1})`,
111
+ evidence: (r.findings || []).map((f) => f.evidence).join("\n"),
112
+ failureSummary: "Error message lacks fix guidance",
113
+ screenshotFile,
114
+ });
115
+ }
116
+ } catch (_) {}
117
+ }
118
+
119
+ // ── 3.3.7 Redundant Entry ─ rough check: count fields whose label/name
120
+ // suggests duplication (email vs confirm-email, password vs confirm-password)
121
+ // when no autofill help is offered.
122
+ const redundant = formInfo.fields.filter((f) =>
123
+ /confirm|repeat|verify|again/i.test(f.name + " " + f.id)
124
+ );
125
+ if (redundant.length) {
126
+ findings.push({
127
+ source: "playwright",
128
+ ruleId: "forms/redundant-entry",
129
+ criteria: "3.3.7",
130
+ level: "A",
131
+ impact: "moderate",
132
+ description: `Form requires the user to re-enter information (${redundant.map((r) => r.name || r.id).join(", ")}). WCAG 3.3.7 (added in 2.2) forbids requiring redundant entry within the same process unless essential.`,
133
+ help: "Replace 'confirm email/password' fields with a single field plus a 'show' toggle, or use the browser's autocomplete attributes.",
134
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html",
135
+ selector: `form:nth-of-type(${fi + 1})`,
136
+ evidence: redundant.map((r) => `${r.tag}[name=${r.name}]`).join(", "),
137
+ failureSummary: `${redundant.length} confirm-style fields`,
138
+ screenshotFile: null,
139
+ });
140
+ }
141
+
142
+ // Navigate back if we left the page
143
+ if (page.url() !== beforeUrl) {
144
+ try { await page.goBack({ waitUntil: "networkidle" }); } catch (_) {}
145
+ }
146
+ }
147
+
148
+ return { findings };
149
+ }
@@ -0,0 +1,142 @@
1
+ // Interaction checker. Covers:
2
+ //
3
+ // 1.4.13 Content on Hover or Focus — hover each tooltip-trigger and verify
4
+ // dismissible (Esc), persistent (doesn't disappear when pointer
5
+ // moves to revealed content), hoverable
6
+ // 3.2.1 On Focus — focus each control, watch for navigation/context change
7
+ // 3.2.2 On Input — type into each input, watch for navigation/context change
8
+
9
+ import { captureElement, saveScreenshot } from "../util/screenshot.js";
10
+
11
+ export async function runInteraction(ctx) {
12
+ const { page } = ctx;
13
+ const findings = [];
14
+
15
+ // ── 1.4.13 Content on Hover/Focus ───────────────────────────────────
16
+ // Find candidate trigger elements: things with title attr, aria-describedby
17
+ // pointing to a hidden element, or [data-tooltip], common UI library hints.
18
+ const triggers = await page.evaluate(() => {
19
+ const sel = "[title]:not([title='']),[aria-describedby],[data-tooltip],[data-tippy-content],.tooltip,[role='tooltip']";
20
+ const arr = Array.from(document.querySelectorAll(sel)).slice(0, 30);
21
+ return arr.map((el, i) => {
22
+ const r = el.getBoundingClientRect();
23
+ return {
24
+ idx: i,
25
+ tag: el.tagName.toLowerCase(),
26
+ title: el.getAttribute("title") || el.getAttribute("data-tooltip") || "",
27
+ rect: { x: r.x + r.width / 2, y: r.y + r.height / 2 },
28
+ };
29
+ });
30
+ });
31
+
32
+ for (const t of triggers.slice(0, 15)) {
33
+ if (t.rect.x <= 0 || t.rect.y <= 0) continue;
34
+ try {
35
+ // Capture body innerText length before hover.
36
+ const before = await page.evaluate(() => document.body.innerText.length);
37
+ await page.mouse.move(t.rect.x, t.rect.y);
38
+ await page.waitForTimeout(400);
39
+ const afterHover = await page.evaluate(() => document.body.innerText.length);
40
+
41
+ if (afterHover - before < 5) continue; // nothing revealed; not a hover-content trigger
42
+
43
+ // Press Escape — content should disappear (dismissible)
44
+ await page.keyboard.press("Escape");
45
+ await page.waitForTimeout(200);
46
+ const afterEsc = await page.evaluate(() => document.body.innerText.length);
47
+ const dismissible = afterEsc < afterHover - 2;
48
+
49
+ if (!dismissible) {
50
+ findings.push({
51
+ source: "playwright",
52
+ ruleId: "interaction/hover-not-dismissible",
53
+ criteria: "1.4.13",
54
+ level: "AA",
55
+ impact: "moderate",
56
+ description: `Hover-revealed content was not dismissible via Escape on ${t.tag}.`,
57
+ help: "WCAG 1.4.13 requires that content shown on hover or focus be dismissable without moving the pointer (typically by Escape).",
58
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html",
59
+ selector: t.tag,
60
+ evidence: t.title,
61
+ failureSummary: "Esc did not remove the revealed content.",
62
+ screenshotFile: null,
63
+ });
64
+ }
65
+ // Reset
66
+ await page.mouse.move(0, 0);
67
+ await page.waitForTimeout(150);
68
+ } catch (_) {}
69
+ }
70
+
71
+ // ── 3.2.1 / 3.2.2 On Focus / On Input ──────────────────────────────
72
+ // Iterate the first ~15 form controls. For each: focus and watch URL.
73
+ // Then type a character and watch URL again.
74
+ const startUrl = page.url();
75
+ const controls = await page.$$eval("input,select,textarea,button", (els) =>
76
+ els.slice(0, 15).map((el) => {
77
+ function cssPath(node) {
78
+ const path = [];
79
+ while (node && node.nodeType === Node.ELEMENT_NODE && path.length < 6) {
80
+ let s = node.nodeName.toLowerCase();
81
+ if (node.id) { s += "#" + node.id; path.unshift(s); break; }
82
+ let sib = node, n = 1;
83
+ while ((sib = sib.previousElementSibling)) if (sib.nodeName === node.nodeName) n++;
84
+ if (n !== 1) s += `:nth-of-type(${n})`;
85
+ path.unshift(s);
86
+ node = node.parentElement;
87
+ }
88
+ return path.join(" > ");
89
+ }
90
+ return { tag: el.tagName.toLowerCase(), type: el.type || "", selector: cssPath(el) };
91
+ })
92
+ );
93
+
94
+ for (const c of controls) {
95
+ try {
96
+ const loc = page.locator(c.selector).first();
97
+ await loc.focus({ timeout: 1000 });
98
+ await page.waitForTimeout(150);
99
+ if (page.url() !== startUrl) {
100
+ findings.push({
101
+ source: "playwright",
102
+ ruleId: "interaction/on-focus-context-change",
103
+ criteria: "3.2.1",
104
+ level: "A",
105
+ impact: "serious",
106
+ description: `Focusing the ${c.tag} element triggered a navigation. WCAG 3.2.1 forbids context changes on focus.`,
107
+ help: "Move navigation behaviour to an explicit submit/click action, not focus.",
108
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/on-focus.html",
109
+ selector: c.selector,
110
+ evidence: `Focus triggered navigation`,
111
+ failureSummary: `${startUrl} → ${page.url()}`,
112
+ screenshotFile: null,
113
+ });
114
+ return { findings };
115
+ }
116
+ if ((c.tag === "input" || c.tag === "textarea") && c.type !== "submit" && c.type !== "button") {
117
+ await loc.fill("a", { timeout: 1000 }).catch(() => {});
118
+ await page.waitForTimeout(150);
119
+ if (page.url() !== startUrl) {
120
+ findings.push({
121
+ source: "playwright",
122
+ ruleId: "interaction/on-input-context-change",
123
+ criteria: "3.2.2",
124
+ level: "A",
125
+ impact: "serious",
126
+ description: `Typing into the ${c.tag} element triggered a navigation. WCAG 3.2.2 forbids context changes on input without prior warning.`,
127
+ help: "Submit forms only on explicit user action, not on input event.",
128
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/on-input.html",
129
+ selector: c.selector,
130
+ evidence: `Input triggered navigation`,
131
+ failureSummary: `${startUrl} → ${page.url()}`,
132
+ screenshotFile: null,
133
+ });
134
+ return { findings };
135
+ }
136
+ await loc.fill("").catch(() => {});
137
+ }
138
+ } catch (_) {}
139
+ }
140
+
141
+ return { findings };
142
+ }