automatica11y 0.0.0-stage → 0.3.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 +32 -0
- package/LICENSE +21 -0
- package/README.md +136 -2
- package/bin/automatica11y.js +4 -0
- package/package.json +50 -4
- package/skills/automatica11y/SKILL.md +23 -0
- package/skills/automatica11y-runner/SKILL.md +181 -0
- package/skills/automatica11y-runner/references/fixtures.md +59 -0
- package/src/cli.js +49 -0
- package/src/commands/audit.js +4 -0
- package/src/commands/common.js +274 -0
- package/src/commands/compare.js +4 -0
- package/src/commands/doctor.js +20 -0
- package/src/commands/guide.js +45 -0
- package/src/env/browser.js +136 -0
- package/src/env/versions.js +60 -0
- package/src/globals.d.ts +10 -0
- package/src/harness/browser.js +20 -0
- package/src/harness/bundle.js +49 -0
- package/src/harness/npm-install.js +65 -0
- package/src/harness/npm-react.js +39 -0
- package/src/harness/npm-wc.js +30 -0
- package/src/harness/shadow.js +42 -0
- package/src/harness/static-serve.js +68 -0
- package/src/harness/storybook.js +116 -0
- package/src/harness/url.js +41 -0
- package/src/plan/build-plan.js +62 -0
- package/src/plan/classify.js +185 -0
- package/src/plan/mapping.js +101 -0
- package/src/plan/resolve-npm.js +87 -0
- package/src/report/comparison.js +183 -0
- package/src/report/index.js +10 -0
- package/src/report/parts.js +334 -0
- package/src/report/single.js +16 -0
- package/src/run/audit-npm.js +279 -0
- package/src/run/fail-check.js +62 -0
- package/src/run/pool.js +21 -0
- package/src/run/run-plan.js +262 -0
- package/src/run/summary.js +49 -0
- package/src/schema.js +255 -0
- package/src/text.js +9 -0
- package/src/tiers/interactions/archetypes.js +417 -0
- package/src/tiers/interactions/helpers.js +145 -0
- package/src/tiers/interactions/index.js +107 -0
- package/src/tiers/rules/axe.js +75 -0
- package/src/tiers/rules/canvas.js +34 -0
- package/src/tiers/rules/ibm.js +121 -0
- package/src/tiers/rules/index.js +81 -0
- package/src/tiers/vsr.js +134 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { IMPACTS } from "../schema.js";
|
|
2
|
+
|
|
3
|
+
const RANK = Object.fromEntries(IMPACTS.map((impact, index) => [impact, index]));
|
|
4
|
+
|
|
5
|
+
/** All findings an engine reported for one target, across every archetype and configuration. */
|
|
6
|
+
function violationsFor(target, engine) {
|
|
7
|
+
const found = [];
|
|
8
|
+
for (const archetype of Object.values(target.archetypes)) {
|
|
9
|
+
for (const config of archetype.configs) {
|
|
10
|
+
const result = config.tiers.rules?.engines?.[engine];
|
|
11
|
+
if (result?.status === "ran") found.push(...(result.violations ?? []));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return found;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Count axe violations at or above the impact threshold. */
|
|
18
|
+
export function countAxeHits(target, threshold) {
|
|
19
|
+
return violationsFor(target, "axe").filter((finding) => finding.impact && RANK[finding.impact] >= RANK[threshold]).length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Count IBM violations whose Toolkit level is at or below the threshold. */
|
|
23
|
+
export function countIbmHits(target, threshold) {
|
|
24
|
+
return violationsFor(target, "ibm").filter((finding) => finding.toolkitLevel != null && finding.toolkitLevel <= threshold).length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Decide whether the run trips its fail check. Each engine has its own threshold, and counts are never added across engines.
|
|
29
|
+
* `any` trips a target when any checked engine hits. `all` trips it only when every checked engine hits.
|
|
30
|
+
* A target that failed, or an engine that failed, counts as no hit.
|
|
31
|
+
* @param {Array<any>} targets Target results.
|
|
32
|
+
* @param {{ mode: string, axe: string | null, ibm: number | null } | null} fail
|
|
33
|
+
*/
|
|
34
|
+
export function evaluateFailCheck(targets, fail) {
|
|
35
|
+
if (!fail) return null;
|
|
36
|
+
const checked = [fail.axe ? "axe" : null, fail.ibm ? "ibm" : null].filter(Boolean);
|
|
37
|
+
const totals = { axe: fail.axe ? { threshold: fail.axe, hits: 0, tripped: false } : null, ibm: fail.ibm ? { threshold: fail.ibm, hits: 0, tripped: false } : null };
|
|
38
|
+
const perTarget = {};
|
|
39
|
+
let tripped = false;
|
|
40
|
+
for (const target of targets) {
|
|
41
|
+
const entry = { axe: null, ibm: null, tripped: false };
|
|
42
|
+
if (target.status === "ran") {
|
|
43
|
+
if (fail.axe && totals.axe) {
|
|
44
|
+
const hits = countAxeHits(target, fail.axe);
|
|
45
|
+
entry.axe = { threshold: fail.axe, hits, tripped: hits > 0 };
|
|
46
|
+
totals.axe.hits += hits;
|
|
47
|
+
totals.axe.tripped ||= hits > 0;
|
|
48
|
+
}
|
|
49
|
+
if (fail.ibm && totals.ibm) {
|
|
50
|
+
const hits = countIbmHits(target, fail.ibm);
|
|
51
|
+
entry.ibm = { threshold: fail.ibm, hits, tripped: hits > 0 };
|
|
52
|
+
totals.ibm.hits += hits;
|
|
53
|
+
totals.ibm.tripped ||= hits > 0;
|
|
54
|
+
}
|
|
55
|
+
const flags = checked.map((engine) => entry[/** @type {"axe" | "ibm"} */ (engine)]?.tripped === true);
|
|
56
|
+
entry.tripped = fail.mode === "all" ? flags.every(Boolean) : flags.some(Boolean);
|
|
57
|
+
}
|
|
58
|
+
perTarget[target.id] = entry;
|
|
59
|
+
tripped ||= entry.tripped;
|
|
60
|
+
}
|
|
61
|
+
return { mode: fail.mode, axe: totals.axe, ibm: totals.ibm, targets: perTarget, tripped };
|
|
62
|
+
}
|
package/src/run/pool.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `fn` over `items` with at most `limit` in flight. Results keep the order of `items`.
|
|
3
|
+
* @template T, R
|
|
4
|
+
* @param {T[]} items
|
|
5
|
+
* @param {number} limit
|
|
6
|
+
* @param {(item: T, index: number) => Promise<R>} fn
|
|
7
|
+
* @returns {Promise<R[]>}
|
|
8
|
+
*/
|
|
9
|
+
export async function mapPool(items, limit, fn) {
|
|
10
|
+
const results = new Array(items.length);
|
|
11
|
+
let next = 0;
|
|
12
|
+
const worker = async () => {
|
|
13
|
+
while (next < items.length) {
|
|
14
|
+
const index = next;
|
|
15
|
+
next += 1;
|
|
16
|
+
results[index] = await fn(items[index], index);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
|
+
import { checkEnvironment } from "../env/browser.js";
|
|
4
|
+
import { readToolVersions } from "../env/versions.js";
|
|
5
|
+
import { launchBrowser } from "../harness/browser.js";
|
|
6
|
+
import { serveStatic } from "../harness/static-serve.js";
|
|
7
|
+
import { listStories, readIndex, selectStories, storyUrl, waitForStory } from "../harness/storybook.js";
|
|
8
|
+
import { closedShadowHosts, notTestableEntries } from "../harness/shadow.js";
|
|
9
|
+
import { NOT_APPLICABLE_FOR_PAGES } from "../tiers/interactions/index.js";
|
|
10
|
+
import { failedVsr, runVsr } from "../tiers/vsr.js";
|
|
11
|
+
import { openPage } from "../harness/url.js";
|
|
12
|
+
import { renderReport } from "../report/index.js";
|
|
13
|
+
import { num } from "../text.js";
|
|
14
|
+
import { parseResults } from "../schema.js";
|
|
15
|
+
import { runRules, selfTest } from "../tiers/rules/index.js";
|
|
16
|
+
import { evaluateFailCheck } from "./fail-check.js";
|
|
17
|
+
import { mapPool } from "./pool.js";
|
|
18
|
+
import { auditNpm } from "./audit-npm.js";
|
|
19
|
+
import { failedTarget, summarize } from "./summary.js";
|
|
20
|
+
|
|
21
|
+
/** How many stories to audit at once. */
|
|
22
|
+
const STORY_CONCURRENCY = 4;
|
|
23
|
+
|
|
24
|
+
const EXIT = { OK: 0, FAIL_THRESHOLD: 1, ENVIRONMENT: 3, ALL_TARGETS_FAILED: 4 };
|
|
25
|
+
|
|
26
|
+
const NPM_KINDS = new Set(["npm", "npm-react", "npm-wc", "npm-unsupported"]);
|
|
27
|
+
const UNSUPPORTED_KIND = (kind) => `${kind} targets aren't supported.`;
|
|
28
|
+
|
|
29
|
+
/** Audit one page and return its target result. */
|
|
30
|
+
async function auditPage(browser, url, planTarget, plan, extraWarnings) {
|
|
31
|
+
const opened = await openPage(browser, url);
|
|
32
|
+
try {
|
|
33
|
+
/** @type {Record<string, any>} */
|
|
34
|
+
const tiers = {};
|
|
35
|
+
for (const tier of plan.options.tiers) {
|
|
36
|
+
if (tier === "rules") {
|
|
37
|
+
tiers.rules = await runRules(opened.page, { engines: plan.options.engines, wcag: plan.options.wcag, level: plan.options.level });
|
|
38
|
+
} else if (tier === "interactions") {
|
|
39
|
+
tiers.interactions = NOT_APPLICABLE_FOR_PAGES;
|
|
40
|
+
} else {
|
|
41
|
+
tiers.vsr = await runVsr(opened.page, { scope: "body" }).catch(failedVsr);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const archetypes = { page: { status: "ran", configs: [{ libA11y: "n/a", tiers }] } };
|
|
45
|
+
const hidden = notTestableEntries(await closedShadowHosts(opened.page));
|
|
46
|
+
const summary = summarize(archetypes, plan.options.engines);
|
|
47
|
+
summary.notTestable = [...summary.notTestable, ...hidden];
|
|
48
|
+
return {
|
|
49
|
+
id: planTarget.id,
|
|
50
|
+
status: "ran",
|
|
51
|
+
reason: null,
|
|
52
|
+
archetypes,
|
|
53
|
+
summary,
|
|
54
|
+
warnings: [...extraWarnings, ...opened.warnings, ...hidden.map((h) => `Not testable: ${h}.`)],
|
|
55
|
+
};
|
|
56
|
+
} finally {
|
|
57
|
+
await opened.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Audit one story in its own page. The story's root element is the scope, so page-level rules don't fire. */
|
|
62
|
+
async function auditStory(browser, base, story, plan) {
|
|
63
|
+
const opened = await openPage(browser, storyUrl(base, story.id));
|
|
64
|
+
try {
|
|
65
|
+
await waitForStory(opened.page);
|
|
66
|
+
/** @type {Record<string, any>} */
|
|
67
|
+
const tiers = {};
|
|
68
|
+
for (const tier of plan.options.tiers) {
|
|
69
|
+
tiers[tier] =
|
|
70
|
+
tier === "rules"
|
|
71
|
+
? await runRules(opened.page, { engines: plan.options.engines, wcag: plan.options.wcag, level: plan.options.level, scope: "#storybook-root" })
|
|
72
|
+
: tier === "interactions"
|
|
73
|
+
? NOT_APPLICABLE_FOR_PAGES
|
|
74
|
+
: await runVsr(opened.page, { scope: "#storybook-root" }).catch(failedVsr);
|
|
75
|
+
}
|
|
76
|
+
const hidden = notTestableEntries(await closedShadowHosts(opened.page));
|
|
77
|
+
return { id: story.id, ok: true, archetype: { status: "ran", configs: [{ libA11y: "n/a", tiers }] }, hidden };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { id: story.id, ok: false, reason: error instanceof Error ? error.message.split("\n")[0] : String(error) };
|
|
80
|
+
} finally {
|
|
81
|
+
await opened.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Audit a Storybook: list the stories, pick the ones to run, and audit each as its own unit. */
|
|
86
|
+
async function auditStorybook(browser, planTarget, plan, servers) {
|
|
87
|
+
const resolved = planTarget.resolved;
|
|
88
|
+
const index = await readIndex(resolved);
|
|
89
|
+
const stories = listStories(index);
|
|
90
|
+
if (stories.length === 0) return failedTarget(planTarget.id, "The Storybook index lists no stories.");
|
|
91
|
+
const picked = selectStories(stories, { archetypes: plan.options.archetypes, max: plan.options.maxStories });
|
|
92
|
+
if (picked.selected.length === 0) {
|
|
93
|
+
return failedTarget(planTarget.id, `No stories matched the archetypes ${plan.options.archetypes.join(", ")}.`);
|
|
94
|
+
}
|
|
95
|
+
let base = resolved.url;
|
|
96
|
+
if (resolved.path) {
|
|
97
|
+
const server = await serveStatic(resolved.path);
|
|
98
|
+
servers.push(server);
|
|
99
|
+
base = `${server.origin}/`;
|
|
100
|
+
}
|
|
101
|
+
const audited = await mapPool(picked.selected, STORY_CONCURRENCY, (story) => auditStory(browser, base, story, plan));
|
|
102
|
+
|
|
103
|
+
const archetypes = {};
|
|
104
|
+
const failedStories = [];
|
|
105
|
+
for (const item of audited) {
|
|
106
|
+
if (item.ok) archetypes[`story:${item.id}`] = item.archetype;
|
|
107
|
+
else {
|
|
108
|
+
archetypes[`story:${item.id}`] = { status: "gap", configs: [] };
|
|
109
|
+
failedStories.push({ id: item.id, reason: item.reason });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const warnings = [];
|
|
113
|
+
if (picked.truncated) {
|
|
114
|
+
warnings.push(`The story cap cut the list short. ${num(picked.selected.length)} of ${num(picked.matched)} ${plan.options.archetypes ? "matching " : ""}stories ${picked.selected.length === 1 ? "was" : "were"} audited, spread across components. Raise --max-stories to audit more.`);
|
|
115
|
+
}
|
|
116
|
+
const gaps = failedStories.map((f) => `story:${f.id}`);
|
|
117
|
+
for (const archetype of plan.options.archetypes ?? []) {
|
|
118
|
+
if (!picked.matchedByArchetype[archetype]) {
|
|
119
|
+
gaps.push(`archetype:${archetype}`);
|
|
120
|
+
warnings.push(`No stories matched the ${archetype} archetype. That's a gap in coverage, not a pass.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const ranCount = audited.filter((item) => item.ok).length;
|
|
124
|
+
if (ranCount === 0) return failedTarget(planTarget.id, `None of the ${audited.length} audited stories rendered.`);
|
|
125
|
+
return {
|
|
126
|
+
id: planTarget.id,
|
|
127
|
+
status: "ran",
|
|
128
|
+
reason: null,
|
|
129
|
+
archetypes,
|
|
130
|
+
storybook: {
|
|
131
|
+
index: resolved.index,
|
|
132
|
+
total: picked.total,
|
|
133
|
+
matched: picked.matched,
|
|
134
|
+
audited: audited.length,
|
|
135
|
+
truncated: picked.truncated,
|
|
136
|
+
maxStories: plan.options.maxStories,
|
|
137
|
+
filtered: picked.filtered,
|
|
138
|
+
archetypeMatches: picked.matchedByArchetype,
|
|
139
|
+
failedStories,
|
|
140
|
+
},
|
|
141
|
+
summary: (() => {
|
|
142
|
+
const base = summarize(archetypes, plan.options.engines, gaps);
|
|
143
|
+
return { ...base, notTestable: [...base.notTestable, ...audited.flatMap((item) => (item.ok ? item.hidden.map((h) => `${item.id}: ${h}`) : []))] };
|
|
144
|
+
})(),
|
|
145
|
+
warnings,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Run one target. Anything that goes wrong becomes a failed result, so one target can't stop the rest. */
|
|
150
|
+
async function runTarget(browser, planTarget, plan, io) {
|
|
151
|
+
if (planTarget.status === "failed") return { result: failedTarget(planTarget.id, planTarget.reason), mapping: null };
|
|
152
|
+
const servers = [];
|
|
153
|
+
try {
|
|
154
|
+
const pageNote = plan.options.archetypes ? ["--archetypes only applies to Storybook and npm targets. This page was checked as a whole."] : [];
|
|
155
|
+
if (planTarget.kind === "storybook") return { result: await auditStorybook(browser, planTarget, plan, servers), mapping: null };
|
|
156
|
+
if (NPM_KINDS.has(planTarget.kind)) return await auditNpm({ browser, planTarget, plan, cwd: io.cwd, install: io.installPackage });
|
|
157
|
+
if (planTarget.kind === "url") return { result: await auditPage(browser, planTarget.resolved.url, planTarget, plan, pageNote), mapping: null };
|
|
158
|
+
if (planTarget.kind === "html-file") {
|
|
159
|
+
const file = planTarget.resolved.path;
|
|
160
|
+
const server = await serveStatic(dirname(file));
|
|
161
|
+
servers.push(server);
|
|
162
|
+
return { result: await auditPage(browser, `${server.origin}/${encodeURIComponent(basename(file))}`, planTarget, plan, pageNote), mapping: null };
|
|
163
|
+
}
|
|
164
|
+
if (planTarget.kind === "static-dir") {
|
|
165
|
+
const dir = planTarget.resolved.path;
|
|
166
|
+
if (!existsSync(resolve(dir, "index.html"))) return { result: failedTarget(planTarget.id, "The directory has no index.html to check."), mapping: null };
|
|
167
|
+
const server = await serveStatic(dir);
|
|
168
|
+
servers.push(server);
|
|
169
|
+
return { result: await auditPage(browser, `${server.origin}/`, planTarget, plan, ["Only index.html was checked. Other pages in the directory weren't.", ...pageNote]), mapping: null };
|
|
170
|
+
}
|
|
171
|
+
return { result: failedTarget(planTarget.id, UNSUPPORTED_KIND(planTarget.kind)), mapping: null };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return { result: failedTarget(planTarget.id, error instanceof Error ? error.message.split("\n")[0] : String(error)), mapping: null };
|
|
174
|
+
} finally {
|
|
175
|
+
for (const server of servers) await server.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A short line per target for the terminal. */
|
|
180
|
+
function describeResult(target) {
|
|
181
|
+
if (target.status !== "ran") return `${target.id}: ${target.status} (${target.reason})`;
|
|
182
|
+
const parts = Object.entries(target.summary.engines).map(([engine, s]) => {
|
|
183
|
+
if (s.status !== "ran") return `${engine} ${s.status}`;
|
|
184
|
+
const extra = engine === "axe" ? Object.entries(s.violationsByImpact).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`) : [];
|
|
185
|
+
return `${engine} ${s.violations} violation${s.violations === 1 ? "" : "s"}${extra.length ? ` (${extra.join(", ")})` : ""}, ${s.needsReview} to review`;
|
|
186
|
+
});
|
|
187
|
+
return `${target.id}: ${parts.join("; ") || "no engines ran"}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Run a plan: check the environment, launch one browser, audit each target, write results.json and report.md.
|
|
192
|
+
* @param {any} plan
|
|
193
|
+
* @param {import("../commands/common.js").Io} io
|
|
194
|
+
* @returns {Promise<number>} The exit code.
|
|
195
|
+
*/
|
|
196
|
+
export async function runPlan(plan, io) {
|
|
197
|
+
const environment = await checkEnvironment({ env: io.env, platform: io.platform });
|
|
198
|
+
if (!environment.ok) {
|
|
199
|
+
for (const problem of environment.problems) io.stderr.write(`${problem.message}\n Fix: ${problem.fix}\n`);
|
|
200
|
+
return EXIT.ENVIRONMENT;
|
|
201
|
+
}
|
|
202
|
+
let session;
|
|
203
|
+
try {
|
|
204
|
+
session = await launchBrowser({ env: io.env, platform: io.platform });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
io.stderr.write(`Couldn't start the browser: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}\n Run "automatica11y doctor" to check the setup.\n`);
|
|
207
|
+
return EXIT.ENVIRONMENT;
|
|
208
|
+
}
|
|
209
|
+
const { browser, version } = session;
|
|
210
|
+
try {
|
|
211
|
+
if (plan.options.tiers.includes("rules")) {
|
|
212
|
+
const test = await selfTest(browser, plan.options.engines);
|
|
213
|
+
if (!test.passed) {
|
|
214
|
+
io.stderr.write(`The rule engines failed their self-test, so no results are reported.\n${test.problems.map((p) => ` ${p}`).join("\n")}\n`);
|
|
215
|
+
return EXIT.ENVIRONMENT;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const targets = [];
|
|
219
|
+
const mappings = {};
|
|
220
|
+
for (const planTarget of plan.targets) {
|
|
221
|
+
const { result, mapping } = await runTarget(browser, planTarget, plan, io);
|
|
222
|
+
targets.push(result);
|
|
223
|
+
if (mapping) {
|
|
224
|
+
mappings[planTarget.id] = mapping;
|
|
225
|
+
planTarget.mapping = mapping;
|
|
226
|
+
}
|
|
227
|
+
if (result.npm) planTarget.kind = result.npm.flavor === "react" ? "npm-react" : "npm-wc";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const results = parseResults({
|
|
231
|
+
schema: 1,
|
|
232
|
+
planRef: "plan.json",
|
|
233
|
+
runAt: new Date().toISOString(),
|
|
234
|
+
tools: readToolVersions({ chromium: version }),
|
|
235
|
+
targets,
|
|
236
|
+
failCheck: evaluateFailCheck(targets, plan.options.fail),
|
|
237
|
+
warnings: [],
|
|
238
|
+
});
|
|
239
|
+
const outDir = resolve(io.cwd, plan.options.out);
|
|
240
|
+
mkdirSync(outDir, { recursive: true });
|
|
241
|
+
writeFileSync(resolve(outDir, "results.json"), `${JSON.stringify(results, null, 2)}\n`);
|
|
242
|
+
writeFileSync(resolve(outDir, "report.md"), renderReport({ plan, results }));
|
|
243
|
+
if (Object.keys(mappings).length > 0) {
|
|
244
|
+
// The candidate mapping, in the shape --mapping reads, so it can be edited and passed back in.
|
|
245
|
+
writeFileSync(resolve(outDir, "mapping.json"), `${JSON.stringify(mappings, null, 2)}\n`);
|
|
246
|
+
writeFileSync(resolve(outDir, "plan.json"), `${JSON.stringify(plan, null, 2)}\n`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const lines = ["", ...results.targets.map((t) => ` ${describeResult(t)}`)];
|
|
250
|
+
if (results.failCheck) lines.push(` Fail check (${results.failCheck.mode}): ${results.failCheck.tripped ? "tripped" : "not tripped"}`);
|
|
251
|
+
lines.push(` Wrote ${resolve(outDir, "results.json")}`, ` Wrote ${resolve(outDir, "report.md")}`);
|
|
252
|
+
io.stdout.write(`${lines.join("\n")}\n`);
|
|
253
|
+
|
|
254
|
+
if (!results.targets.some((t) => t.status === "ran")) {
|
|
255
|
+
io.stderr.write("No target produced results.\n");
|
|
256
|
+
return EXIT.ALL_TARGETS_FAILED;
|
|
257
|
+
}
|
|
258
|
+
return results.failCheck?.tripped ? EXIT.FAIL_THRESHOLD : EXIT.OK;
|
|
259
|
+
} finally {
|
|
260
|
+
await browser.close().catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Roll the engine results up into counts. Impact counts belong to axe and Toolkit-level counts belong to IBM. */
|
|
2
|
+
export function summarize(archetypes, engines, gaps = []) {
|
|
3
|
+
/** @type {any} */
|
|
4
|
+
const summary = { engines: {}, gaps, notTestable: [] };
|
|
5
|
+
for (const engine of engines) {
|
|
6
|
+
const results = Object.values(archetypes).flatMap((a) => a.configs.map((c) => c.tiers.rules?.engines?.[engine]).filter(Boolean));
|
|
7
|
+
if (results.length === 0) continue;
|
|
8
|
+
const ran = results.filter((r) => r.status === "ran");
|
|
9
|
+
const entry = {
|
|
10
|
+
status: ran.length ? "ran" : results[0].status,
|
|
11
|
+
violations: ran.reduce((n, r) => n + r.violations.length, 0),
|
|
12
|
+
needsReview: ran.reduce((n, r) => n + r.incomplete.length, 0),
|
|
13
|
+
};
|
|
14
|
+
if (engine === "axe") {
|
|
15
|
+
entry.violationsByImpact = { critical: 0, serious: 0, moderate: 0, minor: 0 };
|
|
16
|
+
for (const r of ran) for (const f of r.violations) if (f.impact) entry.violationsByImpact[f.impact] += 1;
|
|
17
|
+
}
|
|
18
|
+
if (engine === "ibm") {
|
|
19
|
+
entry.violationsByToolkitLevel = { 1: 0, 2: 0, 3: 0, 4: 0 };
|
|
20
|
+
for (const r of ran) for (const f of r.violations) if (f.toolkitLevel != null) entry.violationsByToolkitLevel[f.toolkitLevel] += 1;
|
|
21
|
+
}
|
|
22
|
+
summary.engines[engine] = entry;
|
|
23
|
+
}
|
|
24
|
+
for (const [name, archetype] of Object.entries(archetypes)) {
|
|
25
|
+
for (const config of archetype.configs) {
|
|
26
|
+
const rules = config.tiers.rules;
|
|
27
|
+
if (rules?.status === "not-testable") summary.notTestable.push(`${name === "page" ? "" : `${name}: `}${rules.reason}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const checks = Object.values(archetypes).flatMap((a) => a.configs.flatMap((c) => c.tiers.interactions?.checks ?? []));
|
|
31
|
+
if (checks.length) {
|
|
32
|
+
summary.interactions = { pass: 0, fail: 0, notApplicable: 0, error: 0 };
|
|
33
|
+
for (const check of checks) summary.interactions[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
|
|
34
|
+
}
|
|
35
|
+
const walks = Object.entries(archetypes).flatMap(([name, a]) => a.configs.map((c) => ({ name, vsr: c.tiers.vsr })).filter((x) => x.vsr?.status === "ran"));
|
|
36
|
+
if (walks.length) {
|
|
37
|
+
summary.vsr = { walks: walks.length, flagged: walks.reduce((n, w) => n + w.vsr.flags.length, 0) };
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (const { name, vsr } of walks) for (const entry of vsr.notTestable ?? []) {
|
|
40
|
+
const line = name === "page" ? entry : `${name}: ${entry}`;
|
|
41
|
+
if (!seen.has(line)) { seen.add(line); summary.notTestable.push(line); }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return summary;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function failedTarget(id, reason) {
|
|
48
|
+
return { id, status: "failed", reason, archetypes: {}, summary: { engines: {}, gaps: [], notTestable: [] }, warnings: [] };
|
|
49
|
+
}
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
|
|
3
|
+
export const WCAG_VERSIONS = ["2.0", "2.1", "2.2"];
|
|
4
|
+
export const LEVELS = ["A", "AA", "AAA"];
|
|
5
|
+
export const TIERS = ["rules", "interactions", "vsr"];
|
|
6
|
+
export const ENGINES = ["axe", "ibm"];
|
|
7
|
+
export const LIB_A11Y = ["on", "off"];
|
|
8
|
+
export const IMPACTS = ["minor", "moderate", "serious", "critical"];
|
|
9
|
+
export const TOOLKIT_LEVELS = [1, 2, 3];
|
|
10
|
+
export const FAIL_MODES = ["any", "all"];
|
|
11
|
+
export const ARCHETYPES = ["button", "link", "dialog", "menu", "tabs", "combobox", "form-field", "accordion", "tooltip", "chart"];
|
|
12
|
+
export const FLAVORS = ["react", "wc"];
|
|
13
|
+
export const MAPPING_STATUSES = ["template", "authored", "needs-fixture", "no-match"];
|
|
14
|
+
|
|
15
|
+
/** What a candidate mapping says about one archetype of one npm target. */
|
|
16
|
+
export const MappingEntrySchema = v.object({
|
|
17
|
+
flavor: v.optional(v.picklist(FLAVORS)),
|
|
18
|
+
/** Export name (React) the fixture or template uses. */
|
|
19
|
+
export: v.optional(v.string()),
|
|
20
|
+
/** Custom element tag (web components) the fixture or template uses. */
|
|
21
|
+
tag: v.optional(v.string()),
|
|
22
|
+
/** Path to an authored fixture, relative to where the command runs. */
|
|
23
|
+
fixture: v.optional(v.nullable(v.string())),
|
|
24
|
+
/** The library ships opt-in accessibility features. The fixture gets `libA11y` (true or false) and `--lib-a11y` runs it both ways. */
|
|
25
|
+
libA11y: v.optional(v.boolean()),
|
|
26
|
+
status: v.optional(v.picklist(MAPPING_STATUSES)),
|
|
27
|
+
candidates: v.optional(v.array(v.string())),
|
|
28
|
+
parts: v.optional(v.array(v.string())),
|
|
29
|
+
reason: v.optional(v.string()),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** The file `--mapping` points to: target id, then archetype. */
|
|
33
|
+
export const MappingFileSchema = v.record(v.string(), v.record(v.picklist(ARCHETYPES), MappingEntrySchema));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a mapping file. Throws an Error with a readable summary when it's invalid.
|
|
37
|
+
* @param {unknown} input
|
|
38
|
+
*/
|
|
39
|
+
export function parseMappingFile(input) {
|
|
40
|
+
const result = v.safeParse(MappingFileSchema, input);
|
|
41
|
+
if (!result.success) throw new Error(`Invalid mapping:\n${v.summarize(result.issues)}`);
|
|
42
|
+
return result.output;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const TARGET_KINDS = ["npm", "npm-react", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
|
|
46
|
+
|
|
47
|
+
const nullableString = v.nullable(v.string());
|
|
48
|
+
|
|
49
|
+
export const TargetSchema = v.object({
|
|
50
|
+
id: v.string(),
|
|
51
|
+
input: v.string(),
|
|
52
|
+
label: v.string(),
|
|
53
|
+
status: v.picklist(["ok", "failed"]),
|
|
54
|
+
reason: nullableString,
|
|
55
|
+
kind: v.nullable(v.picklist(TARGET_KINDS)),
|
|
56
|
+
evidenceLevel: v.nullable(v.picklist(["component", "page"])),
|
|
57
|
+
resolved: v.nullable(v.record(v.string(), nullableString)),
|
|
58
|
+
mapping: v.nullable(v.record(v.string(), MappingEntrySchema)),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const FailConfigSchema = v.object({
|
|
62
|
+
mode: v.picklist(FAIL_MODES),
|
|
63
|
+
axe: v.nullable(v.picklist(IMPACTS)),
|
|
64
|
+
ibm: v.nullable(v.picklist(TOOLKIT_LEVELS)),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const PlanSchema = v.object({
|
|
68
|
+
schema: v.literal(1),
|
|
69
|
+
createdAt: v.string(),
|
|
70
|
+
command: v.picklist(["audit", "compare"]),
|
|
71
|
+
options: v.object({
|
|
72
|
+
wcag: v.picklist(WCAG_VERSIONS),
|
|
73
|
+
level: v.picklist(LEVELS),
|
|
74
|
+
tiers: v.array(v.picklist(TIERS)),
|
|
75
|
+
engines: v.array(v.picklist(ENGINES)),
|
|
76
|
+
libA11y: v.array(v.picklist(LIB_A11Y)),
|
|
77
|
+
archetypes: v.nullable(v.array(v.picklist(ARCHETYPES))),
|
|
78
|
+
mapping: nullableString,
|
|
79
|
+
maxStories: v.pipe(v.number(), v.integer(), v.minValue(1)),
|
|
80
|
+
out: v.string(),
|
|
81
|
+
fail: v.nullable(FailConfigSchema),
|
|
82
|
+
}),
|
|
83
|
+
tools: v.record(v.string(), nullableString),
|
|
84
|
+
targets: v.array(TargetSchema),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a plan. Throws an Error with a readable summary when the plan is invalid.
|
|
89
|
+
* @param {unknown} input
|
|
90
|
+
*/
|
|
91
|
+
export function parsePlan(input) {
|
|
92
|
+
const result = v.safeParse(PlanSchema, input);
|
|
93
|
+
if (!result.success) throw new Error(`Invalid plan:\n${v.summarize(result.issues)}`);
|
|
94
|
+
return result.output;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- Results ----
|
|
98
|
+
|
|
99
|
+
const TIER_STATUS = ["ran", "skipped", "not-applicable", "not-testable", "failed"];
|
|
100
|
+
const IMPACT_COUNTS = v.object({ critical: v.number(), serious: v.number(), moderate: v.number(), minor: v.number() });
|
|
101
|
+
|
|
102
|
+
const NodeSchema = v.object({ selector: v.string(), html: v.string() });
|
|
103
|
+
|
|
104
|
+
/** One finding from one engine. `impact` belongs to axe. `toolkitLevel` belongs to IBM. Neither converts to the other. */
|
|
105
|
+
const FindingSchema = v.object({
|
|
106
|
+
ruleId: v.string(),
|
|
107
|
+
impact: v.nullable(v.picklist(IMPACTS)),
|
|
108
|
+
toolkitLevel: v.optional(v.nullable(v.number())),
|
|
109
|
+
kind: v.optional(v.picklist(["potential", "manual", "recommendation"])),
|
|
110
|
+
wcag: v.array(v.string()),
|
|
111
|
+
tags: v.optional(v.array(v.string())),
|
|
112
|
+
help: v.string(),
|
|
113
|
+
helpUrl: v.string(),
|
|
114
|
+
nodeCount: v.number(),
|
|
115
|
+
nodes: v.array(NodeSchema),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const EngineResultSchema = v.object({
|
|
119
|
+
status: v.picklist(TIER_STATUS),
|
|
120
|
+
reason: v.optional(v.nullable(v.string())),
|
|
121
|
+
version: v.optional(v.nullable(v.string())),
|
|
122
|
+
config: v.optional(v.record(v.string(), v.unknown())),
|
|
123
|
+
violations: v.optional(v.array(FindingSchema)),
|
|
124
|
+
incomplete: v.optional(v.array(FindingSchema)),
|
|
125
|
+
/** Rules that passed. Both engines count rules, not elements. */
|
|
126
|
+
passesCount: v.optional(v.number()),
|
|
127
|
+
notes: v.optional(v.array(v.string())),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const TierResultSchema = v.object({
|
|
131
|
+
status: v.picklist(TIER_STATUS),
|
|
132
|
+
reason: v.optional(v.nullable(v.string())),
|
|
133
|
+
engines: v.optional(v.record(v.string(), EngineResultSchema)),
|
|
134
|
+
checks: v.optional(
|
|
135
|
+
v.array(
|
|
136
|
+
v.object({
|
|
137
|
+
name: v.string(),
|
|
138
|
+
criteria: v.optional(v.array(v.string())),
|
|
139
|
+
result: v.picklist(["pass", "fail", "not-applicable", "error"]),
|
|
140
|
+
detail: v.string(),
|
|
141
|
+
/** How the focus indicator was detected: computed-style or screenshot. */
|
|
142
|
+
method: v.optional(v.string()),
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
simulated: v.optional(v.boolean()),
|
|
147
|
+
version: v.optional(v.nullable(v.string())),
|
|
148
|
+
log: v.optional(
|
|
149
|
+
v.array(
|
|
150
|
+
v.object({
|
|
151
|
+
state: v.nullable(v.optional(v.string())),
|
|
152
|
+
announcements: v.array(v.string()),
|
|
153
|
+
reachedEnd: v.boolean(),
|
|
154
|
+
truncated: v.boolean(),
|
|
155
|
+
}),
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
/** Phrases a person should look at: a control announced as only its role, or a role announced as generic. */
|
|
159
|
+
flags: v.optional(v.array(v.object({ type: v.string(), phrase: v.string(), index: v.number(), state: v.nullable(v.optional(v.string())) }))),
|
|
160
|
+
notTestable: v.optional(v.array(v.string())),
|
|
161
|
+
notes: v.optional(v.array(v.string())),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const EngineSummarySchema = v.object({
|
|
165
|
+
status: v.picklist(TIER_STATUS),
|
|
166
|
+
violations: v.number(),
|
|
167
|
+
needsReview: v.number(),
|
|
168
|
+
/** axe only. IBM findings have no impact. */
|
|
169
|
+
violationsByImpact: v.optional(IMPACT_COUNTS),
|
|
170
|
+
/** IBM only. Keys are Toolkit levels 1 to 4. */
|
|
171
|
+
violationsByToolkitLevel: v.optional(v.record(v.string(), v.number())),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const StorybookInfoSchema = v.object({
|
|
175
|
+
index: v.string(),
|
|
176
|
+
/** Stories in the index, before any filtering. */
|
|
177
|
+
total: v.number(),
|
|
178
|
+
/** Stories left after the archetype filter. */
|
|
179
|
+
matched: v.number(),
|
|
180
|
+
audited: v.number(),
|
|
181
|
+
/** True when the story cap cut the list short. */
|
|
182
|
+
truncated: v.boolean(),
|
|
183
|
+
maxStories: v.number(),
|
|
184
|
+
/** True when --archetypes limited which stories were audited. */
|
|
185
|
+
filtered: v.optional(v.boolean()),
|
|
186
|
+
/** Every archetype each story's title, name, and tags suggest, for all stories in the index. */
|
|
187
|
+
archetypeMatches: v.record(v.string(), v.array(v.string())),
|
|
188
|
+
/** Stories that didn't render. They count as failures, never as passes. */
|
|
189
|
+
failedStories: v.array(v.object({ id: v.string(), reason: v.string() })),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
export const TargetResultSchema = v.object({
|
|
193
|
+
id: v.string(),
|
|
194
|
+
status: v.picklist(["ran", "failed", "unsupported", "not-applicable"]),
|
|
195
|
+
reason: nullableString,
|
|
196
|
+
archetypes: v.record(
|
|
197
|
+
v.string(),
|
|
198
|
+
v.object({
|
|
199
|
+
status: v.picklist(["ran", "gap"]),
|
|
200
|
+
reason: v.optional(v.nullable(v.string())),
|
|
201
|
+
configs: v.array(v.object({ libA11y: v.picklist(["on", "off", "n/a"]), state: v.optional(v.string()), tiers: v.record(v.string(), TierResultSchema) })),
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
storybook: v.optional(StorybookInfoSchema),
|
|
205
|
+
npm: v.optional(
|
|
206
|
+
v.object({
|
|
207
|
+
name: v.string(),
|
|
208
|
+
version: nullableString,
|
|
209
|
+
flavor: v.picklist(FLAVORS),
|
|
210
|
+
framework: nullableString,
|
|
211
|
+
react: nullableString,
|
|
212
|
+
reactDom: nullableString,
|
|
213
|
+
tags: v.array(v.string()),
|
|
214
|
+
}),
|
|
215
|
+
),
|
|
216
|
+
summary: v.object({
|
|
217
|
+
engines: v.record(v.string(), EngineSummarySchema),
|
|
218
|
+
gaps: v.array(v.string()),
|
|
219
|
+
notTestable: v.array(v.string()),
|
|
220
|
+
interactions: v.optional(v.object({ pass: v.number(), fail: v.number(), notApplicable: v.number(), error: v.number() })),
|
|
221
|
+
vsr: v.optional(v.object({ walks: v.number(), flagged: v.number() })),
|
|
222
|
+
}),
|
|
223
|
+
warnings: v.array(v.string()),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const FailEngineSchema = v.object({ threshold: v.union([v.string(), v.number()]), hits: v.number(), tripped: v.boolean() });
|
|
227
|
+
|
|
228
|
+
export const FailCheckSchema = v.object({
|
|
229
|
+
mode: v.picklist(FAIL_MODES),
|
|
230
|
+
axe: v.nullable(FailEngineSchema),
|
|
231
|
+
ibm: v.nullable(FailEngineSchema),
|
|
232
|
+
/** Per target, because the combined mode decides one target at a time. */
|
|
233
|
+
targets: v.record(v.string(), v.object({ axe: v.nullable(FailEngineSchema), ibm: v.nullable(FailEngineSchema), tripped: v.boolean() })),
|
|
234
|
+
tripped: v.boolean(),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
export const ResultsSchema = v.object({
|
|
238
|
+
schema: v.literal(1),
|
|
239
|
+
planRef: v.string(),
|
|
240
|
+
runAt: v.string(),
|
|
241
|
+
tools: v.record(v.string(), nullableString),
|
|
242
|
+
targets: v.array(TargetResultSchema),
|
|
243
|
+
failCheck: v.nullable(FailCheckSchema),
|
|
244
|
+
warnings: v.array(v.string()),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Parse a results object. Throws an Error with a readable summary when it's invalid.
|
|
249
|
+
* @param {unknown} input
|
|
250
|
+
*/
|
|
251
|
+
export function parseResults(input) {
|
|
252
|
+
const result = v.safeParse(ResultsSchema, input);
|
|
253
|
+
if (!result.success) throw new Error(`Invalid results:\n${v.summarize(result.issues)}`);
|
|
254
|
+
return result.output;
|
|
255
|
+
}
|
package/src/text.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];
|
|
2
|
+
|
|
3
|
+
/** Spell out zero through nine and use numerals from 10, as the house style asks. */
|
|
4
|
+
export const num = (n) => (Number.isInteger(n) && n >= 0 && n <= 9 ? WORDS[n] : String(n));
|
|
5
|
+
|
|
6
|
+
/** "one story", "three stories". */
|
|
7
|
+
export const plural = (n, word, many = `${word}s`) => `${num(n)} ${n === 1 ? word : many}`;
|
|
8
|
+
|
|
9
|
+
export const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|