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