automatica11y 0.3.2 → 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 (53) hide show
  1. package/README.md +35 -8
  2. package/package.json +6 -3
  3. package/skills/automatica11y-runner/SKILL.md +47 -13
  4. package/skills/automatica11y-runner/references/fixtures.md +38 -2
  5. package/src/commands/common.js +5 -2
  6. package/src/data/README.md +19 -0
  7. package/src/data/wcag-2.2.json +7557 -0
  8. package/src/data/wcag-2.2.source.json +6 -0
  9. package/src/frameworks/index.js +42 -0
  10. package/src/frameworks/react.js +77 -0
  11. package/src/frameworks/vue.js +90 -0
  12. package/src/frameworks/wc.js +55 -0
  13. package/src/globals.d.ts +2 -0
  14. package/src/harness/bundle.js +23 -8
  15. package/src/harness/generate/dialects.js +64 -0
  16. package/src/harness/generate/index.js +12 -0
  17. package/src/harness/generate/jsx-recipes.js +224 -0
  18. package/src/harness/generate/jsx.js +76 -0
  19. package/src/harness/generate/kit.js +50 -0
  20. package/src/harness/generate/marking.js +64 -0
  21. package/src/harness/generate/probe.js +97 -0
  22. package/src/harness/generate/shared.js +12 -0
  23. package/src/harness/generate/wc-recipes.js +132 -0
  24. package/src/harness/npm-install.js +85 -5
  25. package/src/harness/settle.js +17 -0
  26. package/src/harness/storybook.js +1 -0
  27. package/src/harness/url.js +13 -3
  28. package/src/plan/classify.js +11 -4
  29. package/src/plan/mapping.js +11 -7
  30. package/src/plan/resolve-npm.js +15 -9
  31. package/src/plan/subpath.js +133 -0
  32. package/src/report/comparison.js +21 -6
  33. package/src/report/parts.js +60 -8
  34. package/src/run/audit-npm.js +137 -29
  35. package/src/run/generate-fixture.js +74 -0
  36. package/src/run/run-plan.js +24 -4
  37. package/src/run/summary.js +10 -0
  38. package/src/schema.js +34 -7
  39. package/src/tiers/computed/checks.js +197 -0
  40. package/src/tiers/computed/color.js +48 -0
  41. package/src/tiers/computed/index.js +25 -0
  42. package/src/tiers/computed/measure-kit.js +225 -0
  43. package/src/tiers/conditions/checks.js +283 -0
  44. package/src/tiers/conditions/index.js +30 -0
  45. package/src/tiers/conditions/kit.js +133 -0
  46. package/src/tiers/interactions/archetypes.js +117 -4
  47. package/src/tiers/interactions/focus-indicator.js +39 -0
  48. package/src/tiers/interactions/helpers.js +87 -16
  49. package/src/tiers/interactions/index.js +17 -4
  50. package/src/tiers/rules/axe.js +4 -3
  51. package/src/wcag/index.js +83 -0
  52. package/src/harness/npm-react.js +0 -39
  53. package/src/harness/npm-wc.js +0 -30
@@ -2,14 +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 { 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";
16
+ import { runComputed } from "../tiers/computed/index.js";
17
+ import { runConditions } from "../tiers/conditions/index.js";
13
18
  import { runInteractions } from "../tiers/interactions/index.js";
14
19
  import { runRules } from "../tiers/rules/index.js";
15
20
  import { failedVsr, runVsr } from "../tiers/vsr.js";
@@ -23,6 +28,7 @@ const STATES = {
23
28
  tooltip: ["closed", "open"],
24
29
  combobox: ["closed", "open"],
25
30
  accordion: ["collapsed", "expanded"],
31
+ "live-region": ["before message", "message shown"],
26
32
  };
27
33
 
28
34
  const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error";
@@ -40,12 +46,28 @@ function watchErrors(errors) {
40
46
  };
41
47
  }
42
48
 
49
+ /**
50
+ * Bundle, and if the library needs an optional peer dependency that npm left out, install it and bundle once more.
51
+ * @param {Parameters<typeof bundleEntries>[0]} options
52
+ * @param {string[]} warnings Gets a note when peers were installed.
53
+ */
54
+ async function bundleWithPeers(options, warnings) {
55
+ try {
56
+ return await bundleEntries(options);
57
+ } catch (error) {
58
+ const added = await installOptionalPeers({ dir: options.workDir, unresolved: error.unresolved ?? [] });
59
+ if (added.installed.length === 0) throw error;
60
+ warnings.push(...added.warnings);
61
+ return bundleEntries(options);
62
+ }
63
+ }
64
+
43
65
  /** 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;
66
+ async function discover({ browser, workDir, flavor, pkg, buildDir, warnings }) {
67
+ const adapter = adapterFor(flavor);
46
68
  const entryFile = join(workDir, "discover.js");
47
- writeFileSync(entryFile, helper.discoverEntry(pkg));
48
- await bundleEntries({ entries: { discover: entryFile }, outdir: buildDir, workDir, react: flavor === "react" });
69
+ writeFileSync(entryFile, adapter.discoverEntry(pkg));
70
+ await bundleWithPeers({ entries: { discover: entryFile }, outdir: buildDir, workDir, framework: adapter }, warnings);
49
71
  const server = await serveStatic(buildDir);
50
72
  const errors = [];
51
73
  try {
@@ -57,8 +79,31 @@ async function discover({ browser, workDir, flavor, pkg, buildDir }) {
57
79
  }
58
80
  const exportsList = await opened.page.evaluate(() => /** @type {any} */ (window).__a11yExports);
59
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);
60
105
  await opened.close();
61
- return { exports: exportsList, tags };
106
+ return { exports: exportsList, tags, facts };
62
107
  } finally {
63
108
  await server.close();
64
109
  }
@@ -79,6 +124,9 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
79
124
  if (count !== 1) return { gap: `The fixture must mark exactly one data-a11y-trigger. It marked ${num(count)}.` };
80
125
  if (errors.length) return { gap: `The fixture logged errors when it mounted: ${errors[0]}` };
81
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
+ }
82
130
  const states = STATES[archetype] ?? ["initial"];
83
131
  const configs = [];
84
132
  for (const [index, state] of states.entries()) {
@@ -88,23 +136,25 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
88
136
  if (archetype === "tooltip") await trigger.first().focus();
89
137
  else await trigger.first().click();
90
138
  await opened.page.waitForFunction(
91
- () => {
139
+ (needsText) => {
92
140
  const root = document.querySelector("[data-a11y-root]");
93
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")));
94
144
  return shown || document.querySelector('[data-a11y-trigger][aria-expanded="true"]') !== null;
95
145
  },
96
- undefined,
146
+ archetype === "live-region",
97
147
  { timeout: 3000 },
98
148
  );
99
149
  } catch {
100
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.`;
101
151
  }
102
152
  }
103
- await opened.page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
153
+ await settleAnimations(opened.page);
104
154
  /** @type {Record<string, any>} */
105
155
  const tiers = {};
106
156
  for (const tier of plan.options.tiers) {
107
- if (tier === "interactions") continue;
157
+ if (tier === "interactions" || tier === "computed" || tier === "conditions") continue;
108
158
  if (tier === "vsr") tiers.vsr = failure ? { status: "skipped", simulated: true, reason: failure } : await runVsr(opened.page, { scope: "body", state }).catch(failedVsr);
109
159
  else if (failure) tiers.rules = { status: "failed", reason: failure, engines: Object.fromEntries(plan.options.engines.map((e) => [e, { status: "failed", reason: failure }])) };
110
160
  else {
@@ -117,6 +167,8 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
117
167
  const hidden = notTestableEntries(await closedShadowHosts(opened.page));
118
168
  // The checks open their own fresh pages, so run them after this page's rules results are in.
119
169
  if (plan.options.tiers.includes("interactions")) configs[0].tiers.interactions = await runInteractions(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, archetype);
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);
120
172
  return { configs, hidden };
121
173
  } finally {
122
174
  await opened.close();
@@ -143,14 +195,14 @@ async function auditFixture({ browser, url, archetype, plan, toggle }) {
143
195
  /**
144
196
  * Audit an npm package: install it on its own, find what it exports, and audit each archetype that has a fixture.
145
197
  * 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 }>}
198
+ * @returns {Promise<{ result: any, mapping: Record<string, any> | null, files?: Record<string, string> }>}
147
199
  */
148
200
  export async function auditNpm({ browser, planTarget, plan, cwd, install = installPackage }) {
149
201
  install ??= installPackage;
150
202
  const base = { id: planTarget.id, reason: null, archetypes: {}, summary: { engines: {}, gaps: [], notTestable: [] }, warnings: [] };
151
203
  const resolved = planTarget.resolved ?? {};
152
204
  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 };
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 };
154
206
  }
155
207
  const tmp = mkdtempSync(join(tmpdir(), "automatica11y-npm-"));
156
208
  const workDir = join(tmp, "install");
@@ -159,22 +211,33 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
159
211
  mkdirSync(fixtureDir, { recursive: true });
160
212
  const servers = [];
161
213
  try {
162
- /** @type {"react" | "wc" | "unknown"} */
163
- 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";
164
217
  const installed = await install({ dir: workDir, name: resolved.name, version: resolved.version, flavor });
165
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
+ }
166
225
 
167
- const found = await discover({ browser, workDir, flavor: flavor === "react" ? "react" : "wc", pkg: resolved.name, buildDir });
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 });
168
231
  if (flavor === "unknown") {
169
232
  if (found.tags.length > 0) flavor = "wc";
170
233
  else if (installed.react && found.exports.some((e) => /^[A-Z]/.test(e.name))) flavor = "react";
171
234
  }
172
235
  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 };
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 };
174
237
  }
175
238
 
176
239
  const candidates = candidateMapping({ flavor, exports: found.exports, tags: found.tags });
177
- const kindFlavor = /** @type {"react" | "wc"} */ (flavor);
240
+ const adapter = adapterFor(flavor);
178
241
  const wanted = plan.options.archetypes ?? ARCHETYPES;
179
242
  /** @type {Record<string, any>} */
180
243
  const mapping = {};
@@ -182,7 +245,20 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
182
245
  const archetypes = {};
183
246
  const gaps = [];
184
247
  const hidden = [];
185
- 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
+ };
186
262
 
187
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.
188
264
  const runnable = {};
@@ -200,29 +276,57 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
200
276
  entry.status = "needs-fixture";
201
277
  entry.reason = `The mapping names ${user.fixture}, but that file doesn't exist.`;
202
278
  } 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);
279
+ const source = adapter.template(archetype, importSpec, flavor === "wc" ? entry.tag : entry.export);
204
280
  if (source) {
205
281
  entry.status = "template";
206
282
  delete entry.reason;
207
- fixture = join(fixtureDir, `${archetype}.${flavor === "react" ? "jsx" : "js"}`);
283
+ fixture = join(fixtureDir, `${archetype}.${adapter.extension}`);
208
284
  writeFileSync(fixture, source);
209
285
  }
210
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
+ }
211
312
  mapping[archetype] = entry;
212
313
  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: [] };
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 } } : {}) };
215
318
  gaps.push(`archetype:${archetype}`);
216
319
  continue;
217
320
  }
218
321
  const entryFile = join(tmp, "entries", `${archetype}-entry.js`);
219
322
  mkdirSync(join(tmp, "entries"), { recursive: true });
220
- writeFileSync(entryFile, helper.entry(fixture, resolved.name));
323
+ writeFileSync(entryFile, adapter.entry(fixture, importSpec));
221
324
  try {
222
- await bundleEntries({ entries: { [archetype]: entryFile }, outdir: buildDir, workDir, react: flavor === "react" });
325
+ await bundleWithPeers({ entries: { [archetype]: entryFile }, outdir: buildDir, workDir, framework: adapter }, warnings);
223
326
  runnable[archetype] = `/${archetype}.html`;
327
+ sources[archetype] = source;
224
328
  } catch (error) {
225
- 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 } } : {}) };
226
330
  gaps.push(`archetype:${archetype}`);
227
331
  entry.status = "needs-fixture";
228
332
  entry.reason = firstLine(error);
@@ -235,13 +339,14 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
235
339
  for (const [archetype, path] of Object.entries(runnable)) {
236
340
  /** @type {any} */
237
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];
238
343
  if (outcome.gap) {
239
- 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 } } : {}) };
240
345
  gaps.push(`archetype:${archetype}`);
241
346
  mapping[archetype].status = "needs-fixture";
242
347
  mapping[archetype].reason = outcome.gap;
243
348
  } else {
244
- archetypes[archetype] = { status: "ran", configs: outcome.configs };
349
+ archetypes[archetype] = { status: "ran", configs: outcome.configs, ...(fixtureInfo ? { fixture: fixtureInfo } : {}) };
245
350
  hidden.push(...outcome.hidden.map((h) => `${archetype}: ${h}`));
246
351
  }
247
352
  }
@@ -257,11 +362,13 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
257
362
  archetypes: ordered,
258
363
  npm: {
259
364
  name: resolved.name,
365
+ subpath: resolved.subpath ?? null,
260
366
  version: installed.version ?? resolved.version,
261
367
  flavor,
262
368
  framework: resolved.framework ?? null,
263
369
  react: installed.react,
264
370
  reactDom: installed.reactDom,
371
+ vue: installed.vue ?? null,
265
372
  tags: flavor === "wc" ? found.tags : [],
266
373
  },
267
374
  summary: (() => {
@@ -271,6 +378,7 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
271
378
  warnings,
272
379
  },
273
380
  mapping: Object.fromEntries(Object.entries(mapping).map(([k, v]) => [k, { ...v, fixture: v.fixture ?? null }])),
381
+ files,
274
382
  };
275
383
  } finally {
276
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
+ }
@@ -6,6 +6,8 @@ import { launchBrowser } from "../harness/browser.js";
6
6
  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
+ import { COMPUTED_NOT_APPLICABLE_FOR_PAGES } from "../tiers/computed/index.js";
10
+ import { CONDITIONS_NOT_APPLICABLE_FOR_STORIES, runConditions } from "../tiers/conditions/index.js";
9
11
  import { NOT_APPLICABLE_FOR_PAGES } from "../tiers/interactions/index.js";
10
12
  import { failedVsr, runVsr } from "../tiers/vsr.js";
11
13
  import { openPage } from "../harness/url.js";
@@ -16,6 +18,7 @@ import { runRules, selfTest } from "../tiers/rules/index.js";
16
18
  import { evaluateFailCheck } from "./fail-check.js";
17
19
  import { mapPool } from "./pool.js";
18
20
  import { auditNpm } from "./audit-npm.js";
21
+ import { adapterFor } from "../frameworks/index.js";
19
22
  import { failedTarget, summarize } from "./summary.js";
20
23
 
21
24
  /** How many stories to audit at once. */
@@ -23,7 +26,7 @@ const STORY_CONCURRENCY = 4;
23
26
 
24
27
  const EXIT = { OK: 0, FAIL_THRESHOLD: 1, ENVIRONMENT: 3, ALL_TARGETS_FAILED: 4 };
25
28
 
26
- 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"]);
27
30
  const UNSUPPORTED_KIND = (kind) => `${kind} targets aren't supported.`;
28
31
 
29
32
  /** Audit one page and return its target result. */
@@ -37,6 +40,11 @@ async function auditPage(browser, url, planTarget, plan, extraWarnings) {
37
40
  tiers.rules = await runRules(opened.page, { engines: plan.options.engines, wcag: plan.options.wcag, level: plan.options.level });
38
41
  } else if (tier === "interactions") {
39
42
  tiers.interactions = NOT_APPLICABLE_FOR_PAGES;
43
+ } else if (tier === "computed") {
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");
40
48
  } else {
41
49
  tiers.vsr = await runVsr(opened.page, { scope: "body" }).catch(failedVsr);
42
50
  }
@@ -71,7 +79,11 @@ async function auditStory(browser, base, story, plan) {
71
79
  ? await runRules(opened.page, { engines: plan.options.engines, wcag: plan.options.wcag, level: plan.options.level, scope: "#storybook-root" })
72
80
  : tier === "interactions"
73
81
  ? NOT_APPLICABLE_FOR_PAGES
74
- : await runVsr(opened.page, { scope: "#storybook-root" }).catch(failedVsr);
82
+ : tier === "computed"
83
+ ? COMPUTED_NOT_APPLICABLE_FOR_PAGES
84
+ : tier === "conditions"
85
+ ? CONDITIONS_NOT_APPLICABLE_FOR_STORIES
86
+ : await runVsr(opened.page, { scope: "#storybook-root" }).catch(failedVsr);
75
87
  }
76
88
  const hidden = notTestableEntries(await closedShadowHosts(opened.page));
77
89
  return { id: story.id, ok: true, archetype: { status: "ran", configs: [{ libA11y: "n/a", tiers }] }, hidden };
@@ -217,14 +229,17 @@ export async function runPlan(plan, io) {
217
229
  }
218
230
  const targets = [];
219
231
  const mappings = {};
232
+ /** Fixtures the tool generated, by path under the output folder. */
233
+ const generatedFiles = {};
220
234
  for (const planTarget of plan.targets) {
221
- const { result, mapping } = await runTarget(browser, planTarget, plan, io);
235
+ const { result, mapping, files } = await runTarget(browser, planTarget, plan, io);
222
236
  targets.push(result);
237
+ if (files) Object.assign(generatedFiles, files);
223
238
  if (mapping) {
224
239
  mappings[planTarget.id] = mapping;
225
240
  planTarget.mapping = mapping;
226
241
  }
227
- if (result.npm) planTarget.kind = result.npm.flavor === "react" ? "npm-react" : "npm-wc";
242
+ if (result.npm) planTarget.kind = adapterFor(result.npm.flavor).kind;
228
243
  }
229
244
 
230
245
  const results = parseResults({
@@ -240,6 +255,11 @@ export async function runPlan(plan, io) {
240
255
  mkdirSync(outDir, { recursive: true });
241
256
  writeFileSync(resolve(outDir, "results.json"), `${JSON.stringify(results, null, 2)}\n`);
242
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
+ }
243
263
  if (Object.keys(mappings).length > 0) {
244
264
  // The candidate mapping, in the shape --mapping reads, so it can be edited and passed back in.
245
265
  writeFileSync(resolve(outDir, "mapping.json"), `${JSON.stringify(mappings, null, 2)}\n`);
@@ -32,6 +32,16 @@ export function summarize(archetypes, engines, gaps = []) {
32
32
  summary.interactions = { pass: 0, fail: 0, notApplicable: 0, error: 0 };
33
33
  for (const check of checks) summary.interactions[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
34
34
  }
35
+ const measured = Object.values(archetypes).flatMap((a) => a.configs.flatMap((c) => c.tiers.computed?.checks ?? []));
36
+ if (measured.length) {
37
+ summary.computed = { pass: 0, fail: 0, undetermined: 0, notApplicable: 0, error: 0 };
38
+ for (const check of measured) summary.computed[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
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
+ }
35
45
  const walks = Object.entries(archetypes).flatMap(([name, a]) => a.configs.map((c) => ({ name, vsr: c.tiers.vsr })).filter((x) => x.vsr?.status === "ran"));
36
46
  if (walks.length) {
37
47
  summary.vsr = { walks: walks.length, flagged: walks.reduce((n, w) => n + w.vsr.flags.length, 0) };
package/src/schema.js CHANGED
@@ -2,20 +2,20 @@ import * as v from "valibot";
2
2
 
3
3
  export const WCAG_VERSIONS = ["2.0", "2.1", "2.2"];
4
4
  export const LEVELS = ["A", "AA", "AAA"];
5
- export const TIERS = ["rules", "interactions", "vsr"];
5
+ export const TIERS = ["rules", "interactions", "computed", "conditions", "vsr"];
6
6
  export const ENGINES = ["axe", "ibm"];
7
7
  export const LIB_A11Y = ["on", "off"];
8
8
  export const IMPACTS = ["minor", "moderate", "serious", "critical"];
9
9
  export const TOOLKIT_LEVELS = [1, 2, 3];
10
10
  export const FAIL_MODES = ["any", "all"];
11
- export const ARCHETYPES = ["button", "link", "dialog", "menu", "tabs", "combobox", "form-field", "accordion", "tooltip", "chart"];
12
- export const FLAVORS = ["react", "wc"];
13
- export const MAPPING_STATUSES = ["template", "authored", "needs-fixture", "no-match"];
11
+ export const ARCHETYPES = ["button", "link", "dialog", "menu", "tabs", "combobox", "form-field", "accordion", "tooltip", "live-region", "chart"];
12
+ export const FLAVORS = ["react", "vue", "wc"];
13
+ export const MAPPING_STATUSES = ["template", "authored", "generated", "needs-fixture", "no-match"];
14
14
 
15
15
  /** What a candidate mapping says about one archetype of one npm target. */
16
16
  export const MappingEntrySchema = v.object({
17
17
  flavor: v.optional(v.picklist(FLAVORS)),
18
- /** Export name (React) the fixture or template uses. */
18
+ /** Export name (React or Vue) the fixture or template uses. */
19
19
  export: v.optional(v.string()),
20
20
  /** Custom element tag (web components) the fixture or template uses. */
21
21
  tag: v.optional(v.string()),
@@ -23,7 +23,14 @@ export const MappingEntrySchema = v.object({
23
23
  fixture: v.optional(v.nullable(v.string())),
24
24
  /** The library ships opt-in accessibility features. The fixture gets `libA11y` (true or false) and `--lib-a11y` runs it both ways. */
25
25
  libA11y: v.optional(v.boolean()),
26
+ /** Other packages to install beside the target, such as the token stylesheet or theme the library asks for. A fixture can then import them. */
27
+ install: v.optional(v.array(v.string())),
26
28
  status: v.optional(v.picklist(MAPPING_STATUSES)),
29
+ /** For a generated fixture: which recipe worked, what it was, which parts it used, and where its source was written. */
30
+ recipe: v.optional(v.string()),
31
+ summary: v.optional(v.string()),
32
+ used: v.optional(v.array(v.string())),
33
+ generatedFile: v.optional(v.string()),
27
34
  candidates: v.optional(v.array(v.string())),
28
35
  parts: v.optional(v.array(v.string())),
29
36
  reason: v.optional(v.string()),
@@ -42,7 +49,7 @@ export function parseMappingFile(input) {
42
49
  return result.output;
43
50
  }
44
51
 
45
- export const TARGET_KINDS = ["npm", "npm-react", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
52
+ export const TARGET_KINDS = ["npm", "npm-react", "npm-vue", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
46
53
 
47
54
  const nullableString = v.nullable(v.string());
48
55
 
@@ -77,6 +84,8 @@ export const PlanSchema = v.object({
77
84
  archetypes: v.nullable(v.array(v.picklist(ARCHETYPES))),
78
85
  mapping: nullableString,
79
86
  maxStories: v.pipe(v.number(), v.integer(), v.minValue(1)),
87
+ /** Build fixtures from a package's parts when none is authored. A plan saved before this existed generates. */
88
+ generate: v.optional(v.boolean(), true),
80
89
  out: v.string(),
81
90
  fail: v.nullable(FailConfigSchema),
82
91
  }),
@@ -136,10 +145,12 @@ const TierResultSchema = v.object({
136
145
  v.object({
137
146
  name: v.string(),
138
147
  criteria: v.optional(v.array(v.string())),
139
- result: v.picklist(["pass", "fail", "not-applicable", "error"]),
148
+ result: v.picklist(["pass", "fail", "undetermined", "not-applicable", "error"]),
140
149
  detail: v.string(),
141
150
  /** How the focus indicator was detected: computed-style or screenshot. */
142
151
  method: v.optional(v.string()),
152
+ /** The numbers behind a computed check, for example contrast ratios by state. */
153
+ measurements: v.optional(v.array(v.record(v.string(), v.unknown()))),
143
154
  }),
144
155
  ),
145
156
  ),
@@ -198,6 +209,17 @@ export const TargetResultSchema = v.object({
198
209
  v.object({
199
210
  status: v.picklist(["ran", "gap"]),
200
211
  reason: v.optional(v.nullable(v.string())),
212
+ /** Where the fixture came from, and for a generated one what was tried. */
213
+ fixture: v.optional(
214
+ v.object({
215
+ source: v.picklist(["template", "authored", "generated", "none"]),
216
+ recipe: v.optional(v.string()),
217
+ summary: v.optional(v.string()),
218
+ used: v.optional(v.array(v.string())),
219
+ file: v.optional(v.nullable(v.string())),
220
+ attempts: v.optional(v.array(v.object({ recipe: v.string(), summary: v.string(), ok: v.boolean(), reason: v.nullable(v.string()) }))),
221
+ }),
222
+ ),
201
223
  configs: v.array(v.object({ libA11y: v.picklist(["on", "off", "n/a"]), state: v.optional(v.string()), tiers: v.record(v.string(), TierResultSchema) })),
202
224
  }),
203
225
  ),
@@ -205,11 +227,14 @@ export const TargetResultSchema = v.object({
205
227
  npm: v.optional(
206
228
  v.object({
207
229
  name: v.string(),
230
+ /** The sub-path of the package that was tested, such as `button/v2`, or null for the package itself. */
231
+ subpath: v.optional(nullableString),
208
232
  version: nullableString,
209
233
  flavor: v.picklist(FLAVORS),
210
234
  framework: nullableString,
211
235
  react: nullableString,
212
236
  reactDom: nullableString,
237
+ vue: v.optional(nullableString),
213
238
  tags: v.array(v.string()),
214
239
  }),
215
240
  ),
@@ -218,6 +243,8 @@ export const TargetResultSchema = v.object({
218
243
  gaps: v.array(v.string()),
219
244
  notTestable: v.array(v.string()),
220
245
  interactions: v.optional(v.object({ pass: v.number(), fail: v.number(), notApplicable: v.number(), error: v.number() })),
246
+ computed: v.optional(v.object({ pass: v.number(), fail: v.number(), undetermined: v.number(), notApplicable: v.number(), error: v.number() })),
247
+ conditions: v.optional(v.object({ pass: v.number(), fail: v.number(), undetermined: v.number(), notApplicable: v.number(), error: v.number() })),
221
248
  vsr: v.optional(v.object({ walks: v.number(), flagged: v.number() })),
222
249
  }),
223
250
  warnings: v.array(v.string()),