automatica11y 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/bin/automatica11y.js +4 -0
- package/package.json +38 -4
- 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/init-skill.js +7 -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 +173 -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
- package/README.md +0 -3
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const ASSET_LOADERS = /** @type {Record<string, import("esbuild").Loader>} */ (Object.fromEntries(
|
|
5
|
+
[".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".woff", ".woff2", ".ttf", ".eot"].map((ext) => [ext, "dataurl"]),
|
|
6
|
+
));
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Bundle fixture entries into one folder of browser-ready ES modules, plus one HTML shell per entry.
|
|
10
|
+
* esbuild loads here and only here. It doesn't type-check, and fixtures don't need it to.
|
|
11
|
+
* @param {{ entries: Record<string, string>, outdir: string, workDir: string, react?: boolean }} options
|
|
12
|
+
* @returns {Promise<Record<string, string>>} Entry name to page path, for example `{ dialog: "/dialog.html" }`.
|
|
13
|
+
*/
|
|
14
|
+
export async function bundleEntries({ entries, outdir, workDir, react = false }) {
|
|
15
|
+
const esbuild = await import("esbuild");
|
|
16
|
+
mkdirSync(outdir, { recursive: true });
|
|
17
|
+
try {
|
|
18
|
+
await esbuild.build({
|
|
19
|
+
entryPoints: entries,
|
|
20
|
+
outdir,
|
|
21
|
+
bundle: true,
|
|
22
|
+
format: "esm",
|
|
23
|
+
platform: "browser",
|
|
24
|
+
target: "es2022",
|
|
25
|
+
jsx: "automatic",
|
|
26
|
+
absWorkingDir: workDir,
|
|
27
|
+
nodePaths: [join(workDir, "node_modules")],
|
|
28
|
+
// One copy of React for the library and the fixture, or hooks break.
|
|
29
|
+
alias: react ? { react: join(workDir, "node_modules", "react"), "react-dom": join(workDir, "node_modules", "react-dom") } : {},
|
|
30
|
+
loader: ASSET_LOADERS,
|
|
31
|
+
define: { "process.env.NODE_ENV": '"development"' },
|
|
32
|
+
logLevel: "silent",
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const messages = (error.errors ?? []).slice(0, 3).map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`);
|
|
36
|
+
throw new Error(`Bundling failed: ${messages.join("; ") || error.message}`);
|
|
37
|
+
}
|
|
38
|
+
/** @type {Record<string, string>} */
|
|
39
|
+
const pages = {};
|
|
40
|
+
for (const name of Object.keys(entries)) {
|
|
41
|
+
const css = existsSync(join(outdir, `${name}.css`)) ? `<link rel="stylesheet" href="./${name}.css">` : "";
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(outdir, `${name}.html`),
|
|
44
|
+
`<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>${name}</title><link rel="icon" href="data:,">${css}</head>\n<body><div id="root"></div><script type="module" src="./${name}.js"></script></body>\n</html>\n`,
|
|
45
|
+
);
|
|
46
|
+
pages[name] = `/${name}.html`;
|
|
47
|
+
}
|
|
48
|
+
return pages;
|
|
49
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const NPM_FLAGS = ["--ignore-scripts", "--no-audit", "--no-fund", "--prefer-offline", "--loglevel=error"];
|
|
6
|
+
|
|
7
|
+
/** Run npm in `cwd`. Resolves with stdout and rejects with npm's own words. */
|
|
8
|
+
export function runNpm(args, cwd, { timeoutMs = 300_000 } = {}) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
execFile(
|
|
11
|
+
process.platform === "win32" ? "npm.cmd" : "npm",
|
|
12
|
+
args,
|
|
13
|
+
{ cwd, timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024, shell: process.platform === "win32" },
|
|
14
|
+
(error, stdout, stderr) => {
|
|
15
|
+
if (error) reject(Object.assign(new Error(`${stderr}\n${stdout}`.trim() || error.message), { code: "NPM_FAILED" }));
|
|
16
|
+
else resolve(stdout);
|
|
17
|
+
},
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The version of an installed package, or null. */
|
|
23
|
+
export function installedVersion(dir, name) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(join(dir, "node_modules", name, "package.json"), "utf8")).version ?? null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Install a package into its own directory, never next to another target's install.
|
|
33
|
+
* npm adds the peer dependencies. A React package also needs react-dom, so add it when the peers left it out.
|
|
34
|
+
* Install scripts stay off, because the code is untrusted until it runs in the browser sandbox.
|
|
35
|
+
* @param {{ dir: string, name: string, version: string, flavor: "react" | "wc" | "unknown", run?: typeof runNpm }} options
|
|
36
|
+
*/
|
|
37
|
+
export async function installPackage({ dir, name, version, flavor, run = runNpm }) {
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
if (!existsSync(join(dir, "package.json"))) writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "automatica11y-target", private: true }));
|
|
40
|
+
/** @type {string[]} */
|
|
41
|
+
const warnings = [];
|
|
42
|
+
try {
|
|
43
|
+
await run(["install", `${name}@${version}`, ...NPM_FLAGS], dir);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!/ERESOLVE|peer dep/i.test(String(error.message))) throw new Error(`npm couldn't install ${name}@${version}: ${firstLine(error.message)}`);
|
|
46
|
+
await run(["install", `${name}@${version}`, "--legacy-peer-deps", ...NPM_FLAGS], dir).catch((retry) => {
|
|
47
|
+
throw new Error(`npm couldn't install ${name}@${version}: ${firstLine(retry.message)}`);
|
|
48
|
+
});
|
|
49
|
+
warnings.push(`npm couldn't satisfy ${name}'s peer dependencies, so it installed them loosely (--legacy-peer-deps). Results may not match a supported setup.`);
|
|
50
|
+
}
|
|
51
|
+
if (flavor === "react") {
|
|
52
|
+
const react = installedVersion(dir, "react");
|
|
53
|
+
if (!react) {
|
|
54
|
+
await run(["install", "react", "react-dom", ...NPM_FLAGS, "--legacy-peer-deps"], dir);
|
|
55
|
+
warnings.push(`${name} didn't bring in react, so the latest react and react-dom were added.`);
|
|
56
|
+
} else if (!installedVersion(dir, "react-dom")) {
|
|
57
|
+
await run(["install", `react-dom@${react}`, ...NPM_FLAGS, "--legacy-peer-deps"], dir);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), version: installedVersion(dir, name) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function firstLine(text) {
|
|
64
|
+
return String(text).split("\n").find((line) => line.trim())?.replace(/^npm (error|ERR!)\s*/i, "").trim() ?? "unknown error";
|
|
65
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** React flavor: the entry files and button and link templates that go into a bundle. */
|
|
2
|
+
|
|
3
|
+
/** Mounts a fixture's default export. */
|
|
4
|
+
export const entry = (fixturePath, _pkg) => `import { createElement } from "react";
|
|
5
|
+
import { createRoot } from "react-dom/client";
|
|
6
|
+
import Fixture from ${JSON.stringify(fixturePath)};
|
|
7
|
+
const libA11y = new URLSearchParams(location.search).get("libA11y") === "on";
|
|
8
|
+
createRoot(document.getElementById("root")).render(createElement(Fixture, { libA11y }));
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
/** Loads the whole package, lists its exports, and records compound parts such as Dialog.Trigger. */
|
|
12
|
+
export const discoverEntry = (pkg) => `import * as lib from ${JSON.stringify(pkg)};
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const name of Object.keys(lib)) {
|
|
15
|
+
const value = lib[name];
|
|
16
|
+
const type = typeof value;
|
|
17
|
+
if (value === null || (type !== "function" && type !== "object")) continue;
|
|
18
|
+
const parts = Object.keys(value).filter((key) => /^[A-Z]/.test(key)).slice(0, 30);
|
|
19
|
+
out.push({ name, type, parts });
|
|
20
|
+
}
|
|
21
|
+
window.__a11yExports = out;
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A fixture for the simple archetypes, from the export name alone.
|
|
26
|
+
* Compound components (dialog, tabs, menu) can't be guessed, so they need a fixture someone writes.
|
|
27
|
+
*/
|
|
28
|
+
export function template(archetype, pkg, exportName) {
|
|
29
|
+
const body = {
|
|
30
|
+
button: `<Component data-a11y-trigger data-a11y-root type="button">Save</Component>`,
|
|
31
|
+
link: `<Component data-a11y-trigger data-a11y-root href="#top">Read more</Component>`,
|
|
32
|
+
}[archetype];
|
|
33
|
+
if (!body) return null;
|
|
34
|
+
return `import { ${exportName} as Component } from ${JSON.stringify(pkg)};
|
|
35
|
+
export default function Fixture() {
|
|
36
|
+
return ${body};
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Web component flavor: framework-free entries and templates. */
|
|
2
|
+
|
|
3
|
+
/** Loads the package so its custom elements get defined, then calls the fixture's default export, `mount(container)`. */
|
|
4
|
+
export const entry = (fixturePath, pkg) => `import ${JSON.stringify(pkg)};
|
|
5
|
+
import mount from ${JSON.stringify(fixturePath)};
|
|
6
|
+
const libA11y = new URLSearchParams(location.search).get("libA11y") === "on";
|
|
7
|
+
await mount(document.getElementById("root"), { libA11y });
|
|
8
|
+
`;
|
|
9
|
+
|
|
10
|
+
/** Loads the whole package. The page's init script records every custom element the package defines. */
|
|
11
|
+
export const discoverEntry = (pkg) => `import * as lib from ${JSON.stringify(pkg)};
|
|
12
|
+
window.__a11yExports = Object.keys(lib).map((name) => ({ name, type: typeof lib[name], parts: [] }));
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
/** A fixture for the simple archetypes, from the tag name alone. */
|
|
16
|
+
export function template(archetype, tag) {
|
|
17
|
+
const attrs = {
|
|
18
|
+
button: `el.textContent = "Save";`,
|
|
19
|
+
link: `el.setAttribute("href", "#top");\n el.textContent = "Read more";`,
|
|
20
|
+
}[archetype];
|
|
21
|
+
if (!attrs) return null;
|
|
22
|
+
return `export default function mount(container) {
|
|
23
|
+
const el = document.createElement(${JSON.stringify(tag)});
|
|
24
|
+
el.setAttribute("data-a11y-trigger", "");
|
|
25
|
+
el.setAttribute("data-a11y-root", "");
|
|
26
|
+
${attrs}
|
|
27
|
+
container.append(el);
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs before any page script. It notes every closed shadow root, because axe and IBM can't see inside one
|
|
3
|
+
* and would report nothing, which reads as clean.
|
|
4
|
+
*/
|
|
5
|
+
export function recordClosedShadowRoots() {
|
|
6
|
+
const original = Element.prototype.attachShadow;
|
|
7
|
+
const hosts = [];
|
|
8
|
+
Object.defineProperty(window, "__a11yClosedShadowHosts", { value: hosts, configurable: true });
|
|
9
|
+
Element.prototype.attachShadow = function attachShadow(init) {
|
|
10
|
+
if (init && init.mode === "closed") hosts.push(this.localName);
|
|
11
|
+
return original.call(this, init);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The closed shadow roots the page created, as host tag names with counts, for example `{ "x-thing": 2 }`.
|
|
17
|
+
* @param {import("playwright-core").Page} page
|
|
18
|
+
* @returns {Promise<Record<string, number>>}
|
|
19
|
+
*/
|
|
20
|
+
export async function closedShadowHosts(page) {
|
|
21
|
+
const hosts = await page.evaluate(() => /** @type {any} */ (window).__a11yClosedShadowHosts ?? []).catch(() => []);
|
|
22
|
+
/** @type {Record<string, number>} */
|
|
23
|
+
const counts = {};
|
|
24
|
+
for (const host of hosts) counts[host] = (counts[host] ?? 0) + 1;
|
|
25
|
+
return counts;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A readable not-testable entry for each host tag. */
|
|
29
|
+
export function notTestableEntries(counts) {
|
|
30
|
+
return Object.entries(counts).map(([tag, n]) => `closed shadow root in <${tag}> (${n}): its content isn't visible to the rule engines`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Runs before any page script. Notes every custom element the page or a package defines. */
|
|
34
|
+
export function recordCustomElements() {
|
|
35
|
+
const original = customElements.define.bind(customElements);
|
|
36
|
+
const tags = [];
|
|
37
|
+
Object.defineProperty(window, "__a11yDefined", { value: tags, configurable: true });
|
|
38
|
+
customElements.define = (name, constructor, options) => {
|
|
39
|
+
tags.push(name);
|
|
40
|
+
return original(name, constructor, options);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
const TYPES = {
|
|
6
|
+
".html": "text/html; charset=utf-8",
|
|
7
|
+
".htm": "text/html; charset=utf-8",
|
|
8
|
+
".js": "text/javascript; charset=utf-8",
|
|
9
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
10
|
+
".css": "text/css; charset=utf-8",
|
|
11
|
+
".json": "application/json; charset=utf-8",
|
|
12
|
+
".svg": "image/svg+xml",
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".jpeg": "image/jpeg",
|
|
16
|
+
".gif": "image/gif",
|
|
17
|
+
".webp": "image/webp",
|
|
18
|
+
".ico": "image/x-icon",
|
|
19
|
+
".woff": "font/woff",
|
|
20
|
+
".woff2": "font/woff2",
|
|
21
|
+
".ttf": "font/ttf",
|
|
22
|
+
".txt": "text/plain; charset=utf-8",
|
|
23
|
+
".map": "application/json",
|
|
24
|
+
".wasm": "application/wasm",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Serve a directory over http://127.0.0.1 so module scripts and relative assets work. Never use file://.
|
|
29
|
+
* Binds a free port, serves only files inside `root`, and lists no directories.
|
|
30
|
+
* @param {string} root
|
|
31
|
+
* @returns {Promise<{ origin: string, close: () => Promise<void> }>}
|
|
32
|
+
*/
|
|
33
|
+
export async function serveStatic(root) {
|
|
34
|
+
const base = resolve(root);
|
|
35
|
+
const server = createServer((request, response) => {
|
|
36
|
+
try {
|
|
37
|
+
const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
|
|
38
|
+
let file = resolve(join(base, normalize(pathname)));
|
|
39
|
+
if (file !== base && !file.startsWith(base + sep)) {
|
|
40
|
+
response.writeHead(403).end("Forbidden");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html");
|
|
44
|
+
if (!existsSync(file) || !statSync(file).isFile()) {
|
|
45
|
+
response.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
response.writeHead(200, { "content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream", "cache-control": "no-store" });
|
|
49
|
+
if (request.method === "HEAD") response.end();
|
|
50
|
+
else createReadStream(file).pipe(response);
|
|
51
|
+
} catch {
|
|
52
|
+
response.writeHead(400).end("Bad request");
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
await new Promise((done, fail) => {
|
|
56
|
+
server.once("error", fail);
|
|
57
|
+
server.listen(0, "127.0.0.1", () => done(undefined));
|
|
58
|
+
});
|
|
59
|
+
const address = /** @type {import("node:net").AddressInfo} */ (server.address());
|
|
60
|
+
return {
|
|
61
|
+
origin: `http://127.0.0.1:${address.port}`,
|
|
62
|
+
close: () =>
|
|
63
|
+
new Promise((done) => {
|
|
64
|
+
server.closeAllConnections?.();
|
|
65
|
+
server.close(() => done());
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Name patterns that suggest an archetype. They read the story title, name, and tags. */
|
|
5
|
+
export const ARCHETYPE_PATTERNS = {
|
|
6
|
+
button: /\bbuttons?\b/i,
|
|
7
|
+
link: /\blinks?\b|\banchors?\b/i,
|
|
8
|
+
dialog: /\bdialogs?\b|\bmodals?\b|\bdrawers?\b|\balert ?dialogs?\b/i,
|
|
9
|
+
menu: /\bmenus?\b|\bdropdown ?menus?\b/i,
|
|
10
|
+
tabs: /\btabs?\b|\btab ?list\b/i,
|
|
11
|
+
combobox: /\bcombo ?box(es)?\b|\bautocomplete\b|\btypeahead\b|\bselect\b/i,
|
|
12
|
+
"form-field": /\binputs?\b|\btext ?(field|area|box)\b|\bform\b|\bcheckbox(es)?\b|\bradio\b|\bswitch\b|\bfield\b/i,
|
|
13
|
+
accordion: /\baccordions?\b|\bcollaps(e|ible)\b|\bdisclosure\b/i,
|
|
14
|
+
tooltip: /\btooltips?\b|\bpopovers?\b/i,
|
|
15
|
+
chart: /\bcharts?\b|\bgraphs?\b|\bplots?\b/i,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pull the stories out of a Storybook index. Handles `index.json` (`entries`, with docs pages) and the older `stories.json`.
|
|
20
|
+
* @param {any} index
|
|
21
|
+
* @returns {Array<{ id: string, title: string, name: string, tags: string[] }>}
|
|
22
|
+
*/
|
|
23
|
+
export function listStories(index) {
|
|
24
|
+
const table = index.entries ?? index.stories ?? {};
|
|
25
|
+
return Object.values(table)
|
|
26
|
+
.filter((entry) => entry && typeof entry === "object" && (entry.type === undefined || entry.type === "story"))
|
|
27
|
+
.map((entry) => ({
|
|
28
|
+
id: String(entry.id),
|
|
29
|
+
title: String(entry.title ?? entry.kind ?? ""),
|
|
30
|
+
name: String(entry.name ?? entry.story ?? ""),
|
|
31
|
+
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
|
|
32
|
+
}))
|
|
33
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The archetypes a story's title, name, and tags suggest. */
|
|
37
|
+
export function matchArchetypes(story) {
|
|
38
|
+
const text = `${story.title} ${story.name} ${story.tags.join(" ")}`;
|
|
39
|
+
return Object.entries(ARCHETYPE_PATTERNS)
|
|
40
|
+
.filter(([, pattern]) => pattern.test(text))
|
|
41
|
+
.map(([archetype]) => archetype);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Choose which stories to audit. With `archetypes`, keep only stories that match one. Then cap the count.
|
|
46
|
+
* The cap spreads over components, taking one story per title in turn, so one big component can't use up the whole budget.
|
|
47
|
+
* The order is stable, so two runs pick the same stories.
|
|
48
|
+
* @param {ReturnType<typeof listStories>} stories
|
|
49
|
+
* @param {{ archetypes?: string[] | null, max: number }} options
|
|
50
|
+
*/
|
|
51
|
+
export function selectStories(stories, { archetypes = null, max }) {
|
|
52
|
+
/** Every archetype each story suggests. The comparison matrix uses this even when no filter is set. */
|
|
53
|
+
/** @type {Record<string, string[]>} */
|
|
54
|
+
const all = {};
|
|
55
|
+
for (const story of stories) for (const a of matchArchetypes(story)) (all[a] ??= []).push(story.id);
|
|
56
|
+
const filtered = Boolean(archetypes?.length);
|
|
57
|
+
let pool = stories;
|
|
58
|
+
/** @type {Record<string, string[]>} */
|
|
59
|
+
let matchedByArchetype = all;
|
|
60
|
+
if (filtered) {
|
|
61
|
+
matchedByArchetype = {};
|
|
62
|
+
pool = stories.filter((story) => {
|
|
63
|
+
const hits = matchArchetypes(story).filter((a) => archetypes.includes(a));
|
|
64
|
+
for (const a of hits) (matchedByArchetype[a] ??= []).push(story.id);
|
|
65
|
+
return hits.length > 0;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const byTitle = new Map();
|
|
69
|
+
for (const story of pool) byTitle.set(story.title, [...(byTitle.get(story.title) ?? []), story]);
|
|
70
|
+
const titles = [...byTitle.keys()].sort();
|
|
71
|
+
const selected = [];
|
|
72
|
+
for (let round = 0; selected.length < Math.min(max, pool.length); round += 1) {
|
|
73
|
+
for (const title of titles) {
|
|
74
|
+
const story = byTitle.get(title)[round];
|
|
75
|
+
if (story && selected.length < max) selected.push(story);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
selected.sort((a, b) => a.id.localeCompare(b.id));
|
|
79
|
+
return { selected, total: stories.length, matched: pool.length, truncated: pool.length > selected.length, matchedByArchetype, filtered };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The URL that renders one story on its own. `base` must end with a slash. */
|
|
83
|
+
export function storyUrl(base, id) {
|
|
84
|
+
return `${base}iframe.html?id=${encodeURIComponent(id)}&viewMode=story`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read a Storybook's index from disk or over HTTP.
|
|
89
|
+
* @param {{ path?: string | null, url?: string | null, index: string }} resolved
|
|
90
|
+
* @param {Function} [doFetch]
|
|
91
|
+
*/
|
|
92
|
+
export async function readIndex(resolved, doFetch = globalThis.fetch) {
|
|
93
|
+
if (resolved.path) return JSON.parse(readFileSync(join(resolved.path, resolved.index), "utf8"));
|
|
94
|
+
const response = await doFetch(new URL(resolved.index, /** @type {string} */ (resolved.url)), { signal: AbortSignal.timeout(15000) });
|
|
95
|
+
if (!response.ok) throw new Error(`The Storybook index responded with HTTP ${response.status}.`);
|
|
96
|
+
return response.json();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Wait until Storybook says a story rendered, or failed. Storybook marks `body` with a class for each outcome.
|
|
101
|
+
* @param {import("playwright-core").Page} page
|
|
102
|
+
*/
|
|
103
|
+
export async function waitForStory(page, timeout = 20_000) {
|
|
104
|
+
await page.waitForFunction(
|
|
105
|
+
() => ["sb-show-main", "sb-show-errordisplay", "sb-show-nopreview"].some((name) => document.body.classList.contains(name)),
|
|
106
|
+
undefined,
|
|
107
|
+
{ timeout },
|
|
108
|
+
);
|
|
109
|
+
const outcome = await page.evaluate(() => ({
|
|
110
|
+
error: document.body.classList.contains("sb-show-errordisplay"),
|
|
111
|
+
missing: document.body.classList.contains("sb-show-nopreview"),
|
|
112
|
+
detail: document.querySelector(".sb-errordisplay_main, #error-message")?.textContent?.trim().slice(0, 200) ?? "",
|
|
113
|
+
}));
|
|
114
|
+
if (outcome.error) throw new Error(`The story threw an error${outcome.detail ? `: ${outcome.detail}` : "."}`);
|
|
115
|
+
if (outcome.missing) throw new Error("Storybook couldn't find this story.");
|
|
116
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { VIEWPORT } from "./browser.js";
|
|
2
|
+
import { recordClosedShadowRoots, recordCustomElements } from "./shadow.js";
|
|
3
|
+
|
|
4
|
+
const NETWORK_IDLE_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Open a page in its own browser context and wait for the network to go quiet.
|
|
8
|
+
* The returned `warnings` hold anything the report should mention, such as a network that never went idle.
|
|
9
|
+
* @param {import("playwright-core").Browser} browser
|
|
10
|
+
* @param {string} url
|
|
11
|
+
* @param {{ viewport?: { width: number, height: number }, forcedColors?: boolean, beforeGoto?: (page: import("playwright-core").Page) => void | Promise<unknown>, waitUntil?: "load" | "networkidle" }} [options]
|
|
12
|
+
* `beforeGoto` runs before navigation, so a caller can attach console listeners that see the first messages.
|
|
13
|
+
* `waitUntil` defaults to `networkidle`. Local fixture pages don't need to wait for the network.
|
|
14
|
+
*/
|
|
15
|
+
export async function openPage(browser, url, { viewport = VIEWPORT, forcedColors = false, beforeGoto, waitUntil = "networkidle" } = {}) {
|
|
16
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor: 1, forcedColors: forcedColors ? "active" : "none" });
|
|
17
|
+
await context.addInitScript(recordClosedShadowRoots);
|
|
18
|
+
await context.addInitScript(recordCustomElements);
|
|
19
|
+
/** @type {string[]} */
|
|
20
|
+
const warnings = [];
|
|
21
|
+
try {
|
|
22
|
+
const page = await context.newPage();
|
|
23
|
+
page.setDefaultTimeout(30_000);
|
|
24
|
+
await beforeGoto?.(page);
|
|
25
|
+
let response;
|
|
26
|
+
try {
|
|
27
|
+
response = await page.goto(url, { waitUntil, timeout: NETWORK_IDLE_MS });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof Error && error.name === "TimeoutError" && page.url() !== "about:blank") {
|
|
30
|
+
warnings.push(`The network never went idle within ${NETWORK_IDLE_MS / 1000} seconds. The page was checked as it stood.`);
|
|
31
|
+
} else {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (response && response.status() >= 400) throw new Error(`The page responded with HTTP ${response.status()}.`);
|
|
36
|
+
return { context, page, warnings, close: () => context.close().catch(() => {}) };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
await context.close().catch(() => {});
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { classifyTarget, parseTargetInput } from "./classify.js";
|
|
2
|
+
import { npmView, resolveNpmTarget } from "./resolve-npm.js";
|
|
3
|
+
import { readToolVersions } from "../env/versions.js";
|
|
4
|
+
|
|
5
|
+
/** @param {string} text */
|
|
6
|
+
function slug(text) {
|
|
7
|
+
const out = text.toLowerCase().replace(/^@/, "").replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
8
|
+
return out || "target";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Classify every target and assemble plan.json. Nothing here installs a package or launches a browser.
|
|
13
|
+
* @param {{
|
|
14
|
+
* command: "audit" | "compare",
|
|
15
|
+
* targets: string[],
|
|
16
|
+
* options: import("valibot").InferOutput<typeof import("../schema.js").PlanSchema>["options"],
|
|
17
|
+
* browserVersion?: string | null,
|
|
18
|
+
* ctx?: import("./classify.js").ClassifyContext,
|
|
19
|
+
* now?: Date,
|
|
20
|
+
* }} input
|
|
21
|
+
*/
|
|
22
|
+
export async function buildPlan({ command, targets, options, browserVersion = null, ctx = {}, now = new Date() }) {
|
|
23
|
+
const view = ctx.npmView ?? npmView;
|
|
24
|
+
const classified = await Promise.all(targets.map(async (raw) => resolveNpmTarget(await classifyTarget(raw, ctx), view)));
|
|
25
|
+
const seen = new Map();
|
|
26
|
+
const planTargets = classified.map((target) => {
|
|
27
|
+
const base = slug(target.label ?? target.name);
|
|
28
|
+
const count = (seen.get(base) ?? 0) + 1;
|
|
29
|
+
seen.set(base, count);
|
|
30
|
+
return {
|
|
31
|
+
id: count === 1 ? base : `${base}-${count}`,
|
|
32
|
+
input: target.input,
|
|
33
|
+
label: target.label ?? target.name,
|
|
34
|
+
status: target.status,
|
|
35
|
+
reason: target.reason,
|
|
36
|
+
kind: target.kind,
|
|
37
|
+
evidenceLevel: target.evidenceLevel,
|
|
38
|
+
resolved: target.resolved,
|
|
39
|
+
mapping: null,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
schema: /** @type {const} */ (1),
|
|
44
|
+
createdAt: now.toISOString(),
|
|
45
|
+
command,
|
|
46
|
+
options,
|
|
47
|
+
tools: readToolVersions({ chromium: browserVersion }),
|
|
48
|
+
targets: planTargets,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Labels the user typed must be unique. Returns the first duplicate, or null. */
|
|
53
|
+
export function findDuplicateLabel(rawTargets) {
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const raw of rawTargets) {
|
|
56
|
+
const { label } = parseTargetInput(raw.trim());
|
|
57
|
+
if (!label) continue;
|
|
58
|
+
if (seen.has(label)) return label;
|
|
59
|
+
seen.add(label);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|