automatica11y 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -2
  4. package/bin/automatica11y.js +4 -0
  5. package/package.json +50 -4
  6. package/skills/automatica11y/SKILL.md +23 -0
  7. package/skills/automatica11y-runner/SKILL.md +181 -0
  8. package/skills/automatica11y-runner/references/fixtures.md +59 -0
  9. package/src/cli.js +49 -0
  10. package/src/commands/audit.js +4 -0
  11. package/src/commands/common.js +274 -0
  12. package/src/commands/compare.js +4 -0
  13. package/src/commands/doctor.js +20 -0
  14. package/src/commands/guide.js +45 -0
  15. package/src/env/browser.js +136 -0
  16. package/src/env/versions.js +60 -0
  17. package/src/globals.d.ts +10 -0
  18. package/src/harness/browser.js +20 -0
  19. package/src/harness/bundle.js +49 -0
  20. package/src/harness/npm-install.js +65 -0
  21. package/src/harness/npm-react.js +39 -0
  22. package/src/harness/npm-wc.js +30 -0
  23. package/src/harness/shadow.js +42 -0
  24. package/src/harness/static-serve.js +68 -0
  25. package/src/harness/storybook.js +116 -0
  26. package/src/harness/url.js +41 -0
  27. package/src/plan/build-plan.js +62 -0
  28. package/src/plan/classify.js +185 -0
  29. package/src/plan/mapping.js +101 -0
  30. package/src/plan/resolve-npm.js +87 -0
  31. package/src/report/comparison.js +183 -0
  32. package/src/report/index.js +10 -0
  33. package/src/report/parts.js +334 -0
  34. package/src/report/single.js +16 -0
  35. package/src/run/audit-npm.js +279 -0
  36. package/src/run/fail-check.js +62 -0
  37. package/src/run/pool.js +21 -0
  38. package/src/run/run-plan.js +262 -0
  39. package/src/run/summary.js +49 -0
  40. package/src/schema.js +255 -0
  41. package/src/text.js +9 -0
  42. package/src/tiers/interactions/archetypes.js +417 -0
  43. package/src/tiers/interactions/helpers.js +145 -0
  44. package/src/tiers/interactions/index.js +107 -0
  45. package/src/tiers/rules/axe.js +75 -0
  46. package/src/tiers/rules/canvas.js +34 -0
  47. package/src/tiers/rules/ibm.js +121 -0
  48. package/src/tiers/rules/index.js +81 -0
  49. package/src/tiers/vsr.js +134 -0
@@ -0,0 +1,334 @@
1
+ import { cap, num, plural } from "../text.js";
2
+
3
+ /** Markdown helpers. */
4
+ export const cell = (text) => String(text ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
5
+ export const code = (text) => `\`${String(text).replace(/`/g, "'")}\``;
6
+ /** Keep a reason from ending in two periods. */
7
+ export const sentence = (text) => String(text).replace(/\.+$/, "");
8
+ /** Escape angle brackets so rule text like <input> doesn't turn into HTML. */
9
+ export const esc = (text) => String(text ?? "").replace(/</g, "\\<");
10
+ export const ENGINE_NAMES = { axe: "axe-core", ibm: "IBM Equal Access" };
11
+ export const TIER_NAMES = { rules: "Rules", interactions: "Interactions", vsr: "Virtual screen reader" };
12
+
13
+ export function toolLines(tools) {
14
+ return Object.entries(tools)
15
+ .filter(([, version]) => version)
16
+ .map(([name, version]) => `- ${name}: ${version}`);
17
+ }
18
+
19
+ /** The text for one coverage-matrix cell. */
20
+ export function engineCell(summary, engine) {
21
+ const s = summary?.engines?.[engine];
22
+ if (!s) return "-";
23
+ if (s.status !== "ran") return s.status;
24
+ return `ran, ${plural(s.violations, "violation")}`;
25
+ }
26
+
27
+ export function tierCell(target, tier) {
28
+ const counts = target.summary?.interactions;
29
+ const vsr = target.summary?.vsr;
30
+ if (tier === "vsr" && vsr) return `ran (simulated), ${vsr.flagged ? `${num(vsr.flagged)} flagged` : "none flagged"}`;
31
+ if (tier === "interactions" && counts) {
32
+ const parts = [counts.fail && plural(counts.fail, "failed", "failed"), counts.error && plural(counts.error, "error"), counts.pass && `${num(counts.pass)} passed`, counts.notApplicable && `${num(counts.notApplicable)} not applicable`].filter(Boolean);
33
+ return `ran, ${parts.join(", ")}`;
34
+ }
35
+ const configs = Object.values(target.archetypes).flatMap((a) => a.configs);
36
+ const status = configs.map((c) => c.tiers[tier]?.status).find(Boolean);
37
+ return status ?? "-";
38
+ }
39
+
40
+ export function coverageMatrix(plan, results) {
41
+ const engines = plan.options.engines;
42
+ const tiers = plan.options.tiers.filter((t) => t !== "rules");
43
+ const rulesRequested = plan.options.tiers.includes("rules");
44
+ const head = ["Target", ...(rulesRequested ? engines.map((e) => ENGINE_NAMES[e]) : []), ...tiers.map((t) => TIER_NAMES[t])];
45
+ const rows = results.targets.map((target) => {
46
+ if (target.status === "failed") return [cell(target.id), ...head.slice(1).map(() => "failed")];
47
+ return [
48
+ cell(target.id),
49
+ ...(rulesRequested ? engines.map((e) => engineCell(target.summary, e)) : []),
50
+ ...tiers.map((t) => tierCell(target, t)),
51
+ ];
52
+ });
53
+ return [`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`, ...rows.map((r) => `| ${r.join(" | ")} |`)].join("\n");
54
+ }
55
+
56
+ export function findingBlock(finding, { showImpact, showToolkit }) {
57
+ const meta = [
58
+ showImpact && finding.impact ? `impact: ${finding.impact}` : null,
59
+ showToolkit && finding.toolkitLevel != null ? `IBM Toolkit level ${finding.toolkitLevel}` : null,
60
+ finding.kind ? `kind: ${finding.kind}` : null,
61
+ finding.wcag.length ? `WCAG ${finding.wcag.join(", ")}` : null,
62
+ plural(finding.nodeCount, "element"),
63
+ ].filter(Boolean);
64
+ const lines = [`- ${code(finding.ruleId)} (${meta.join("; ")}). ${esc(finding.help)} [Rule help](${finding.helpUrl})`];
65
+ for (const node of finding.nodes) lines.push(` - ${code(node.selector)} ${node.html ? code(node.html) : ""}`.trimEnd());
66
+ if (finding.nodeCount > finding.nodes.length) lines.push(` - and ${num(finding.nodeCount - finding.nodes.length)} more`);
67
+ return lines.join("\n");
68
+ }
69
+
70
+ export function engineSection(engine, result, nested = false) {
71
+ const title = `${nested ? "#####" : "####"} ${ENGINE_NAMES[engine] ?? engine}${result.version ? ` ${result.version}` : ""}.`;
72
+ if (result.status === "not-testable") return `${title}\n\nNot testable: ${sentence(result.reason)}. Treat this as a coverage gap, not a pass.\n`;
73
+ if (result.status !== "ran") return `${title}\n\nThis engine didn't run: ${sentence(result.reason ?? result.status)}. Treat this as a coverage gap, not a pass.\n`;
74
+ const showImpact = engine === "axe";
75
+ const showToolkit = engine === "ibm";
76
+ const cfg = result.config ? `Configuration: ${code(JSON.stringify(result.config))}.` : "";
77
+ const out = [title, "", cfg, ""];
78
+ out.push("**Violations.**", "");
79
+ if (result.violations.length === 0) out.push(`No automated violations found by ${ENGINE_NAMES[engine]}.`);
80
+ else out.push(...result.violations.map((f) => findingBlock(f, { showImpact, showToolkit })));
81
+ out.push("", "**Needs review.**", "");
82
+ if (result.incomplete.length === 0) out.push("Nothing flagged for review.");
83
+ else out.push(...result.incomplete.map((f) => findingBlock(f, { showImpact, showToolkit })));
84
+ out.push("", "**Passes.**", "", `${cap(plural(result.passesCount, "rule"))} passed. A pass means the rule found nothing to flag. It doesn't show the page meets those criteria.`);
85
+ for (const note of result.notes ?? []) out.push("", `Note: ${note}`);
86
+ return out.join("\n");
87
+ }
88
+
89
+
90
+ /** Group one engine's findings across every story, so a long Storybook reads as rules, not as hundreds of repeats. */
91
+ export function aggregate(target, engine, listName) {
92
+ const byRule = new Map();
93
+ let version = null;
94
+ let ranStories = 0;
95
+ for (const [key, archetype] of Object.entries(target.archetypes)) {
96
+ const result = archetype.configs[0]?.tiers.rules?.engines?.[engine];
97
+ if (!result || result.status !== "ran") continue;
98
+ version ??= result.version;
99
+ ranStories += 1;
100
+ for (const finding of result[listName]) {
101
+ const id = `${finding.ruleId}|${finding.kind ?? ""}`;
102
+ const entry = byRule.get(id) ?? { finding, stories: [] };
103
+ entry.stories.push(key.replace(/^story:/, ""));
104
+ byRule.set(id, entry);
105
+ }
106
+ }
107
+ return { rows: [...byRule.values()].sort((a, b) => b.stories.length - a.stories.length || a.finding.ruleId.localeCompare(b.finding.ruleId)), version, ranStories };
108
+ }
109
+
110
+ export function ruleTable(rows, engine) {
111
+ if (rows.length === 0) return ["None."];
112
+ const head = engine === "axe" ? "Impact" : "Toolkit level";
113
+ const lines = [`| Rule | ${head} | WCAG | Stories | Examples |`, "| --- | --- | --- | --- | --- |"];
114
+ for (const { finding, stories } of rows) {
115
+ const level = engine === "axe" ? finding.impact ?? "-" : finding.toolkitLevel ?? "-";
116
+ const rule = finding.kind ? `${finding.ruleId} (${finding.kind})` : finding.ruleId;
117
+ lines.push(`| ${cell(code(rule))} | ${level} | ${cell(finding.wcag.join(", ") || "-")} | ${stories.length} | ${cell(stories.slice(0, 3).join(", "))}${stories.length > 3 ? ", and more" : ""} |`);
118
+ }
119
+ return lines;
120
+ }
121
+
122
+ export function storybookSection(planTarget, target) {
123
+ const sb = target.storybook;
124
+ const lines = [`### ${planTarget.label}.`, "", `Storybook ${code(planTarget.input)}, component evidence. Each story ran as its own unit, and rules were scoped to the story's root element.`, ""];
125
+ const filtered = sb.filtered ?? sb.matched !== sb.total;
126
+ lines.push(
127
+ `The index lists ${plural(sb.total, "story", "stories")}. ${filtered ? `${cap(num(sb.matched))} matched the archetype filter. ` : ""}${cap(num(sb.audited))} ${sb.audited === 1 ? "was" : "were"} audited, with a cap of ${num(sb.maxStories)}.`,
128
+ "",
129
+ );
130
+ for (const warning of target.warnings) lines.push(`Warning: ${warning}`, "");
131
+ const matches = Object.entries(sb.archetypeMatches);
132
+ if (matches.length) {
133
+ lines.push("**Archetype matches.** Matches come from story titles, names, and tags, so check them.", "");
134
+ for (const [archetype, ids] of matches) lines.push(`- ${archetype}: ${ids.slice(0, 8).map(code).join(", ")}${ids.length > 8 ? `, and ${num(ids.length - 8)} more` : ""}`);
135
+ lines.push("");
136
+ }
137
+ if (sb.failedStories.length) {
138
+ lines.push("**Stories that didn't render.** Each one is a gap in coverage, not a pass.", "");
139
+ for (const failure of sb.failedStories) lines.push(`- ${code(failure.id)}: ${sentence(failure.reason)}.`);
140
+ lines.push("");
141
+ }
142
+ for (const engine of Object.keys(target.summary.engines)) {
143
+ const violations = aggregate(target, engine, "violations");
144
+ const review = aggregate(target, engine, "incomplete");
145
+ lines.push(`#### ${ENGINE_NAMES[engine] ?? engine}${violations.version ? ` ${violations.version}` : ""}.`, "");
146
+ if (violations.ranStories === 0) {
147
+ lines.push("This engine didn't run on any story. Treat that as a coverage gap, not a pass.", "");
148
+ continue;
149
+ }
150
+ lines.push("**Violations by rule.**", "");
151
+ if (violations.rows.length === 0) lines.push(`No automated violations found by ${ENGINE_NAMES[engine]} across ${plural(violations.ranStories, "story", "stories")}.`);
152
+ else lines.push(...ruleTable(violations.rows, engine));
153
+ lines.push("", "**Needs review by rule.**", "", ...ruleTable(review.rows, engine));
154
+ lines.push("", "Element-level detail for every story is in results.json.", "");
155
+ }
156
+ const walks = Object.entries(target.archetypes).map(([key, a]) => ({ id: key.replace(/^story:/, ""), vsr: a.configs[0]?.tiers.vsr })).filter((w) => w.vsr?.status === "ran");
157
+ if (walks.length) {
158
+ /** @type {Map<string, { flag: any, stories: string[] }>} */
159
+ const byFlag = new Map();
160
+ for (const { id, vsr } of walks) for (const flag of vsr.flags) {
161
+ const key = `${flag.type}|${flag.phrase}`;
162
+ const entry = byFlag.get(key) ?? { flag, stories: [] };
163
+ entry.stories.push(id);
164
+ byFlag.set(key, entry);
165
+ }
166
+ lines.push("#### Virtual screen reader (simulated).", "", `Simulated output from @guidepup/virtual-screen-reader${walks[0].vsr.version ? ` ${walks[0].vsr.version}` : ""}. It isn't a real screen reader. ${cap(num(walks.length))} ${walks.length === 1 ? "story was" : "stories were"} walked. The flags only mark a phrase that is a bare role or a generic role, for a person to check. Full announcement logs are in results.json.`, "");
167
+ if (byFlag.size === 0) lines.push("No phrases flagged.", "");
168
+ else {
169
+ lines.push("| Phrase | Why it's flagged | Stories | Examples |", "| --- | --- | --- | --- |");
170
+ for (const { flag, stories } of [...byFlag.values()].sort((a, b) => b.stories.length - a.stories.length)) {
171
+ lines.push(`| ${cell(code(flag.phrase))} | ${FLAG_TEXT[flag.type] ?? flag.type} | ${stories.length} | ${cell(stories.slice(0, 3).join(", "))}${stories.length > 3 ? ", and more" : ""} |`);
172
+ }
173
+ lines.push("");
174
+ }
175
+ }
176
+ return lines.join("\n");
177
+ }
178
+
179
+ export const FLAG_TEXT = {
180
+ "unnamed-control": "announced as only its role, with no name",
181
+ "generic-role": "announced with a generic role",
182
+ };
183
+
184
+ export function vsrSection(result, nested) {
185
+ const lines = [`${nested ? "#####" : "####"} Virtual screen reader (simulated).`, ""];
186
+ lines.push(`Simulated output from @guidepup/virtual-screen-reader${result.version ? ` ${result.version}` : ""}. It isn't a real screen reader, and real ones announce things differently. The log is data. The flags only mark a phrase that is a bare role or a generic role, for a person to check.`, "");
187
+ for (const note of result.notes ?? []) lines.push(`Note: ${note}`, "");
188
+ for (const entry of result.notTestable ?? []) lines.push(`Not testable: ${entry}.`, "");
189
+ lines.push("**Flagged phrases.**", "");
190
+ if (result.flags.length === 0) lines.push("None.");
191
+ else for (const flag of result.flags) lines.push(`- ${code(flag.phrase)} at position ${flag.index + 1}: ${FLAG_TEXT[flag.type] ?? flag.type}.`);
192
+ for (const entry of result.log) {
193
+ const shown = entry.announcements.slice(0, 40);
194
+ lines.push("", `**Announcement log${entry.state ? ` (${entry.state} state)` : ""}.**`, "", "```text", ...shown, ...(entry.announcements.length > shown.length ? [`... and ${num(entry.announcements.length - shown.length)} more in results.json`] : []), "```");
195
+ }
196
+ return lines.join("\n");
197
+ }
198
+
199
+ export function interactionsSection(result, nested) {
200
+ const lines = [`${nested ? "#####" : "####"} Interactions.`, "", "Each check ran on a fresh page, using only the trigger and root hooks and ARIA roles. A check that couldn't finish is an error, which counts as a gap and never as a pass.", ""];
201
+ lines.push("| Check | Result | WCAG | Detail |", "| --- | --- | --- | --- |");
202
+ for (const check of result.checks) {
203
+ lines.push(`| ${code(check.name)} | ${check.result} | ${cell((check.criteria ?? []).join(", ") || "-")} | ${cell(check.detail)}${check.method ? cell(` (method: ${check.method})`) : ""} |`);
204
+ }
205
+ return lines.join("\n");
206
+ }
207
+
208
+ /** " (open state, library accessibility on)" for a config. */
209
+ export function configLabel(config) {
210
+ const parts = [config.state ? `${config.state} state` : null, config.libA11y && config.libA11y !== "n/a" ? `library accessibility ${config.libA11y}` : null].filter(Boolean);
211
+ return parts.length ? ` (${parts.join(", ")})` : "";
212
+ }
213
+
214
+ export function archetypeTable(target) {
215
+ const rows = Object.entries(target.archetypes).map(([name, archetype]) => {
216
+ if (archetype.status === "gap") return `| ${name} | gap | - | ${cell(sentence(archetype.reason ?? "No fixture."))} |`;
217
+ const states = [...new Set(archetype.configs.map((c) => c.state).filter(Boolean))].join(", ") || "-";
218
+ const libs = [...new Set(archetype.configs.map((c) => c.libA11y).filter((l) => l && l !== "n/a"))];
219
+ return `| ${name} | ran | ${states} | ${libs.length ? `library accessibility ${libs.join(" and ")}` : ""} |`;
220
+ });
221
+ return ["| Archetype | Status | States | Note |", "| --- | --- | --- | --- |", ...rows];
222
+ }
223
+
224
+ export function targetSection(planTarget, target) {
225
+ if (target.status === "ran" && target.storybook) return storybookSection(planTarget, target);
226
+ const lines = [`### ${planTarget.label}.`, ""];
227
+ lines.push(`Target ${code(planTarget.input)}, ${planTarget.kind ?? "unclassified"}${planTarget.evidenceLevel ? `, ${planTarget.evidenceLevel} evidence` : ""}.`, "");
228
+ if (target.status !== "ran" && target.status !== "failed") {
229
+ lines.push(`This target is ${target.status}: ${sentence(target.reason)}. That's a gap in coverage. It isn't a pass.`, "");
230
+ return lines.join("\n");
231
+ }
232
+ if (target.npm) {
233
+ const n = target.npm;
234
+ lines.push(`Installed ${code(`${n.name}@${n.version}`)} on its own, as ${n.flavor === "react" ? `React${n.react ? ` (react ${n.react})` : ""}` : `web components (${n.tags.length ? n.tags.slice(0, 6).join(", ") : "no tags found"})`}.`, "");
235
+ }
236
+ for (const warning of target.warnings) lines.push(`Warning: ${warning}`, "");
237
+ if (target.status === "failed") {
238
+ lines.push(`This target failed: ${sentence(target.reason)}. A failed target is a gap in coverage. It isn't a pass.`, "");
239
+ }
240
+ if (target.npm) lines.push("**Archetypes.** A gap means the archetype wasn't tested, so it counts against coverage and never as a pass.", "", ...archetypeTable(target), "");
241
+ /** @type {Set<string>} */
242
+ const skipped = new Set();
243
+ for (const [name, archetype] of Object.entries(target.archetypes)) {
244
+ for (const config of archetype.configs) {
245
+ if (name !== "page") lines.push(`#### ${name}${configLabel(config)}.`, "");
246
+ for (const [tier, result] of Object.entries(config.tiers)) {
247
+ if (tier === "rules") {
248
+ for (const [engine, engineResult] of Object.entries(result.engines ?? {})) lines.push(engineSection(engine, engineResult, name !== "page"), "");
249
+ } else if (tier === "vsr" && result.status === "ran") {
250
+ lines.push(vsrSection(result, name !== "page"), "");
251
+ } else if (tier === "interactions" && result.status === "ran") {
252
+ lines.push(interactionsSection(result, name !== "page"), "");
253
+ } else if (result.status !== "ran") {
254
+ skipped.add(`${TIER_NAMES[tier] ?? tier}: ${sentence(result.reason ?? result.status)}.`);
255
+ }
256
+ }
257
+ }
258
+ }
259
+ if (skipped.size) lines.push("**Not run.**", "", ...[...skipped].map((item) => `- ${item}`), "");
260
+ return lines.join("\n");
261
+ }
262
+
263
+ export function failCheckSection(fail) {
264
+ if (!fail) return "";
265
+ const lines = ["## Fail check.", "", `Mode: ${fail.mode}. Each engine counts against its own threshold, and the counts aren't added together.`, ""];
266
+ if (fail.axe) lines.push(`- axe-core, impact ${fail.axe.threshold} or higher: ${plural(fail.axe.hits, "violation")}${fail.axe.tripped ? " (tripped)" : ""}.`);
267
+ if (fail.ibm) lines.push(`- IBM Equal Access, Toolkit level ${fail.ibm.threshold} or lower: ${plural(fail.ibm.hits, "violation")}${fail.ibm.tripped ? " (tripped)" : ""}.`);
268
+ lines.push("", `Result: ${fail.tripped ? "the fail check tripped." : "the fail check didn't trip."}`, "");
269
+ return lines.join("\n");
270
+ }
271
+
272
+
273
+ /** Top of every report: date, settings, tool versions, targets, and the warnings that apply to the whole run. */
274
+ export function reportHeader(plan, results, title) {
275
+ const o = plan.options;
276
+ const lines = [
277
+ `# ${title}`,
278
+ "",
279
+ `Run date: ${results.runAt}.`,
280
+ "",
281
+ `Command: ${plan.command}. WCAG ${o.wcag}, level ${o.level}. Tiers: ${o.tiers.join(", ")}. Engines: ${o.engines.join(", ")}.`,
282
+ "",
283
+ "These results are a snapshot. The tools ran at their latest versions on this date, and a later run can differ.",
284
+ "",
285
+ "**Tool versions.**",
286
+ "",
287
+ ...toolLines(results.tools),
288
+ "",
289
+ "**Targets.**",
290
+ "",
291
+ ...plan.targets.map((t) => `- ${t.label}: ${t.resolved ? Object.values(t.resolved).filter(Boolean).join(" ") : t.input}${t.status === "failed" ? ` (failed: ${sentence(t.reason)})` : ""}`),
292
+ "",
293
+ ];
294
+ const evidence = new Set(plan.targets.filter((t) => t.evidenceLevel).map((t) => t.evidenceLevel));
295
+ if (evidence.size > 1) {
296
+ lines.push("**Warning.** This comparison mixes component evidence and page evidence. They test different things, so the results aren't equivalent.", "");
297
+ }
298
+ for (const warning of results.warnings) lines.push(`Warning: ${warning}`, "");
299
+ return lines;
300
+ }
301
+
302
+ /** What each target couldn't test, listed under the coverage section. */
303
+ export function notTestableLines(results) {
304
+ const lines = [];
305
+ for (const target of results.targets) {
306
+ const items = target.summary.notTestable;
307
+ if (!items.length) continue;
308
+ lines.push(`**Not testable in ${target.id}.**`, "", ...items.slice(0, 20).map((item) => `- ${item}`), ...(items.length > 20 ? [`- and ${num(items.length - 20)} more`] : []), "");
309
+ }
310
+ return lines;
311
+ }
312
+
313
+ export const FINDINGS_NOTE = "Findings from different engines are listed separately. The engines overlap, and each catches things the other misses, so don't add their counts together. Impact is axe-core's own label. IBM Toolkit level is IBM's staged adoption scale (level 1 is essential requirements with high user impact). The two scales aren't comparable.";
314
+
315
+ /** The fail check and the method note that close every report. */
316
+ export function reportFooter(results) {
317
+ const lines = [];
318
+ const fail = failCheckSection(results.failCheck);
319
+ if (fail) lines.push(fail);
320
+ lines.push(
321
+ "## Method note.",
322
+ "",
323
+ "Automated rules cover only part of WCAG. A result of no automated violations found doesn't show that a page conforms to WCAG or works for everyone.",
324
+ "",
325
+ "A person has to check what these tools can't judge: whether alt text is meaningful, whether link and heading text make sense in context, cognitive load, reading order and focus order in real use, and how real screen readers behave.",
326
+ "",
327
+ "Contrast results depend on how the browser rendered the page, so the browser version is recorded above.",
328
+ "",
329
+ );
330
+ return lines;
331
+ }
332
+
333
+ /** Join lines into the final document. */
334
+ export const finish = (lines) => `${lines.join("\n").replace(/\n{3,}/g, "\n\n")}\n`;
@@ -0,0 +1,16 @@
1
+ import { FINDINGS_NOTE, coverageMatrix, finish, failCheckSection, notTestableLines, reportFooter, reportHeader, targetSection } from "./parts.js";
2
+
3
+ /**
4
+ * Render report.md for an audit. The text comes from results.json and nothing else.
5
+ * @param {{ plan: any, results: any }} input
6
+ */
7
+ export function renderSingleReport({ plan, results }) {
8
+ const lines = reportHeader(plan, results, "Accessibility report.");
9
+ lines.push("## Coverage.", "", coverageMatrix(plan, results), "");
10
+ lines.push("A gap, a failed engine, or a target that wasn't testable is a finding. It never counts as a pass.", "");
11
+ lines.push(...notTestableLines(results));
12
+ lines.push("## Findings.", "", FINDINGS_NOTE, "");
13
+ for (const target of results.targets) lines.push(targetSection(plan.targets.find((t) => t.id === target.id), target));
14
+ lines.push(...reportFooter(results));
15
+ return finish(lines);
16
+ }
@@ -0,0 +1,279 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { bundleEntries } from "../harness/bundle.js";
5
+ import { installPackage } from "../harness/npm-install.js";
6
+ import * as react from "../harness/npm-react.js";
7
+ import * as wc from "../harness/npm-wc.js";
8
+ import { closedShadowHosts, notTestableEntries } from "../harness/shadow.js";
9
+ import { serveStatic } from "../harness/static-serve.js";
10
+ import { openPage } from "../harness/url.js";
11
+ import { candidateMapping, findAuthoredFixture } from "../plan/mapping.js";
12
+ import { ARCHETYPES } from "../schema.js";
13
+ import { runInteractions } from "../tiers/interactions/index.js";
14
+ import { runRules } from "../tiers/rules/index.js";
15
+ import { failedVsr, runVsr } from "../tiers/vsr.js";
16
+ import { num } from "../text.js";
17
+ import { summarize } from "./summary.js";
18
+
19
+ /** The states worth checking for each archetype. The first is where a page starts. */
20
+ const STATES = {
21
+ dialog: ["closed", "open"],
22
+ menu: ["closed", "open"],
23
+ tooltip: ["closed", "open"],
24
+ combobox: ["closed", "open"],
25
+ accordion: ["collapsed", "expanded"],
26
+ };
27
+
28
+ const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error";
29
+
30
+ /** Page and console errors that mean a fixture didn't mount cleanly. A missing favicon doesn't count. */
31
+ function watchErrors(errors) {
32
+ return (page) => {
33
+ page.on("pageerror", (error) => errors.push(error.message.split("\n")[0]));
34
+ page.on("console", (message) => {
35
+ if (message.type() !== "error") return;
36
+ const text = message.text();
37
+ if (/favicon|Failed to load resource/i.test(text) && !/\.(js|css)\b/.test(text)) return;
38
+ errors.push(text.split("\n")[0]);
39
+ });
40
+ };
41
+ }
42
+
43
+ /** Load the whole package in a page to list its exports and the custom elements it defines. */
44
+ async function discover({ browser, workDir, flavor, pkg, buildDir }) {
45
+ const helper = flavor === "react" ? react : wc;
46
+ const entryFile = join(workDir, "discover.js");
47
+ writeFileSync(entryFile, helper.discoverEntry(pkg));
48
+ await bundleEntries({ entries: { discover: entryFile }, outdir: buildDir, workDir, react: flavor === "react" });
49
+ const server = await serveStatic(buildDir);
50
+ const errors = [];
51
+ try {
52
+ const opened = await openPage(browser, `${server.origin}/discover.html`, { beforeGoto: watchErrors(errors) });
53
+ try {
54
+ await opened.page.waitForFunction(() => /** @type {any} */ (window).__a11yExports !== undefined, undefined, { timeout: 15_000 });
55
+ } catch {
56
+ throw new Error(`The package didn't finish loading in the browser${errors.length ? `: ${errors[0]}` : "."}`);
57
+ }
58
+ const exportsList = await opened.page.evaluate(() => /** @type {any} */ (window).__a11yExports);
59
+ const tags = await opened.page.evaluate(() => [.../** @type {any} */ (window).__a11yDefined ?? []]);
60
+ await opened.close();
61
+ return { exports: exportsList, tags };
62
+ } finally {
63
+ await server.close();
64
+ }
65
+ }
66
+
67
+ /** Open one fixture page, check it follows the contract, and run the tiers in each state. `libA11y` is `on`, `off`, or `n/a`. */
68
+ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
69
+ const errors = [];
70
+ const opened = await openPage(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, { beforeGoto: watchErrors(errors) });
71
+ try {
72
+ const trigger = opened.page.locator("[data-a11y-trigger]");
73
+ try {
74
+ await trigger.first().waitFor({ state: "attached", timeout: 5000 });
75
+ } catch {
76
+ return { gap: `The fixture didn't render an element with data-a11y-trigger${errors.length ? `: ${errors[0]}` : "."}` };
77
+ }
78
+ const count = await trigger.count();
79
+ if (count !== 1) return { gap: `The fixture must mark exactly one data-a11y-trigger. It marked ${num(count)}.` };
80
+ if (errors.length) return { gap: `The fixture logged errors when it mounted: ${errors[0]}` };
81
+
82
+ const states = STATES[archetype] ?? ["initial"];
83
+ const configs = [];
84
+ for (const [index, state] of states.entries()) {
85
+ let failure = null;
86
+ if (index > 0) {
87
+ try {
88
+ if (archetype === "tooltip") await trigger.first().focus();
89
+ else await trigger.first().click();
90
+ await opened.page.waitForFunction(
91
+ () => {
92
+ const root = document.querySelector("[data-a11y-root]");
93
+ const shown = root && /** @type {HTMLElement} */ (root).getClientRects().length > 0;
94
+ return shown || document.querySelector('[data-a11y-trigger][aria-expanded="true"]') !== null;
95
+ },
96
+ undefined,
97
+ { timeout: 3000 },
98
+ );
99
+ } catch {
100
+ failure = `The ${state} state never appeared after activating the trigger. A fixture's data-a11y-root has to show up when the ${archetype} opens.`;
101
+ }
102
+ }
103
+ await opened.page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
104
+ /** @type {Record<string, any>} */
105
+ const tiers = {};
106
+ for (const tier of plan.options.tiers) {
107
+ if (tier === "interactions") continue;
108
+ if (tier === "vsr") tiers.vsr = failure ? { status: "skipped", simulated: true, reason: failure } : await runVsr(opened.page, { scope: "body", state }).catch(failedVsr);
109
+ else if (failure) tiers.rules = { status: "failed", reason: failure, engines: Object.fromEntries(plan.options.engines.map((e) => [e, { status: "failed", reason: failure }])) };
110
+ else {
111
+ tiers.rules = await runRules(opened.page, { engines: plan.options.engines, wcag: plan.options.wcag, level: plan.options.level, scope: index === 0 ? "#root" : ["#root", "[data-a11y-root]"] });
112
+ }
113
+ }
114
+ configs.push({ libA11y, state, tiers });
115
+ if (errors.length) return { gap: `The fixture logged errors in the ${state} state: ${errors[0]}` };
116
+ }
117
+ const hidden = notTestableEntries(await closedShadowHosts(opened.page));
118
+ // The checks open their own fresh pages, so run them after this page's rules results are in.
119
+ if (plan.options.tiers.includes("interactions")) configs[0].tiers.interactions = await runInteractions(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, archetype);
120
+ return { configs, hidden };
121
+ } finally {
122
+ await opened.close();
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Audit one fixture. A library that ships opt-in accessibility features runs once for each `--lib-a11y` value, and each result carries its label.
128
+ * @param {{ browser: any, url: string, archetype: string, plan: any, toggle: boolean }} options
129
+ */
130
+ async function auditFixture({ browser, url, archetype, plan, toggle }) {
131
+ const values = toggle ? plan.options.libA11y : ["n/a"];
132
+ const configs = [];
133
+ const hidden = [];
134
+ for (const libA11y of values) {
135
+ const outcome = await auditFixturePage({ browser, url, archetype, plan, libA11y });
136
+ if (outcome.gap) return { gap: toggle ? `With library accessibility ${libA11y}: ${outcome.gap}` : outcome.gap };
137
+ configs.push(...outcome.configs);
138
+ hidden.push(...outcome.hidden);
139
+ }
140
+ return { configs, hidden };
141
+ }
142
+
143
+ /**
144
+ * Audit an npm package: install it on its own, find what it exports, and audit each archetype that has a fixture.
145
+ * An archetype without a usable fixture is a gap with a reason, never a pass.
146
+ * @returns {Promise<{ result: any, mapping: Record<string, any> | null }>}
147
+ */
148
+ export async function auditNpm({ browser, planTarget, plan, cwd, install = installPackage }) {
149
+ install ??= installPackage;
150
+ const base = { id: planTarget.id, reason: null, archetypes: {}, summary: { engines: {}, gaps: [], notTestable: [] }, warnings: [] };
151
+ const resolved = planTarget.resolved ?? {};
152
+ if (planTarget.kind === "npm-unsupported") {
153
+ return { result: { ...base, status: "unsupported", reason: `${resolved.framework ?? "This framework"} packages aren't supported. v1 covers React and web components.` }, mapping: null };
154
+ }
155
+ const tmp = mkdtempSync(join(tmpdir(), "automatica11y-npm-"));
156
+ const workDir = join(tmp, "install");
157
+ const buildDir = join(tmp, "build");
158
+ const fixtureDir = join(tmp, "fixtures");
159
+ mkdirSync(fixtureDir, { recursive: true });
160
+ const servers = [];
161
+ try {
162
+ /** @type {"react" | "wc" | "unknown"} */
163
+ let flavor = planTarget.kind === "npm-react" ? "react" : planTarget.kind === "npm-wc" ? "wc" : "unknown";
164
+ const installed = await install({ dir: workDir, name: resolved.name, version: resolved.version, flavor });
165
+ const warnings = [...installed.warnings];
166
+
167
+ const found = await discover({ browser, workDir, flavor: flavor === "react" ? "react" : "wc", pkg: resolved.name, buildDir });
168
+ if (flavor === "unknown") {
169
+ if (found.tags.length > 0) flavor = "wc";
170
+ else if (installed.react && found.exports.some((e) => /^[A-Z]/.test(e.name))) flavor = "react";
171
+ }
172
+ if (flavor === "unknown" || (flavor === "wc" && found.tags.length === 0)) {
173
+ return { result: { ...base, status: "not-applicable", reason: "The package has no rendering surface. It exports no React components and defines no custom elements.", warnings }, mapping: null };
174
+ }
175
+
176
+ const candidates = candidateMapping({ flavor, exports: found.exports, tags: found.tags });
177
+ const kindFlavor = /** @type {"react" | "wc"} */ (flavor);
178
+ const wanted = plan.options.archetypes ?? ARCHETYPES;
179
+ /** @type {Record<string, any>} */
180
+ const mapping = {};
181
+ /** @type {Record<string, any>} */
182
+ const archetypes = {};
183
+ const gaps = [];
184
+ const hidden = [];
185
+ const helper = flavor === "react" ? react : wc;
186
+
187
+ // Decide where each archetype's fixture comes from, then bundle each one on its own so one bad fixture can't break the rest.
188
+ const runnable = {};
189
+ for (const archetype of wanted) {
190
+ const user = planTarget.mapping?.[archetype] ?? {};
191
+ const entry = { ...candidates[archetype], ...user };
192
+ const authored = findAuthoredFixture({ cwd, targetId: planTarget.id, archetype, mapped: user });
193
+ let fixture = null;
194
+ if (authored) {
195
+ entry.status = "authored";
196
+ entry.fixture = authored;
197
+ delete entry.reason;
198
+ fixture = authored;
199
+ } else if (user.fixture) {
200
+ entry.status = "needs-fixture";
201
+ entry.reason = `The mapping names ${user.fixture}, but that file doesn't exist.`;
202
+ } else if (entry.status === "template" || (user.export || user.tag) && ["button", "link"].includes(archetype)) {
203
+ const source = flavor === "react" ? react.template(archetype, resolved.name, entry.export) : wc.template(archetype, entry.tag);
204
+ if (source) {
205
+ entry.status = "template";
206
+ delete entry.reason;
207
+ fixture = join(fixtureDir, `${archetype}.${flavor === "react" ? "jsx" : "js"}`);
208
+ writeFileSync(fixture, source);
209
+ }
210
+ }
211
+ mapping[archetype] = entry;
212
+ if (!fixture) {
213
+ const reason = entry.status === "no-match" ? entry.reason : entry.reason ?? `The ${archetype} archetype needs a fixture someone writes.`;
214
+ archetypes[archetype] = { status: "gap", reason: archetype && entry.status !== "no-match" ? `${reason} Write fixtures/${planTarget.id}/${archetype}.${flavor === "react" ? "jsx" : "js"}.` : reason, configs: [] };
215
+ gaps.push(`archetype:${archetype}`);
216
+ continue;
217
+ }
218
+ const entryFile = join(tmp, "entries", `${archetype}-entry.js`);
219
+ mkdirSync(join(tmp, "entries"), { recursive: true });
220
+ writeFileSync(entryFile, helper.entry(fixture, resolved.name));
221
+ try {
222
+ await bundleEntries({ entries: { [archetype]: entryFile }, outdir: buildDir, workDir, react: flavor === "react" });
223
+ runnable[archetype] = `/${archetype}.html`;
224
+ } catch (error) {
225
+ archetypes[archetype] = { status: "gap", reason: `The fixture didn't bundle. ${firstLine(error)}`, configs: [] };
226
+ gaps.push(`archetype:${archetype}`);
227
+ entry.status = "needs-fixture";
228
+ entry.reason = firstLine(error);
229
+ }
230
+ }
231
+
232
+ if (Object.keys(runnable).length > 0) {
233
+ const live = await serveStatic(buildDir);
234
+ servers.push(live);
235
+ for (const [archetype, path] of Object.entries(runnable)) {
236
+ /** @type {any} */
237
+ const outcome = await auditFixture({ browser, url: `${live.origin}${path}`, archetype, plan, toggle: mapping[archetype].libA11y === true }).catch((error) => ({ gap: firstLine(error) }));
238
+ if (outcome.gap) {
239
+ archetypes[archetype] = { status: "gap", reason: outcome.gap, configs: [] };
240
+ gaps.push(`archetype:${archetype}`);
241
+ mapping[archetype].status = "needs-fixture";
242
+ mapping[archetype].reason = outcome.gap;
243
+ } else {
244
+ archetypes[archetype] = { status: "ran", configs: outcome.configs };
245
+ hidden.push(...outcome.hidden.map((h) => `${archetype}: ${h}`));
246
+ }
247
+ }
248
+ }
249
+
250
+ const ordered = Object.fromEntries(wanted.filter((a) => archetypes[a]).map((a) => [a, archetypes[a]]));
251
+ const ranAny = Object.values(ordered).some((a) => a.status === "ran");
252
+ return {
253
+ result: {
254
+ id: planTarget.id,
255
+ status: ranAny ? "ran" : "failed",
256
+ reason: ranAny ? null : "No archetype had a usable fixture, so nothing was tested. See the gaps for what each one needs.",
257
+ archetypes: ordered,
258
+ npm: {
259
+ name: resolved.name,
260
+ version: installed.version ?? resolved.version,
261
+ flavor,
262
+ framework: resolved.framework ?? null,
263
+ react: installed.react,
264
+ reactDom: installed.reactDom,
265
+ tags: flavor === "wc" ? found.tags : [],
266
+ },
267
+ summary: (() => {
268
+ const base = summarize(ordered, plan.options.engines, gaps);
269
+ return { ...base, notTestable: [...base.notTestable, ...hidden] };
270
+ })(),
271
+ warnings,
272
+ },
273
+ mapping: Object.fromEntries(Object.entries(mapping).map(([k, v]) => [k, { ...v, fixture: v.fixture ?? null }])),
274
+ };
275
+ } finally {
276
+ for (const s of servers) await s.close();
277
+ if (!process.env.AUTOMATICA11Y_KEEP_TEMP) rmSync(tmp, { recursive: true, force: true });
278
+ }
279
+ }