automatica11y 0.4.0 → 0.6.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 (44) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +39 -122
  3. package/package.json +28 -2
  4. package/skills/automatica11y/SKILL.md +3 -1
  5. package/skills/automatica11y-runner/SKILL.md +19 -4
  6. package/skills/automatica11y-runner/references/fixtures.md +22 -0
  7. package/src/cli.js +1 -0
  8. package/src/commands/common.js +14 -11
  9. package/src/frameworks/angular-errors.js +27 -0
  10. package/src/frameworks/angular-selectors.js +36 -0
  11. package/src/frameworks/angular.js +156 -0
  12. package/src/frameworks/html.js +163 -0
  13. package/src/frameworks/index.js +12 -7
  14. package/src/frameworks/react.js +2 -1
  15. package/src/frameworks/vue.js +2 -1
  16. package/src/globals.d.ts +123 -6
  17. package/src/harness/bundle.js +2 -1
  18. package/src/harness/generate/angular-recipes.js +360 -0
  19. package/src/harness/generate/probe.js +16 -5
  20. package/src/harness/npm-install.js +43 -11
  21. package/src/harness/shadow.js +1 -1
  22. package/src/harness/storybook.js +18 -5
  23. package/src/plan/build-plan.js +1 -0
  24. package/src/plan/classify.js +39 -11
  25. package/src/plan/mapping.js +5 -5
  26. package/src/plan/resolve-npm.js +47 -17
  27. package/src/plan/subpath.js +17 -0
  28. package/src/report/comparison.js +4 -1
  29. package/src/report/index.js +1 -1
  30. package/src/report/parts.js +13 -1
  31. package/src/report/single.js +1 -1
  32. package/src/run/audit-npm.js +93 -26
  33. package/src/run/fail-check.js +10 -2
  34. package/src/run/generate-fixture.js +5 -4
  35. package/src/run/run-plan.js +8 -7
  36. package/src/run/summary.js +1 -1
  37. package/src/schema.js +9 -2
  38. package/src/tiers/computed/checks.js +3 -1
  39. package/src/tiers/conditions/kit.js +3 -3
  40. package/src/tiers/interactions/archetypes.js +6 -2
  41. package/src/tiers/interactions/focus-indicator.js +4 -2
  42. package/src/tiers/interactions/helpers.js +2 -1
  43. package/src/tiers/rules/ibm.js +2 -2
  44. package/src/tiers/rules/index.js +1 -1
@@ -1,13 +1,16 @@
1
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, mkdtempSync, readFileSync, 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
5
  import { GENERATABLE } from "../harness/generate/index.js";
6
6
  import { generateFixture } from "./generate-fixture.js";
7
7
  import { settleAnimations } from "../harness/settle.js";
8
- import { installExtraPackages, installOptionalPeers, installPackage } from "../harness/npm-install.js";
8
+ import { installedVersion, installExtraPackages, installOptionalPeers, installPackage } from "../harness/npm-install.js";
9
+ import { shipsBrowserAssets } from "../frameworks/html.js";
10
+ import { ANGULAR_FLOOR } from "../frameworks/angular.js";
11
+ import { explainAngularError } from "../frameworks/angular-errors.js";
9
12
  import { adapterFor, adapterForKind } from "../frameworks/index.js";
10
- import { subpathProblem } from "../plan/subpath.js";
13
+ import { offeredSubpaths, subpathProblem } from "../plan/subpath.js";
11
14
  import { closedShadowHosts, notTestableEntries } from "../harness/shadow.js";
12
15
  import { serveStatic } from "../harness/static-serve.js";
13
16
  import { openPage } from "../harness/url.js";
@@ -21,6 +24,12 @@ import { failedVsr, runVsr } from "../tiers/vsr.js";
21
24
  import { num } from "../text.js";
22
25
  import { summarize } from "./summary.js";
23
26
 
27
+ /** @typedef {ReturnType<typeof import("../schema.js").parsePlan>} Plan */
28
+ /** @typedef {ReturnType<typeof import("../schema.js").parseResults>["targets"][number]} TargetResult */
29
+ /** @typedef {ReturnType<typeof import("../schema.js").parseMappingFile>[string]} TargetMapping */
30
+ /** @typedef {NonNullable<TargetResult["archetypes"][string]["fixture"]>} FixtureInfo */
31
+ /** @typedef {TargetResult["archetypes"][string]["configs"][number]} FixtureConfig */
32
+
24
33
  /** The states worth checking for each archetype. The first is where a page starts. */
25
34
  const STATES = {
26
35
  dialog: ["closed", "open"],
@@ -36,12 +45,12 @@ const firstLine = (error) => (error instanceof Error ? error.message : String(er
36
45
  /** Page and console errors that mean a fixture didn't mount cleanly. A missing favicon doesn't count. */
37
46
  function watchErrors(errors) {
38
47
  return (page) => {
39
- page.on("pageerror", (error) => errors.push(error.message.split("\n")[0]));
48
+ page.on("pageerror", (error) => errors.push(explainAngularError(error.message.split("\n")[0])));
40
49
  page.on("console", (message) => {
41
50
  if (message.type() !== "error") return;
42
51
  const text = message.text();
43
52
  if (/favicon|Failed to load resource/i.test(text) && !/\.(js|css)\b/.test(text)) return;
44
- errors.push(text.split("\n")[0]);
53
+ errors.push(explainAngularError(text.split("\n")[0]));
45
54
  });
46
55
  };
47
56
  }
@@ -62,6 +71,15 @@ async function bundleWithPeers(options, warnings) {
62
71
  }
63
72
  }
64
73
 
74
+ /** The installed package's own package.json, or an empty object. */
75
+ function readInstalledMeta(workDir, name) {
76
+ try {
77
+ return JSON.parse(readFileSync(join(workDir, "node_modules", name, "package.json"), "utf8"));
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
65
83
  /** Load the whole package in a page to list its exports and the custom elements it defines. */
66
84
  async function discover({ browser, workDir, flavor, pkg, buildDir, warnings }) {
67
85
  const adapter = adapterFor(flavor);
@@ -73,12 +91,12 @@ async function discover({ browser, workDir, flavor, pkg, buildDir, warnings }) {
73
91
  try {
74
92
  const opened = await openPage(browser, `${server.origin}/discover.html`, { beforeGoto: watchErrors(errors) });
75
93
  try {
76
- await opened.page.waitForFunction(() => /** @type {any} */ (window).__a11yExports !== undefined, undefined, { timeout: 15_000 });
94
+ await opened.page.waitForFunction(() => window.__a11yExports !== undefined, undefined, { timeout: 15_000 });
77
95
  } catch {
78
96
  throw new Error(`The package didn't finish loading in the browser${errors.length ? `: ${errors[0]}` : "."}`);
79
97
  }
80
- const exportsList = await opened.page.evaluate(() => /** @type {any} */ (window).__a11yExports);
81
- const tags = await opened.page.evaluate(() => [.../** @type {any} */ (window).__a11yDefined ?? []]);
98
+ const exportsList = await opened.page.evaluate(() => window.__a11yExports ?? []);
99
+ const tags = await opened.page.evaluate(() => [...(window.__a11yDefined ?? [])]);
82
100
  // What each element says about itself, so a fixture can be built around it: observed attributes, class members, slots.
83
101
  const facts = await opened.page.evaluate((names) => {
84
102
  const out = {};
@@ -98,7 +116,8 @@ async function discover({ browser, workDir, flavor, pkg, buildDir, warnings }) {
98
116
  } catch {
99
117
  // An element that can't be created on its own has no slots to report.
100
118
  }
101
- out[name] = { attributes: [.../** @type {any} */ (Element).observedAttributes ?? []], members: [...members], slots: slots.filter(Boolean) };
119
+ const attributes = "observedAttributes" in Element && Array.isArray(Element.observedAttributes) ? Element.observedAttributes : [];
120
+ out[name] = { attributes: [...attributes], members: [...members], slots: slots.filter(Boolean) };
102
121
  }
103
122
  return out;
104
123
  }, tags);
@@ -151,7 +170,7 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
151
170
  }
152
171
  }
153
172
  await settleAnimations(opened.page);
154
- /** @type {Record<string, any>} */
173
+ /** @type {TargetResult["archetypes"][string]["configs"][number]["tiers"]} */
155
174
  const tiers = {};
156
175
  for (const tier of plan.options.tiers) {
157
176
  if (tier === "interactions" || tier === "computed" || tier === "conditions") continue;
@@ -177,7 +196,8 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
177
196
 
178
197
  /**
179
198
  * Audit one fixture. A library that ships opt-in accessibility features runs once for each `--lib-a11y` value, and each result carries its label.
180
- * @param {{ browser: any, url: string, archetype: string, plan: any, toggle: boolean }} options
199
+ * @param {{ browser: import("playwright-core").Browser, url: string, archetype: string, plan: Plan, toggle: boolean }} options
200
+ * @returns {Promise<{ gap: string } | { configs: FixtureConfig[], hidden: string[] }>}
181
201
  */
182
202
  async function auditFixture({ browser, url, archetype, plan, toggle }) {
183
203
  const values = toggle ? plan.options.libA11y : ["n/a"];
@@ -185,7 +205,7 @@ async function auditFixture({ browser, url, archetype, plan, toggle }) {
185
205
  const hidden = [];
186
206
  for (const libA11y of values) {
187
207
  const outcome = await auditFixturePage({ browser, url, archetype, plan, libA11y });
188
- if (outcome.gap) return { gap: toggle ? `With library accessibility ${libA11y}: ${outcome.gap}` : outcome.gap };
208
+ if ("gap" in outcome) return { gap: toggle ? `With library accessibility ${libA11y}: ${outcome.gap}` : outcome.gap };
189
209
  configs.push(...outcome.configs);
190
210
  hidden.push(...outcome.hidden);
191
211
  }
@@ -195,14 +215,15 @@ async function auditFixture({ browser, url, archetype, plan, toggle }) {
195
215
  /**
196
216
  * Audit an npm package: install it on its own, find what it exports, and audit each archetype that has a fixture.
197
217
  * An archetype without a usable fixture is a gap with a reason, never a pass.
198
- * @returns {Promise<{ result: any, mapping: Record<string, any> | null, files?: Record<string, string> }>}
218
+ * @param {{ browser: import("playwright-core").Browser, planTarget: Plan["targets"][number], plan: Plan, cwd: string, install?: typeof installPackage }} options
219
+ * @returns {Promise<{ result: TargetResult, mapping: TargetMapping | null, files?: Record<string, string> }>}
199
220
  */
200
221
  export async function auditNpm({ browser, planTarget, plan, cwd, install = installPackage }) {
201
222
  install ??= installPackage;
202
223
  const base = { id: planTarget.id, reason: null, archetypes: {}, summary: { engines: {}, gaps: [], notTestable: [] }, warnings: [] };
203
224
  const resolved = planTarget.resolved ?? {};
204
225
  if (planTarget.kind === "npm-unsupported") {
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 };
226
+ return { result: { ...base, status: "unsupported", reason: `${resolved.framework ?? "This framework"} packages aren't supported.${resolved.framework === "Angular" && resolved.detectedBy ? ` ${resolved.detectedBy}` : ""} This version covers React, Vue 3, Angular ${ANGULAR_FLOOR} and newer, and web components.` }, mapping: null };
206
227
  }
207
228
  const tmp = mkdtempSync(join(tmpdir(), "automatica11y-npm-"));
208
229
  const workDir = join(tmp, "install");
@@ -212,8 +233,9 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
212
233
  const servers = [];
213
234
  try {
214
235
  /** 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";
236
+ const adapterId = adapterForKind(planTarget.kind)?.id;
237
+ /** @type {"react" | "vue" | "angular" | "html" | "wc" | "unknown"} */
238
+ let flavor = adapterId === "react" || adapterId === "vue" || adapterId === "angular" || adapterId === "html" || adapterId === "wc" ? adapterId : "unknown";
217
239
  const installed = await install({ dir: workDir, name: resolved.name, version: resolved.version, flavor });
218
240
  const warnings = [...installed.warnings];
219
241
  // What fixtures and templates import: the package, or the sub-path of it that was asked for.
@@ -223,25 +245,68 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
223
245
  if (problem) throw new Error(problem);
224
246
  }
225
247
 
248
+ // The other packages of a list target (`npm:a,b`) go into the same folder, so the scripts and styles of one see the others.
249
+ /** @type {Array<{ name: string, subpath: string | null, version: string | null }>} */
250
+ const companions = [];
251
+ const alreadyInstalled = new Set([resolved.name]);
252
+ for (const companion of planTarget.companions ?? []) {
253
+ // A second entry for a package that's already in the folder (a stylesheet and a script from one package) needs no second install.
254
+ const added = alreadyInstalled.has(companion.name) ? { warnings: [], version: installedVersion(workDir, companion.name) } : await install({ dir: workDir, name: companion.name, version: companion.version, flavor: "unknown" });
255
+ warnings.push(...added.warnings);
256
+ if (companion.subpath) {
257
+ const problem = subpathProblem(workDir, companion.name, companion.subpath, added.version ?? companion.version);
258
+ if (problem) throw new Error(problem);
259
+ }
260
+ alreadyInstalled.add(companion.name);
261
+ companions.push({ name: companion.name, subpath: companion.subpath ?? null, version: added.version ?? companion.version });
262
+ }
263
+
226
264
  // Packages the mapping names (a token stylesheet, a theme) go in beside the library, so a fixture can import them.
227
265
  const extras = await installExtraPackages({ dir: workDir, specs: Object.values(planTarget.mapping ?? {}).flatMap((entry) => entry.install ?? []) });
228
266
  warnings.push(...extras.warnings);
229
267
 
230
- const found = await discover({ browser, workDir, flavor: flavor === "unknown" ? "wc" : flavor, pkg: importSpec, buildDir, warnings });
268
+ // What the install left behind that an adapter's entry needs to know about, such as whether zone.js came with the library.
269
+ const listed = [{ name: resolved.name, subpath: resolved.subpath ?? null }, ...companions.map((c) => ({ name: c.name, subpath: c.subpath }))];
270
+ let context = adapterForKind(planTarget.kind)?.inspect?.(workDir, listed) ?? {};
271
+
272
+ // Plain HTML has no exports or elements to list. What it loads came from the target itself.
273
+ let found = flavor === "html" ? { exports: [], tags: [], facts: {} } : await discover({ browser, workDir, flavor: flavor === "unknown" ? "wc" : flavor, pkg: importSpec, buildDir, warnings });
231
274
  if (flavor === "unknown") {
232
275
  if (found.tags.length > 0) flavor = "wc";
233
276
  else if (installed.react && found.exports.some((e) => /^[A-Z]/.test(e.name))) flavor = "react";
277
+ // Nothing to load as a component, but the package ships stylesheets or browser scripts: it's plain HTML.
278
+ else if (shipsBrowserAssets(readInstalledMeta(workDir, resolved.name))) {
279
+ flavor = "html";
280
+ found = { exports: [], tags: [], facts: {} };
281
+ context = adapterFor("html").inspect?.(workDir, listed) ?? {};
282
+ }
283
+ }
284
+ if (flavor === "html") {
285
+ const assets = /** @type {any} */ (context).assets;
286
+ if (!assets || (assets.styles.length === 0 && assets.scripts.length === 0)) {
287
+ warnings.push("The target didn't name a stylesheet or a script, so the page loaded none and the templates ran on the browser's own styles. Name them as sub-paths, such as npm:name/style.css.");
288
+ }
234
289
  }
235
290
  if (flavor === "unknown" || (flavor === "wc" && found.tags.length === 0)) {
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 };
291
+ // Some packages keep their components in sub-paths and leave the main entry nearly empty.
292
+ const hints = resolved.subpath ? [] : offeredSubpaths(workDir, resolved.name);
293
+ const where = hints.length ? ` Some packages keep their components in sub-paths, and this one exports ${hints.join(", ")}. Try one, for example npm:${hints[0]}.` : "";
294
+ 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.${where}`, warnings }, mapping: null };
237
295
  }
238
296
 
239
297
  const candidates = candidateMapping({ flavor, exports: found.exports, tags: found.tags });
298
+ if (flavor === "html") {
299
+ // There's no component to find, so an archetype is either a fixture someone writes or a gap that says so.
300
+ for (const [archetype, entry] of Object.entries(candidates)) {
301
+ if (adapterFor("html").template(archetype, "")) Object.assign(entry, { status: "template", reason: undefined });
302
+ else if (entry.status === "no-match") Object.assign(entry, { status: "needs-fixture", reason: `Plain HTML has no component to find, so the ${archetype} archetype needs a fixture someone writes.` });
303
+ }
304
+ }
240
305
  const adapter = adapterFor(flavor);
241
306
  const wanted = plan.options.archetypes ?? ARCHETYPES;
242
- /** @type {Record<string, any>} */
307
+ /** @type {TargetMapping} */
243
308
  const mapping = {};
244
- /** @type {Record<string, any>} */
309
+ /** @type {TargetResult["archetypes"]} */
245
310
  const archetypes = {};
246
311
  const gaps = [];
247
312
  const hidden = [];
@@ -276,7 +341,7 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
276
341
  entry.status = "needs-fixture";
277
342
  entry.reason = `The mapping names ${user.fixture}, but that file doesn't exist.`;
278
343
  } else if (entry.status === "template" || (user.export || user.tag) && ["button", "link"].includes(archetype)) {
279
- const source = adapter.template(archetype, importSpec, flavor === "wc" ? entry.tag : entry.export);
344
+ const source = adapter.template(archetype, importSpec, flavor === "wc" ? entry.tag : entry.export, found.exports.find((e) => e.name === entry.export));
280
345
  if (source) {
281
346
  entry.status = "template";
282
347
  delete entry.reason;
@@ -284,13 +349,13 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
284
349
  writeFileSync(fixture, source);
285
350
  }
286
351
  }
287
- /** @type {any} */
352
+ /** @type {FixtureInfo | null} */
288
353
  let source = fixture ? { source: entry.status === "authored" ? "authored" : "template" } : null;
289
354
  let attempts = [];
290
355
  let generationTried = false;
291
356
  // Nothing authored and no template: build candidates from what the package exports, and keep one only if it works.
292
357
  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) });
358
+ const generated = await generateFixture({ browser, adapter, archetype, entry, found, explicit: Boolean(user.export), pkg: importSpec, tmp, workDir, buildDir, context, getServer, bundle: (options) => bundleWithPeers(options, warnings) });
294
359
  attempts = generated.attempts;
295
360
  generationTried = attempts.length > 0;
296
361
  if (generated.ok && generated.winner) {
@@ -320,7 +385,7 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
320
385
  }
321
386
  const entryFile = join(tmp, "entries", `${archetype}-entry.js`);
322
387
  mkdirSync(join(tmp, "entries"), { recursive: true });
323
- writeFileSync(entryFile, adapter.entry(fixture, importSpec));
388
+ writeFileSync(entryFile, adapter.entry(fixture, importSpec, context));
324
389
  try {
325
390
  await bundleWithPeers({ entries: { [archetype]: entryFile }, outdir: buildDir, workDir, framework: adapter }, warnings);
326
391
  runnable[archetype] = `/${archetype}.html`;
@@ -337,10 +402,9 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
337
402
  const live = await serveStatic(buildDir);
338
403
  servers.push(live);
339
404
  for (const [archetype, path] of Object.entries(runnable)) {
340
- /** @type {any} */
341
405
  const outcome = await auditFixture({ browser, url: `${live.origin}${path}`, archetype, plan, toggle: mapping[archetype].libA11y === true }).catch((error) => ({ gap: firstLine(error) }));
342
406
  const fixtureInfo = sources[archetype];
343
- if (outcome.gap) {
407
+ if ("gap" in outcome) {
344
408
  archetypes[archetype] = { status: "gap", reason: outcome.gap, configs: [], ...(fixtureInfo?.attempts?.length ? { fixture: { source: "none", attempts: fixtureInfo.attempts } } : {}) };
345
409
  gaps.push(`archetype:${archetype}`);
346
410
  mapping[archetype].status = "needs-fixture";
@@ -364,11 +428,14 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
364
428
  name: resolved.name,
365
429
  subpath: resolved.subpath ?? null,
366
430
  version: installed.version ?? resolved.version,
431
+ ...(companions.length ? { companions } : {}),
367
432
  flavor,
368
433
  framework: resolved.framework ?? null,
369
434
  react: installed.react,
370
435
  reactDom: installed.reactDom,
371
436
  vue: installed.vue ?? null,
437
+ angular: installed.angular ?? null,
438
+ ...(flavor === "html" ? { assets: /** @type {any} */ (context).assets } : {}),
372
439
  tags: flavor === "wc" ? found.tags : [],
373
440
  },
374
441
  summary: (() => {
@@ -1,5 +1,13 @@
1
1
  import { IMPACTS } from "../schema.js";
2
2
 
3
+ /**
4
+ * @typedef {{ impact?: string | null, toolkitLevel?: number | null }} FailFinding
5
+ * @typedef {{ status?: string, violations?: FailFinding[] }} FailEngineResult
6
+ * @typedef {{ tiers: { rules?: { engines?: Record<string, FailEngineResult> } } }} FailConfigResult
7
+ * @typedef {{ configs: FailConfigResult[] }} FailArchetypeResult
8
+ * @typedef {{ id: string, status: string, archetypes: Record<string, FailArchetypeResult> }} FailTarget
9
+ */
10
+
3
11
  const RANK = Object.fromEntries(IMPACTS.map((impact, index) => [impact, index]));
4
12
 
5
13
  /** All findings an engine reported for one target, across every archetype and configuration. */
@@ -28,8 +36,8 @@ export function countIbmHits(target, threshold) {
28
36
  * Decide whether the run trips its fail check. Each engine has its own threshold, and counts are never added across engines.
29
37
  * `any` trips a target when any checked engine hits. `all` trips it only when every checked engine hits.
30
38
  * A target that failed, or an engine that failed, counts as no hit.
31
- * @param {Array<any>} targets Target results.
32
- * @param {{ mode: string, axe: string | null, ibm: number | null } | null} fail
39
+ * @param {FailTarget[]} targets Target results.
40
+ * @param {ReturnType<typeof import("../schema.js").parsePlan>["options"]["fail"]} fail
33
41
  */
34
42
  export function evaluateFailCheck(targets, fail) {
35
43
  if (!fail) return null;
@@ -14,18 +14,19 @@ const firstLine = (error) => (error instanceof Error ? error.message : String(er
14
14
  * adapter: import("../frameworks/index.js").Adapter,
15
15
  * archetype: string,
16
16
  * entry: { export?: string, tag?: string },
17
- * found: { exports: any[], tags: string[], facts: Record<string, any> },
17
+ * found: { exports: PackageExport[], tags: string[], facts: Record<string, { attributes: string[], members: string[], slots: string[] }> },
18
18
  * pkg: string,
19
19
  * explicit?: boolean,
20
+ * context?: Record<string, unknown>,
20
21
  * tmp: string,
21
22
  * workDir: string,
22
23
  * buildDir: string,
23
24
  * getServer: () => Promise<{ origin: string }>,
24
- * bundle: (options: any) => Promise<unknown>,
25
+ * bundle: (options: Parameters<typeof import("../harness/bundle.js").bundleEntries>[0]) => Promise<unknown>,
25
26
  * }} input
26
27
  * @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
  */
28
- export async function generateFixture({ browser, adapter, archetype, entry, found, explicit = false, pkg, tmp, workDir, buildDir, getServer, bundle }) {
29
+ export async function generateFixture({ browser, adapter, archetype, entry, found, explicit = false, pkg, tmp, workDir, buildDir, context = {}, getServer, bundle }) {
29
30
  const { candidates, reason } = adapter.generate({ archetype, pkg, entry, exports: found.exports, facts: found.facts, explicit });
30
31
  if (candidates.length === 0) return { ok: false, reason, attempts: [] };
31
32
 
@@ -37,7 +38,7 @@ export async function generateFixture({ browser, adapter, archetype, entry, foun
37
38
  const file = join(tmp, "generated", `${archetype}-${index}.${extension}`);
38
39
  writeFileSync(file, candidate.source);
39
40
  const entryFile = join(tmp, "entries", `${name}.js`);
40
- writeFileSync(entryFile, adapter.entry(file, pkg));
41
+ writeFileSync(entryFile, adapter.entry(file, pkg, context));
41
42
  return { candidate, name, file, entryFile, bundled: null };
42
43
  });
43
44
 
@@ -26,14 +26,14 @@ const STORY_CONCURRENCY = 4;
26
26
 
27
27
  const EXIT = { OK: 0, FAIL_THRESHOLD: 1, ENVIRONMENT: 3, ALL_TARGETS_FAILED: 4 };
28
28
 
29
- const NPM_KINDS = new Set(["npm", "npm-react", "npm-vue", "npm-wc", "npm-unsupported"]);
29
+ const NPM_KINDS = new Set(["npm", "npm-react", "npm-vue", "npm-angular", "npm-html", "npm-wc", "npm-unsupported"]);
30
30
  const UNSUPPORTED_KIND = (kind) => `${kind} targets aren't supported.`;
31
31
 
32
32
  /** Audit one page and return its target result. */
33
33
  async function auditPage(browser, url, planTarget, plan, extraWarnings) {
34
34
  const opened = await openPage(browser, url);
35
35
  try {
36
- /** @type {Record<string, any>} */
36
+ /** @type {ReturnType<typeof import("../schema.js").parseResults>["targets"][number]["archetypes"][string]["configs"][number]["tiers"]} */
37
37
  const tiers = {};
38
38
  for (const tier of plan.options.tiers) {
39
39
  if (tier === "rules") {
@@ -71,7 +71,7 @@ async function auditStory(browser, base, story, plan) {
71
71
  const opened = await openPage(browser, storyUrl(base, story.id));
72
72
  try {
73
73
  await waitForStory(opened.page);
74
- /** @type {Record<string, any>} */
74
+ /** @type {ReturnType<typeof import("../schema.js").parseResults>["targets"][number]["archetypes"][string]["configs"][number]["tiers"]} */
75
75
  const tiers = {};
76
76
  for (const tier of plan.options.tiers) {
77
77
  tiers[tier] =
@@ -201,7 +201,7 @@ function describeResult(target) {
201
201
 
202
202
  /**
203
203
  * Run a plan: check the environment, launch one browser, audit each target, write results.json and report.md.
204
- * @param {any} plan
204
+ * @param {ReturnType<typeof import("../schema.js").parsePlan>} plan
205
205
  * @param {import("../commands/common.js").Io} io
206
206
  * @returns {Promise<number>} The exit code.
207
207
  */
@@ -232,14 +232,15 @@ export async function runPlan(plan, io) {
232
232
  /** Fixtures the tool generated, by path under the output folder. */
233
233
  const generatedFiles = {};
234
234
  for (const planTarget of plan.targets) {
235
- const { result, mapping, files } = await runTarget(browser, planTarget, plan, io);
235
+ const targetRun = await runTarget(browser, planTarget, plan, io);
236
+ const { result, mapping } = targetRun;
236
237
  targets.push(result);
237
- if (files) Object.assign(generatedFiles, files);
238
+ if ("files" in targetRun && targetRun.files) Object.assign(generatedFiles, targetRun.files);
238
239
  if (mapping) {
239
240
  mappings[planTarget.id] = mapping;
240
241
  planTarget.mapping = mapping;
241
242
  }
242
- if (result.npm) planTarget.kind = adapterFor(result.npm.flavor).kind;
243
+ if ("npm" in result && result.npm) planTarget.kind = adapterFor(result.npm.flavor).kind;
243
244
  }
244
245
 
245
246
  const results = parseResults({
@@ -1,6 +1,6 @@
1
1
  /** Roll the engine results up into counts. Impact counts belong to axe and Toolkit-level counts belong to IBM. */
2
2
  export function summarize(archetypes, engines, gaps = []) {
3
- /** @type {any} */
3
+ /** @type {ReturnType<typeof import("../schema.js").parseResults>["targets"][number]["summary"]} */
4
4
  const summary = { engines: {}, gaps, notTestable: [] };
5
5
  for (const engine of engines) {
6
6
  const results = Object.values(archetypes).flatMap((a) => a.configs.map((c) => c.tiers.rules?.engines?.[engine]).filter(Boolean));
package/src/schema.js CHANGED
@@ -9,7 +9,7 @@ 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
11
  export const ARCHETYPES = ["button", "link", "dialog", "menu", "tabs", "combobox", "form-field", "accordion", "tooltip", "live-region", "chart"];
12
- export const FLAVORS = ["react", "vue", "wc"];
12
+ export const FLAVORS = ["react", "vue", "angular", "html", "wc"];
13
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. */
@@ -49,7 +49,7 @@ export function parseMappingFile(input) {
49
49
  return result.output;
50
50
  }
51
51
 
52
- export const TARGET_KINDS = ["npm", "npm-react", "npm-vue", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
52
+ export const TARGET_KINDS = ["npm", "npm-react", "npm-vue", "npm-angular", "npm-html", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
53
53
 
54
54
  const nullableString = v.nullable(v.string());
55
55
 
@@ -62,6 +62,8 @@ export const TargetSchema = v.object({
62
62
  kind: v.nullable(v.picklist(TARGET_KINDS)),
63
63
  evidenceLevel: v.nullable(v.picklist(["component", "page"])),
64
64
  resolved: v.nullable(v.record(v.string(), nullableString)),
65
+ /** The other packages of an npm list target (`npm:a,b`), which install beside the primary one. */
66
+ companions: v.optional(v.array(v.object({ name: v.string(), requested: nullableString, version: nullableString, subpath: nullableString }))),
65
67
  mapping: v.nullable(v.record(v.string(), MappingEntrySchema)),
66
68
  });
67
69
 
@@ -230,11 +232,16 @@ export const TargetResultSchema = v.object({
230
232
  /** The sub-path of the package that was tested, such as `button/v2`, or null for the package itself. */
231
233
  subpath: v.optional(nullableString),
232
234
  version: nullableString,
235
+ /** The other packages of a list target (`npm:a,b`), installed beside the primary one. */
236
+ companions: v.optional(v.array(v.object({ name: v.string(), subpath: nullableString, version: nullableString }))),
233
237
  flavor: v.picklist(FLAVORS),
234
238
  framework: nullableString,
235
239
  react: nullableString,
236
240
  reactDom: nullableString,
237
241
  vue: v.optional(nullableString),
242
+ angular: v.optional(nullableString),
243
+ /** For plain HTML, the stylesheets and scripts the page loaded, as `package/sub-path`. */
244
+ assets: v.optional(v.object({ styles: v.array(v.string()), scripts: v.array(v.string()) })),
238
245
  tags: v.array(v.string()),
239
246
  }),
240
247
  ),
@@ -9,6 +9,8 @@ import { num } from "../../text.js";
9
9
  import { criterionRef } from "../../wcag/index.js";
10
10
  import { contrastOver, contrastRatio, formatRatio, ringIsEnough, textThreshold } from "./color.js";
11
11
 
12
+ /** @typedef {{ key: number, text: string, color: number[] | null, size: number, weight: string, backdrop: number[] | null, undetermined: string | null }} TextPart */
13
+
12
14
  const pass = (detail, extra = {}) => ({ result: "pass", detail, ...extra });
13
15
  const fail = (detail, extra = {}) => ({ result: "fail", detail, ...extra });
14
16
  const na = (detail) => ({ result: "not-applicable", detail });
@@ -31,7 +33,7 @@ const TEXT_CONTRAST = {
31
33
  name: "text-contrast-by-state",
32
34
  criteria: ["1.4.3"],
33
35
  async run(ctx) {
34
- /** @type {Array<{ state: string, parts: any[] }>} */
36
+ /** @type {Array<{ state: string, parts: TextPart[] }>} */
35
37
  const states = [];
36
38
  const measure = async (state) => states.push({ state, parts: (await ctx.page.evaluate(() => window.__a11yMeasure.text())) ?? [] });
37
39
  await parkPointer(ctx);
@@ -33,8 +33,8 @@ export function installConditions() {
33
33
  describe,
34
34
  /** Every animation and transition running right now. */
35
35
  animations() {
36
- return document.getAnimations().map((/** @type {any} */ animation) => {
37
- const effect = /** @type {any} */ (animation.effect);
36
+ return document.getAnimations().map((animation) => {
37
+ const effect = animation.effect instanceof KeyframeEffect ? animation.effect : null;
38
38
  const timing = effect?.getComputedTiming?.() ?? {};
39
39
  const keyframes = effect?.getKeyframes?.() ?? [];
40
40
  const props = [...new Set(keyframes.flatMap((frame) => Object.keys(frame)).filter((key) => !IGNORED_KEYS.has(key)))];
@@ -42,7 +42,7 @@ export function installConditions() {
42
42
  const iterations = timing.iterations === Infinity ? "infinite" : timing.iterations;
43
43
  return {
44
44
  kind: animation.constructor.name,
45
- name: animation.animationName ?? animation.transitionProperty ?? "",
45
+ name: animation instanceof CSSAnimation ? animation.animationName : animation instanceof CSSTransition ? animation.transitionProperty : "",
46
46
  target: describe(target),
47
47
  duration: typeof timing.duration === "number" ? timing.duration : null,
48
48
  iterations,
@@ -34,7 +34,10 @@ const COMMON = [
34
34
  await ctx.page.evaluate(() => window.__a11y.remember());
35
35
  const clip = ctx.clipAround(focused.box);
36
36
  const shotFocused = await ctx.page.screenshot({ clip });
37
- await ctx.page.evaluate(() => /** @type {any} */ (window).__a11yLast?.blur());
37
+ await ctx.page.evaluate(() => {
38
+ const last = window.__a11yLast;
39
+ if (last && "blur" in last && typeof last.blur === "function") last.blur();
40
+ });
38
41
  await ctx.settle();
39
42
  const unfocused = await ctx.page.evaluate(() => window.__a11y.lastSnapshot());
40
43
  const changed = focusIndicatorChanges(focused.parts, unfocused?.parts);
@@ -422,7 +425,8 @@ export const ARCHETYPE_CHECKS = {
422
425
  async run(ctx) {
423
426
  const facts = await ctx.page.evaluate(() => {
424
427
  const c = window.__a11y.queryDeep("[data-a11y-trigger]");
425
- return { tag: c?.localName, type: c?.getAttribute("type") ?? "text", required: c?.hasAttribute("required"), pattern: c?.hasAttribute("pattern"), minlength: c?.hasAttribute("minlength"), valid: c?.checkValidity?.() ?? true };
428
+ const validity = c && "checkValidity" in c && typeof c.checkValidity === "function" ? c.checkValidity() : undefined;
429
+ return { tag: c?.localName, type: c?.getAttribute("type") ?? "text", required: c?.hasAttribute("required"), pattern: c?.hasAttribute("pattern"), minlength: c?.hasAttribute("minlength"), valid: validity ?? true };
426
430
  });
427
431
  const control = ctx.trigger;
428
432
  const before = await ctx.page.evaluate(() => window.__a11y.errorInfo());
@@ -1,9 +1,11 @@
1
+ /** @typedef {{ outlineStyle: string, outlineWidth: string, outlineColor: string, boxShadow: string, borderTopStyle: string, borderTopColor: string, borderTopWidth: string, backgroundColor: string, color: string, textDecorationLine: string, rendered: boolean }} FocusPart */
2
+
1
3
  /**
2
4
  * Compare the resolved styles of a control with and without keyboard focus, and say which changes can show a focus indicator.
3
5
  * A change counts only if a person could see it: an outline with a width and a color, a box shadow, a border, a color change,
4
6
  * a text decoration, or an element that appeared inside. An outline offset alone doesn't count, because it moves nothing visible.
5
- * @param {Record<string, Record<string, any>>} focused Parts of the focused control, keyed by where they are.
6
- * @param {Record<string, Record<string, any>> | undefined} unfocused The same parts without focus.
7
+ * @param {Record<string, FocusPart>} focused Parts of the focused control, keyed by where they are.
8
+ * @param {Record<string, FocusPart> | undefined} unfocused The same parts without focus.
7
9
  * @returns {string[]} Each change, such as `the element: outline`.
8
10
  */
9
11
  export function focusIndicatorChanges(focused, unfocused) {
@@ -120,7 +120,8 @@ export function installHelpers() {
120
120
  clicks: window.__a11yClicks,
121
121
  triggerTag: trigger?.localName ?? null,
122
122
  triggerRole: trigger?.getAttribute("role") ?? null,
123
- expanded: trigger?.getAttribute("aria-expanded") ?? null,
123
+ // A native disclosure (<summary> in <details>) says whether it's open with the details element's own state, not an attribute.
124
+ expanded: trigger?.getAttribute("aria-expanded") ?? (trigger?.localName === "summary" && trigger.parentElement?.localName === "details" ? String(trigger.parentElement.open) : null),
124
125
  rootExists: Boolean(root),
125
126
  rootVisible: visible(root),
126
127
  rootModal: root?.getAttribute("aria-modal") === "true" || root?.getAttribute("role") === "alertdialog",
@@ -17,9 +17,9 @@ export const helpUrl = (ruleId) => `https://able.ibm.com/rules/archives/latest/d
17
17
 
18
18
  /**
19
19
  * Group raw IBM results into findings, one per rule and kind, with the first few nodes.
20
- * @param {Array<{ ruleId: string, message: string, dom?: string, snippet?: string }>} items
20
+ * @param {Array<{ ruleId: string, message: string, value: string[], dom?: string, snippet?: string }>} items
21
21
  * @param {Map<string, { wcag: string[], toolkitLevel: number | null }>} ruleInfo
22
- * @param {(item: any) => string} kindOf
22
+ * @param {(item: { value: string[] }) => string} kindOf
23
23
  * @param {number} maxNodes
24
24
  */
25
25
  function group(items, ruleInfo, kindOf, maxNodes, withKind) {
@@ -19,7 +19,7 @@ export async function runRules(page, { engines, wcag, level, maxNodes = 5, scope
19
19
  engines: Object.fromEntries(engines.map((engine) => [engine, { status: "not-testable", reason }])),
20
20
  };
21
21
  }
22
- /** @type {Record<string, any>} */
22
+ /** @type {NonNullable<ReturnType<typeof import("../../schema.js").parseResults>["targets"][number]["archetypes"][string]["configs"][number]["tiers"][string]["engines"]>} */
23
23
  const results = {};
24
24
  for (const engine of engines) {
25
25
  try {