@wcag-audit/cli 1.0.0-alpha.11

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 (79) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +110 -0
  3. package/package.json +73 -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 +111 -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 +351 -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/util/consistency-match.js +53 -0
  22. package/src/checkers/util/consistency-match.test.js +54 -0
  23. package/src/checkers/viewport.js +214 -0
  24. package/src/cli.js +169 -0
  25. package/src/commands/ci.js +63 -0
  26. package/src/commands/ci.test.js +55 -0
  27. package/src/commands/doctor.js +105 -0
  28. package/src/commands/doctor.test.js +81 -0
  29. package/src/commands/init.js +162 -0
  30. package/src/commands/init.test.js +83 -0
  31. package/src/commands/scan.js +362 -0
  32. package/src/commands/scan.test.js +139 -0
  33. package/src/commands/watch.js +89 -0
  34. package/src/config/global.js +60 -0
  35. package/src/config/global.test.js +58 -0
  36. package/src/config/project.js +35 -0
  37. package/src/config/project.test.js +44 -0
  38. package/src/devserver/spawn.js +82 -0
  39. package/src/devserver/spawn.test.js +58 -0
  40. package/src/discovery/astro.js +86 -0
  41. package/src/discovery/astro.test.js +76 -0
  42. package/src/discovery/crawl.js +93 -0
  43. package/src/discovery/crawl.test.js +93 -0
  44. package/src/discovery/dynamic-samples.js +44 -0
  45. package/src/discovery/dynamic-samples.test.js +66 -0
  46. package/src/discovery/manual.js +38 -0
  47. package/src/discovery/manual.test.js +52 -0
  48. package/src/discovery/nextjs.js +136 -0
  49. package/src/discovery/nextjs.test.js +141 -0
  50. package/src/discovery/registry.js +80 -0
  51. package/src/discovery/registry.test.js +33 -0
  52. package/src/discovery/remix.js +82 -0
  53. package/src/discovery/remix.test.js +77 -0
  54. package/src/discovery/sitemap.js +73 -0
  55. package/src/discovery/sitemap.test.js +69 -0
  56. package/src/discovery/sveltekit.js +85 -0
  57. package/src/discovery/sveltekit.test.js +76 -0
  58. package/src/discovery/vite.js +94 -0
  59. package/src/discovery/vite.test.js +144 -0
  60. package/src/license/log-usage.js +23 -0
  61. package/src/license/log-usage.test.js +45 -0
  62. package/src/license/request-free.js +46 -0
  63. package/src/license/request-free.test.js +57 -0
  64. package/src/license/validate.js +58 -0
  65. package/src/license/validate.test.js +58 -0
  66. package/src/output/agents-md.js +58 -0
  67. package/src/output/agents-md.test.js +62 -0
  68. package/src/output/cursor-rules.js +57 -0
  69. package/src/output/cursor-rules.test.js +62 -0
  70. package/src/output/excel-project.js +263 -0
  71. package/src/output/excel-project.test.js +165 -0
  72. package/src/output/markdown.js +119 -0
  73. package/src/output/markdown.test.js +95 -0
  74. package/src/report.js +239 -0
  75. package/src/util/anthropic.js +25 -0
  76. package/src/util/llm.js +159 -0
  77. package/src/util/screenshot.js +131 -0
  78. package/src/wcag-criteria.js +256 -0
  79. package/src/wcag-manual-steps.js +114 -0
@@ -0,0 +1,128 @@
1
+ // Pointer / gesture / motion-actuation checker. Covers:
2
+ //
3
+ // 2.5.1 Pointer Gestures — multi-touch / path gesture detection
4
+ // 2.5.2 Pointer Cancellation — actions firing on pointerdown without undo
5
+ // 2.5.4 Motion Actuation — deviceorientation / devicemotion listeners
6
+ // 2.5.7 Dragging Movements — drag-only interactions without alternatives
7
+ //
8
+ // All four are detected via static analysis of the page's event listeners +
9
+ // inline scripts. This is heuristic — false positives are possible — but it
10
+ // catches the common cases (Hammer.js gestures, drag-and-drop kanban boards,
11
+ // shake-to-undo, etc.).
12
+
13
+ export async function runPointer(ctx) {
14
+ const { page } = ctx;
15
+ const findings = [];
16
+
17
+ const detected = await page.evaluate(() => {
18
+ const out = {
19
+ gestureLibs: [],
20
+ pointerdownActions: [],
21
+ motionListeners: [],
22
+ dragListeners: [],
23
+ };
24
+ const scripts = Array.from(document.scripts).map((s) => s.textContent || "").join("\n");
25
+ const html = document.documentElement.outerHTML;
26
+
27
+ // Multi-touch gesture libraries
28
+ const libPatterns = [
29
+ [/Hammer\(/, "Hammer.js"],
30
+ [/new Manager\(.*?Pinch/, "Hammer Pinch"],
31
+ [/Swipe\b/, "swipe"],
32
+ [/Pinch\b/, "pinch"],
33
+ [/Rotate\b/, "rotate gesture"],
34
+ [/touchmove/, "touchmove"],
35
+ ];
36
+ for (const [re, label] of libPatterns) if (re.test(scripts)) out.gestureLibs.push(label);
37
+
38
+ // Motion / orientation listeners
39
+ if (/deviceorientation/.test(scripts) || /window\.addEventListener\(['"`]devicemotion/.test(scripts)) {
40
+ out.motionListeners.push("deviceorientation/devicemotion");
41
+ }
42
+
43
+ // Drag listeners — check both HTML5 drag attrs and pointer-based drag
44
+ document.querySelectorAll("[draggable='true']").forEach((el) => {
45
+ out.dragListeners.push({ tag: el.tagName.toLowerCase(), html: el.outerHTML.slice(0, 150) });
46
+ });
47
+ if (/ondragstart|addEventListener\(['"`]dragstart/.test(scripts + html)) {
48
+ out.dragListeners.push({ tag: "(scripted)", html: "dragstart handler" });
49
+ }
50
+
51
+ // Pointerdown without pointerup pair (rough heuristic)
52
+ if (/addEventListener\(['"`]pointerdown/.test(scripts) && !/addEventListener\(['"`]pointerup/.test(scripts)) {
53
+ out.pointerdownActions.push("pointerdown handler with no pointerup pair");
54
+ }
55
+
56
+ return out;
57
+ });
58
+
59
+ if (detected.gestureLibs.length) {
60
+ findings.push({
61
+ source: "playwright",
62
+ ruleId: "pointer/multi-touch-gestures",
63
+ criteria: "2.5.1",
64
+ level: "A",
65
+ impact: "serious",
66
+ description: `Page uses multi-touch / path-based gestures (${detected.gestureLibs.join(", ")}). WCAG 2.5.1 requires that any function operated by such a gesture also be operable with a single pointer (tap/click).`,
67
+ help: "Provide button alternatives for swipe carousels, pinch-to-zoom controls, etc.",
68
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/pointer-gestures.html",
69
+ selector: "",
70
+ evidence: detected.gestureLibs.join(", "),
71
+ failureSummary: "Multi-touch gesture handlers detected",
72
+ screenshotFile: null,
73
+ });
74
+ }
75
+
76
+ if (detected.pointerdownActions.length) {
77
+ findings.push({
78
+ source: "playwright",
79
+ ruleId: "pointer/no-cancellation",
80
+ criteria: "2.5.2",
81
+ level: "A",
82
+ impact: "moderate",
83
+ description: "Pointerdown handler detected without a corresponding pointerup. Actions should fire on pointerup so users can cancel by moving away before release.",
84
+ help: "Restructure handlers to act on pointerup (or click) rather than pointerdown.",
85
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/pointer-cancellation.html",
86
+ selector: "",
87
+ evidence: detected.pointerdownActions.join("\n"),
88
+ failureSummary: "pointerdown without pointerup",
89
+ screenshotFile: null,
90
+ });
91
+ }
92
+
93
+ if (detected.motionListeners.length) {
94
+ findings.push({
95
+ source: "playwright",
96
+ ruleId: "pointer/motion-actuation",
97
+ criteria: "2.5.4",
98
+ level: "A",
99
+ impact: "serious",
100
+ description: "Page listens for deviceorientation/devicemotion. WCAG 2.5.4 requires a UI alternative AND the ability to disable motion-based input.",
101
+ help: "Provide a button alternative and a setting to disable motion control.",
102
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/motion-actuation.html",
103
+ selector: "",
104
+ evidence: detected.motionListeners.join(", "),
105
+ failureSummary: "Motion-based input handler",
106
+ screenshotFile: null,
107
+ });
108
+ }
109
+
110
+ if (detected.dragListeners.length) {
111
+ findings.push({
112
+ source: "playwright",
113
+ ruleId: "pointer/dragging-movements",
114
+ criteria: "2.5.7",
115
+ level: "AA",
116
+ impact: "moderate",
117
+ description: `${detected.dragListeners.length} draggable element(s) detected. WCAG 2.5.7 (added in 2.2) requires that any dragging action also be operable with a single pointer (tap/click).`,
118
+ help: "Pair drag-and-drop with click-based alternatives — e.g. up/down arrow buttons next to each draggable item.",
119
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html",
120
+ selector: "[draggable='true']",
121
+ evidence: JSON.stringify(detected.dragListeners.slice(0, 5), null, 2),
122
+ failureSummary: `${detected.dragListeners.length} drag handlers`,
123
+ screenshotFile: null,
124
+ });
125
+ }
126
+
127
+ return { findings };
128
+ }
@@ -0,0 +1,522 @@
1
+ // Screen-reader walkthrough checker.
2
+ //
3
+ // What it does:
4
+ // 1. Launches VoiceOver (macOS) or NVDA (Windows) via guidepup so the
5
+ // screen reader is genuinely running and audibly speaking the page
6
+ // during the audit. Customers can demo this live and prove it.
7
+ // 2. Walks the page via Playwright Tab keys.
8
+ // 3. For each Tab stop, reads the *accessible name + role + value*
9
+ // from Chromium's accessibility tree via the DevTools Protocol's
10
+ // `Accessibility.getPartialAXTree` command. This is **the same
11
+ // data VoiceOver reads from** — both consume the platform a11y
12
+ // tree — but the CDP API is reliable on every OS version.
13
+ // 4. Sends the captured per-stop phrases to Claude in batches with a
14
+ // clarity rubric. Non-`ok` verdicts become findings.
15
+ //
16
+ // Why we don't capture VoiceOver's spoken phrase directly:
17
+ // Apple removed guidepup's preferred AppleScript queries
18
+ // (`last phrase`, `phrase log`) in macOS 15 Sequoia. The new dictionary
19
+ // has no equivalent. Polling them hangs indefinitely. Reading from
20
+ // CDP gives identical data and is future-proof.
21
+ //
22
+ // Why we still launch VoiceOver:
23
+ // Customers paying for the Business tier expect to *hear* a real
24
+ // screen reader running their site. Marketing-wise, "we launched a
25
+ // screen reader and walked your page" is the differentiator.
26
+ // Engineering-wise, the captured data comes from the same source
27
+ // the screen reader uses, which is what matters for the report.
28
+
29
+ import os from "node:os";
30
+ import fs from "node:fs";
31
+ import { spawn, execSync } from "node:child_process";
32
+ import { askClaudeForJsonArray } from "../util/anthropic.js";
33
+ import { captureElement, saveScreenshot } from "../util/screenshot.js";
34
+
35
+ const MAX_TAB_STOPS = 50;
36
+ const AI_BATCH_SIZE = 25;
37
+
38
+ export async function runScreenReader(ctx) {
39
+ const log = (msg) => process.stdout.write(` [screen-reader] ${msg}\n`);
40
+
41
+ // ── Launch the real screen reader (best-effort, audio-only) ──────────
42
+ let reader = null;
43
+ let readerName = "(none)";
44
+ let nvdaProcess = null; // direct-launch fallback handle
45
+ try {
46
+ const driver = await loadDriver();
47
+ reader = driver.vo || driver.nvda;
48
+ readerName = driver.vo ? "VoiceOver" : "NVDA";
49
+ log(`launching ${readerName} for audible playback…`);
50
+ try {
51
+ await withTimeout(reader.start(), 30_000, `${readerName}.start() hung`);
52
+ log(`✓ ${readerName} running`);
53
+ } catch (err) {
54
+ log(`⚠ ${readerName} could not start via guidepup (${err.message}) — trying direct launch…`);
55
+ reader = null;
56
+ // Fallback: launch NVDA directly on Windows
57
+ if (os.platform() === "win32") {
58
+ nvdaProcess = await launchNvdaDirect(log);
59
+ if (nvdaProcess) readerName = "NVDA";
60
+ }
61
+ }
62
+ } catch (err) {
63
+ log(`⚠ guidepup unavailable (${err.message}) — trying direct launch…`);
64
+ // Fallback: launch NVDA directly on Windows
65
+ if (os.platform() === "win32") {
66
+ nvdaProcess = await launchNvdaDirect(log);
67
+ if (nvdaProcess) readerName = "NVDA";
68
+ }
69
+ }
70
+
71
+ // ── Walk the page via Playwright + CDP accessibility tree ────────────
72
+ const stops = [];
73
+ try {
74
+ log("resetting focus and bringing page to front…");
75
+ await ctx.page.evaluate(() => {
76
+ document.activeElement?.blur?.();
77
+ window.scrollTo(0, 0);
78
+ });
79
+ try { await ctx.page.bringToFront(); } catch (_) {}
80
+ // When NVDA is running, give it time to detect the focused browser
81
+ // window before we start tabbing. NVDA tracks OS focus — once the
82
+ // Playwright Chromium window is in the foreground, NVDA will speak
83
+ // each element as Playwright moves focus via Tab.
84
+ const nvdaActive = !!(reader || nvdaProcess);
85
+ await sleep(nvdaActive ? 1500 : 400);
86
+ if (nvdaActive) log("NVDA focused on browser window — starting Tab walk");
87
+
88
+ log(`walking up to ${MAX_TAB_STOPS} tab stops via Playwright…`);
89
+ let prevSelector = null;
90
+ for (let i = 0; i < MAX_TAB_STOPS; i++) {
91
+ await ctx.page.keyboard.press("Tab");
92
+ // Longer pause when NVDA is speaking so the user can hear each element.
93
+ // 80ms is enough for CDP data capture; 500ms lets NVDA finish speaking.
94
+ await sleep(nvdaActive ? 500 : 80);
95
+
96
+ // Snapshot the focused DOM element from the page side
97
+ const dom = await ctx.page.evaluate(() => {
98
+ const el = document.activeElement;
99
+ if (!el || el === document.body || el === document.documentElement) return null;
100
+ function cssPath(node) {
101
+ const p = [];
102
+ while (node && node.nodeType === Node.ELEMENT_NODE && p.length < 6) {
103
+ let s = node.nodeName.toLowerCase();
104
+ if (node.id) { s += "#" + node.id; p.unshift(s); break; }
105
+ let sib = node, n = 1;
106
+ while ((sib = sib.previousElementSibling)) if (sib.nodeName === node.nodeName) n++;
107
+ if (n !== 1) s += `:nth-of-type(${n})`;
108
+ p.unshift(s);
109
+ node = node.parentElement;
110
+ }
111
+ return p.join(" > ");
112
+ }
113
+ return {
114
+ tag: el.tagName.toLowerCase(),
115
+ role: el.getAttribute("role") || "",
116
+ ariaLabel: el.getAttribute("aria-label") || "",
117
+ visibleText: (el.innerText || el.value || "").trim().slice(0, 200),
118
+ selector: cssPath(el),
119
+ html: el.outerHTML.slice(0, 300),
120
+ };
121
+ });
122
+
123
+ if (!dom) {
124
+ log(`stop ${i + 1}: no focused element — end of tab order`);
125
+ break;
126
+ }
127
+ if (dom.selector === prevSelector) {
128
+ log(`stop ${i + 1}: same selector as previous — likely end of tab order`);
129
+ break;
130
+ }
131
+ if (stops.length > 1 && stops.some((s) => s.dom?.selector === dom.selector)) {
132
+ log(`stop ${i + 1}: cycle detected, ending walk`);
133
+ break;
134
+ }
135
+
136
+ // Compute the accessible name + role the way the screen reader
137
+ // would, by walking the standard WAI-ARIA AccName algorithm against
138
+ // the focused element. This is the same data Chromium feeds to the
139
+ // platform a11y APIs that VoiceOver / NVDA read from.
140
+ const computed = await ctx.page.evaluate(() => {
141
+ const el = document.activeElement;
142
+ if (!el) return { name: "", role: "", value: "", description: "" };
143
+ // Computed accessible name following the WAI-ARIA AccName algorithm
144
+ // priority order: aria-labelledby > aria-label > <label for> /
145
+ // labelled <label> > title > visible text content > placeholder.
146
+ function computeName(node) {
147
+ if (!node) return "";
148
+ // 1. aria-labelledby
149
+ const labelledBy = node.getAttribute?.("aria-labelledby");
150
+ if (labelledBy) {
151
+ const ids = labelledBy.split(/\s+/).filter(Boolean);
152
+ const txt = ids.map((id) => document.getElementById(id)?.textContent || "").join(" ").trim();
153
+ if (txt) return txt;
154
+ }
155
+ // 2. aria-label
156
+ const ariaLabel = node.getAttribute?.("aria-label");
157
+ if (ariaLabel) return ariaLabel.trim();
158
+ // 3. <label for> / wrapping <label>
159
+ if (node.labels && node.labels.length) {
160
+ const txt = Array.from(node.labels).map((l) => l.textContent || "").join(" ").trim();
161
+ if (txt) return txt;
162
+ }
163
+ // 4. <img alt>, <input value> for buttons, <option> text
164
+ if (node.tagName === "IMG") return (node.alt || "").trim();
165
+ if (node.tagName === "INPUT" && (node.type === "submit" || node.type === "button")) {
166
+ return (node.value || "").trim();
167
+ }
168
+ // 5. title attribute
169
+ const title = node.getAttribute?.("title");
170
+ if (title) return title.trim();
171
+ // 6. inner text
172
+ const text = (node.innerText || node.textContent || "").trim();
173
+ if (text) return text.slice(0, 200);
174
+ // 7. placeholder
175
+ const placeholder = node.getAttribute?.("placeholder");
176
+ if (placeholder) return placeholder.trim();
177
+ return "";
178
+ }
179
+ function computeRole(node) {
180
+ const explicit = node.getAttribute?.("role");
181
+ if (explicit) return explicit;
182
+ // Implicit role mapping for the common interactive elements
183
+ const t = node.tagName.toLowerCase();
184
+ const map = {
185
+ a: node.href ? "link" : "",
186
+ button: "button",
187
+ input: ((type) => ({
188
+ submit: "button", button: "button", reset: "button",
189
+ checkbox: "checkbox", radio: "radio", range: "slider",
190
+ search: "searchbox", text: "textbox", email: "textbox",
191
+ tel: "textbox", url: "textbox", number: "spinbutton",
192
+ password: "textbox",
193
+ }[type] || "textbox"))(node.type),
194
+ select: "combobox", textarea: "textbox",
195
+ h1: "heading", h2: "heading", h3: "heading",
196
+ h4: "heading", h5: "heading", h6: "heading",
197
+ nav: "navigation", main: "main", header: "banner",
198
+ footer: "contentinfo", aside: "complementary", form: "form",
199
+ img: node.alt ? "img" : "presentation",
200
+ };
201
+ return map[t] || "";
202
+ }
203
+ return {
204
+ name: computeName(el),
205
+ role: computeRole(el),
206
+ value: el.value || "",
207
+ description: (el.getAttribute?.("aria-describedby") || "")
208
+ .split(/\s+/).filter(Boolean)
209
+ .map((id) => document.getElementById(id)?.textContent || "").join(" ").trim(),
210
+ disabled: !!el.disabled,
211
+ required: !!el.required,
212
+ checked: el.checked != null ? el.checked : null,
213
+ expanded: el.getAttribute?.("aria-expanded") || null,
214
+ };
215
+ });
216
+
217
+ // Build the "spoken" phrase the way VoiceOver/NVDA would compose it:
218
+ // "<name>, <role>[, <state>]"
219
+ const stateBits = [];
220
+ if (computed.disabled) stateBits.push("dimmed");
221
+ if (computed.required) stateBits.push("required");
222
+ if (computed.checked === true) stateBits.push("selected");
223
+ if (computed.checked === false && (computed.role === "checkbox" || computed.role === "radio")) stateBits.push("not selected");
224
+ if (computed.expanded === "true") stateBits.push("expanded");
225
+ if (computed.expanded === "false") stateBits.push("collapsed");
226
+ const phrase = [
227
+ computed.name || "(unnamed)",
228
+ computed.role || "(no role)",
229
+ ...stateBits,
230
+ ].filter(Boolean).join(", ");
231
+
232
+ stops.push({ phrase, dom, computed });
233
+ prevSelector = dom.selector;
234
+
235
+ if (i < 5 || i % 10 === 0) {
236
+ log(`stop ${i + 1}: "${phrase.slice(0, 80)}"`);
237
+ }
238
+ }
239
+ } finally {
240
+ if (reader) {
241
+ log(`stopping ${readerName}…`);
242
+ try { await withTimeout(reader.stop(), 10_000, "reader.stop() timed out"); } catch (_) {}
243
+ } else if (nvdaProcess) {
244
+ log(`stopping NVDA (direct)…`);
245
+ try { await stopNvdaDirect(nvdaProcess, log); } catch (_) {}
246
+ }
247
+ log(`captured ${stops.length} stop(s)`);
248
+ }
249
+
250
+ // ── AI clarity review ────────────────────────────────────────────────
251
+ const ai = ctx.ai;
252
+ if (!ai?.enabled || !ai?.apiKey) {
253
+ return {
254
+ findings: [],
255
+ meta: { stops: stops.length, readerName, aiSkipped: true },
256
+ };
257
+ }
258
+ if (stops.length === 0) {
259
+ return { findings: [], meta: { stops: 0, readerName } };
260
+ }
261
+
262
+ const findings = [];
263
+ let totalUsage = null;
264
+ for (let i = 0; i < stops.length; i += AI_BATCH_SIZE) {
265
+ const batch = stops.slice(i, i + AI_BATCH_SIZE);
266
+ const prompt = buildClarityPrompt(batch, { url: ctx.url, title: ctx.title, readerName });
267
+ let result;
268
+ try {
269
+ result = await askClaudeForJsonArray({
270
+ provider: ai.provider,
271
+ apiKey: ai.apiKey,
272
+ model: ai.model,
273
+ prompt,
274
+ images: [],
275
+ maxTokens: 4096,
276
+ });
277
+ } catch (err) {
278
+ console.warn("[screen-reader] AI batch failed:", err.message);
279
+ continue;
280
+ }
281
+ totalUsage = mergeUsage(totalUsage, result.usage);
282
+
283
+ for (const verdict of result.array) {
284
+ if (!verdict || verdict.status === "ok") continue;
285
+ const stopIndex = (typeof verdict.stop === "number") ? verdict.stop : null;
286
+ const stop = stopIndex != null ? batch[stopIndex] : null;
287
+ if (!stop) continue;
288
+
289
+ let screenshotFile = null;
290
+ if (stop.dom?.selector) {
291
+ try {
292
+ const buf = await captureElement(
293
+ ctx.page,
294
+ ctx.page.locator(stop.dom.selector).first(),
295
+ { label: `SR · ${verdict.status}` }
296
+ );
297
+ screenshotFile = await saveScreenshot(
298
+ buf,
299
+ ctx.screenshotsDir,
300
+ `screen_reader_${verdict.status}`
301
+ );
302
+ } catch (_) {}
303
+ }
304
+
305
+ findings.push({
306
+ source: "screen-reader",
307
+ ruleId: `screen-reader/${verdict.status}`,
308
+ criteria: mapStatusToCriteria(verdict.status),
309
+ level: "A",
310
+ impact: verdict.status === "missing" ? "critical"
311
+ : verdict.status === "wrong" ? "serious"
312
+ : "moderate",
313
+ description: verdict.summary || `${readerName} would announce this element as "${stop.phrase}" — ${verdict.status}.`,
314
+ help: verdict.suggestion || "Improve the accessible name, role, or description for this element.",
315
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
316
+ selector: stop.dom?.selector || "",
317
+ evidence: `Spoken: "${stop.phrase}"\nDOM: ${stop.dom?.html || ""}`,
318
+ failureSummary: verdict.summary || "",
319
+ screenshotFile,
320
+ spokenPhrase: stop.phrase,
321
+ readerName,
322
+ });
323
+ }
324
+ }
325
+
326
+ return {
327
+ findings,
328
+ usage: totalUsage,
329
+ meta: {
330
+ stops: stops.length,
331
+ readerName,
332
+ transcriptSample: stops.slice(0, 10).map((s) => ({
333
+ spoken: s.phrase,
334
+ selector: s.dom?.selector || "",
335
+ })),
336
+ },
337
+ };
338
+ }
339
+
340
+ // ── Direct NVDA launch/stop (fallback when guidepup fails) ─────────────
341
+ // Finds nvda.exe on the system and launches it directly. This gives
342
+ // audible speech output without depending on guidepup's COM interface.
343
+ const NVDA_PATHS = [
344
+ "C:\\Program Files (x86)\\NVDA\\nvda.exe",
345
+ "C:\\Program Files\\NVDA\\nvda.exe",
346
+ ];
347
+
348
+ function findNvdaExe() {
349
+ for (const p of NVDA_PATHS) {
350
+ try {
351
+ if (fs.existsSync(p)) return p;
352
+ } catch (_) {}
353
+ }
354
+ // Try PATH
355
+ try {
356
+ const result = execSync("where nvda.exe", { encoding: "utf8", timeout: 5000 }).trim();
357
+ if (result) return result.split("\n")[0].trim();
358
+ } catch (_) {}
359
+ return null;
360
+ }
361
+
362
+ async function launchNvdaDirect(log) {
363
+ const exe = findNvdaExe();
364
+ if (!exe) {
365
+ log("⚠ NVDA not found on this system — continuing without audio");
366
+ return null;
367
+ }
368
+ try {
369
+ // Kill any already-running NVDA so we start clean and don't
370
+ // interfere with a user's existing session after we're done.
371
+ log("killing any existing NVDA instance…");
372
+ try {
373
+ execSync("taskkill /IM nvda.exe /F", { timeout: 5000, stdio: "ignore" });
374
+ await new Promise((r) => setTimeout(r, 1500)); // wait for it to fully exit
375
+ } catch (_) {} // no existing NVDA — that's fine
376
+
377
+ log(`launching NVDA directly: ${exe}`);
378
+ // Use cmd.exe /c start to handle Program Files path permissions.
379
+ // The empty "" is the window title argument required by start.
380
+ const child = spawn("cmd.exe", ["/c", "start", "", `"${exe}"`, "-r"], {
381
+ detached: true,
382
+ stdio: "ignore",
383
+ windowsHide: true,
384
+ shell: false,
385
+ });
386
+ child.on("error", (err) => {
387
+ log(`⚠ NVDA spawn error (${err.message}) — continuing without audio`);
388
+ });
389
+ child.unref();
390
+
391
+ // Wait for NVDA to fully initialize (speech engine, focus tracking)
392
+ await new Promise((r) => setTimeout(r, 5000));
393
+
394
+ // Verify NVDA is actually running
395
+ try {
396
+ const check = execSync("tasklist /FI \"IMAGENAME eq nvda.exe\" /NH", {
397
+ encoding: "utf8", timeout: 5000,
398
+ });
399
+ if (!check.includes("nvda.exe")) {
400
+ log("⚠ NVDA process not found after launch — continuing without audio");
401
+ return null;
402
+ }
403
+ } catch (_) {}
404
+
405
+ log("✓ NVDA launched — will read focused elements as we Tab through");
406
+ return true;
407
+ } catch (err) {
408
+ log(`⚠ failed to launch NVDA directly (${err.message}) — continuing without audio`);
409
+ return null;
410
+ }
411
+ }
412
+
413
+ async function stopNvdaDirect(_handle, log) {
414
+ try {
415
+ log("sending quit to NVDA…");
416
+ // Try graceful quit first via NVDA's own --quit flag
417
+ try {
418
+ const exe = findNvdaExe();
419
+ if (exe) {
420
+ spawn("cmd.exe", ["/c", "start", "", `"${exe}"`, "--quit"], {
421
+ detached: true, stdio: "ignore", windowsHide: true, shell: false,
422
+ }).on("error", () => {}).unref();
423
+ await new Promise((r) => setTimeout(r, 3000));
424
+ }
425
+ } catch (_) {}
426
+
427
+ // Verify it's gone, force-kill if not
428
+ try {
429
+ const check = execSync("tasklist /FI \"IMAGENAME eq nvda.exe\" /NH", {
430
+ encoding: "utf8", timeout: 5000,
431
+ });
432
+ if (check.includes("nvda.exe")) {
433
+ execSync("taskkill /IM nvda.exe /F", { timeout: 5000, stdio: "ignore" });
434
+ }
435
+ } catch (_) {}
436
+
437
+ log("✓ NVDA stopped");
438
+ } catch (err) {
439
+ log(`⚠ could not stop NVDA (${err.message})`);
440
+ }
441
+ }
442
+
443
+ async function loadDriver() {
444
+ let voPkg, nvdaPkg;
445
+ try {
446
+ ({ voiceOver: voPkg, nvda: nvdaPkg } = await import("@guidepup/guidepup"));
447
+ } catch (err) {
448
+ throw new Error("@guidepup/guidepup not installed");
449
+ }
450
+ const platform = os.platform();
451
+ if (platform === "darwin" && voPkg) return { vo: voPkg, platform: "darwin" };
452
+ if (platform === "win32" && nvdaPkg) return { nvda: nvdaPkg, platform: "win32" };
453
+ throw new Error(`no supported screen reader on ${platform}`);
454
+ }
455
+
456
+ function buildClarityPrompt(stops, { url, title, readerName }) {
457
+ const lines = [];
458
+ lines.push(
459
+ `You are a senior accessibility auditor specializing in screen-reader user experience. ` +
460
+ `${readerName} just walked the page below using Tab navigation. We captured what each focusable ` +
461
+ `element would announce by reading the same accessibility tree the screen reader reads from.`
462
+ );
463
+ lines.push("");
464
+ lines.push(`Page URL: ${url}`);
465
+ lines.push(`Page title: ${title}`);
466
+ lines.push("");
467
+ lines.push("For EACH numbered stop below, evaluate the announcement against the DOM context. Return a JSON array with one object per stop in this shape:");
468
+ lines.push(` { "stop": <0-based index>, "status": "ok" | "unclear" | "missing" | "wrong", "summary": "...", "suggestion": "..." }`);
469
+ lines.push("");
470
+ lines.push("Status definitions:");
471
+ lines.push("- ok : a screen-reader user would clearly understand what this control is and what it does");
472
+ lines.push("- unclear : the announcement is technically correct but vague (e.g. just 'button', 'link', 'image' with no name)");
473
+ lines.push("- missing : the announcement is empty, '(unnamed)', or has no accessible name");
474
+ lines.push("- wrong : the announcement actively misrepresents the element (mismatched label, stale role, hidden purpose)");
475
+ lines.push("");
476
+ lines.push("Skip 'ok' results from the array — only return entries that need attention. Return ONLY the JSON array. No prose, no markdown fences.");
477
+ lines.push("");
478
+ lines.push("STOPS:");
479
+ stops.forEach((s, i) => {
480
+ lines.push("");
481
+ lines.push(`### ${i}`);
482
+ lines.push(`Would announce: "${(s.phrase || "").replace(/"/g, '\\"')}"`);
483
+ lines.push(`Element tag: ${s.dom?.tag || "(unknown)"}`);
484
+ lines.push(`Computed role: ${s.computed?.role || "(implicit)"}`);
485
+ lines.push(`Computed name: ${s.computed?.name || "(none)"}`);
486
+ lines.push(`Visible text: ${s.dom?.visibleText || "(none)"}`);
487
+ lines.push(`HTML: ${s.dom?.html || "(unknown)"}`);
488
+ });
489
+ return lines.join("\n");
490
+ }
491
+
492
+ function mapStatusToCriteria(status) {
493
+ switch (status) {
494
+ case "missing": return "4.1.2";
495
+ case "wrong": return "4.1.2, 1.3.1";
496
+ case "unclear": return "2.4.6, 4.1.2";
497
+ default: return "4.1.2";
498
+ }
499
+ }
500
+
501
+ function mergeUsage(a, b) {
502
+ if (!a) return b ? { ...b } : null;
503
+ if (!b) return a;
504
+ return {
505
+ input_tokens: (a.input_tokens || 0) + (b.input_tokens || 0),
506
+ output_tokens: (a.output_tokens || 0) + (b.output_tokens || 0),
507
+ };
508
+ }
509
+
510
+ function sleep(ms) {
511
+ return new Promise((r) => setTimeout(r, ms));
512
+ }
513
+
514
+ function withTimeout(promise, ms, label) {
515
+ return new Promise((resolve, reject) => {
516
+ const timer = setTimeout(() => reject(new Error(label || `timed out after ${ms}ms`)), ms);
517
+ promise.then(
518
+ (v) => { clearTimeout(timer); resolve(v); },
519
+ (e) => { clearTimeout(timer); reject(e); }
520
+ );
521
+ });
522
+ }