automatica11y 0.3.3 → 0.4.1

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 +2 -0
  2. package/README.md +39 -11
  3. package/package.json +5 -3
  4. package/skills/automatica11y/SKILL.md +3 -1
  5. package/skills/automatica11y-runner/SKILL.md +47 -14
  6. package/skills/automatica11y-runner/references/fixtures.md +38 -2
  7. package/src/commands/common.js +5 -2
  8. package/src/frameworks/index.js +42 -0
  9. package/src/frameworks/react.js +77 -0
  10. package/src/frameworks/vue.js +90 -0
  11. package/src/frameworks/wc.js +55 -0
  12. package/src/globals.d.ts +1 -0
  13. package/src/harness/bundle.js +7 -6
  14. package/src/harness/generate/dialects.js +64 -0
  15. package/src/harness/generate/index.js +12 -0
  16. package/src/harness/generate/jsx-recipes.js +224 -0
  17. package/src/harness/generate/jsx.js +76 -0
  18. package/src/harness/generate/kit.js +50 -0
  19. package/src/harness/generate/marking.js +64 -0
  20. package/src/harness/generate/probe.js +97 -0
  21. package/src/harness/generate/shared.js +12 -0
  22. package/src/harness/generate/wc-recipes.js +132 -0
  23. package/src/harness/npm-install.js +38 -7
  24. package/src/harness/settle.js +17 -0
  25. package/src/harness/storybook.js +1 -0
  26. package/src/harness/url.js +13 -3
  27. package/src/plan/classify.js +11 -4
  28. package/src/plan/mapping.js +10 -7
  29. package/src/plan/resolve-npm.js +15 -9
  30. package/src/plan/subpath.js +133 -0
  31. package/src/report/comparison.js +14 -9
  32. package/src/report/parts.js +36 -12
  33. package/src/run/audit-npm.js +118 -28
  34. package/src/run/generate-fixture.js +74 -0
  35. package/src/run/run-plan.js +19 -4
  36. package/src/run/summary.js +5 -0
  37. package/src/schema.js +30 -6
  38. package/src/tiers/computed/checks.js +44 -5
  39. package/src/tiers/computed/color.js +8 -0
  40. package/src/tiers/computed/index.js +2 -2
  41. package/src/tiers/computed/measure-kit.js +30 -10
  42. package/src/tiers/conditions/checks.js +283 -0
  43. package/src/tiers/conditions/index.js +30 -0
  44. package/src/tiers/conditions/kit.js +133 -0
  45. package/src/tiers/interactions/archetypes.js +111 -0
  46. package/src/tiers/interactions/helpers.js +56 -0
  47. package/src/tiers/interactions/index.js +16 -3
  48. package/src/harness/npm-react.js +0 -39
  49. package/src/harness/npm-wc.js +0 -30
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Sub-paths of an npm package: `@scope/pkg/button/v2` imports `button/v2` from `@scope/pkg`.
3
+ * A package says what it lets people import in its `exports` field. A package without one lets people import any file in it.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ /**
9
+ * Split a sub-path into clean segments, or say what's wrong with it. Rejects empty parts, `.` and `..`, and backslashes,
10
+ * so a sub-path can't point outside the package. A trailing slash is dropped.
11
+ * @param {string} text
12
+ * @returns {{ ok: true, subpath: string } | { ok: false, reason: string }}
13
+ */
14
+ export function cleanSubpath(text) {
15
+ const trimmed = text.replace(/\/+$/, "");
16
+ if (!trimmed) return { ok: false, reason: "the sub-path is empty" };
17
+ if (trimmed.includes("\\")) return { ok: false, reason: "a sub-path uses forward slashes" };
18
+ const segments = trimmed.split("/");
19
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return { ok: false, reason: 'a sub-path can\'t contain empty parts, "." or ".."' };
20
+ return { ok: true, subpath: segments.join("/") };
21
+ }
22
+
23
+ /**
24
+ * The export keys that name a sub-path (`./button`), and the patterns (`./es/*`), from an `exports` field.
25
+ * A string, an array, or an object with no `./` keys only exports the package root.
26
+ */
27
+ function exportKeys(exportsField) {
28
+ if (!exportsField || typeof exportsField !== "object" || Array.isArray(exportsField)) return { exact: [], patterns: [], hasMap: false };
29
+ const keys = Object.keys(exportsField).filter((key) => key === "." || key.startsWith("./"));
30
+ if (keys.length === 0) return { exact: [], patterns: [], hasMap: false };
31
+ const usable = keys.filter((key) => exportsField[key] !== null);
32
+ return { exact: usable.filter((key) => !key.includes("*") && key !== "." && key !== "./package.json"), patterns: usable.filter((key) => key.includes("*")), hasMap: true };
33
+ }
34
+
35
+ /** Does a pattern key like `./es/*` or `./features/*.js` match a sub-path? */
36
+ function matchesPattern(pattern, subpath) {
37
+ const [before, after] = pattern.slice(2).split("*");
38
+ return subpath.length >= before.length + after.length && subpath.startsWith(before) && subpath.endsWith(after);
39
+ }
40
+
41
+ /**
42
+ * Does the package's `exports` field let a sub-path be imported? Returns the sub-paths to suggest when it doesn't.
43
+ * `package.json` is always allowed. A package with no exports map isn't judged here, because its files are the answer.
44
+ * @param {unknown} exportsField
45
+ * @param {string} subpath
46
+ * @returns {{ checked: boolean, ok: boolean, exact: string[], patterns: string[] }}
47
+ */
48
+ export function checkExports(exportsField, subpath) {
49
+ const { exact, patterns, hasMap } = exportKeys(exportsField);
50
+ if (!hasMap) {
51
+ // A string or array exports field means only the root is exported. No field means nothing here can say.
52
+ return { checked: exportsField !== undefined && exportsField !== null, ok: false, exact: [], patterns: [] };
53
+ }
54
+ const key = `./${subpath}`;
55
+ const ok = key === "./package.json" || exact.includes(key) || patterns.some((pattern) => matchesPattern(pattern, subpath));
56
+ return { checked: true, ok, exact, patterns };
57
+ }
58
+
59
+ /**
60
+ * What's wrong with a sub-path of an installed package, or null when it can be imported.
61
+ * @param {string} workDir The folder the package was installed into.
62
+ * @param {string} name
63
+ * @param {string} subpath
64
+ * @param {string | null} version
65
+ * @returns {string | null}
66
+ */
67
+ export function subpathProblem(workDir, name, subpath, version) {
68
+ const packageDir = join(workDir, "node_modules", name);
69
+ let manifest;
70
+ try {
71
+ manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
72
+ } catch {
73
+ return `${name} was installed, but its package.json couldn't be read to check the sub-path "${subpath}".`;
74
+ }
75
+ const result = checkExports(manifest.exports, subpath);
76
+ if (result.checked) return result.ok ? null : notExportedMessage({ name, version, subpath, exact: result.exact, patterns: result.patterns });
77
+ return fileExists(packageDir, subpath) ? null : `"${name}/${subpath}" isn't a file in ${name}${version ? `@${version}` : ""}, and the package has no exports map that lists what it offers.`;
78
+ }
79
+
80
+ /** The extensions a file can be imported without writing, for a package with no exports map. */
81
+ const EXTENSIONS = ["", ".js", ".mjs", ".cjs", ".json", "/index.js", "/index.mjs", "/index.cjs"];
82
+
83
+ /** Is there a file for this sub-path in an installed package that has no exports map? */
84
+ export function fileExists(packageDir, subpath) {
85
+ return EXTENSIONS.some((extension) => existsSync(join(packageDir, `${subpath}${extension}`)));
86
+ }
87
+
88
+ /** How many single-character edits turn one word into another. */
89
+ function distance(a, b) {
90
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
91
+ for (let i = 1; i <= a.length; i += 1) {
92
+ const row = [i];
93
+ for (let j = 1; j <= b.length; j += 1) row.push(Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)));
94
+ previous = row;
95
+ }
96
+ return previous[b.length];
97
+ }
98
+
99
+ /** The exports closest to what was asked for: a shared start, a shared last part, or a typo of it. */
100
+ function closest(subpath, exact) {
101
+ const wanted = subpath.toLowerCase().split("/");
102
+ const last = wanted[wanted.length - 1];
103
+ return exact
104
+ .map((key) => {
105
+ const parts = key.slice(2).toLowerCase().split("/");
106
+ const shared = parts.findIndex((part, i) => part !== wanted[i]);
107
+ const lead = shared === -1 ? parts.length : shared;
108
+ const tail = parts[parts.length - 1];
109
+ const rank = lead * 2 + (tail.includes(last) || last.includes(tail) ? 1 : 0) + (distance(last, tail) <= 2 ? 2 : 0);
110
+ return { key, rank };
111
+ })
112
+ .filter((entry) => entry.rank > 0)
113
+ .sort((a, b) => b.rank - a.rank || a.key.length - b.key.length)
114
+ .slice(0, 3)
115
+ .map((entry) => entry.key);
116
+ }
117
+
118
+ /**
119
+ * The message for a sub-path a package doesn't offer, with the closest matches first.
120
+ * @param {{ name: string, version: string | null, subpath: string, exact: string[], patterns: string[] }} input
121
+ */
122
+ export function notExportedMessage({ name, version, subpath, exact, patterns }) {
123
+ const spec = (key) => `${name}${key === "." ? "" : key.slice(1)}`;
124
+ const close = closest(subpath, exact);
125
+ const shown = exact.slice(0, 12).map(spec);
126
+ const more = exact.length > shown.length ? `, and ${exact.length - shown.length} more` : "";
127
+ const parts = [`"${name}/${subpath}" isn't something ${name}${version ? `@${version}` : ""} exports.`];
128
+ if (close.length) parts.push(`Did you mean ${close.map(spec).join(" or ")}?`);
129
+ if (exact.length) parts.push(`It exports ${shown.join(", ")}${more}.`);
130
+ if (patterns.length) parts.push(`It also exports files by pattern: ${patterns.slice(0, 4).map(spec).join(", ")}.`);
131
+ if (!exact.length && !patterns.length) parts.push("It exports only its main entry.");
132
+ return parts.join(" ");
133
+ }
@@ -3,11 +3,13 @@ import { num, plural } from "../text.js";
3
3
  import {
4
4
  ENGINE_NAMES,
5
5
  FINDINGS_NOTE,
6
+ GENERATED_NOTE,
6
7
  TIER_NAMES,
7
8
  cell,
8
9
  code,
9
10
  configLabel,
10
11
  finish,
12
+ hasGenerated,
11
13
  notTestableLines,
12
14
  reportFooter,
13
15
  reportHeader,
@@ -15,7 +17,7 @@ import {
15
17
  targetSection,
16
18
  } from "./parts.js";
17
19
 
18
- const TIER_ORDER = ["rules", "interactions", "computed", "vsr"];
20
+ const TIER_ORDER = ["rules", "interactions", "computed", "conditions", "vsr"];
19
21
 
20
22
  /** The archetype rows: component archetypes in a fixed order, then whole pages, then Storybook stories. */
21
23
  function archetypeKeys(results) {
@@ -67,7 +69,9 @@ function coverageTable(plan, results, tier, keys) {
67
69
  // Name the configuration only when there's more than one to tell apart, or the row stands for several stories.
68
70
  const withTier = rows.filter((r) => r.configs?.some((c) => c.tiers[tier]));
69
71
  const labels = withTier.length > 1 || withTier[0]?.always ? withTier.map((r) => r.label).filter((l) => l && l !== "-") : [];
70
- return labels.length ? `${status} (${labels.join("; ")})` : status;
72
+ const generated = target.archetypes?.[key]?.fixture?.source === "generated" && String(status).startsWith("ran");
73
+ const base = labels.length ? `${status} (${labels.join("; ")})` : status;
74
+ return generated ? `${base} (generated fixture)` : base;
71
75
  });
72
76
  lines.push(`| ${key} | ${cells.join(" | ")} |`);
73
77
  }
@@ -104,9 +108,9 @@ function interactionsCell(configs) {
104
108
  return parts.filter(Boolean).join("; ");
105
109
  }
106
110
 
107
- function computedCell(configs) {
108
- const checks = configs.flatMap((c) => c.tiers.computed?.checks ?? []);
109
- if (checks.length === 0) return configs.map((c) => c.tiers.computed?.status).find(Boolean) ?? "-";
111
+ function measuredCell(configs, tier) {
112
+ const checks = configs.flatMap((c) => c.tiers[tier]?.checks ?? []);
113
+ if (checks.length === 0) return configs.map((c) => c.tiers[tier]?.status).find(Boolean) ?? "-";
110
114
  const failed = checks.filter((c) => c.result === "fail").map((c) => code(c.name));
111
115
  const unknown = checks.filter((c) => c.result === "undetermined").length;
112
116
  const errors = checks.filter((c) => c.result === "error").length;
@@ -123,17 +127,17 @@ function vsrCell(configs) {
123
127
  }
124
128
 
125
129
  function findingsTable(plan, results, key) {
126
- const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Computed checks", "Virtual screen reader (simulated)"];
130
+ const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Computed checks", "Conditions", "Virtual screen reader (simulated)"];
127
131
  const lines = [`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`];
128
132
  for (const target of results.targets) {
129
133
  const planTarget = plan.targets.find((t) => t.id === target.id);
130
134
  for (const row of rowsFor(planTarget, target, key)) {
131
135
  if (row.note) {
132
- lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | | |`);
136
+ lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | | | |`);
133
137
  continue;
134
138
  }
135
139
  const c = row.configs;
136
- lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(computedCell(c))} | ${cell(vsrCell(c))} |`);
140
+ lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(measuredCell(c, "computed"))} | ${cell(measuredCell(c, "conditions"))} | ${cell(vsrCell(c))} |`);
137
141
  }
138
142
  }
139
143
  return lines;
@@ -180,6 +184,7 @@ export function renderComparison({ plan, results }) {
180
184
  lines.push(`### ${TIER_NAMES[tier]}${tier === "vsr" ? " (simulated)" : ""}.`, "", ...coverageTable(plan, results, tier, keys), "");
181
185
  }
182
186
  lines.push(...notTestableLines(results));
187
+ if (hasGenerated(results)) lines.push(GENERATED_NOTE, "");
183
188
 
184
189
  lines.push("## Findings.", "", FINDINGS_NOTE, "", ...(o.tiers.includes("rules") ? impactTables(results, o.engines) : []));
185
190
  for (const key of keys) {
@@ -187,7 +192,7 @@ export function renderComparison({ plan, results }) {
187
192
  }
188
193
 
189
194
  lines.push("## Details by target.", "", "Every finding, with its elements, rule help, and logs, for each target in turn.", "");
190
- for (const target of results.targets) lines.push(targetSection(plan.targets.find((t) => t.id === target.id), target));
195
+ for (const target of results.targets) lines.push(targetSection(plan.targets.find((t) => t.id === target.id), target, { generatedNote: false }));
191
196
  lines.push(...reportFooter(results));
192
197
  return finish(lines);
193
198
  }
@@ -1,3 +1,4 @@
1
+ import { adapterFor } from "../frameworks/index.js";
1
2
  import { criterion, criterionName, wcagAttribution } from "../wcag/index.js";
2
3
  import { cap, num, plural } from "../text.js";
3
4
 
@@ -9,7 +10,7 @@ export const sentence = (text) => String(text).replace(/\.+$/, "");
9
10
  /** Escape angle brackets so rule text like <input> doesn't turn into HTML. */
10
11
  export const esc = (text) => String(text ?? "").replace(/</g, "\\<");
11
12
  export const ENGINE_NAMES = { axe: "axe-core", ibm: "IBM Equal Access" };
12
- export const TIER_NAMES = { rules: "Rules", interactions: "Interactions", computed: "Computed checks", vsr: "Virtual screen reader" };
13
+ export const TIER_NAMES = { rules: "Rules", interactions: "Interactions", computed: "Computed checks", conditions: "Conditions", vsr: "Virtual screen reader" };
13
14
 
14
15
  export function toolLines(tools) {
15
16
  return Object.entries(tools)
@@ -28,8 +29,8 @@ export function engineCell(summary, engine) {
28
29
  export function tierCell(target, tier) {
29
30
  const counts = target.summary?.interactions;
30
31
  const vsr = target.summary?.vsr;
31
- const measured = target.summary?.computed;
32
- if (tier === "computed" && measured) {
32
+ const measured = target.summary?.[tier];
33
+ if ((tier === "computed" || tier === "conditions") && measured) {
33
34
  const parts = [measured.fail && `${num(measured.fail)} failed`, measured.undetermined && `${num(measured.undetermined)} undetermined`, measured.error && plural(measured.error, "error"), measured.pass && `${num(measured.pass)} passed`, measured.notApplicable && `${num(measured.notApplicable)} not applicable`].filter(Boolean);
34
35
  return `ran, ${parts.join(", ")}`;
35
36
  }
@@ -220,8 +221,14 @@ export function criteriaCell(list) {
220
221
  }).join(", ");
221
222
  }
222
223
 
223
- export function computedSection(result, nested) {
224
- const lines = [`${nested ? "#####" : "####"} Computed checks.`, "", "automatica11y's own measurements from resolved styles in the browser, with the numbers WCAG gives. They're reported on their own and never added to the axe-core or IBM Equal Access counts. A check that can't reduce the page to colors (a gradient, an image, transparency) is undetermined, which counts as a gap and never as a pass.", ""];
224
+ const MEASURED_INTRO = {
225
+ computed: ["Computed checks", "automatica11y's own measurements from resolved styles in the browser, with the numbers WCAG gives. They're reported on their own and never added to the axe-core or IBM Equal Access counts. A check that can't reduce the page to colors (a gradient, an image, transparency) is undetermined, which counts as a gap and never as a pass."],
226
+ conditions: ["Conditions", "automatica11y's own checks of how the page holds up under a user's settings (reduced motion, dark mode, more or less contrast, reduced transparency, forced colors) and environment (a 320 pixel window, wider text spacing). Each check opens fresh copies of the page. They're reported on their own and never added to the axe-core or IBM Equal Access counts. A check that can't tell is undetermined, which counts as a gap and never as a pass. \"Not applicable\" means the page doesn't use the feature, which isn't a failure."],
227
+ };
228
+
229
+ export function measuredSection(tier, result, nested) {
230
+ const [title, intro] = MEASURED_INTRO[tier];
231
+ const lines = [`${nested ? "#####" : "####"} ${title}.`, "", intro, ""];
225
232
  lines.push("| Check | Result | WCAG | Detail |", "| --- | --- | --- | --- |");
226
233
  for (const check of result.checks) {
227
234
  lines.push(`| ${code(check.name)} | ${check.result} | ${cell(criteriaCell(check.criteria))} | ${cell(check.detail)}${check.method ? cell(` (method: ${check.method})`) : ""} |`);
@@ -235,17 +242,33 @@ export function configLabel(config) {
235
242
  return parts.length ? ` (${parts.join(", ")})` : "";
236
243
  }
237
244
 
245
+ /** What the Fixture column says about where a fixture came from. */
246
+ function fixtureLabel(archetype) {
247
+ const f = archetype.fixture;
248
+ if (!f || f.source === "none") return "-";
249
+ return f.source === "generated" ? `generated (${f.recipe})` : f.source;
250
+ }
251
+
238
252
  export function archetypeTable(target) {
239
253
  const rows = Object.entries(target.archetypes).map(([name, archetype]) => {
240
- if (archetype.status === "gap") return `| ${name} | gap | - | ${cell(sentence(archetype.reason ?? "No fixture."))} |`;
254
+ if (archetype.status === "gap") return `| ${name} | gap | ${fixtureLabel(archetype)} | - | ${cell(sentence(archetype.reason ?? "No fixture."))} |`;
241
255
  const states = [...new Set(archetype.configs.map((c) => c.state).filter(Boolean))].join(", ") || "-";
242
256
  const libs = [...new Set(archetype.configs.map((c) => c.libA11y).filter((l) => l && l !== "n/a"))];
243
- return `| ${name} | ran | ${states} | ${libs.length ? `library accessibility ${libs.join(" and ")}` : ""} |`;
257
+ const notes = [libs.length ? `library accessibility ${libs.join(" and ")}` : "", archetype.fixture?.source === "generated" ? `${sentence(archetype.fixture.summary ?? "")}, from ${(archetype.fixture.used ?? []).join(", ")}. Source: ${archetype.fixture.file}` : ""].filter(Boolean);
258
+ return `| ${name} | ran | ${fixtureLabel(archetype)} | ${states} | ${cell(notes.join(". "))} |`;
244
259
  });
245
- return ["| Archetype | Status | States | Note |", "| --- | --- | --- | --- |", ...rows];
260
+ return ["| Archetype | Status | Fixture | States | Note |", "| --- | --- | --- | --- | --- |", ...rows];
246
261
  }
247
262
 
248
- export function targetSection(planTarget, target) {
263
+ /** True when any archetype in the results ran from a fixture the tool generated. */
264
+ export function hasGenerated(results) {
265
+ return results.targets.some((t) => Object.values(t.archetypes ?? {}).some((a) => a.fixture?.source === "generated"));
266
+ }
267
+
268
+ /** What "generated" means, for any report that has one. */
269
+ export const GENERATED_NOTE = "**Generated fixtures.** Where no fixture was written, the tool built one from the parts the package exports (or from what a custom element says about itself) and ran it only after it checked that the trigger and root behaved. A generated fixture is a guess about how the library is meant to be assembled, so a failure may come from how it was wired and not from the library. Treat generated results as lower evidence than an authored fixture. The source of each is in the `generated` folder beside this report. Copy one to `fixtures/<target id>/<archetype>.jsx` (`.js` for web components) and edit it to make it an authored fixture.";
270
+
271
+ export function targetSection(planTarget, target, { generatedNote = true } = {}) {
249
272
  if (target.status === "ran" && target.storybook) return storybookSection(planTarget, target);
250
273
  const lines = [`### ${planTarget.label}.`, ""];
251
274
  lines.push(`Target ${code(planTarget.input)}, ${planTarget.kind ?? "unclassified"}${planTarget.evidenceLevel ? `, ${planTarget.evidenceLevel} evidence` : ""}.`, "");
@@ -255,13 +278,14 @@ export function targetSection(planTarget, target) {
255
278
  }
256
279
  if (target.npm) {
257
280
  const n = target.npm;
258
- 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"})`}.`, "");
281
+ lines.push(`Installed ${code(`${n.name}@${n.version}`)} on its own${n.subpath ? ` and tested its ${code(`${n.name}/${n.subpath}`)} entry` : ""}, as ${adapterFor(n.flavor).describe(n)}.`, "");
259
282
  }
260
283
  for (const warning of target.warnings) lines.push(`Warning: ${warning}`, "");
261
284
  if (target.status === "failed") {
262
285
  lines.push(`This target failed: ${sentence(target.reason)}. A failed target is a gap in coverage. It isn't a pass.`, "");
263
286
  }
264
287
  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), "");
288
+ if (generatedNote && target.npm && hasGenerated({ targets: [target] })) lines.push(GENERATED_NOTE, "");
265
289
  /** @type {Set<string>} */
266
290
  const skipped = new Set();
267
291
  for (const [name, archetype] of Object.entries(target.archetypes)) {
@@ -274,8 +298,8 @@ export function targetSection(planTarget, target) {
274
298
  lines.push(vsrSection(result, name !== "page"), "");
275
299
  } else if (tier === "interactions" && result.status === "ran") {
276
300
  lines.push(interactionsSection(result, name !== "page"), "");
277
- } else if (tier === "computed" && result.status === "ran") {
278
- lines.push(computedSection(result, name !== "page"), "");
301
+ } else if ((tier === "computed" || tier === "conditions") && result.status === "ran") {
302
+ lines.push(measuredSection(tier, result, name !== "page"), "");
279
303
  } else if (result.status !== "ran") {
280
304
  skipped.add(`${TIER_NAMES[tier] ?? tier}: ${sentence(result.reason ?? result.status)}.`);
281
305
  }
@@ -2,15 +2,19 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { bundleEntries } from "../harness/bundle.js";
5
- import { installOptionalPeers, installPackage } from "../harness/npm-install.js";
6
- import * as react from "../harness/npm-react.js";
7
- import * as wc from "../harness/npm-wc.js";
5
+ import { GENERATABLE } from "../harness/generate/index.js";
6
+ import { generateFixture } from "./generate-fixture.js";
7
+ import { settleAnimations } from "../harness/settle.js";
8
+ import { installExtraPackages, installOptionalPeers, installPackage } from "../harness/npm-install.js";
9
+ import { adapterFor, adapterForKind } from "../frameworks/index.js";
10
+ import { subpathProblem } from "../plan/subpath.js";
8
11
  import { closedShadowHosts, notTestableEntries } from "../harness/shadow.js";
9
12
  import { serveStatic } from "../harness/static-serve.js";
10
13
  import { openPage } from "../harness/url.js";
11
14
  import { candidateMapping, findAuthoredFixture } from "../plan/mapping.js";
12
15
  import { ARCHETYPES } from "../schema.js";
13
16
  import { runComputed } from "../tiers/computed/index.js";
17
+ import { runConditions } from "../tiers/conditions/index.js";
14
18
  import { runInteractions } from "../tiers/interactions/index.js";
15
19
  import { runRules } from "../tiers/rules/index.js";
16
20
  import { failedVsr, runVsr } from "../tiers/vsr.js";
@@ -24,6 +28,7 @@ const STATES = {
24
28
  tooltip: ["closed", "open"],
25
29
  combobox: ["closed", "open"],
26
30
  accordion: ["collapsed", "expanded"],
31
+ "live-region": ["before message", "message shown"],
27
32
  };
28
33
 
29
34
  const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error";
@@ -59,10 +64,10 @@ async function bundleWithPeers(options, warnings) {
59
64
 
60
65
  /** Load the whole package in a page to list its exports and the custom elements it defines. */
61
66
  async function discover({ browser, workDir, flavor, pkg, buildDir, warnings }) {
62
- const helper = flavor === "react" ? react : wc;
67
+ const adapter = adapterFor(flavor);
63
68
  const entryFile = join(workDir, "discover.js");
64
- writeFileSync(entryFile, helper.discoverEntry(pkg));
65
- await bundleWithPeers({ entries: { discover: entryFile }, outdir: buildDir, workDir, react: flavor === "react" }, warnings);
69
+ writeFileSync(entryFile, adapter.discoverEntry(pkg));
70
+ await bundleWithPeers({ entries: { discover: entryFile }, outdir: buildDir, workDir, framework: adapter }, warnings);
66
71
  const server = await serveStatic(buildDir);
67
72
  const errors = [];
68
73
  try {
@@ -74,8 +79,31 @@ async function discover({ browser, workDir, flavor, pkg, buildDir, warnings }) {
74
79
  }
75
80
  const exportsList = await opened.page.evaluate(() => /** @type {any} */ (window).__a11yExports);
76
81
  const tags = await opened.page.evaluate(() => [.../** @type {any} */ (window).__a11yDefined ?? []]);
82
+ // What each element says about itself, so a fixture can be built around it: observed attributes, class members, slots.
83
+ const facts = await opened.page.evaluate((names) => {
84
+ const out = {};
85
+ for (const name of names.slice(0, 80)) {
86
+ const Element = customElements.get(name);
87
+ if (!Element) continue;
88
+ const members = new Set();
89
+ for (let proto = Element.prototype; proto && proto !== HTMLElement.prototype && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
90
+ for (const key of Object.getOwnPropertyNames(proto)) if (!key.startsWith("_") && key !== "constructor") members.add(key);
91
+ }
92
+ let slots = [];
93
+ try {
94
+ const el = document.createElement(name);
95
+ document.body.append(el);
96
+ slots = [...(el.shadowRoot?.querySelectorAll("slot") ?? [])].map((slot) => slot.getAttribute("name") ?? "");
97
+ el.remove();
98
+ } catch {
99
+ // An element that can't be created on its own has no slots to report.
100
+ }
101
+ out[name] = { attributes: [.../** @type {any} */ (Element).observedAttributes ?? []], members: [...members], slots: slots.filter(Boolean) };
102
+ }
103
+ return out;
104
+ }, tags);
77
105
  await opened.close();
78
- return { exports: exportsList, tags };
106
+ return { exports: exportsList, tags, facts };
79
107
  } finally {
80
108
  await server.close();
81
109
  }
@@ -96,6 +124,9 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
96
124
  if (count !== 1) return { gap: `The fixture must mark exactly one data-a11y-trigger. It marked ${num(count)}.` };
97
125
  if (errors.length) return { gap: `The fixture logged errors when it mounted: ${errors[0]}` };
98
126
 
127
+ if (archetype === "live-region" && (await trigger.first().evaluate((el) => el.matches("[data-a11y-root]")))) {
128
+ return { gap: "The fixture marks the same element as the trigger and the message. A live-region fixture needs a control that makes the message appear (data-a11y-trigger) and the message itself (data-a11y-root)." };
129
+ }
99
130
  const states = STATES[archetype] ?? ["initial"];
100
131
  const configs = [];
101
132
  for (const [index, state] of states.entries()) {
@@ -105,23 +136,25 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
105
136
  if (archetype === "tooltip") await trigger.first().focus();
106
137
  else await trigger.first().click();
107
138
  await opened.page.waitForFunction(
108
- () => {
139
+ (needsText) => {
109
140
  const root = document.querySelector("[data-a11y-root]");
110
141
  const shown = root && /** @type {HTMLElement} */ (root).getClientRects().length > 0;
142
+ // A live region can be on the page and empty until the trigger fills it, so wait for the message itself.
143
+ if (needsText) return Boolean(shown && ((root.textContent ?? "").trim() || root.getAttribute("aria-label") || root.getAttribute("aria-labelledby")));
111
144
  return shown || document.querySelector('[data-a11y-trigger][aria-expanded="true"]') !== null;
112
145
  },
113
- undefined,
146
+ archetype === "live-region",
114
147
  { timeout: 3000 },
115
148
  );
116
149
  } catch {
117
150
  failure = `The ${state} state never appeared after activating the trigger. A fixture's data-a11y-root has to show up when the ${archetype} opens.`;
118
151
  }
119
152
  }
120
- await opened.page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
153
+ await settleAnimations(opened.page);
121
154
  /** @type {Record<string, any>} */
122
155
  const tiers = {};
123
156
  for (const tier of plan.options.tiers) {
124
- if (tier === "interactions" || tier === "computed") continue;
157
+ if (tier === "interactions" || tier === "computed" || tier === "conditions") continue;
125
158
  if (tier === "vsr") tiers.vsr = failure ? { status: "skipped", simulated: true, reason: failure } : await runVsr(opened.page, { scope: "body", state }).catch(failedVsr);
126
159
  else if (failure) tiers.rules = { status: "failed", reason: failure, engines: Object.fromEntries(plan.options.engines.map((e) => [e, { status: "failed", reason: failure }])) };
127
160
  else {
@@ -135,6 +168,7 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
135
168
  // The checks open their own fresh pages, so run them after this page's rules results are in.
136
169
  if (plan.options.tiers.includes("interactions")) configs[0].tiers.interactions = await runInteractions(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, archetype);
137
170
  if (plan.options.tiers.includes("computed")) configs[0].tiers.computed = await runComputed(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, archetype);
171
+ if (plan.options.tiers.includes("conditions")) configs[0].tiers.conditions = await runConditions(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, archetype);
138
172
  return { configs, hidden };
139
173
  } finally {
140
174
  await opened.close();
@@ -161,14 +195,14 @@ async function auditFixture({ browser, url, archetype, plan, toggle }) {
161
195
  /**
162
196
  * Audit an npm package: install it on its own, find what it exports, and audit each archetype that has a fixture.
163
197
  * An archetype without a usable fixture is a gap with a reason, never a pass.
164
- * @returns {Promise<{ result: any, mapping: Record<string, any> | null }>}
198
+ * @returns {Promise<{ result: any, mapping: Record<string, any> | null, files?: Record<string, string> }>}
165
199
  */
166
200
  export async function auditNpm({ browser, planTarget, plan, cwd, install = installPackage }) {
167
201
  install ??= installPackage;
168
202
  const base = { id: planTarget.id, reason: null, archetypes: {}, summary: { engines: {}, gaps: [], notTestable: [] }, warnings: [] };
169
203
  const resolved = planTarget.resolved ?? {};
170
204
  if (planTarget.kind === "npm-unsupported") {
171
- return { result: { ...base, status: "unsupported", reason: `${resolved.framework ?? "This framework"} packages aren't supported. v1 covers React and web components.` }, mapping: null };
205
+ return { result: { ...base, status: "unsupported", reason: `${resolved.framework ?? "This framework"} packages aren't supported. This version covers React, Vue 3, and web components.` }, mapping: null };
172
206
  }
173
207
  const tmp = mkdtempSync(join(tmpdir(), "automatica11y-npm-"));
174
208
  const workDir = join(tmp, "install");
@@ -177,22 +211,33 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
177
211
  mkdirSync(fixtureDir, { recursive: true });
178
212
  const servers = [];
179
213
  try {
180
- /** @type {"react" | "wc" | "unknown"} */
181
- let flavor = planTarget.kind === "npm-react" ? "react" : planTarget.kind === "npm-wc" ? "wc" : "unknown";
214
+ /** The framework's id (react, vue, or wc), or "unknown" when the metadata couldn't say. */
215
+ /** @type {any} */
216
+ let flavor = adapterForKind(planTarget.kind)?.id ?? "unknown";
182
217
  const installed = await install({ dir: workDir, name: resolved.name, version: resolved.version, flavor });
183
218
  const warnings = [...installed.warnings];
219
+ // What fixtures and templates import: the package, or the sub-path of it that was asked for.
220
+ const importSpec = resolved.subpath ? `${resolved.name}/${resolved.subpath}` : resolved.name;
221
+ if (resolved.subpath) {
222
+ const problem = subpathProblem(workDir, resolved.name, resolved.subpath, installed.version ?? resolved.version);
223
+ if (problem) throw new Error(problem);
224
+ }
184
225
 
185
- const found = await discover({ browser, workDir, flavor: flavor === "react" ? "react" : "wc", pkg: resolved.name, buildDir, warnings });
226
+ // Packages the mapping names (a token stylesheet, a theme) go in beside the library, so a fixture can import them.
227
+ const extras = await installExtraPackages({ dir: workDir, specs: Object.values(planTarget.mapping ?? {}).flatMap((entry) => entry.install ?? []) });
228
+ warnings.push(...extras.warnings);
229
+
230
+ const found = await discover({ browser, workDir, flavor: flavor === "unknown" ? "wc" : flavor, pkg: importSpec, buildDir, warnings });
186
231
  if (flavor === "unknown") {
187
232
  if (found.tags.length > 0) flavor = "wc";
188
233
  else if (installed.react && found.exports.some((e) => /^[A-Z]/.test(e.name))) flavor = "react";
189
234
  }
190
235
  if (flavor === "unknown" || (flavor === "wc" && found.tags.length === 0)) {
191
- 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 };
236
+ return { result: { ...base, status: "not-applicable", reason: "The package has no rendering surface. It exports no components for a supported framework and defines no custom elements.", warnings }, mapping: null };
192
237
  }
193
238
 
194
239
  const candidates = candidateMapping({ flavor, exports: found.exports, tags: found.tags });
195
- const kindFlavor = /** @type {"react" | "wc"} */ (flavor);
240
+ const adapter = adapterFor(flavor);
196
241
  const wanted = plan.options.archetypes ?? ARCHETYPES;
197
242
  /** @type {Record<string, any>} */
198
243
  const mapping = {};
@@ -200,7 +245,20 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
200
245
  const archetypes = {};
201
246
  const gaps = [];
202
247
  const hidden = [];
203
- const helper = flavor === "react" ? react : wc;
248
+ /** Files to write beside the report: the fixtures the tool generated, so they can be reviewed and adopted. */
249
+ const files = {};
250
+ /** Where each archetype's fixture came from, for the results. */
251
+ const sources = {};
252
+ /** One server for the probes, started when the first one is needed. */
253
+ let probeServer = null;
254
+ const getServer = async () => {
255
+ if (!probeServer) {
256
+ mkdirSync(buildDir, { recursive: true });
257
+ probeServer = await serveStatic(buildDir);
258
+ servers.push(probeServer);
259
+ }
260
+ return probeServer;
261
+ };
204
262
 
205
263
  // Decide where each archetype's fixture comes from, then bundle each one on its own so one bad fixture can't break the rest.
206
264
  const runnable = {};
@@ -218,29 +276,57 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
218
276
  entry.status = "needs-fixture";
219
277
  entry.reason = `The mapping names ${user.fixture}, but that file doesn't exist.`;
220
278
  } else if (entry.status === "template" || (user.export || user.tag) && ["button", "link"].includes(archetype)) {
221
- const source = flavor === "react" ? react.template(archetype, resolved.name, entry.export) : wc.template(archetype, entry.tag);
279
+ const source = adapter.template(archetype, importSpec, flavor === "wc" ? entry.tag : entry.export);
222
280
  if (source) {
223
281
  entry.status = "template";
224
282
  delete entry.reason;
225
- fixture = join(fixtureDir, `${archetype}.${flavor === "react" ? "jsx" : "js"}`);
283
+ fixture = join(fixtureDir, `${archetype}.${adapter.extension}`);
226
284
  writeFileSync(fixture, source);
227
285
  }
228
286
  }
287
+ /** @type {any} */
288
+ let source = fixture ? { source: entry.status === "authored" ? "authored" : "template" } : null;
289
+ let attempts = [];
290
+ let generationTried = false;
291
+ // Nothing authored and no template: build candidates from what the package exports, and keep one only if it works.
292
+ if (!fixture && !user.fixture && plan.options.generate !== false && GENERATABLE.has(archetype) && !(entry.status === "no-match" && flavor === "wc")) {
293
+ const generated = await generateFixture({ browser, adapter, archetype, entry, found, explicit: Boolean(user.export), pkg: importSpec, tmp, workDir, buildDir, getServer, bundle: (options) => bundleWithPeers(options, warnings) });
294
+ attempts = generated.attempts;
295
+ generationTried = attempts.length > 0;
296
+ if (generated.ok && generated.winner) {
297
+ const { winner } = generated;
298
+ const relative = `generated/${planTarget.id}/${archetype}.${winner.extension}`;
299
+ files[relative] = winner.source;
300
+ fixture = winner.file;
301
+ entry.status = "generated";
302
+ entry.recipe = winner.recipe;
303
+ entry.summary = winner.summary;
304
+ entry.used = winner.used;
305
+ entry.generatedFile = relative;
306
+ delete entry.reason;
307
+ source = { source: "generated", recipe: winner.recipe, summary: winner.summary, used: winner.used, file: relative, attempts };
308
+ } else if (generated.reason && (generationTried || entry.status !== "no-match")) {
309
+ entry.reason = `${entry.status === "no-match" ? "" : `${entry.reason ?? `The ${archetype} archetype needs a fixture someone writes.`} `}Generating one didn't work. ${generated.reason}`.trim();
310
+ }
311
+ }
229
312
  mapping[archetype] = entry;
230
313
  if (!fixture) {
231
- const reason = entry.status === "no-match" ? entry.reason : entry.reason ?? `The ${archetype} archetype needs a fixture someone writes.`;
232
- archetypes[archetype] = { status: "gap", reason: archetype && entry.status !== "no-match" ? `${reason} Write fixtures/${planTarget.id}/${archetype}.${flavor === "react" ? "jsx" : "js"}.` : reason, configs: [] };
314
+ // A no-match archetype has no component to write a fixture for, unless a generator looked and said why it couldn't build one.
315
+ const writable = entry.status !== "no-match" || generationTried;
316
+ const reason = entry.reason ?? `The ${archetype} archetype needs a fixture someone writes.`;
317
+ archetypes[archetype] = { status: "gap", reason: writable ? `${reason} Write fixtures/${planTarget.id}/${archetype}.${adapter.extension}.` : reason, configs: [], ...(attempts.length ? { fixture: { source: "none", attempts } } : {}) };
233
318
  gaps.push(`archetype:${archetype}`);
234
319
  continue;
235
320
  }
236
321
  const entryFile = join(tmp, "entries", `${archetype}-entry.js`);
237
322
  mkdirSync(join(tmp, "entries"), { recursive: true });
238
- writeFileSync(entryFile, helper.entry(fixture, resolved.name));
323
+ writeFileSync(entryFile, adapter.entry(fixture, importSpec));
239
324
  try {
240
- await bundleWithPeers({ entries: { [archetype]: entryFile }, outdir: buildDir, workDir, react: flavor === "react" }, warnings);
325
+ await bundleWithPeers({ entries: { [archetype]: entryFile }, outdir: buildDir, workDir, framework: adapter }, warnings);
241
326
  runnable[archetype] = `/${archetype}.html`;
327
+ sources[archetype] = source;
242
328
  } catch (error) {
243
- archetypes[archetype] = { status: "gap", reason: `The fixture didn't bundle. ${firstLine(error)}`, configs: [] };
329
+ archetypes[archetype] = { status: "gap", reason: `The fixture didn't bundle. ${firstLine(error)}`, configs: [], ...(attempts.length ? { fixture: { source: "none", attempts } } : {}) };
244
330
  gaps.push(`archetype:${archetype}`);
245
331
  entry.status = "needs-fixture";
246
332
  entry.reason = firstLine(error);
@@ -253,13 +339,14 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
253
339
  for (const [archetype, path] of Object.entries(runnable)) {
254
340
  /** @type {any} */
255
341
  const outcome = await auditFixture({ browser, url: `${live.origin}${path}`, archetype, plan, toggle: mapping[archetype].libA11y === true }).catch((error) => ({ gap: firstLine(error) }));
342
+ const fixtureInfo = sources[archetype];
256
343
  if (outcome.gap) {
257
- archetypes[archetype] = { status: "gap", reason: outcome.gap, configs: [] };
344
+ archetypes[archetype] = { status: "gap", reason: outcome.gap, configs: [], ...(fixtureInfo?.attempts?.length ? { fixture: { source: "none", attempts: fixtureInfo.attempts } } : {}) };
258
345
  gaps.push(`archetype:${archetype}`);
259
346
  mapping[archetype].status = "needs-fixture";
260
347
  mapping[archetype].reason = outcome.gap;
261
348
  } else {
262
- archetypes[archetype] = { status: "ran", configs: outcome.configs };
349
+ archetypes[archetype] = { status: "ran", configs: outcome.configs, ...(fixtureInfo ? { fixture: fixtureInfo } : {}) };
263
350
  hidden.push(...outcome.hidden.map((h) => `${archetype}: ${h}`));
264
351
  }
265
352
  }
@@ -275,11 +362,13 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
275
362
  archetypes: ordered,
276
363
  npm: {
277
364
  name: resolved.name,
365
+ subpath: resolved.subpath ?? null,
278
366
  version: installed.version ?? resolved.version,
279
367
  flavor,
280
368
  framework: resolved.framework ?? null,
281
369
  react: installed.react,
282
370
  reactDom: installed.reactDom,
371
+ vue: installed.vue ?? null,
283
372
  tags: flavor === "wc" ? found.tags : [],
284
373
  },
285
374
  summary: (() => {
@@ -289,6 +378,7 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
289
378
  warnings,
290
379
  },
291
380
  mapping: Object.fromEntries(Object.entries(mapping).map(([k, v]) => [k, { ...v, fixture: v.fixture ?? null }])),
381
+ files,
292
382
  };
293
383
  } finally {
294
384
  for (const s of servers) await s.close();