automatica11y 0.3.2 → 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 +35 -8
- package/package.json +6 -3
- package/skills/automatica11y-runner/SKILL.md +47 -13
- package/skills/automatica11y-runner/references/fixtures.md +38 -2
- package/src/commands/common.js +5 -2
- package/src/data/README.md +19 -0
- package/src/data/wcag-2.2.json +7557 -0
- package/src/data/wcag-2.2.source.json +6 -0
- 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 +2 -0
- package/src/harness/bundle.js +23 -8
- 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 +85 -5
- 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 +11 -7
- package/src/plan/resolve-npm.js +15 -9
- package/src/plan/subpath.js +133 -0
- package/src/report/comparison.js +21 -6
- package/src/report/parts.js +60 -8
- package/src/run/audit-npm.js +137 -29
- package/src/run/generate-fixture.js +74 -0
- package/src/run/run-plan.js +24 -4
- package/src/run/summary.js +10 -0
- package/src/schema.js +34 -7
- package/src/tiers/computed/checks.js +197 -0
- package/src/tiers/computed/color.js +48 -0
- package/src/tiers/computed/index.js +25 -0
- package/src/tiers/computed/measure-kit.js +225 -0
- 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 +117 -4
- package/src/tiers/interactions/focus-indicator.js +39 -0
- package/src/tiers/interactions/helpers.js +87 -16
- package/src/tiers/interactions/index.js +17 -4
- package/src/tiers/rules/axe.js +4 -3
- package/src/wcag/index.js +83 -0
- package/src/harness/npm-react.js +0 -39
- package/src/harness/npm-wc.js +0 -30
package/src/plan/mapping.js
CHANGED
|
@@ -14,6 +14,7 @@ const BASE_NAMES = {
|
|
|
14
14
|
"form-field": ["input", "textfield", "textinput", "field", "checkbox"],
|
|
15
15
|
accordion: ["accordion", "collapsible", "disclosure"],
|
|
16
16
|
tooltip: ["tooltip", "popover"],
|
|
17
|
+
"live-region": ["alert", "status", "toast", "snackbar", "notification", "liveregion"],
|
|
17
18
|
chart: ["chart", "linechart", "barchart"],
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -24,21 +25,23 @@ export const TEMPLATED = new Set(["button", "link"]);
|
|
|
24
25
|
const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ");
|
|
25
26
|
|
|
26
27
|
/** How well a name fits an archetype. 0 means it doesn't. */
|
|
27
|
-
function score(archetype, name) {
|
|
28
|
+
export function score(archetype, name) {
|
|
28
29
|
if (!ARCHETYPE_PATTERNS[archetype].test(words(name))) return 0;
|
|
29
30
|
const compact = name.replace(/[-_\s]/g, "").toLowerCase();
|
|
30
31
|
const last = words(name).toLowerCase().split(" ").pop();
|
|
31
32
|
const bases = BASE_NAMES[archetype];
|
|
32
|
-
|
|
33
|
-
if (bases.includes(
|
|
34
|
-
if (bases.
|
|
33
|
+
// Names listed first are closer to the archetype itself, so `tooltip` beats `popover` when both are there.
|
|
34
|
+
if (bases.includes(compact)) return 4 - bases.indexOf(compact) * 0.1;
|
|
35
|
+
if (bases.includes(last)) return 3 - bases.indexOf(last) * 0.1;
|
|
36
|
+
const starts = bases.findIndex((base) => compact.startsWith(base));
|
|
37
|
+
if (starts !== -1) return 2 - starts * 0.1;
|
|
35
38
|
return 1;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
|
-
* Guess which exports (React) or tags (web components) stand for each archetype.
|
|
42
|
+
* Guess which exports (React or Vue) or tags (web components) stand for each archetype.
|
|
40
43
|
* The result is a starting point. A person or the skill checks it before trusting it.
|
|
41
|
-
* @param {{ flavor: "react" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
|
|
44
|
+
* @param {{ flavor: "react" | "vue" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
|
|
42
45
|
* @returns {Record<string, any>}
|
|
43
46
|
*/
|
|
44
47
|
export function candidateMapping({ flavor, exports = [], tags = [] }) {
|
|
@@ -58,7 +61,8 @@ export function candidateMapping({ flavor, exports = [], tags = [] }) {
|
|
|
58
61
|
const info = exports.find((e) => e.name === best);
|
|
59
62
|
const parts = info?.parts ?? [];
|
|
60
63
|
// Flat compound libraries (DialogRoot, DialogTrigger, DialogContent) have sibling exports that share a prefix.
|
|
61
|
-
|
|
64
|
+
// A button or link usually sits beside ButtonBase, ButtonGroup, and the like, which aren't its parts, so only real parts (Button.Root) count there.
|
|
65
|
+
const siblings = flavor !== "wc" && !TEMPLATED.has(archetype) ? names.filter((n) => n !== best && n.startsWith(best) && n.length > best.length) : [];
|
|
62
66
|
const compound = parts.length > 0 || siblings.length >= 2;
|
|
63
67
|
const templated = TEMPLATED.has(archetype) && !compound;
|
|
64
68
|
mapping[archetype] = {
|
package/src/plan/resolve-npm.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { ADAPTERS } from "../frameworks/index.js";
|
|
3
|
+
import { checkExports, notExportedMessage } from "./subpath.js";
|
|
2
4
|
|
|
3
|
-
const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated"];
|
|
5
|
+
const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated", "exports"];
|
|
4
6
|
const OTHER_FRAMEWORKS = {
|
|
5
|
-
vue: "Vue",
|
|
6
7
|
"@angular/core": "Angular",
|
|
7
8
|
svelte: "Svelte",
|
|
8
9
|
"solid-js": "Solid",
|
|
@@ -44,19 +45,19 @@ export function npmView(spec, { timeoutMs = 60_000 } = {}) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
|
-
* Guess how a package renders from its metadata alone. React wins when both signals appear.
|
|
48
|
+
* Guess how a package renders from its metadata alone. React wins when both signals appear, then a custom elements manifest, then Vue.
|
|
48
49
|
* `npm` means the metadata can't say, so the run decides after it installs and loads the package.
|
|
49
50
|
* @param {any} meta
|
|
50
|
-
* @returns {{ kind: "npm-react" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
|
|
51
|
+
* @returns {{ kind: "npm-react" | "npm-vue" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
|
|
51
52
|
*/
|
|
52
53
|
export function detectFlavor(meta) {
|
|
53
54
|
const peers = meta.peerDependencies ?? {};
|
|
54
55
|
const deps = meta.dependencies ?? {};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return { kind: "npm-react", framework: "React", reason: `The package lists react as a ${how}.` };
|
|
58
|
-
}
|
|
56
|
+
const react = /** @type {any} */ (ADAPTERS.react.detect(meta));
|
|
57
|
+
if (react) return react;
|
|
59
58
|
if (meta.customElements) return { kind: "npm-wc", framework: "Web components", reason: "The package has a customElements manifest." };
|
|
59
|
+
const vue = /** @type {any} */ (ADAPTERS.vue.detect(meta));
|
|
60
|
+
if (vue) return vue;
|
|
60
61
|
for (const [name, label] of Object.entries(OTHER_FRAMEWORKS)) {
|
|
61
62
|
if (name in peers) return { kind: "npm-unsupported", framework: label, reason: `The package needs ${label}.` };
|
|
62
63
|
}
|
|
@@ -73,9 +74,14 @@ export function detectFlavor(meta) {
|
|
|
73
74
|
*/
|
|
74
75
|
export async function resolveNpmTarget(target, view) {
|
|
75
76
|
if (target.status !== "ok" || target.kind !== "npm" || !target.resolved) return target;
|
|
76
|
-
const { name, requested } = target.resolved;
|
|
77
|
+
const { name, requested, subpath } = target.resolved;
|
|
77
78
|
try {
|
|
78
79
|
const meta = await view(`${name}@${requested ?? "latest"}`);
|
|
80
|
+
// The registry lists a package's exports, so a wrong sub-path is caught here, before anything is installed.
|
|
81
|
+
if (subpath) {
|
|
82
|
+
const result = checkExports(meta.exports, subpath);
|
|
83
|
+
if (result.checked && !result.ok) throw new Error(notExportedMessage({ name, version: meta.version ?? null, subpath, exact: result.exact, patterns: result.patterns }));
|
|
84
|
+
}
|
|
79
85
|
const flavor = detectFlavor(meta);
|
|
80
86
|
return {
|
|
81
87
|
...target,
|
|
@@ -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", "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,6 +108,16 @@ function interactionsCell(configs) {
|
|
|
104
108
|
return parts.filter(Boolean).join("; ");
|
|
105
109
|
}
|
|
106
110
|
|
|
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) ?? "-";
|
|
114
|
+
const failed = checks.filter((c) => c.result === "fail").map((c) => code(c.name));
|
|
115
|
+
const unknown = checks.filter((c) => c.result === "undetermined").length;
|
|
116
|
+
const errors = checks.filter((c) => c.result === "error").length;
|
|
117
|
+
const parts = [failed.length ? `failed: ${failed.slice(0, 3).join(", ")}${failed.length > 3 ? `, and ${num(failed.length - 3)} more` : ""}` : "no failures", unknown ? `${num(unknown)} undetermined` : null, errors ? plural(errors, "error") : null];
|
|
118
|
+
return parts.filter(Boolean).join("; ");
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
function vsrCell(configs) {
|
|
108
122
|
const walks = configs.map((c) => c.tiers.vsr).filter((v) => v?.status === "ran");
|
|
109
123
|
if (walks.length === 0) return configs.map((c) => c.tiers.vsr?.status).find(Boolean) ?? "-";
|
|
@@ -113,17 +127,17 @@ function vsrCell(configs) {
|
|
|
113
127
|
}
|
|
114
128
|
|
|
115
129
|
function findingsTable(plan, results, key) {
|
|
116
|
-
const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Virtual screen reader (simulated)"];
|
|
130
|
+
const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Computed checks", "Conditions", "Virtual screen reader (simulated)"];
|
|
117
131
|
const lines = [`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`];
|
|
118
132
|
for (const target of results.targets) {
|
|
119
133
|
const planTarget = plan.targets.find((t) => t.id === target.id);
|
|
120
134
|
for (const row of rowsFor(planTarget, target, key)) {
|
|
121
135
|
if (row.note) {
|
|
122
|
-
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | |`);
|
|
136
|
+
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | | | |`);
|
|
123
137
|
continue;
|
|
124
138
|
}
|
|
125
139
|
const c = row.configs;
|
|
126
|
-
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(vsrCell(c))} |`);
|
|
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))} |`);
|
|
127
141
|
}
|
|
128
142
|
}
|
|
129
143
|
return lines;
|
|
@@ -170,6 +184,7 @@ export function renderComparison({ plan, results }) {
|
|
|
170
184
|
lines.push(`### ${TIER_NAMES[tier]}${tier === "vsr" ? " (simulated)" : ""}.`, "", ...coverageTable(plan, results, tier, keys), "");
|
|
171
185
|
}
|
|
172
186
|
lines.push(...notTestableLines(results));
|
|
187
|
+
if (hasGenerated(results)) lines.push(GENERATED_NOTE, "");
|
|
173
188
|
|
|
174
189
|
lines.push("## Findings.", "", FINDINGS_NOTE, "", ...(o.tiers.includes("rules") ? impactTables(results, o.engines) : []));
|
|
175
190
|
for (const key of keys) {
|
|
@@ -177,7 +192,7 @@ export function renderComparison({ plan, results }) {
|
|
|
177
192
|
}
|
|
178
193
|
|
|
179
194
|
lines.push("## Details by target.", "", "Every finding, with its elements, rule help, and logs, for each target in turn.", "");
|
|
180
|
-
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 }));
|
|
181
196
|
lines.push(...reportFooter(results));
|
|
182
197
|
return finish(lines);
|
|
183
198
|
}
|
package/src/report/parts.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { adapterFor } from "../frameworks/index.js";
|
|
2
|
+
import { criterion, criterionName, wcagAttribution } from "../wcag/index.js";
|
|
1
3
|
import { cap, num, plural } from "../text.js";
|
|
2
4
|
|
|
3
5
|
/** Markdown helpers. */
|
|
@@ -8,7 +10,7 @@ export const sentence = (text) => String(text).replace(/\.+$/, "");
|
|
|
8
10
|
/** Escape angle brackets so rule text like <input> doesn't turn into HTML. */
|
|
9
11
|
export const esc = (text) => String(text ?? "").replace(/</g, "\\<");
|
|
10
12
|
export const ENGINE_NAMES = { axe: "axe-core", ibm: "IBM Equal Access" };
|
|
11
|
-
export const TIER_NAMES = { rules: "Rules", interactions: "Interactions", vsr: "Virtual screen reader" };
|
|
13
|
+
export const TIER_NAMES = { rules: "Rules", interactions: "Interactions", computed: "Computed checks", conditions: "Conditions", vsr: "Virtual screen reader" };
|
|
12
14
|
|
|
13
15
|
export function toolLines(tools) {
|
|
14
16
|
return Object.entries(tools)
|
|
@@ -27,6 +29,11 @@ export function engineCell(summary, engine) {
|
|
|
27
29
|
export function tierCell(target, tier) {
|
|
28
30
|
const counts = target.summary?.interactions;
|
|
29
31
|
const vsr = target.summary?.vsr;
|
|
32
|
+
const measured = target.summary?.[tier];
|
|
33
|
+
if ((tier === "computed" || tier === "conditions") && measured) {
|
|
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);
|
|
35
|
+
return `ran, ${parts.join(", ")}`;
|
|
36
|
+
}
|
|
30
37
|
if (tier === "vsr" && vsr) return `ran (simulated), ${vsr.flagged ? `${num(vsr.flagged)} flagged` : "none flagged"}`;
|
|
31
38
|
if (tier === "interactions" && counts) {
|
|
32
39
|
const parts = [counts.fail && plural(counts.fail, "failed", "failed"), counts.error && plural(counts.error, "error"), counts.pass && `${num(counts.pass)} passed`, counts.notApplicable && `${num(counts.notApplicable)} not applicable`].filter(Boolean);
|
|
@@ -58,7 +65,7 @@ export function findingBlock(finding, { showImpact, showToolkit }) {
|
|
|
58
65
|
showImpact && finding.impact ? `impact: ${finding.impact}` : null,
|
|
59
66
|
showToolkit && finding.toolkitLevel != null ? `IBM Toolkit level ${finding.toolkitLevel}` : null,
|
|
60
67
|
finding.kind ? `kind: ${finding.kind}` : null,
|
|
61
|
-
finding.wcag.length ? `WCAG ${finding.wcag.join(", ")}` : null,
|
|
68
|
+
finding.wcag.length ? `WCAG ${finding.wcag.map(criterionName).join(", ")}` : null,
|
|
62
69
|
plural(finding.nodeCount, "element"),
|
|
63
70
|
].filter(Boolean);
|
|
64
71
|
const lines = [`- ${code(finding.ruleId)} (${meta.join("; ")}). ${esc(finding.help)} [Rule help](${finding.helpUrl})`];
|
|
@@ -200,7 +207,31 @@ export function interactionsSection(result, nested) {
|
|
|
200
207
|
const lines = [`${nested ? "#####" : "####"} Interactions.`, "", "Each check ran on a fresh page, using only the trigger and root hooks and ARIA roles. A check that couldn't finish is an error, which counts as a gap and never as a pass.", ""];
|
|
201
208
|
lines.push("| Check | Result | WCAG | Detail |", "| --- | --- | --- | --- |");
|
|
202
209
|
for (const check of result.checks) {
|
|
203
|
-
lines.push(`| ${code(check.name)} | ${check.result} | ${cell((check.criteria
|
|
210
|
+
lines.push(`| ${code(check.name)} | ${check.result} | ${cell(criteriaCell(check.criteria))} | ${cell(check.detail)}${check.method ? cell(` (method: ${check.method})`) : ""} |`);
|
|
211
|
+
}
|
|
212
|
+
return lines.join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Criterion numbers as links to the W3C text, with the W3C's name for each. Unknown numbers stay plain. */
|
|
216
|
+
export function criteriaCell(list) {
|
|
217
|
+
if (!list || list.length === 0) return "-";
|
|
218
|
+
return list.map((num) => {
|
|
219
|
+
const c = criterion(num);
|
|
220
|
+
return c ? `[${c.num} ${c.handle}](${c.url})` : num;
|
|
221
|
+
}).join(", ");
|
|
222
|
+
}
|
|
223
|
+
|
|
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, ""];
|
|
232
|
+
lines.push("| Check | Result | WCAG | Detail |", "| --- | --- | --- | --- |");
|
|
233
|
+
for (const check of result.checks) {
|
|
234
|
+
lines.push(`| ${code(check.name)} | ${check.result} | ${cell(criteriaCell(check.criteria))} | ${cell(check.detail)}${check.method ? cell(` (method: ${check.method})`) : ""} |`);
|
|
204
235
|
}
|
|
205
236
|
return lines.join("\n");
|
|
206
237
|
}
|
|
@@ -211,17 +242,33 @@ export function configLabel(config) {
|
|
|
211
242
|
return parts.length ? ` (${parts.join(", ")})` : "";
|
|
212
243
|
}
|
|
213
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
|
+
|
|
214
252
|
export function archetypeTable(target) {
|
|
215
253
|
const rows = Object.entries(target.archetypes).map(([name, archetype]) => {
|
|
216
|
-
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."))} |`;
|
|
217
255
|
const states = [...new Set(archetype.configs.map((c) => c.state).filter(Boolean))].join(", ") || "-";
|
|
218
256
|
const libs = [...new Set(archetype.configs.map((c) => c.libA11y).filter((l) => l && l !== "n/a"))];
|
|
219
|
-
|
|
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(". "))} |`;
|
|
220
259
|
});
|
|
221
|
-
return ["| Archetype | Status | States | Note |", "| --- | --- | --- | --- |", ...rows];
|
|
260
|
+
return ["| Archetype | Status | Fixture | States | Note |", "| --- | --- | --- | --- | --- |", ...rows];
|
|
222
261
|
}
|
|
223
262
|
|
|
224
|
-
|
|
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 } = {}) {
|
|
225
272
|
if (target.status === "ran" && target.storybook) return storybookSection(planTarget, target);
|
|
226
273
|
const lines = [`### ${planTarget.label}.`, ""];
|
|
227
274
|
lines.push(`Target ${code(planTarget.input)}, ${planTarget.kind ?? "unclassified"}${planTarget.evidenceLevel ? `, ${planTarget.evidenceLevel} evidence` : ""}.`, "");
|
|
@@ -231,13 +278,14 @@ export function targetSection(planTarget, target) {
|
|
|
231
278
|
}
|
|
232
279
|
if (target.npm) {
|
|
233
280
|
const n = target.npm;
|
|
234
|
-
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)}.`, "");
|
|
235
282
|
}
|
|
236
283
|
for (const warning of target.warnings) lines.push(`Warning: ${warning}`, "");
|
|
237
284
|
if (target.status === "failed") {
|
|
238
285
|
lines.push(`This target failed: ${sentence(target.reason)}. A failed target is a gap in coverage. It isn't a pass.`, "");
|
|
239
286
|
}
|
|
240
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, "");
|
|
241
289
|
/** @type {Set<string>} */
|
|
242
290
|
const skipped = new Set();
|
|
243
291
|
for (const [name, archetype] of Object.entries(target.archetypes)) {
|
|
@@ -250,6 +298,8 @@ export function targetSection(planTarget, target) {
|
|
|
250
298
|
lines.push(vsrSection(result, name !== "page"), "");
|
|
251
299
|
} else if (tier === "interactions" && result.status === "ran") {
|
|
252
300
|
lines.push(interactionsSection(result, name !== "page"), "");
|
|
301
|
+
} else if ((tier === "computed" || tier === "conditions") && result.status === "ran") {
|
|
302
|
+
lines.push(measuredSection(tier, result, name !== "page"), "");
|
|
253
303
|
} else if (result.status !== "ran") {
|
|
254
304
|
skipped.add(`${TIER_NAMES[tier] ?? tier}: ${sentence(result.reason ?? result.status)}.`);
|
|
255
305
|
}
|
|
@@ -326,6 +376,8 @@ export function reportFooter(results) {
|
|
|
326
376
|
"",
|
|
327
377
|
"Contrast results depend on how the browser rendered the page, so the browser version is recorded above.",
|
|
328
378
|
"",
|
|
379
|
+
`**WCAG data.** ${wcagAttribution()}`,
|
|
380
|
+
"",
|
|
329
381
|
);
|
|
330
382
|
return lines;
|
|
331
383
|
}
|