automatica11y 0.4.1 → 0.7.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 +37 -130
- package/package.json +30 -2
- package/skills/automatica11y-runner/SKILL.md +16 -3
- package/skills/automatica11y-runner/references/fixtures.md +24 -0
- package/src/cli.js +1 -0
- package/src/commands/common.js +14 -11
- package/src/frameworks/angular-errors.js +27 -0
- package/src/frameworks/angular-selectors.js +36 -0
- package/src/frameworks/angular.js +162 -0
- package/src/frameworks/errors.js +12 -0
- package/src/frameworks/html.js +163 -0
- package/src/frameworks/index.js +13 -7
- package/src/frameworks/react.js +8 -4
- package/src/frameworks/svelte-errors.js +30 -0
- package/src/frameworks/svelte.js +162 -0
- package/src/frameworks/vue.js +2 -1
- package/src/globals.d.ts +123 -6
- package/src/harness/bundle.js +2 -1
- package/src/harness/generate/angular-recipes.js +360 -0
- package/src/harness/generate/dialects.js +35 -1
- package/src/harness/generate/jsx-recipes.js +4 -6
- package/src/harness/generate/probe.js +16 -5
- package/src/harness/npm-install.js +47 -11
- package/src/harness/shadow.js +1 -1
- package/src/harness/storybook.js +18 -5
- package/src/plan/build-plan.js +1 -0
- package/src/plan/classify.js +39 -11
- package/src/plan/mapping.js +8 -6
- package/src/plan/resolve-npm.js +49 -18
- package/src/plan/subpath.js +17 -0
- package/src/report/comparison.js +4 -1
- package/src/report/index.js +1 -1
- package/src/report/parts.js +13 -1
- package/src/report/single.js +1 -1
- package/src/run/audit-npm.js +95 -27
- package/src/run/fail-check.js +10 -2
- package/src/run/generate-fixture.js +7 -5
- package/src/run/run-plan.js +10 -8
- package/src/run/summary.js +1 -1
- package/src/schema.js +10 -2
- package/src/tiers/computed/checks.js +3 -1
- package/src/tiers/conditions/kit.js +3 -3
- package/src/tiers/interactions/archetypes.js +6 -2
- package/src/tiers/interactions/focus-indicator.js +4 -2
- package/src/tiers/interactions/helpers.js +2 -1
- package/src/tiers/rules/ibm.js +2 -2
- package/src/tiers/rules/index.js +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { explainFrameworkError } from "../../frameworks/errors.js";
|
|
1
2
|
import { installHelpers } from "../../tiers/interactions/helpers.js";
|
|
2
3
|
import { openPage } from "../url.js";
|
|
3
4
|
import { ROOT_ROLES } from "./marking.js";
|
|
@@ -25,9 +26,9 @@ export async function probeFixture(browser, url, archetype) {
|
|
|
25
26
|
opened = await openPage(browser, url, {
|
|
26
27
|
waitUntil: "load",
|
|
27
28
|
beforeGoto: async (page) => {
|
|
28
|
-
page.on("pageerror", (error) => errors.push(error.message.split("\n")[0]));
|
|
29
|
+
page.on("pageerror", (error) => errors.push(explainFrameworkError(error.message.split("\n")[0])));
|
|
29
30
|
page.on("console", (message) => {
|
|
30
|
-
if (message.type() === "error" && !/favicon|Failed to load resource/i.test(message.text())) errors.push(message.text().split("\n")[0]);
|
|
31
|
+
if (message.type() === "error" && !/favicon|Failed to load resource/i.test(message.text())) errors.push(explainFrameworkError(message.text().split("\n")[0]));
|
|
31
32
|
});
|
|
32
33
|
await page.addInitScript(installHelpers);
|
|
33
34
|
},
|
|
@@ -60,8 +61,8 @@ export async function probeFixture(browser, url, archetype) {
|
|
|
60
61
|
} catch (error) {
|
|
61
62
|
return { ok: false, reason: `the trigger couldn't be activated: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}` };
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
/** Wait for the surface. A tooltip gets a second chance below, so this one is short for it. */
|
|
65
|
+
const waitForSurface = (timeout) => page.waitForFunction(
|
|
65
66
|
({ live, needsRoot }) => {
|
|
66
67
|
const state = window.__a11y.live();
|
|
67
68
|
if (live) return state.present && state.visible && Boolean(state.text || state.named);
|
|
@@ -71,8 +72,18 @@ export async function probeFixture(browser, url, archetype) {
|
|
|
71
72
|
return (state.present && state.visible) || trigger?.getAttribute("aria-expanded") === "true";
|
|
72
73
|
},
|
|
73
74
|
{ live: archetype === "live-region", needsRoot: Boolean(ROOT_ROLES[archetype]) },
|
|
74
|
-
{ timeout
|
|
75
|
+
{ timeout },
|
|
75
76
|
);
|
|
77
|
+
try {
|
|
78
|
+
try {
|
|
79
|
+
await waitForSurface(archetype === "tooltip" ? 1500 : 4000);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (archetype !== "tooltip") throw error;
|
|
82
|
+
// Some tooltips open only for focus that came from the keyboard, so focus the trigger again the way a person would.
|
|
83
|
+
await page.evaluate(() => /** @type {HTMLElement | null} */ (document.activeElement)?.blur());
|
|
84
|
+
await page.keyboard.press("Tab");
|
|
85
|
+
await waitForSurface(3000);
|
|
86
|
+
}
|
|
76
87
|
} catch {
|
|
77
88
|
return { ok: false, reason: `activating the trigger showed no ${archetype === "live-region" ? "message" : "element marked as the root"}${ROOT_ROLES[archetype] ? ` (looked for an element with role ${ROOT_ROLES[archetype].join(", ")})` : ""}, which can also mean the library doesn't set that role` };
|
|
78
89
|
}
|
|
@@ -35,7 +35,7 @@ export function installedVersion(dir, name) {
|
|
|
35
35
|
* Install a package into its own directory, never next to another target's install.
|
|
36
36
|
* npm adds the peer dependencies. A React package also needs react-dom, and a Vue package needs vue, so add them when the peers left them out.
|
|
37
37
|
* Install scripts stay off, because the code is untrusted until it runs in the browser sandbox.
|
|
38
|
-
* @param {{ dir: string, name: string, version: string, flavor: "react" | "vue" | "wc" | "unknown", run?: typeof runNpm }} options
|
|
38
|
+
* @param {{ dir: string, name: string, version: string, flavor: "react" | "vue" | "angular" | "svelte" | "html" | "wc" | "unknown", run?: typeof runNpm }} options
|
|
39
39
|
*/
|
|
40
40
|
export async function installPackage({ dir, name, version, flavor, run = runNpm }) {
|
|
41
41
|
mkdirSync(dir, { recursive: true });
|
|
@@ -66,23 +66,59 @@ export async function installPackage({ dir, name, version, flavor, run = runNpm
|
|
|
66
66
|
if (flavor === "react") {
|
|
67
67
|
const react = installedVersion(dir, "react");
|
|
68
68
|
if (!react) {
|
|
69
|
-
await npm(["install", "react", "react-dom", ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
69
|
+
await npm(["install", "react", "react-dom", ...pinnedRuntime(dir, ["react", "react-dom"]), ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
70
70
|
warnings.push(`${name} didn't bring in react, so the latest react and react-dom were added.`);
|
|
71
71
|
} else if (!installedVersion(dir, "react-dom")) {
|
|
72
72
|
// Name react too. A loose install prunes a package that only arrived as a peer, and react did.
|
|
73
|
-
await npm(["install", `react@${react}`, `react-dom@${react}`, ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
73
|
+
await npm(["install", `react@${react}`, `react-dom@${react}`, ...pinnedRuntime(dir, ["react", "react-dom"]), ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
if (flavor === "vue" && !installedVersion(dir, "vue")) {
|
|
77
|
-
await npm(["install", "vue", ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
77
|
+
await npm(["install", "vue", ...pinnedRuntime(dir, ["vue"]), ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
78
78
|
warnings.push(`${name} didn't bring in vue, so the latest vue was added.`);
|
|
79
79
|
}
|
|
80
|
-
|
|
80
|
+
if (flavor === "svelte" && !installedVersion(dir, "svelte")) {
|
|
81
|
+
await npm(["install", "svelte", ...pinnedRuntime(dir, ["svelte"]), ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
82
|
+
warnings.push(`${name} didn't bring in svelte, so the latest svelte was added.`);
|
|
83
|
+
}
|
|
84
|
+
if (flavor === "angular") {
|
|
85
|
+
// The Angular packages have to be the same version as core. A library's peers usually bring in core and common, not the compiler or the platform.
|
|
86
|
+
const core = installedVersion(dir, "@angular/core");
|
|
87
|
+
const missing = ANGULAR_RUNTIME.filter((pkg) => !installedVersion(dir, pkg));
|
|
88
|
+
if (!core) {
|
|
89
|
+
await npm(["install", ...ANGULAR_RUNTIME, ...pinnedRuntime(dir, ANGULAR_RUNTIME), ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
90
|
+
warnings.push(`${name} didn't bring in @angular/core, so the latest Angular packages were added.`);
|
|
91
|
+
} else if (missing.length) {
|
|
92
|
+
// Name everything already installed too. A loose install prunes a package that only arrived as a peer.
|
|
93
|
+
const adding = missing.map((pkg) => (pkg.startsWith("@angular/") ? `${pkg}@${core}` : pkg));
|
|
94
|
+
await npm(["install", ...adding, ...pinnedRuntime(dir, adding), ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), vue: installedVersion(dir, "vue"), angular: installedVersion(dir, "@angular/core"), svelte: installedVersion(dir, "svelte"), version: installedVersion(dir, name) };
|
|
81
98
|
}
|
|
82
99
|
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
/** What an Angular fixture needs beside the library: core, the runtime compiler, the platform, and the pieces core uses. */
|
|
101
|
+
const ANGULAR_RUNTIME = ["@angular/core", "@angular/common", "@angular/compiler", "@angular/platform-browser", "rxjs"];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every package installed at the top level, as exact specs. A loose install (--legacy-peer-deps) prunes a package that only arrived
|
|
105
|
+
* as someone's peer, which is how React, and then Angular's CDK, went missing. Naming everything that's already there keeps it all.
|
|
106
|
+
* A spec for a package in `except` is left out, because the caller is installing that one at a different version.
|
|
107
|
+
* @param {string} dir
|
|
108
|
+
* @param {string[]} [except] Package names, or specs such as `name@range`.
|
|
109
|
+
* @returns {string[]}
|
|
110
|
+
*/
|
|
111
|
+
function pinnedRuntime(dir, except = []) {
|
|
112
|
+
const skip = new Set(except.map((spec) => spec.replace(/^(@?[^@]+)@.*$/, "$1")));
|
|
113
|
+
const root = join(dir, "node_modules");
|
|
114
|
+
/** @type {string[]} */
|
|
115
|
+
const names = [];
|
|
116
|
+
for (const entry of existsSync(root) ? readdirSync(root) : []) {
|
|
117
|
+
if (entry.startsWith(".")) continue;
|
|
118
|
+
if (entry.startsWith("@")) for (const inner of readdirSync(join(root, entry))) names.push(`${entry}/${inner}`);
|
|
119
|
+
else names.push(entry);
|
|
120
|
+
}
|
|
121
|
+
return names.filter((name) => !skip.has(name)).flatMap((name) => (installedVersion(dir, name) ? [`${name}@${installedVersion(dir, name)}`] : []));
|
|
86
122
|
}
|
|
87
123
|
|
|
88
124
|
const PACKAGE_SPEC = /^(@[a-z0-9~][\w.~-]*\/)?[a-z0-9~][\w.~-]*(@[\w.^~<>=*|-]+)?$/i;
|
|
@@ -98,7 +134,7 @@ export async function installExtraPackages({ dir, specs, run = runNpm }) {
|
|
|
98
134
|
if (wanted.length === 0) return { installed: [], warnings: [] };
|
|
99
135
|
for (const spec of wanted) if (!PACKAGE_SPEC.test(spec)) throw new Error(`The mapping's install list has "${spec}", which isn't a package name with an optional version.`);
|
|
100
136
|
try {
|
|
101
|
-
await run(["install", ...wanted, ...pinnedRuntime(dir), "--legacy-peer-deps", ...NPM_FLAGS], dir);
|
|
137
|
+
await run(["install", ...wanted, ...pinnedRuntime(dir, wanted), "--legacy-peer-deps", ...NPM_FLAGS], dir);
|
|
102
138
|
} catch (error) {
|
|
103
139
|
throw new Error(`npm couldn't install ${wanted.join(", ")}: ${firstLine(error.message)}`);
|
|
104
140
|
}
|
|
@@ -145,8 +181,8 @@ export async function installOptionalPeers({ dir, unresolved, run = runNpm }) {
|
|
|
145
181
|
const wanted = unresolved.filter((name) => declared.has(name) && !installedVersion(dir, name));
|
|
146
182
|
if (wanted.length === 0) return { installed: [], warnings: [] };
|
|
147
183
|
// A loose install prunes packages that only arrived as peers, so name the framework again to keep it.
|
|
148
|
-
const
|
|
149
|
-
const specs = [...
|
|
184
|
+
const adding = wanted.map((name) => `${name}@${declared.get(name)}`);
|
|
185
|
+
const specs = [...adding, ...pinnedRuntime(dir, adding)];
|
|
150
186
|
try {
|
|
151
187
|
await run(["install", ...specs, "--legacy-peer-deps", ...NPM_FLAGS], dir);
|
|
152
188
|
} catch (error) {
|
package/src/harness/shadow.js
CHANGED
|
@@ -18,7 +18,7 @@ export function recordClosedShadowRoots() {
|
|
|
18
18
|
* @returns {Promise<Record<string, number>>}
|
|
19
19
|
*/
|
|
20
20
|
export async function closedShadowHosts(page) {
|
|
21
|
-
const hosts = await page.evaluate(() =>
|
|
21
|
+
const hosts = await page.evaluate(() => window.__a11yClosedShadowHosts ?? []).catch(() => []);
|
|
22
22
|
/** @type {Record<string, number>} */
|
|
23
23
|
const counts = {};
|
|
24
24
|
for (const host of hosts) counts[host] = (counts[host] ?? 0) + 1;
|
package/src/harness/storybook.js
CHANGED
|
@@ -16,20 +16,32 @@ export const ARCHETYPE_PATTERNS = {
|
|
|
16
16
|
chart: /\bcharts?\b|\bgraphs?\b|\bplots?\b/i,
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
20
|
+
function isRecord(value) {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {unknown} value @returns {value is unknown[]} */
|
|
25
|
+
function isArray(value) {
|
|
26
|
+
return Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
/**
|
|
20
30
|
* Pull the stories out of a Storybook index. Handles `index.json` (`entries`, with docs pages) and the older `stories.json`.
|
|
21
|
-
* @param {
|
|
31
|
+
* @param {unknown} index
|
|
22
32
|
* @returns {Array<{ id: string, title: string, name: string, tags: string[] }>}
|
|
23
33
|
*/
|
|
24
34
|
export function listStories(index) {
|
|
25
|
-
const
|
|
35
|
+
const data = isRecord(index) ? index : {};
|
|
36
|
+
const table = (isRecord(data.entries) ? data.entries : null) ?? (isRecord(data.stories) ? data.stories : {});
|
|
26
37
|
return Object.values(table)
|
|
27
|
-
.filter(
|
|
38
|
+
.filter(isRecord)
|
|
39
|
+
.filter((entry) => entry.type === undefined || entry.type === "story")
|
|
28
40
|
.map((entry) => ({
|
|
29
41
|
id: String(entry.id),
|
|
30
42
|
title: String(entry.title ?? entry.kind ?? ""),
|
|
31
43
|
name: String(entry.name ?? entry.story ?? ""),
|
|
32
|
-
tags:
|
|
44
|
+
tags: isArray(entry.tags) ? entry.tags.map(String) : [],
|
|
33
45
|
}))
|
|
34
46
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
35
47
|
}
|
|
@@ -88,7 +100,8 @@ export function storyUrl(base, id) {
|
|
|
88
100
|
/**
|
|
89
101
|
* Read a Storybook's index from disk or over HTTP.
|
|
90
102
|
* @param {{ path?: string | null, url?: string | null, index: string }} resolved
|
|
91
|
-
* @param {
|
|
103
|
+
* @param {(url: URL, options?: { signal?: AbortSignal }) => Promise<{ ok: boolean, status: number, json(): Promise<unknown> }>} [doFetch]
|
|
104
|
+
* @returns {Promise<unknown>}
|
|
92
105
|
*/
|
|
93
106
|
export async function readIndex(resolved, doFetch = globalThis.fetch) {
|
|
94
107
|
if (resolved.path) return JSON.parse(readFileSync(join(resolved.path, resolved.index), "utf8"));
|
package/src/plan/build-plan.js
CHANGED
|
@@ -36,6 +36,7 @@ export async function buildPlan({ command, targets, options, browserVersion = nu
|
|
|
36
36
|
kind: target.kind,
|
|
37
37
|
evidenceLevel: target.evidenceLevel,
|
|
38
38
|
resolved: target.resolved,
|
|
39
|
+
...(target.companions?.length ? { companions: target.companions } : {}),
|
|
39
40
|
mapping: null,
|
|
40
41
|
};
|
|
41
42
|
});
|
package/src/plan/classify.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { cleanSubpath } from "./subpath.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* @typedef {"npm" | "storybook" | "url" | "html-file" | "static-dir"} TargetKind
|
|
8
|
+
* @typedef {"npm" | "npm-react" | "npm-vue" | "npm-angular" | "npm-svelte" | "npm-html" | "npm-wc" | "npm-unsupported" | "storybook" | "url" | "html-file" | "static-dir"} TargetKind
|
|
9
9
|
* @typedef {{
|
|
10
10
|
* input: string,
|
|
11
11
|
* label: string | null,
|
|
@@ -15,9 +15,10 @@ import { cleanSubpath } from "./subpath.js";
|
|
|
15
15
|
* kind: TargetKind | null,
|
|
16
16
|
* evidenceLevel: "component" | "page" | null,
|
|
17
17
|
* resolved: Record<string, string | null> | null,
|
|
18
|
+
* companions?: Array<{ name: string, requested: string | null, version: string | null, subpath: string | null }>,
|
|
18
19
|
* }} ClassifiedTarget
|
|
19
20
|
* @typedef {(url: string | URL, init?: { signal?: AbortSignal, redirect?: string }) => Promise<{ ok: boolean, status: number, text(): Promise<string> }>} FetchLike
|
|
20
|
-
* @typedef {{ cwd?: string, home?: string, fetch?: FetchLike, timeoutMs?: number, npmView?: (spec: string) => Promise<
|
|
21
|
+
* @typedef {{ cwd?: string, home?: string, fetch?: FetchLike, timeoutMs?: number, npmView?: (spec: string) => Promise<unknown> }} ClassifyContext
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
const LOCAL_PATH = /^(\.{1,2}(\/|$)|\/|~(\/|$)|file:)/;
|
|
@@ -123,30 +124,57 @@ async function classifyUrl(spec, base, ctx) {
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
/**
|
|
127
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Read one npm spec: `name`, `name@version`, or either with a sub-path.
|
|
129
|
+
* @param {string} spec
|
|
130
|
+
* @returns {{ name: string, requested: string | null, subpath: string | null } | { error: string }}
|
|
131
|
+
*/
|
|
132
|
+
function parseNpmSpec(spec) {
|
|
128
133
|
const match = NPM_NAME.exec(spec);
|
|
129
134
|
if (!match) {
|
|
130
|
-
if (/[A-Z]/.test(spec) && NPM_NAME.test(spec.toLowerCase())) return
|
|
131
|
-
return
|
|
135
|
+
if (/[A-Z]/.test(spec) && NPM_NAME.test(spec.toLowerCase())) return { error: `"${spec}" isn't a valid package name. npm package names are lowercase.` };
|
|
136
|
+
return { error: `"${spec}" isn't a valid npm package name. Write npm:name, npm:@scope/name, or npm:name@version.` };
|
|
132
137
|
}
|
|
133
138
|
const [, scope, name, version, rawSubpath] = match;
|
|
134
|
-
if (version === "") return
|
|
139
|
+
if (version === "") return { error: `"${spec}" ends with @ but has no version.` };
|
|
135
140
|
let subpath = null;
|
|
136
141
|
if (rawSubpath !== undefined) {
|
|
137
142
|
const cleaned = cleanSubpath(rawSubpath);
|
|
138
|
-
if ("reason" in cleaned) return
|
|
143
|
+
if ("reason" in cleaned) return { error: `"${spec}" isn't a valid sub-path: ${cleaned.reason}. Write npm:name/sub/path or npm:name@version/sub/path.` };
|
|
139
144
|
subpath = /** @type {{ subpath: string }} */ (cleaned).subpath;
|
|
140
145
|
}
|
|
141
|
-
|
|
146
|
+
return { name: scope ? `@${scope}/${name}` : name, requested: version ?? null, subpath };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* An npm target. A comma-separated list (`npm:a,b@1.2`) is one target made of several packages that install into one folder.
|
|
151
|
+
* The first is the primary: it names the target and decides the framework. The rest are companions, such as a script package
|
|
152
|
+
* that goes with a stylesheet package.
|
|
153
|
+
* @param {string} spec @param {{ input: string, label: string | null, name: string }} base @returns {ClassifiedTarget}
|
|
154
|
+
*/
|
|
155
|
+
function classifyNpm(spec, base) {
|
|
156
|
+
const entries = spec.split(",").map((entry) => entry.trim());
|
|
157
|
+
if (entries.length > 1 && entries.some((entry) => entry === "")) {
|
|
158
|
+
return failed(`"${spec}" has an empty entry in its list. Write the packages with commas and no spaces, for example npm:a,b.`, base);
|
|
159
|
+
}
|
|
160
|
+
const parsed = entries.map(parseNpmSpec);
|
|
161
|
+
for (const entry of parsed) if ("error" in entry) return failed(entry.error, base);
|
|
162
|
+
const [primary, ...companions] = /** @type {Array<{ name: string, requested: string | null, subpath: string | null }>} */ (parsed);
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
for (const entry of [primary, ...companions]) {
|
|
165
|
+
const key = `${entry.name}/${entry.subpath ?? ""}`;
|
|
166
|
+
if (seen.has(key)) return failed(`"${entry.name}${entry.subpath ? `/${entry.subpath}` : ""}" is in the list more than once.`, base);
|
|
167
|
+
seen.add(key);
|
|
168
|
+
}
|
|
142
169
|
return {
|
|
143
170
|
...base,
|
|
144
|
-
name: base.name || (subpath ? `${
|
|
171
|
+
name: base.name || (primary.subpath ? `${primary.name}/${primary.subpath}` : primary.name),
|
|
145
172
|
status: "ok",
|
|
146
173
|
reason: null,
|
|
147
174
|
kind: "npm",
|
|
148
175
|
evidenceLevel: "component",
|
|
149
|
-
resolved: { name:
|
|
176
|
+
resolved: { name: primary.name, requested: primary.requested, version: null, subpath: primary.subpath },
|
|
177
|
+
...(companions.length ? { companions: companions.map((c) => ({ name: c.name, requested: c.requested, version: null, subpath: c.subpath })) } : {}),
|
|
150
178
|
};
|
|
151
179
|
}
|
|
152
180
|
|
package/src/plan/mapping.js
CHANGED
|
@@ -39,14 +39,14 @@ export function score(archetype, name) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Guess which exports (React or
|
|
42
|
+
* Guess which exports (React, Vue, or Angular) or tags (web components) stand for each archetype.
|
|
43
43
|
* The result is a starting point. A person or the skill checks it before trusting it.
|
|
44
|
-
* @param {{ flavor: "react" | "vue" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
|
|
45
|
-
* @returns {
|
|
44
|
+
* @param {{ flavor: "react" | "vue" | "angular" | "svelte" | "html" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
|
|
45
|
+
* @returns {ReturnType<typeof parseMappingFile>[string]}
|
|
46
46
|
*/
|
|
47
47
|
export function candidateMapping({ flavor, exports = [], tags = [] }) {
|
|
48
48
|
const names = flavor === "wc" ? tags : exports.filter((e) => /^[A-Z]/.test(e.name)).map((e) => e.name);
|
|
49
|
-
/** @type {
|
|
49
|
+
/** @type {ReturnType<typeof parseMappingFile>[string]} */
|
|
50
50
|
const mapping = {};
|
|
51
51
|
for (const archetype of ARCHETYPES) {
|
|
52
52
|
const ranked = names
|
|
@@ -64,7 +64,9 @@ export function candidateMapping({ flavor, exports = [], tags = [] }) {
|
|
|
64
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
65
|
const siblings = flavor !== "wc" && !TEMPLATED.has(archetype) ? names.filter((n) => n !== best && n.startsWith(best) && n.length > best.length) : [];
|
|
66
66
|
const compound = parts.length > 0 || siblings.length >= 2;
|
|
67
|
-
|
|
67
|
+
// A button written as `Button.Root` (a namespace whose only part is the root) is still one element, so a template can use the root.
|
|
68
|
+
const hasRoot = TEMPLATED.has(archetype) && parts.length === 1 && /^root$/i.test(parts[0]);
|
|
69
|
+
const templated = TEMPLATED.has(archetype) && (!compound || hasRoot);
|
|
68
70
|
mapping[archetype] = {
|
|
69
71
|
flavor,
|
|
70
72
|
...(flavor === "wc" ? { tag: best } : { export: best }),
|
|
@@ -97,7 +99,7 @@ export function findAuthoredFixture({ cwd, targetId, archetype, mapped }) {
|
|
|
97
99
|
const file = resolve(cwd, mapped.fixture);
|
|
98
100
|
return existsSync(file) ? file : null;
|
|
99
101
|
}
|
|
100
|
-
for (const ext of ["jsx", "js"]) {
|
|
102
|
+
for (const ext of ["jsx", "js", "ts", "svelte", "html"]) {
|
|
101
103
|
const file = resolve(cwd, "fixtures", targetId, `${archetype}.${ext}`);
|
|
102
104
|
if (existsSync(file)) return file;
|
|
103
105
|
}
|
package/src/plan/resolve-npm.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { isAsset } from "../frameworks/html.js";
|
|
2
3
|
import { ADAPTERS } from "../frameworks/index.js";
|
|
3
4
|
import { checkExports, notExportedMessage } from "./subpath.js";
|
|
4
5
|
|
|
5
|
-
const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated", "exports"];
|
|
6
|
+
const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated", "exports", "style", "unpkg", "jsdelivr"];
|
|
6
7
|
const OTHER_FRAMEWORKS = {
|
|
7
|
-
"@angular/core": "Angular",
|
|
8
|
-
svelte: "Svelte",
|
|
9
8
|
"solid-js": "Solid",
|
|
10
9
|
preact: "Preact",
|
|
11
10
|
"@builder.io/qwik": "Qwik",
|
|
@@ -13,11 +12,16 @@ const OTHER_FRAMEWORKS = {
|
|
|
13
12
|
};
|
|
14
13
|
const WEB_COMPONENT_BASES = ["lit", "lit-element", "@lit/reactive-element", "@stencil/core", "@microsoft/fast-element", "@polymer/polymer"];
|
|
15
14
|
|
|
15
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
/**
|
|
17
21
|
* Read a package's registry metadata without installing it.
|
|
18
22
|
* @param {string} spec `name`, `name@version`, or `name@range`
|
|
19
23
|
* @param {{ timeoutMs?: number }} [options]
|
|
20
|
-
* @returns {Promise<
|
|
24
|
+
* @returns {Promise<unknown>} The metadata. Throws an Error with a plain reason when the package can't be read.
|
|
21
25
|
*/
|
|
22
26
|
export function npmView(spec, { timeoutMs = 60_000 } = {}) {
|
|
23
27
|
return new Promise((resolve, reject) => {
|
|
@@ -45,48 +49,75 @@ export function npmView(spec, { timeoutMs = 60_000 } = {}) {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
|
-
* Guess how a package renders from its metadata alone. React wins when both signals appear, then a custom elements manifest, then Vue.
|
|
52
|
+
* Guess how a package renders from its metadata alone. React wins when both signals appear, then a custom elements manifest, then Vue, then Angular.
|
|
49
53
|
* `npm` means the metadata can't say, so the run decides after it installs and loads the package.
|
|
50
|
-
* @param {
|
|
51
|
-
* @returns {{ kind: "npm-react" | "npm-vue" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
|
|
54
|
+
* @param {unknown} meta
|
|
55
|
+
* @returns {{ kind: "npm-react" | "npm-vue" | "npm-angular" | "npm-svelte" | "npm-html" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
|
|
52
56
|
*/
|
|
53
57
|
export function detectFlavor(meta) {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
58
|
+
const data = isRecord(meta) ? meta : {};
|
|
59
|
+
const peers = isRecord(data.peerDependencies) ? data.peerDependencies : {};
|
|
60
|
+
const deps = isRecord(data.dependencies) ? data.dependencies : {};
|
|
61
|
+
const react = ADAPTERS.react.detect(data);
|
|
57
62
|
if (react) return react;
|
|
58
|
-
if (
|
|
59
|
-
const vue =
|
|
63
|
+
if (data.customElements) return { kind: "npm-wc", framework: "Web components", reason: "The package has a customElements manifest." };
|
|
64
|
+
const vue = ADAPTERS.vue.detect(data);
|
|
60
65
|
if (vue) return vue;
|
|
66
|
+
const angular = ADAPTERS.angular.detect(data);
|
|
67
|
+
if (angular) return angular;
|
|
68
|
+
const svelte = ADAPTERS.svelte.detect(data);
|
|
69
|
+
if (svelte) return svelte;
|
|
61
70
|
for (const [name, label] of Object.entries(OTHER_FRAMEWORKS)) {
|
|
62
71
|
if (name in peers) return { kind: "npm-unsupported", framework: label, reason: `The package needs ${label}.` };
|
|
63
72
|
}
|
|
64
73
|
const base = WEB_COMPONENT_BASES.find((name) => name in deps || name in peers);
|
|
65
74
|
if (base) return { kind: "npm-wc", framework: "Web components", reason: `The package builds on ${base}.` };
|
|
75
|
+
const html = ADAPTERS.html.detect(data);
|
|
76
|
+
if (html) return html;
|
|
66
77
|
return { kind: "npm", framework: null, reason: "The metadata doesn't say. The run decides after it loads the package." };
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
/**
|
|
70
81
|
* Fill in a classified npm target: the concrete version and the framework guess.
|
|
71
82
|
* @param {import("./classify.js").ClassifiedTarget} target
|
|
72
|
-
* @param {(spec: string) => Promise<
|
|
83
|
+
* @param {(spec: string) => Promise<unknown>} view
|
|
73
84
|
* @returns {Promise<import("./classify.js").ClassifiedTarget>}
|
|
74
85
|
*/
|
|
75
86
|
export async function resolveNpmTarget(target, view) {
|
|
76
87
|
if (target.status !== "ok" || target.kind !== "npm" || !target.resolved) return target;
|
|
77
88
|
const { name, requested, subpath } = target.resolved;
|
|
78
89
|
try {
|
|
79
|
-
const
|
|
90
|
+
const metadata = await view(`${name}@${requested ?? "latest"}`);
|
|
91
|
+
if (!isRecord(metadata)) throw new Error("npm returned package metadata in an unreadable shape.");
|
|
92
|
+
const meta = metadata;
|
|
80
93
|
// The registry lists a package's exports, so a wrong sub-path is caught here, before anything is installed.
|
|
81
94
|
if (subpath) {
|
|
82
95
|
const result = checkExports(meta.exports, subpath);
|
|
83
|
-
if (result.checked && !result.ok) throw new Error(notExportedMessage({ name, version: meta.version
|
|
96
|
+
if (result.checked && !result.ok) throw new Error(notExportedMessage({ name, version: typeof meta.version === "string" ? meta.version : null, subpath, exact: result.exact, patterns: result.patterns }));
|
|
97
|
+
}
|
|
98
|
+
let flavor = detectFlavor(meta);
|
|
99
|
+
// A list that names a stylesheet or a script (`npm:a/components.css,b/interactions.iife.js`) is plain HTML when the metadata names no framework.
|
|
100
|
+
if (flavor.kind === "npm" && [target.resolved.subpath, ...(target.companions ?? []).map((c) => c.subpath)].some(isAsset)) {
|
|
101
|
+
flavor = { kind: "npm-html", framework: "HTML", reason: "The target names a stylesheet or a script to load." };
|
|
102
|
+
}
|
|
103
|
+
// Each companion is looked up the same way, so a wrong name, version, or sub-path fails here, before anything is installed.
|
|
104
|
+
/** @type {Array<{ name: string, requested: string | null, version: string | null, subpath: string | null }>} */
|
|
105
|
+
const companions = [];
|
|
106
|
+
for (const companion of target.companions ?? []) {
|
|
107
|
+
const found = await view(`${companion.name}@${companion.requested ?? "latest"}`);
|
|
108
|
+
if (!isRecord(found)) throw new Error(`npm returned metadata for ${companion.name} in an unreadable shape.`);
|
|
109
|
+
const version = typeof found.version === "string" ? found.version : null;
|
|
110
|
+
if (companion.subpath) {
|
|
111
|
+
const result = checkExports(found.exports, companion.subpath);
|
|
112
|
+
if (result.checked && !result.ok) throw new Error(notExportedMessage({ name: companion.name, version, subpath: companion.subpath, exact: result.exact, patterns: result.patterns }));
|
|
113
|
+
}
|
|
114
|
+
companions.push({ ...companion, version });
|
|
84
115
|
}
|
|
85
|
-
const flavor = detectFlavor(meta);
|
|
86
116
|
return {
|
|
87
117
|
...target,
|
|
88
|
-
kind:
|
|
89
|
-
resolved: { ...target.resolved, version: meta.version
|
|
118
|
+
kind: flavor.kind,
|
|
119
|
+
resolved: { ...target.resolved, version: typeof meta.version === "string" ? meta.version : null, framework: flavor.framework, detectedBy: flavor.reason },
|
|
120
|
+
...(companions.length ? { companions } : {}),
|
|
90
121
|
};
|
|
91
122
|
} catch (error) {
|
|
92
123
|
return { ...target, status: "failed", reason: error instanceof Error ? error.message : String(error), kind: null, evidenceLevel: null, resolved: null };
|
package/src/plan/subpath.js
CHANGED
|
@@ -77,6 +77,23 @@ export function subpathProblem(workDir, name, subpath, version) {
|
|
|
77
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
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Sub-paths an installed package offers, as specs a person could pass (`@scope/pkg/button`), for a message that says where
|
|
82
|
+
* to look when a package's main entry has no components. Patterns and `package.json` are left out.
|
|
83
|
+
* @param {string} workDir
|
|
84
|
+
* @param {string} name
|
|
85
|
+
* @param {number} [limit]
|
|
86
|
+
* @returns {string[]}
|
|
87
|
+
*/
|
|
88
|
+
export function offeredSubpaths(workDir, name, limit = 4) {
|
|
89
|
+
try {
|
|
90
|
+
const manifest = JSON.parse(readFileSync(join(workDir, "node_modules", name, "package.json"), "utf8"));
|
|
91
|
+
return exportKeys(manifest.exports).exact.slice(0, limit).map((key) => `${name}${key.slice(1)}`);
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
/** The extensions a file can be imported without writing, for a package with no exports map. */
|
|
81
98
|
const EXTENSIONS = ["", ".js", ".mjs", ".cjs", ".json", "/index.js", "/index.mjs", "/index.cjs"];
|
|
82
99
|
|
package/src/report/comparison.js
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
configLabel,
|
|
11
11
|
finish,
|
|
12
12
|
hasGenerated,
|
|
13
|
+
hasNativeTemplates,
|
|
14
|
+
NATIVE_NOTE,
|
|
13
15
|
notTestableLines,
|
|
14
16
|
reportFooter,
|
|
15
17
|
reportHeader,
|
|
@@ -168,7 +170,7 @@ function impactTables(results, engines) {
|
|
|
168
170
|
/**
|
|
169
171
|
* Render report.md for a comparison of two or more targets.
|
|
170
172
|
* Every target ran with the same settings, so the columns line up. A gap or a failure shows in its cell, and never reads as a pass.
|
|
171
|
-
* @param {{ plan:
|
|
173
|
+
* @param {{ plan: ReturnType<typeof import("../schema.js").parsePlan>, results: ReturnType<typeof import("../schema.js").parseResults> }} input
|
|
172
174
|
*/
|
|
173
175
|
export function renderComparison({ plan, results }) {
|
|
174
176
|
const o = plan.options;
|
|
@@ -185,6 +187,7 @@ export function renderComparison({ plan, results }) {
|
|
|
185
187
|
}
|
|
186
188
|
lines.push(...notTestableLines(results));
|
|
187
189
|
if (hasGenerated(results)) lines.push(GENERATED_NOTE, "");
|
|
190
|
+
if (hasNativeTemplates(results)) lines.push(NATIVE_NOTE, "");
|
|
188
191
|
|
|
189
192
|
lines.push("## Findings.", "", FINDINGS_NOTE, "", ...(o.tiers.includes("rules") ? impactTables(results, o.engines) : []));
|
|
190
193
|
for (const key of keys) {
|
package/src/report/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { renderSingleReport } from "./single.js";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Render report.md. A comparison of two or more targets gets the side-by-side report. Anything else gets the single report.
|
|
6
|
-
* @param {{ plan:
|
|
6
|
+
* @param {{ plan: ReturnType<typeof import("../schema.js").parsePlan>, results: ReturnType<typeof import("../schema.js").parseResults> }} input
|
|
7
7
|
*/
|
|
8
8
|
export function renderReport(input) {
|
|
9
9
|
return input.plan.command === "compare" && input.results.targets.length > 1 ? renderComparison(input) : renderSingleReport(input);
|
package/src/report/parts.js
CHANGED
|
@@ -2,6 +2,8 @@ import { adapterFor } from "../frameworks/index.js";
|
|
|
2
2
|
import { criterion, criterionName, wcagAttribution } from "../wcag/index.js";
|
|
3
3
|
import { cap, num, plural } from "../text.js";
|
|
4
4
|
|
|
5
|
+
/** @typedef {NonNullable<ReturnType<typeof import("../schema.js").parseResults>["targets"][number]["archetypes"][string]["configs"][number]["tiers"][string]["flags"]>[number]} VsrFlag */
|
|
6
|
+
|
|
5
7
|
/** Markdown helpers. */
|
|
6
8
|
export const cell = (text) => String(text ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
7
9
|
export const code = (text) => `\`${String(text).replace(/`/g, "'")}\``;
|
|
@@ -162,7 +164,7 @@ export function storybookSection(planTarget, target) {
|
|
|
162
164
|
}
|
|
163
165
|
const walks = Object.entries(target.archetypes).map(([key, a]) => ({ id: key.replace(/^story:/, ""), vsr: a.configs[0]?.tiers.vsr })).filter((w) => w.vsr?.status === "ran");
|
|
164
166
|
if (walks.length) {
|
|
165
|
-
/** @type {Map<string, { flag:
|
|
167
|
+
/** @type {Map<string, { flag: VsrFlag, stories: string[] }>} */
|
|
166
168
|
const byFlag = new Map();
|
|
167
169
|
for (const { id, vsr } of walks) for (const flag of vsr.flags) {
|
|
168
170
|
const key = `${flag.type}|${flag.phrase}`;
|
|
@@ -265,6 +267,14 @@ export function hasGenerated(results) {
|
|
|
265
267
|
return results.targets.some((t) => Object.values(t.archetypes ?? {}).some((a) => a.fixture?.source === "generated"));
|
|
266
268
|
}
|
|
267
269
|
|
|
270
|
+
/** True when a plain HTML target ran from a native-markup template. */
|
|
271
|
+
export function hasNativeTemplates(results) {
|
|
272
|
+
return results.targets.some((t) => t.npm?.flavor === "html" && Object.values(t.archetypes ?? {}).some((a) => a.fixture?.source === "template"));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** What a plain HTML template is, and what it can't say about a package. */
|
|
276
|
+
export const NATIVE_NOTE = "**Native markup templates.** For a plain HTML target the tool used bare native markup (a button, a link, a fieldset, a dialog, details, a popover) with none of the package's classes, so these results show what the package's styles and scripts do to ordinary elements. They don't test a component the package built, and a pass here never means the package's own components pass. A check that needs script behavior the markup doesn't have, such as arrow keys in a menu, measures the bare markup and not the package. Bring a fixture of your own to test the package's components.";
|
|
277
|
+
|
|
268
278
|
/** What "generated" means, for any report that has one. */
|
|
269
279
|
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
280
|
|
|
@@ -279,6 +289,7 @@ export function targetSection(planTarget, target, { generatedNote = true } = {})
|
|
|
279
289
|
if (target.npm) {
|
|
280
290
|
const n = target.npm;
|
|
281
291
|
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)}.`, "");
|
|
292
|
+
if (n.companions?.length) lines.push(`Installed beside it, in the same folder: ${n.companions.map((c) => code(`${c.name}@${c.version}${c.subpath ? `/${c.subpath}` : ""}`)).join(", ")}.`, "");
|
|
282
293
|
}
|
|
283
294
|
for (const warning of target.warnings) lines.push(`Warning: ${warning}`, "");
|
|
284
295
|
if (target.status === "failed") {
|
|
@@ -286,6 +297,7 @@ export function targetSection(planTarget, target, { generatedNote = true } = {})
|
|
|
286
297
|
}
|
|
287
298
|
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
299
|
if (generatedNote && target.npm && hasGenerated({ targets: [target] })) lines.push(GENERATED_NOTE, "");
|
|
300
|
+
if (generatedNote && target.npm && hasNativeTemplates({ targets: [target] })) lines.push(NATIVE_NOTE, "");
|
|
289
301
|
/** @type {Set<string>} */
|
|
290
302
|
const skipped = new Set();
|
|
291
303
|
for (const [name, archetype] of Object.entries(target.archetypes)) {
|
package/src/report/single.js
CHANGED
|
@@ -2,7 +2,7 @@ import { FINDINGS_NOTE, coverageMatrix, finish, failCheckSection, notTestableLin
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Render report.md for an audit. The text comes from results.json and nothing else.
|
|
5
|
-
* @param {{ plan:
|
|
5
|
+
* @param {{ plan: ReturnType<typeof import("../schema.js").parsePlan>, results: ReturnType<typeof import("../schema.js").parseResults> }} input
|
|
6
6
|
*/
|
|
7
7
|
export function renderSingleReport({ plan, results }) {
|
|
8
8
|
const lines = reportHeader(plan, results, "Accessibility report.");
|