automatica11y 0.3.3 → 0.4.1
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 -11
- package/package.json +5 -3
- package/skills/automatica11y/SKILL.md +3 -1
- package/skills/automatica11y-runner/SKILL.md +47 -14
- 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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-paths of an npm package: `@scope/pkg/button/v2` imports `button/v2` from `@scope/pkg`.
|
|
3
|
+
* A package says what it lets people import in its `exports` field. A package without one lets people import any file in it.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Split a sub-path into clean segments, or say what's wrong with it. Rejects empty parts, `.` and `..`, and backslashes,
|
|
10
|
+
* so a sub-path can't point outside the package. A trailing slash is dropped.
|
|
11
|
+
* @param {string} text
|
|
12
|
+
* @returns {{ ok: true, subpath: string } | { ok: false, reason: string }}
|
|
13
|
+
*/
|
|
14
|
+
export function cleanSubpath(text) {
|
|
15
|
+
const trimmed = text.replace(/\/+$/, "");
|
|
16
|
+
if (!trimmed) return { ok: false, reason: "the sub-path is empty" };
|
|
17
|
+
if (trimmed.includes("\\")) return { ok: false, reason: "a sub-path uses forward slashes" };
|
|
18
|
+
const segments = trimmed.split("/");
|
|
19
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return { ok: false, reason: 'a sub-path can\'t contain empty parts, "." or ".."' };
|
|
20
|
+
return { ok: true, subpath: segments.join("/") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The export keys that name a sub-path (`./button`), and the patterns (`./es/*`), from an `exports` field.
|
|
25
|
+
* A string, an array, or an object with no `./` keys only exports the package root.
|
|
26
|
+
*/
|
|
27
|
+
function exportKeys(exportsField) {
|
|
28
|
+
if (!exportsField || typeof exportsField !== "object" || Array.isArray(exportsField)) return { exact: [], patterns: [], hasMap: false };
|
|
29
|
+
const keys = Object.keys(exportsField).filter((key) => key === "." || key.startsWith("./"));
|
|
30
|
+
if (keys.length === 0) return { exact: [], patterns: [], hasMap: false };
|
|
31
|
+
const usable = keys.filter((key) => exportsField[key] !== null);
|
|
32
|
+
return { exact: usable.filter((key) => !key.includes("*") && key !== "." && key !== "./package.json"), patterns: usable.filter((key) => key.includes("*")), hasMap: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Does a pattern key like `./es/*` or `./features/*.js` match a sub-path? */
|
|
36
|
+
function matchesPattern(pattern, subpath) {
|
|
37
|
+
const [before, after] = pattern.slice(2).split("*");
|
|
38
|
+
return subpath.length >= before.length + after.length && subpath.startsWith(before) && subpath.endsWith(after);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Does the package's `exports` field let a sub-path be imported? Returns the sub-paths to suggest when it doesn't.
|
|
43
|
+
* `package.json` is always allowed. A package with no exports map isn't judged here, because its files are the answer.
|
|
44
|
+
* @param {unknown} exportsField
|
|
45
|
+
* @param {string} subpath
|
|
46
|
+
* @returns {{ checked: boolean, ok: boolean, exact: string[], patterns: string[] }}
|
|
47
|
+
*/
|
|
48
|
+
export function checkExports(exportsField, subpath) {
|
|
49
|
+
const { exact, patterns, hasMap } = exportKeys(exportsField);
|
|
50
|
+
if (!hasMap) {
|
|
51
|
+
// A string or array exports field means only the root is exported. No field means nothing here can say.
|
|
52
|
+
return { checked: exportsField !== undefined && exportsField !== null, ok: false, exact: [], patterns: [] };
|
|
53
|
+
}
|
|
54
|
+
const key = `./${subpath}`;
|
|
55
|
+
const ok = key === "./package.json" || exact.includes(key) || patterns.some((pattern) => matchesPattern(pattern, subpath));
|
|
56
|
+
return { checked: true, ok, exact, patterns };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What's wrong with a sub-path of an installed package, or null when it can be imported.
|
|
61
|
+
* @param {string} workDir The folder the package was installed into.
|
|
62
|
+
* @param {string} name
|
|
63
|
+
* @param {string} subpath
|
|
64
|
+
* @param {string | null} version
|
|
65
|
+
* @returns {string | null}
|
|
66
|
+
*/
|
|
67
|
+
export function subpathProblem(workDir, name, subpath, version) {
|
|
68
|
+
const packageDir = join(workDir, "node_modules", name);
|
|
69
|
+
let manifest;
|
|
70
|
+
try {
|
|
71
|
+
manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
72
|
+
} catch {
|
|
73
|
+
return `${name} was installed, but its package.json couldn't be read to check the sub-path "${subpath}".`;
|
|
74
|
+
}
|
|
75
|
+
const result = checkExports(manifest.exports, subpath);
|
|
76
|
+
if (result.checked) return result.ok ? null : notExportedMessage({ name, version, subpath, exact: result.exact, patterns: result.patterns });
|
|
77
|
+
return fileExists(packageDir, subpath) ? null : `"${name}/${subpath}" isn't a file in ${name}${version ? `@${version}` : ""}, and the package has no exports map that lists what it offers.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The extensions a file can be imported without writing, for a package with no exports map. */
|
|
81
|
+
const EXTENSIONS = ["", ".js", ".mjs", ".cjs", ".json", "/index.js", "/index.mjs", "/index.cjs"];
|
|
82
|
+
|
|
83
|
+
/** Is there a file for this sub-path in an installed package that has no exports map? */
|
|
84
|
+
export function fileExists(packageDir, subpath) {
|
|
85
|
+
return EXTENSIONS.some((extension) => existsSync(join(packageDir, `${subpath}${extension}`)));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How many single-character edits turn one word into another. */
|
|
89
|
+
function distance(a, b) {
|
|
90
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
91
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
92
|
+
const row = [i];
|
|
93
|
+
for (let j = 1; j <= b.length; j += 1) row.push(Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)));
|
|
94
|
+
previous = row;
|
|
95
|
+
}
|
|
96
|
+
return previous[b.length];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The exports closest to what was asked for: a shared start, a shared last part, or a typo of it. */
|
|
100
|
+
function closest(subpath, exact) {
|
|
101
|
+
const wanted = subpath.toLowerCase().split("/");
|
|
102
|
+
const last = wanted[wanted.length - 1];
|
|
103
|
+
return exact
|
|
104
|
+
.map((key) => {
|
|
105
|
+
const parts = key.slice(2).toLowerCase().split("/");
|
|
106
|
+
const shared = parts.findIndex((part, i) => part !== wanted[i]);
|
|
107
|
+
const lead = shared === -1 ? parts.length : shared;
|
|
108
|
+
const tail = parts[parts.length - 1];
|
|
109
|
+
const rank = lead * 2 + (tail.includes(last) || last.includes(tail) ? 1 : 0) + (distance(last, tail) <= 2 ? 2 : 0);
|
|
110
|
+
return { key, rank };
|
|
111
|
+
})
|
|
112
|
+
.filter((entry) => entry.rank > 0)
|
|
113
|
+
.sort((a, b) => b.rank - a.rank || a.key.length - b.key.length)
|
|
114
|
+
.slice(0, 3)
|
|
115
|
+
.map((entry) => entry.key);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The message for a sub-path a package doesn't offer, with the closest matches first.
|
|
120
|
+
* @param {{ name: string, version: string | null, subpath: string, exact: string[], patterns: string[] }} input
|
|
121
|
+
*/
|
|
122
|
+
export function notExportedMessage({ name, version, subpath, exact, patterns }) {
|
|
123
|
+
const spec = (key) => `${name}${key === "." ? "" : key.slice(1)}`;
|
|
124
|
+
const close = closest(subpath, exact);
|
|
125
|
+
const shown = exact.slice(0, 12).map(spec);
|
|
126
|
+
const more = exact.length > shown.length ? `, and ${exact.length - shown.length} more` : "";
|
|
127
|
+
const parts = [`"${name}/${subpath}" isn't something ${name}${version ? `@${version}` : ""} exports.`];
|
|
128
|
+
if (close.length) parts.push(`Did you mean ${close.map(spec).join(" or ")}?`);
|
|
129
|
+
if (exact.length) parts.push(`It exports ${shown.join(", ")}${more}.`);
|
|
130
|
+
if (patterns.length) parts.push(`It also exports files by pattern: ${patterns.slice(0, 4).map(spec).join(", ")}.`);
|
|
131
|
+
if (!exact.length && !patterns.length) parts.push("It exports only its main entry.");
|
|
132
|
+
return parts.join(" ");
|
|
133
|
+
}
|
package/src/report/comparison.js
CHANGED
|
@@ -3,11 +3,13 @@ import { num, plural } from "../text.js";
|
|
|
3
3
|
import {
|
|
4
4
|
ENGINE_NAMES,
|
|
5
5
|
FINDINGS_NOTE,
|
|
6
|
+
GENERATED_NOTE,
|
|
6
7
|
TIER_NAMES,
|
|
7
8
|
cell,
|
|
8
9
|
code,
|
|
9
10
|
configLabel,
|
|
10
11
|
finish,
|
|
12
|
+
hasGenerated,
|
|
11
13
|
notTestableLines,
|
|
12
14
|
reportFooter,
|
|
13
15
|
reportHeader,
|
|
@@ -15,7 +17,7 @@ import {
|
|
|
15
17
|
targetSection,
|
|
16
18
|
} from "./parts.js";
|
|
17
19
|
|
|
18
|
-
const TIER_ORDER = ["rules", "interactions", "computed", "vsr"];
|
|
20
|
+
const TIER_ORDER = ["rules", "interactions", "computed", "conditions", "vsr"];
|
|
19
21
|
|
|
20
22
|
/** The archetype rows: component archetypes in a fixed order, then whole pages, then Storybook stories. */
|
|
21
23
|
function archetypeKeys(results) {
|
|
@@ -67,7 +69,9 @@ function coverageTable(plan, results, tier, keys) {
|
|
|
67
69
|
// Name the configuration only when there's more than one to tell apart, or the row stands for several stories.
|
|
68
70
|
const withTier = rows.filter((r) => r.configs?.some((c) => c.tiers[tier]));
|
|
69
71
|
const labels = withTier.length > 1 || withTier[0]?.always ? withTier.map((r) => r.label).filter((l) => l && l !== "-") : [];
|
|
70
|
-
|
|
72
|
+
const generated = target.archetypes?.[key]?.fixture?.source === "generated" && String(status).startsWith("ran");
|
|
73
|
+
const base = labels.length ? `${status} (${labels.join("; ")})` : status;
|
|
74
|
+
return generated ? `${base} (generated fixture)` : base;
|
|
71
75
|
});
|
|
72
76
|
lines.push(`| ${key} | ${cells.join(" | ")} |`);
|
|
73
77
|
}
|
|
@@ -104,9 +108,9 @@ function interactionsCell(configs) {
|
|
|
104
108
|
return parts.filter(Boolean).join("; ");
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
function
|
|
108
|
-
const checks = configs.flatMap((c) => c.tiers
|
|
109
|
-
if (checks.length === 0) return configs.map((c) => c.tiers
|
|
111
|
+
function measuredCell(configs, tier) {
|
|
112
|
+
const checks = configs.flatMap((c) => c.tiers[tier]?.checks ?? []);
|
|
113
|
+
if (checks.length === 0) return configs.map((c) => c.tiers[tier]?.status).find(Boolean) ?? "-";
|
|
110
114
|
const failed = checks.filter((c) => c.result === "fail").map((c) => code(c.name));
|
|
111
115
|
const unknown = checks.filter((c) => c.result === "undetermined").length;
|
|
112
116
|
const errors = checks.filter((c) => c.result === "error").length;
|
|
@@ -123,17 +127,17 @@ function vsrCell(configs) {
|
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
function findingsTable(plan, results, key) {
|
|
126
|
-
const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Computed checks", "Virtual screen reader (simulated)"];
|
|
130
|
+
const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Computed checks", "Conditions", "Virtual screen reader (simulated)"];
|
|
127
131
|
const lines = [`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`];
|
|
128
132
|
for (const target of results.targets) {
|
|
129
133
|
const planTarget = plan.targets.find((t) => t.id === target.id);
|
|
130
134
|
for (const row of rowsFor(planTarget, target, key)) {
|
|
131
135
|
if (row.note) {
|
|
132
|
-
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | | |`);
|
|
136
|
+
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | | | |`);
|
|
133
137
|
continue;
|
|
134
138
|
}
|
|
135
139
|
const c = row.configs;
|
|
136
|
-
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(
|
|
140
|
+
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(measuredCell(c, "computed"))} | ${cell(measuredCell(c, "conditions"))} | ${cell(vsrCell(c))} |`);
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
return lines;
|
|
@@ -180,6 +184,7 @@ export function renderComparison({ plan, results }) {
|
|
|
180
184
|
lines.push(`### ${TIER_NAMES[tier]}${tier === "vsr" ? " (simulated)" : ""}.`, "", ...coverageTable(plan, results, tier, keys), "");
|
|
181
185
|
}
|
|
182
186
|
lines.push(...notTestableLines(results));
|
|
187
|
+
if (hasGenerated(results)) lines.push(GENERATED_NOTE, "");
|
|
183
188
|
|
|
184
189
|
lines.push("## Findings.", "", FINDINGS_NOTE, "", ...(o.tiers.includes("rules") ? impactTables(results, o.engines) : []));
|
|
185
190
|
for (const key of keys) {
|
|
@@ -187,7 +192,7 @@ export function renderComparison({ plan, results }) {
|
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
lines.push("## Details by target.", "", "Every finding, with its elements, rule help, and logs, for each target in turn.", "");
|
|
190
|
-
for (const target of results.targets) lines.push(targetSection(plan.targets.find((t) => t.id === target.id), target));
|
|
195
|
+
for (const target of results.targets) lines.push(targetSection(plan.targets.find((t) => t.id === target.id), target, { generatedNote: false }));
|
|
191
196
|
lines.push(...reportFooter(results));
|
|
192
197
|
return finish(lines);
|
|
193
198
|
}
|
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();
|