automatica11y 0.3.3 → 0.4.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 (47) hide show
  1. package/README.md +25 -7
  2. package/package.json +5 -3
  3. package/skills/automatica11y-runner/SKILL.md +44 -13
  4. package/skills/automatica11y-runner/references/fixtures.md +38 -2
  5. package/src/commands/common.js +5 -2
  6. package/src/frameworks/index.js +42 -0
  7. package/src/frameworks/react.js +77 -0
  8. package/src/frameworks/vue.js +90 -0
  9. package/src/frameworks/wc.js +55 -0
  10. package/src/globals.d.ts +1 -0
  11. package/src/harness/bundle.js +7 -6
  12. package/src/harness/generate/dialects.js +64 -0
  13. package/src/harness/generate/index.js +12 -0
  14. package/src/harness/generate/jsx-recipes.js +224 -0
  15. package/src/harness/generate/jsx.js +76 -0
  16. package/src/harness/generate/kit.js +50 -0
  17. package/src/harness/generate/marking.js +64 -0
  18. package/src/harness/generate/probe.js +97 -0
  19. package/src/harness/generate/shared.js +12 -0
  20. package/src/harness/generate/wc-recipes.js +132 -0
  21. package/src/harness/npm-install.js +38 -7
  22. package/src/harness/settle.js +17 -0
  23. package/src/harness/storybook.js +1 -0
  24. package/src/harness/url.js +13 -3
  25. package/src/plan/classify.js +11 -4
  26. package/src/plan/mapping.js +10 -7
  27. package/src/plan/resolve-npm.js +15 -9
  28. package/src/plan/subpath.js +133 -0
  29. package/src/report/comparison.js +14 -9
  30. package/src/report/parts.js +36 -12
  31. package/src/run/audit-npm.js +118 -28
  32. package/src/run/generate-fixture.js +74 -0
  33. package/src/run/run-plan.js +19 -4
  34. package/src/run/summary.js +5 -0
  35. package/src/schema.js +30 -6
  36. package/src/tiers/computed/checks.js +44 -5
  37. package/src/tiers/computed/color.js +8 -0
  38. package/src/tiers/computed/index.js +2 -2
  39. package/src/tiers/computed/measure-kit.js +30 -10
  40. package/src/tiers/conditions/checks.js +283 -0
  41. package/src/tiers/conditions/index.js +30 -0
  42. package/src/tiers/conditions/kit.js +133 -0
  43. package/src/tiers/interactions/archetypes.js +111 -0
  44. package/src/tiers/interactions/helpers.js +56 -0
  45. package/src/tiers/interactions/index.js +16 -3
  46. package/src/harness/npm-react.js +0 -39
  47. package/src/harness/npm-wc.js +0 -30
@@ -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();
@@ -0,0 +1,74 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { probeFixture } from "../harness/generate/probe.js";
4
+
5
+ const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error";
6
+
7
+ /**
8
+ * Build a fixture for an archetype from what discovery found, and keep it only if it works.
9
+ * Candidates are bundled (all at once, or one by one if that fails), then probed in the browser in order. The first that
10
+ * passes wins. Every attempt is recorded, whether or not one wins, so a report can say what was tried.
11
+ *
12
+ * @param {{
13
+ * browser: import("playwright-core").Browser,
14
+ * adapter: import("../frameworks/index.js").Adapter,
15
+ * archetype: string,
16
+ * entry: { export?: string, tag?: string },
17
+ * found: { exports: any[], tags: string[], facts: Record<string, any> },
18
+ * pkg: string,
19
+ * explicit?: boolean,
20
+ * tmp: string,
21
+ * workDir: string,
22
+ * buildDir: string,
23
+ * getServer: () => Promise<{ origin: string }>,
24
+ * bundle: (options: any) => Promise<unknown>,
25
+ * }} input
26
+ * @returns {Promise<{ ok: boolean, reason: string | null, attempts: Array<{ recipe: string, summary: string, ok: boolean, reason: string | null }>, winner?: { recipe: string, summary: string, used: string[], source: string, file: string, extension: string } }>}
27
+ */
28
+ export async function generateFixture({ browser, adapter, archetype, entry, found, explicit = false, pkg, tmp, workDir, buildDir, getServer, bundle }) {
29
+ const { candidates, reason } = adapter.generate({ archetype, pkg, entry, exports: found.exports, facts: found.facts, explicit });
30
+ if (candidates.length === 0) return { ok: false, reason, attempts: [] };
31
+
32
+ const extension = adapter.extension;
33
+ mkdirSync(join(tmp, "generated"), { recursive: true });
34
+ mkdirSync(join(tmp, "entries"), { recursive: true });
35
+ const prepared = candidates.map((candidate, index) => {
36
+ const name = `gen-${archetype}-${index}`;
37
+ const file = join(tmp, "generated", `${archetype}-${index}.${extension}`);
38
+ writeFileSync(file, candidate.source);
39
+ const entryFile = join(tmp, "entries", `${name}.js`);
40
+ writeFileSync(entryFile, adapter.entry(file, pkg));
41
+ return { candidate, name, file, entryFile, bundled: null };
42
+ });
43
+
44
+ // One build for every candidate. If a single bad one breaks it, build them one by one so the rest still get a chance.
45
+ try {
46
+ await bundle({ entries: Object.fromEntries(prepared.map((p) => [p.name, p.entryFile])), outdir: buildDir, workDir, framework: adapter });
47
+ for (const p of prepared) p.bundled = true;
48
+ } catch {
49
+ for (const p of prepared) {
50
+ try {
51
+ await bundle({ entries: { [p.name]: p.entryFile }, outdir: buildDir, workDir, framework: adapter });
52
+ p.bundled = true;
53
+ } catch (error) {
54
+ p.bundled = firstLine(error);
55
+ }
56
+ }
57
+ }
58
+
59
+ const server = await getServer();
60
+ const attempts = [];
61
+ for (const p of prepared) {
62
+ const base = { recipe: p.candidate.id, summary: p.candidate.summary };
63
+ if (p.bundled !== true) {
64
+ attempts.push({ ...base, ok: false, reason: `it didn't bundle: ${p.bundled}` });
65
+ continue;
66
+ }
67
+ const probe = await probeFixture(browser, `${server.origin}/${p.name}.html`, archetype);
68
+ attempts.push({ ...base, ok: probe.ok, reason: probe.reason });
69
+ if (probe.ok) {
70
+ return { ok: true, reason: null, attempts, winner: { ...base, used: p.candidate.used, source: p.candidate.source, file: p.file, extension } };
71
+ }
72
+ }
73
+ return { ok: false, reason: `${attempts.length === 1 ? "The one generated fixture didn't work" : `None of the ${attempts.length} generated fixtures worked`}. ${attempts.slice(0, 2).map((a) => `${a.summary}: ${a.reason}`).join("; ")}${attempts.length > 2 ? `; and ${attempts.length - 2} more` : ""}.`, attempts };
74
+ }
@@ -7,6 +7,7 @@ import { serveStatic } from "../harness/static-serve.js";
7
7
  import { listStories, readIndex, selectStories, storyUrl, waitForStory } from "../harness/storybook.js";
8
8
  import { closedShadowHosts, notTestableEntries } from "../harness/shadow.js";
9
9
  import { COMPUTED_NOT_APPLICABLE_FOR_PAGES } from "../tiers/computed/index.js";
10
+ import { CONDITIONS_NOT_APPLICABLE_FOR_STORIES, runConditions } from "../tiers/conditions/index.js";
10
11
  import { NOT_APPLICABLE_FOR_PAGES } from "../tiers/interactions/index.js";
11
12
  import { failedVsr, runVsr } from "../tiers/vsr.js";
12
13
  import { openPage } from "../harness/url.js";
@@ -17,6 +18,7 @@ import { runRules, selfTest } from "../tiers/rules/index.js";
17
18
  import { evaluateFailCheck } from "./fail-check.js";
18
19
  import { mapPool } from "./pool.js";
19
20
  import { auditNpm } from "./audit-npm.js";
21
+ import { adapterFor } from "../frameworks/index.js";
20
22
  import { failedTarget, summarize } from "./summary.js";
21
23
 
22
24
  /** How many stories to audit at once. */
@@ -24,7 +26,7 @@ const STORY_CONCURRENCY = 4;
24
26
 
25
27
  const EXIT = { OK: 0, FAIL_THRESHOLD: 1, ENVIRONMENT: 3, ALL_TARGETS_FAILED: 4 };
26
28
 
27
- const NPM_KINDS = new Set(["npm", "npm-react", "npm-wc", "npm-unsupported"]);
29
+ const NPM_KINDS = new Set(["npm", "npm-react", "npm-vue", "npm-wc", "npm-unsupported"]);
28
30
  const UNSUPPORTED_KIND = (kind) => `${kind} targets aren't supported.`;
29
31
 
30
32
  /** Audit one page and return its target result. */
@@ -40,6 +42,9 @@ async function auditPage(browser, url, planTarget, plan, extraWarnings) {
40
42
  tiers.interactions = NOT_APPLICABLE_FOR_PAGES;
41
43
  } else if (tier === "computed") {
42
44
  tiers.computed = COMPUTED_NOT_APPLICABLE_FOR_PAGES;
45
+ } else if (tier === "conditions") {
46
+ // Each check opens its own copies of the page, so this runs while the first page is still open.
47
+ tiers.conditions = await runConditions(browser, url, "page");
43
48
  } else {
44
49
  tiers.vsr = await runVsr(opened.page, { scope: "body" }).catch(failedVsr);
45
50
  }
@@ -76,7 +81,9 @@ async function auditStory(browser, base, story, plan) {
76
81
  ? NOT_APPLICABLE_FOR_PAGES
77
82
  : tier === "computed"
78
83
  ? COMPUTED_NOT_APPLICABLE_FOR_PAGES
79
- : await runVsr(opened.page, { scope: "#storybook-root" }).catch(failedVsr);
84
+ : tier === "conditions"
85
+ ? CONDITIONS_NOT_APPLICABLE_FOR_STORIES
86
+ : await runVsr(opened.page, { scope: "#storybook-root" }).catch(failedVsr);
80
87
  }
81
88
  const hidden = notTestableEntries(await closedShadowHosts(opened.page));
82
89
  return { id: story.id, ok: true, archetype: { status: "ran", configs: [{ libA11y: "n/a", tiers }] }, hidden };
@@ -222,14 +229,17 @@ export async function runPlan(plan, io) {
222
229
  }
223
230
  const targets = [];
224
231
  const mappings = {};
232
+ /** Fixtures the tool generated, by path under the output folder. */
233
+ const generatedFiles = {};
225
234
  for (const planTarget of plan.targets) {
226
- const { result, mapping } = await runTarget(browser, planTarget, plan, io);
235
+ const { result, mapping, files } = await runTarget(browser, planTarget, plan, io);
227
236
  targets.push(result);
237
+ if (files) Object.assign(generatedFiles, files);
228
238
  if (mapping) {
229
239
  mappings[planTarget.id] = mapping;
230
240
  planTarget.mapping = mapping;
231
241
  }
232
- if (result.npm) planTarget.kind = result.npm.flavor === "react" ? "npm-react" : "npm-wc";
242
+ if (result.npm) planTarget.kind = adapterFor(result.npm.flavor).kind;
233
243
  }
234
244
 
235
245
  const results = parseResults({
@@ -245,6 +255,11 @@ export async function runPlan(plan, io) {
245
255
  mkdirSync(outDir, { recursive: true });
246
256
  writeFileSync(resolve(outDir, "results.json"), `${JSON.stringify(results, null, 2)}\n`);
247
257
  writeFileSync(resolve(outDir, "report.md"), renderReport({ plan, results }));
258
+ for (const [relative, text] of Object.entries(generatedFiles)) {
259
+ const file = resolve(outDir, relative);
260
+ mkdirSync(dirname(file), { recursive: true });
261
+ writeFileSync(file, text);
262
+ }
248
263
  if (Object.keys(mappings).length > 0) {
249
264
  // The candidate mapping, in the shape --mapping reads, so it can be edited and passed back in.
250
265
  writeFileSync(resolve(outDir, "mapping.json"), `${JSON.stringify(mappings, null, 2)}\n`);
@@ -37,6 +37,11 @@ export function summarize(archetypes, engines, gaps = []) {
37
37
  summary.computed = { pass: 0, fail: 0, undetermined: 0, notApplicable: 0, error: 0 };
38
38
  for (const check of measured) summary.computed[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
39
39
  }
40
+ const adapted = Object.values(archetypes).flatMap((a) => a.configs.flatMap((c) => c.tiers.conditions?.checks ?? []));
41
+ if (adapted.length) {
42
+ summary.conditions = { pass: 0, fail: 0, undetermined: 0, notApplicable: 0, error: 0 };
43
+ for (const check of adapted) summary.conditions[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
44
+ }
40
45
  const walks = Object.entries(archetypes).flatMap(([name, a]) => a.configs.map((c) => ({ name, vsr: c.tiers.vsr })).filter((x) => x.vsr?.status === "ran"));
41
46
  if (walks.length) {
42
47
  summary.vsr = { walks: walks.length, flagged: walks.reduce((n, w) => n + w.vsr.flags.length, 0) };