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.
- package/AGENTS.md +2 -0
- package/README.md +39 -122
- package/package.json +28 -2
- package/skills/automatica11y/SKILL.md +3 -1
- package/skills/automatica11y-runner/SKILL.md +19 -4
- package/skills/automatica11y-runner/references/fixtures.md +22 -0
- package/src/cli.js +1 -0
- package/src/commands/common.js +14 -11
- package/src/frameworks/angular-errors.js +27 -0
- package/src/frameworks/angular-selectors.js +36 -0
- package/src/frameworks/angular.js +156 -0
- package/src/frameworks/html.js +163 -0
- package/src/frameworks/index.js +12 -7
- package/src/frameworks/react.js +2 -1
- package/src/frameworks/vue.js +2 -1
- package/src/globals.d.ts +123 -6
- package/src/harness/bundle.js +2 -1
- package/src/harness/generate/angular-recipes.js +360 -0
- package/src/harness/generate/probe.js +16 -5
- package/src/harness/npm-install.js +43 -11
- package/src/harness/shadow.js +1 -1
- package/src/harness/storybook.js +18 -5
- package/src/plan/build-plan.js +1 -0
- package/src/plan/classify.js +39 -11
- package/src/plan/mapping.js +5 -5
- package/src/plan/resolve-npm.js +47 -17
- package/src/plan/subpath.js +17 -0
- package/src/report/comparison.js +4 -1
- package/src/report/index.js +1 -1
- package/src/report/parts.js +13 -1
- package/src/report/single.js +1 -1
- package/src/run/audit-npm.js +93 -26
- package/src/run/fail-check.js +10 -2
- package/src/run/generate-fixture.js +5 -4
- package/src/run/run-plan.js +8 -7
- package/src/run/summary.js +1 -1
- package/src/schema.js +9 -2
- package/src/tiers/computed/checks.js +3 -1
- package/src/tiers/conditions/kit.js +3 -3
- package/src/tiers/interactions/archetypes.js +6 -2
- package/src/tiers/interactions/focus-indicator.js +4 -2
- package/src/tiers/interactions/helpers.js +2 -1
- package/src/tiers/rules/ibm.js +2 -2
- package/src/tiers/rules/index.js +1 -1
package/src/run/audit-npm.js
CHANGED
|
@@ -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(() =>
|
|
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(() =>
|
|
81
|
-
const tags = await opened.page.evaluate(() => [
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
307
|
+
/** @type {TargetMapping} */
|
|
243
308
|
const mapping = {};
|
|
244
|
-
/** @type {
|
|
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 {
|
|
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
|
|
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: (() => {
|
package/src/run/fail-check.js
CHANGED
|
@@ -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 {
|
|
32
|
-
* @param {
|
|
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:
|
|
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:
|
|
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
|
|
package/src/run/run-plan.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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({
|
package/src/run/summary.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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((
|
|
37
|
-
const 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
|
|
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(() =>
|
|
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
|
-
|
|
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,
|
|
6
|
-
* @param {Record<string,
|
|
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
|
-
|
|
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",
|
package/src/tiers/rules/ibm.js
CHANGED
|
@@ -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:
|
|
22
|
+
* @param {(item: { value: string[] }) => string} kindOf
|
|
23
23
|
* @param {number} maxNodes
|
|
24
24
|
*/
|
|
25
25
|
function group(items, ruleInfo, kindOf, maxNodes, withKind) {
|
package/src/tiers/rules/index.js
CHANGED
|
@@ -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 {
|
|
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 {
|