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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/bin/automatica11y.js +4 -0
  3. package/package.json +38 -4
  4. package/src/cli.js +49 -0
  5. package/src/commands/audit.js +4 -0
  6. package/src/commands/common.js +274 -0
  7. package/src/commands/compare.js +4 -0
  8. package/src/commands/doctor.js +20 -0
  9. package/src/commands/init-skill.js +7 -0
  10. package/src/env/browser.js +136 -0
  11. package/src/env/versions.js +60 -0
  12. package/src/globals.d.ts +10 -0
  13. package/src/harness/browser.js +20 -0
  14. package/src/harness/bundle.js +49 -0
  15. package/src/harness/npm-install.js +65 -0
  16. package/src/harness/npm-react.js +39 -0
  17. package/src/harness/npm-wc.js +30 -0
  18. package/src/harness/shadow.js +42 -0
  19. package/src/harness/static-serve.js +68 -0
  20. package/src/harness/storybook.js +116 -0
  21. package/src/harness/url.js +41 -0
  22. package/src/plan/build-plan.js +62 -0
  23. package/src/plan/classify.js +173 -0
  24. package/src/plan/mapping.js +101 -0
  25. package/src/plan/resolve-npm.js +87 -0
  26. package/src/report/comparison.js +183 -0
  27. package/src/report/index.js +10 -0
  28. package/src/report/parts.js +334 -0
  29. package/src/report/single.js +16 -0
  30. package/src/run/audit-npm.js +279 -0
  31. package/src/run/fail-check.js +62 -0
  32. package/src/run/pool.js +21 -0
  33. package/src/run/run-plan.js +262 -0
  34. package/src/run/summary.js +49 -0
  35. package/src/schema.js +255 -0
  36. package/src/text.js +9 -0
  37. package/src/tiers/interactions/archetypes.js +417 -0
  38. package/src/tiers/interactions/helpers.js +145 -0
  39. package/src/tiers/interactions/index.js +107 -0
  40. package/src/tiers/rules/axe.js +75 -0
  41. package/src/tiers/rules/canvas.js +34 -0
  42. package/src/tiers/rules/ibm.js +121 -0
  43. package/src/tiers/rules/index.js +81 -0
  44. package/src/tiers/vsr.js +134 -0
  45. package/README.md +0 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Instructure, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.js";
3
+
4
+ process.exitCode = await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "automatica11y",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Test and compare the accessibility of web pages, Storybook builds, and npm component libraries.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "automatica11y": "bin/automatica11y.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "SKILL.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test test/*.test.js",
20
+ "lint": "tsc -p jsconfig.json"
21
+ },
22
+ "dependencies": {
23
+ "@axe-core/playwright": "^4.13.0",
24
+ "@guidepup/virtual-screen-reader": "^0.33.0",
25
+ "accessibility-checker-engine": "^4.0.34",
26
+ "axe-core": "^4.14.0",
27
+ "esbuild": "^0.28.2",
28
+ "playwright-core": "^1.64.0",
29
+ "valibot": "^1.5.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^26.6.4",
33
+ "react": "^19.3.0",
34
+ "react-dom": "^19.3.0",
35
+ "typescript": "^7.0.2"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }
package/src/cli.js ADDED
@@ -0,0 +1,49 @@
1
+ import { auditCommand } from "./commands/audit.js";
2
+ import { compareCommand } from "./commands/compare.js";
3
+ import { EXIT, UsageError, runSavedPlan, usage } from "./commands/common.js";
4
+ import { doctorCommand } from "./commands/doctor.js";
5
+ import { initSkillCommand } from "./commands/init-skill.js";
6
+ import { ownVersion } from "./env/versions.js";
7
+
8
+ const COMMANDS = {
9
+ audit: auditCommand,
10
+ compare: compareCommand,
11
+ run: runSavedPlan,
12
+ doctor: doctorCommand,
13
+ "init-skill": initSkillCommand,
14
+ };
15
+
16
+ /**
17
+ * Run the CLI and return its exit code. Takes its streams and environment as arguments so tests can drive it.
18
+ * @param {string[]} argv Arguments after the program name.
19
+ * @param {Partial<import("./commands/common.js").Io>} [overrides]
20
+ * @returns {Promise<number>}
21
+ */
22
+ export async function main(argv, overrides = {}) {
23
+ /** @type {import("./commands/common.js").Io} */
24
+ const io = { stdout: process.stdout, stderr: process.stderr, cwd: process.cwd(), env: process.env, ...overrides };
25
+ const [first, ...rest] = argv;
26
+
27
+ if (first === "--version" || first === "-v") {
28
+ io.stdout.write(`${ownVersion()}\n`);
29
+ return EXIT.OK;
30
+ }
31
+ if (first === "--help" || first === "-h") {
32
+ io.stdout.write(usage());
33
+ return EXIT.OK;
34
+ }
35
+ const handler = first ? COMMANDS[/** @type {keyof typeof COMMANDS} */ (first)] : undefined;
36
+ if (!handler) {
37
+ io.stderr.write(first ? `Unknown command "${first}".\n\n${usage()}` : usage());
38
+ return EXIT.USAGE;
39
+ }
40
+ try {
41
+ return await handler(rest, io);
42
+ } catch (error) {
43
+ if (error instanceof UsageError) {
44
+ io.stderr.write(`${error.message}\n\nRun "automatica11y ${first} --help" for options.\n`);
45
+ return EXIT.USAGE;
46
+ }
47
+ throw error;
48
+ }
49
+ }
@@ -0,0 +1,4 @@
1
+ import { runCommand } from "./common.js";
2
+
3
+ /** @param {string[]} argv @param {import("./common.js").Io} io */
4
+ export const auditCommand = (argv, io) => runCommand("audit", argv, io);
@@ -0,0 +1,274 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { checkEnvironment } from "../env/browser.js";
5
+ import { readToolVersions } from "../env/versions.js";
6
+ import { buildPlan, findDuplicateLabel } from "../plan/build-plan.js";
7
+ import { loadMappingFile } from "../plan/mapping.js";
8
+ import { runPlan } from "../run/run-plan.js";
9
+ import { ARCHETYPES, ENGINES, FAIL_MODES, IMPACTS, LEVELS, LIB_A11Y, TIERS, TOOLKIT_LEVELS, WCAG_VERSIONS, parsePlan } from "../schema.js";
10
+
11
+ export const EXIT = { OK: 0, FAIL_THRESHOLD: 1, USAGE: 2, ENVIRONMENT: 3, ALL_TARGETS_FAILED: 4, NOT_IMPLEMENTED: 70 };
12
+
13
+ /** A bad command line. The CLI prints the message plus a usage hint and exits 2. */
14
+ export class UsageError extends Error {}
15
+
16
+ /**
17
+ * @typedef {{ stdout: { write(text: string): unknown }, stderr: { write(text: string): unknown }, cwd: string, env: NodeJS.ProcessEnv, fetch?: import("../plan/classify.js").FetchLike, npmView?: (spec: string) => Promise<any>, installPackage?: Function, platform?: NodeJS.Platform }} Io
18
+ */
19
+
20
+ const OPTION_DEFS = /** @type {const} */ ({
21
+ wcag: { type: "string" },
22
+ level: { type: "string" },
23
+ tiers: { type: "string" },
24
+ engine: { type: "string" },
25
+ archetypes: { type: "string" },
26
+ "lib-a11y": { type: "string" },
27
+ mapping: { type: "string" },
28
+ plan: { type: "boolean" },
29
+ out: { type: "string" },
30
+ "max-stories": { type: "string" },
31
+ "fail-on-axe": { type: "string" },
32
+ "fail-on-ibm": { type: "string" },
33
+ "fail-mode": { type: "string" },
34
+ help: { type: "boolean", short: "h" },
35
+ });
36
+
37
+ /** Split a comma list and check every item against the allowed values. */
38
+ function parseList(flag, text, allowed) {
39
+ const items = [...new Set(text.split(",").map((item) => item.trim()).filter(Boolean))];
40
+ if (items.length === 0) throw new UsageError(`--${flag} needs at least one value. Choose from: ${allowed.join(", ")}.`);
41
+ const bad = items.filter((item) => !allowed.includes(item));
42
+ if (bad.length) throw new UsageError(`Unknown ${flag} value${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}. Choose from: ${allowed.join(", ")}.`);
43
+ return items;
44
+ }
45
+
46
+ /** @param {string} flag @param {string} value @param {string[]} allowed */
47
+ function parseChoice(flag, value, allowed) {
48
+ if (!allowed.includes(value)) throw new UsageError(`--${flag} must be one of: ${allowed.join(", ")}. Got "${value}".`);
49
+ return value;
50
+ }
51
+
52
+ /**
53
+ * Turn `audit` or `compare` arguments into plan options and raw targets. Throws UsageError on any problem.
54
+ * @param {"audit" | "compare"} command
55
+ * @param {string[]} argv
56
+ */
57
+ export function parseRunArgs(command, argv) {
58
+ let parsed;
59
+ try {
60
+ parsed = parseArgs({ args: argv, options: OPTION_DEFS, allowPositionals: true, strict: true });
61
+ } catch (error) {
62
+ throw new UsageError(error instanceof Error ? error.message.split(". ")[0] : String(error));
63
+ }
64
+ const { values, positionals } = parsed;
65
+ if (values.help) return { help: true, targets: [], planOnly: false, options: null };
66
+
67
+ if (command === "audit" && positionals.length !== 1) {
68
+ throw new UsageError(positionals.length === 0 ? "audit needs one target." : "audit takes one target. Use compare for more than one.");
69
+ }
70
+ if (command === "compare" && positionals.length < 2) {
71
+ throw new UsageError("compare needs at least two targets.");
72
+ }
73
+ if (positionals.some((target) => !target.trim())) throw new UsageError("A target can't be empty.");
74
+ const duplicate = findDuplicateLabel(positionals);
75
+ if (duplicate) throw new UsageError(`Two targets use the label "${duplicate}". Labels must be unique.`);
76
+
77
+ const tiers = values.tiers ? parseList("tiers", values.tiers, TIERS) : [...TIERS];
78
+ const engines = values.engine ? parseList("engine", values.engine, ENGINES) : [...ENGINES];
79
+ const libA11y = values["lib-a11y"] ? parseList("lib-a11y", values["lib-a11y"], LIB_A11Y) : [...LIB_A11Y];
80
+ const archetypes = values.archetypes ? parseList("archetypes", values.archetypes, ARCHETYPES) : null;
81
+
82
+ let maxStories = 200;
83
+ if (values["max-stories"] !== undefined) {
84
+ maxStories = Number(values["max-stories"]);
85
+ if (!Number.isInteger(maxStories) || maxStories < 1) throw new UsageError(`--max-stories must be a whole number of 1 or more. Got "${values["max-stories"]}".`);
86
+ }
87
+
88
+ const axeFlag = values["fail-on-axe"];
89
+ const ibmFlag = values["fail-on-ibm"];
90
+ const modeFlag = values["fail-mode"];
91
+ /** @type {import("valibot").InferOutput<typeof import("../schema.js").FailConfigSchema> | null} */
92
+ let fail = null;
93
+ if (axeFlag === undefined && ibmFlag === undefined) {
94
+ if (modeFlag !== undefined) throw new UsageError("--fail-mode needs --fail-on-axe, --fail-on-ibm, or both.");
95
+ } else {
96
+ if (!tiers.includes("rules")) throw new UsageError("The fail flags check the rules tier. Add rules to --tiers or drop the fail flags.");
97
+ const axe = axeFlag === undefined ? null : parseChoice("fail-on-axe", axeFlag, IMPACTS);
98
+ const ibm = ibmFlag === undefined ? null : Number(parseChoice("fail-on-ibm", ibmFlag, TOOLKIT_LEVELS.map(String)));
99
+ if (axe && !engines.includes("axe")) throw new UsageError("--fail-on-axe needs the axe engine. Add axe to --engine or drop the flag.");
100
+ if (ibm && !engines.includes("ibm")) throw new UsageError("--fail-on-ibm needs the ibm engine. Add ibm to --engine or drop the flag.");
101
+ fail = { mode: /** @type {"any" | "all"} */ (modeFlag === undefined ? "any" : parseChoice("fail-mode", modeFlag, FAIL_MODES)), axe: /** @type {any} */ (axe), ibm: /** @type {any} */ (ibm) };
102
+ }
103
+
104
+ const options = {
105
+ wcag: /** @type {any} */ (values.wcag === undefined ? "2.2" : parseChoice("wcag", values.wcag, WCAG_VERSIONS)),
106
+ level: /** @type {any} */ (values.level === undefined ? "AA" : parseChoice("level", values.level, LEVELS)),
107
+ tiers: /** @type {any} */ (tiers),
108
+ engines: /** @type {any} */ (engines),
109
+ libA11y: /** @type {any} */ (libA11y),
110
+ archetypes: /** @type {any} */ (archetypes),
111
+ mapping: values.mapping ?? null,
112
+ maxStories,
113
+ out: values.out ?? "./a11y-report",
114
+ fail,
115
+ };
116
+ return { help: false, targets: positionals, planOnly: Boolean(values.plan), options };
117
+ }
118
+
119
+ /** One line describing what a target resolved to. */
120
+ export function describeTarget(target) {
121
+ if (target.status === "failed") return `failed: ${target.reason}`;
122
+ const r = target.resolved ?? {};
123
+ if (target.kind?.startsWith("npm")) return `${r.name}@${r.version ?? r.requested ?? "latest"}${r.framework ? ` (${r.framework})` : ""}`;
124
+ return r.url ?? r.path ?? "";
125
+ }
126
+
127
+ /** @param {ReturnType<typeof parsePlan>} plan @param {string} planPath @param {boolean} planOnly @param {Io} io */
128
+ function printSummary(plan, planPath, planOnly, io) {
129
+ const o = plan.options;
130
+ const lines = [
131
+ `automatica11y ${plan.command} ${planOnly ? "plan" : "run"}`,
132
+ ` WCAG ${o.wcag} level ${o.level}, tiers: ${o.tiers.join(", ")}, engines: ${o.engines.join(", ")}`,
133
+ ];
134
+ if (o.fail) lines.push(` Fail checks (${o.fail.mode}): ${[o.fail.axe && `axe ${o.fail.axe}`, o.fail.ibm && `ibm level ${o.fail.ibm}`].filter(Boolean).join(", ")}`);
135
+ lines.push(" Targets:");
136
+ const width = Math.max(...plan.targets.map((t) => t.id.length));
137
+ for (const t of plan.targets) {
138
+ lines.push(` ${t.id.padEnd(width)} ${(t.kind ?? "-").padEnd(10)} ${describeTarget(t)}${t.evidenceLevel ? ` (${t.evidenceLevel} evidence)` : ""}`);
139
+ }
140
+ lines.push(` Wrote ${planPath}`);
141
+ io.stdout.write(`${lines.join("\n")}\n`);
142
+ }
143
+
144
+ /** Warn when installed tool versions differ from the ones a saved plan recorded. */
145
+ function versionWarnings(plan) {
146
+ const now = readToolVersions({ chromium: plan.tools.chromium });
147
+ return Object.entries(plan.tools)
148
+ .filter(([key, was]) => was && now[key] && now[key] !== was)
149
+ .map(([key, was]) => `${key} was ${was} when this plan was made and is ${now[key]} now.`);
150
+ }
151
+
152
+ /**
153
+ * Write plan.json, then stop (plan only) or run the plan.
154
+ * @param {ReturnType<typeof parsePlan>} plan
155
+ * @param {{ planOnly: boolean, savedPlanPath?: string }} mode
156
+ * @param {Io} io
157
+ */
158
+ export async function executePlan(plan, { planOnly, savedPlanPath }, io) {
159
+ const outDir = resolve(io.cwd, plan.options.out);
160
+ const planPath = resolve(outDir, "plan.json");
161
+ if (!savedPlanPath || resolve(io.cwd, savedPlanPath) !== planPath) {
162
+ mkdirSync(outDir, { recursive: true });
163
+ writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
164
+ }
165
+ printSummary(plan, planPath, planOnly, io);
166
+ for (const warning of savedPlanPath ? versionWarnings(plan) : []) io.stderr.write(`Warning: ${warning}\n`);
167
+
168
+ if (plan.targets.every((t) => t.status === "failed")) {
169
+ io.stderr.write("Every target failed to resolve.\n");
170
+ return EXIT.ALL_TARGETS_FAILED;
171
+ }
172
+ if (planOnly) return EXIT.OK;
173
+
174
+ return runPlan(plan, io);
175
+ }
176
+
177
+ /**
178
+ * Shared body of `audit` and `compare`.
179
+ * @param {"audit" | "compare"} command
180
+ * @param {string[]} argv
181
+ * @param {Io} io
182
+ */
183
+ export async function runCommand(command, argv, io) {
184
+ const parsed = parseRunArgs(command, argv);
185
+ if (parsed.help) {
186
+ io.stdout.write(usage(command));
187
+ return EXIT.OK;
188
+ }
189
+ const options = /** @type {NonNullable<typeof parsed.options>} */ (parsed.options);
190
+ let browserVersion = null;
191
+ if (!parsed.planOnly) {
192
+ const env = await checkEnvironment({ env: io.env, platform: io.platform });
193
+ browserVersion = env.browser?.version ?? null;
194
+ }
195
+ let mapping = null;
196
+ if (options.mapping) {
197
+ try {
198
+ mapping = loadMappingFile(options.mapping, io.cwd);
199
+ } catch (error) {
200
+ throw new UsageError(error instanceof Error ? error.message : String(error));
201
+ }
202
+ }
203
+ const built = await buildPlan({ command, targets: parsed.targets, options, browserVersion, ctx: { cwd: io.cwd, fetch: io.fetch, npmView: io.npmView } });
204
+ if (mapping) {
205
+ const ids = new Set(built.targets.map((t) => t.id));
206
+ const unknown = Object.keys(mapping).filter((id) => !ids.has(id));
207
+ if (unknown.length) throw new UsageError(`The mapping has entries for ${unknown.join(", ")}, which ${unknown.length === 1 ? "isn't" : "aren't"} a target in this run. Targets here: ${[...ids].join(", ")}.`);
208
+ for (const target of built.targets) target.mapping = mapping[target.id] ?? null;
209
+ }
210
+ const plan = parsePlan(built);
211
+ return executePlan(plan, { planOnly: parsed.planOnly }, io);
212
+ }
213
+
214
+ /** `run --plan <file>`: re-run a saved plan. */
215
+ export async function runSavedPlan(argv, io) {
216
+ let parsed;
217
+ try {
218
+ parsed = parseArgs({ args: argv, options: { plan: { type: "string" }, help: { type: "boolean", short: "h" } }, allowPositionals: false, strict: true });
219
+ } catch (error) {
220
+ throw new UsageError(error instanceof Error ? error.message.split(". ")[0] : String(error));
221
+ }
222
+ if (parsed.values.help) {
223
+ io.stdout.write(usage("run"));
224
+ return EXIT.OK;
225
+ }
226
+ const file = parsed.values.plan;
227
+ if (!file) throw new UsageError("run needs --plan <plan.json>.");
228
+ let plan;
229
+ try {
230
+ plan = parsePlan(JSON.parse(readFileSync(resolve(io.cwd, file), "utf8")));
231
+ } catch (error) {
232
+ throw new UsageError(`Can't read the plan ${file}: ${error instanceof Error ? error.message : String(error)}`);
233
+ }
234
+ return executePlan(plan, { planOnly: false, savedPlanPath: file }, io);
235
+ }
236
+
237
+ const FLAGS = `Options:
238
+ --wcag <2.0|2.1|2.2> WCAG version. Default 2.2.
239
+ --level <A|AA|AAA> Conformance level. Default AA.
240
+ --tiers <list> rules, interactions, vsr. Default all three.
241
+ --engine <list> axe, ibm. Default both.
242
+ --archetypes <list> Limit npm and Storybook targets to these archetypes.
243
+ --lib-a11y <list> on, off. Default both.
244
+ --mapping <file> Archetype mapping file.
245
+ --max-stories <n> Storybook story cap. Default 200.
246
+ --plan Resolve and print the plan, then stop.
247
+ --out <dir> Output directory. Default ./a11y-report.
248
+ --fail-on-axe <impact> minor, moderate, serious, or critical.
249
+ --fail-on-ibm <level> 1, 2, or 3 (IBM Toolkit level).
250
+ --fail-mode <any|all> How to combine fail checks. Default any.
251
+ `;
252
+
253
+ /** @param {string} [command] */
254
+ export function usage(command) {
255
+ const header = {
256
+ audit: "Usage: automatica11y audit <target> [options]\n\nCheck how accessible one target is.\n\n",
257
+ compare: "Usage: automatica11y compare <target> <target> [<target>...] [options]\n\nCompare two or more targets.\n\n",
258
+ run: "Usage: automatica11y run --plan <plan.json>\n\nRe-run a saved plan.\n",
259
+ }[command ?? ""];
260
+ if (command === "run") return header;
261
+ if (header) {
262
+ return `${header}A target is [label=]<spec>. A spec is a package name, an http(s) URL, or a path starting with ./, ../, /, ~, or file:.\n\n${FLAGS}`;
263
+ }
264
+ return `Usage:
265
+ automatica11y audit <target> [options]
266
+ automatica11y compare <target> <target> [<target>...] [options]
267
+ automatica11y run --plan <plan.json>
268
+ automatica11y doctor
269
+ automatica11y init-skill [--dest <dir>]
270
+ automatica11y --version
271
+
272
+ Run a command with --help for its options.
273
+ `;
274
+ }
@@ -0,0 +1,4 @@
1
+ import { runCommand } from "./common.js";
2
+
3
+ /** @param {string[]} argv @param {import("./common.js").Io} io */
4
+ export const compareCommand = (argv, io) => runCommand("compare", argv, io);
@@ -0,0 +1,20 @@
1
+ import { checkEnvironment } from "../env/browser.js";
2
+ import { EXIT } from "./common.js";
3
+
4
+ /** @param {string[]} _argv @param {import("./common.js").Io} io */
5
+ export async function doctorCommand(_argv, io) {
6
+ const result = await checkEnvironment({ env: io.env, platform: io.platform });
7
+ const lines = ["automatica11y doctor"];
8
+ const nodeProblem = result.problems.find((p) => p.id === "node");
9
+ lines.push(` Node ${result.node} ${nodeProblem ? "problem" : "ok"}`);
10
+ if (result.browser) {
11
+ const { kind, version, path } = result.browser;
12
+ lines.push(` Browser ${kind}${version ? ` ${version}` : ""} ok`, ` ${path}`);
13
+ } else {
14
+ lines.push(" Browser not found problem");
15
+ }
16
+ io.stdout.write(`${lines.join("\n")}\n`);
17
+ if (result.ok) return EXIT.OK;
18
+ for (const problem of result.problems) io.stderr.write(`\n${problem.message}\n Fix: ${problem.fix}\n`);
19
+ return EXIT.ENVIRONMENT;
20
+ }
@@ -0,0 +1,7 @@
1
+ import { EXIT } from "./common.js";
2
+
3
+ /** @param {string[]} _argv @param {import("./common.js").Io} io */
4
+ export async function initSkillCommand(_argv, io) {
5
+ io.stderr.write("init-skill isn't built yet. It arrives in M8.\n");
6
+ return EXIT.NOT_IMPLEMENTED;
7
+ }
@@ -0,0 +1,136 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { delimiter, join } from "node:path";
5
+
6
+ export const INSTALL_COMMAND = "npx playwright-core install --only-shell chromium";
7
+ export const BROWSER_ENV_VAR = "AUTOMATICA11Y_CHROME";
8
+ export const MIN_NODE_MAJOR = 20;
9
+
10
+ /**
11
+ * @typedef {{ kind: "chrome" | "chromium" | "headless-shell" | "custom", path: string, version: string | null }} Browser
12
+ * @typedef {{ id: string, message: string, fix: string }} Problem
13
+ */
14
+
15
+ /** Known install locations for Chrome and Chromium, by platform. */
16
+ function candidatePaths(platform, env) {
17
+ if (platform === "darwin") {
18
+ return [
19
+ { kind: "chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
20
+ { kind: "chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
21
+ ];
22
+ }
23
+ if (platform === "win32") {
24
+ const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
25
+ return roots.map((root) => ({ kind: "chrome", path: join(/** @type {string} */ (root), "Google", "Chrome", "Application", "chrome.exe") }));
26
+ }
27
+ const names = [
28
+ ["chrome", "google-chrome"],
29
+ ["chrome", "google-chrome-stable"],
30
+ ["chromium", "chromium"],
31
+ ["chromium", "chromium-browser"],
32
+ ];
33
+ const dirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
34
+ return names.flatMap(([kind, name]) => dirs.map((dir) => ({ kind, path: join(dir, name) })));
35
+ }
36
+
37
+ /** Where Playwright keeps the browsers `playwright-core install` downloads. */
38
+ function playwrightCacheDir(platform, env) {
39
+ if (env.PLAYWRIGHT_BROWSERS_PATH && env.PLAYWRIGHT_BROWSERS_PATH !== "0") return env.PLAYWRIGHT_BROWSERS_PATH;
40
+ if (platform === "darwin") return join(homedir(), "Library", "Caches", "ms-playwright");
41
+ if (platform === "win32") return join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "ms-playwright");
42
+ return join(homedir(), ".cache", "ms-playwright");
43
+ }
44
+
45
+ /** Find a headless shell that `playwright-core install --only-shell chromium` downloaded. */
46
+ function findHeadlessShell(platform, env) {
47
+ const root = playwrightCacheDir(platform, env);
48
+ const names = new Set(["chrome-headless-shell", "chrome-headless-shell.exe", "headless_shell"]);
49
+ /** @param {string} dir @param {number} depth @returns {string | null} */
50
+ const search = (dir, depth) => {
51
+ let entries;
52
+ try {
53
+ entries = readdirSync(dir, { withFileTypes: true });
54
+ } catch {
55
+ return null;
56
+ }
57
+ for (const entry of entries) {
58
+ if (entry.isFile() && names.has(entry.name)) return join(dir, entry.name);
59
+ }
60
+ if (depth === 0) return null;
61
+ for (const entry of entries) {
62
+ if (entry.isDirectory()) {
63
+ const found = search(join(dir, entry.name), depth - 1);
64
+ if (found) return found;
65
+ }
66
+ }
67
+ return null;
68
+ };
69
+ let shells;
70
+ try {
71
+ shells = readdirSync(root).filter((name) => name.startsWith("chromium_headless_shell-")).sort().reverse();
72
+ } catch {
73
+ return null;
74
+ }
75
+ for (const shell of shells) {
76
+ const found = search(join(root, shell), 3);
77
+ if (found) return found;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** @param {string} path @returns {Promise<string | null>} */
83
+ function readVersion(path) {
84
+ return new Promise((resolve) => {
85
+ execFile(path, ["--version"], { timeout: 5000 }, (error, stdout) => {
86
+ const match = error ? null : /(\d+\.\d+\.\d+\.\d+)/.exec(stdout);
87
+ resolve(match ? match[1] : null);
88
+ });
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Find a browser we can drive. Prefers an explicit override, then installed Chrome or Chromium,
94
+ * then the Playwright headless shell. Never imports Playwright.
95
+ * @param {{ env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform }} [options]
96
+ * @returns {Promise<{ browser: Browser | null, problem: Problem | null }>}
97
+ */
98
+ export async function findBrowser({ env = process.env, platform = process.platform } = {}) {
99
+ const override = env[BROWSER_ENV_VAR];
100
+ if (override) {
101
+ if (!existsSync(override)) {
102
+ return {
103
+ browser: null,
104
+ problem: { id: "browser", message: `${BROWSER_ENV_VAR} points to a file that doesn't exist: ${override}`, fix: `Fix or unset ${BROWSER_ENV_VAR}, or run: ${INSTALL_COMMAND}` },
105
+ };
106
+ }
107
+ return { browser: { kind: "custom", path: override, version: await readVersion(override) }, problem: null };
108
+ }
109
+ for (const candidate of candidatePaths(platform, env)) {
110
+ if (existsSync(candidate.path)) {
111
+ return { browser: { kind: /** @type {"chrome" | "chromium"} */ (candidate.kind), path: candidate.path, version: await readVersion(candidate.path) }, problem: null };
112
+ }
113
+ }
114
+ const shell = findHeadlessShell(platform, env);
115
+ if (shell) return { browser: { kind: "headless-shell", path: shell, version: await readVersion(shell) }, problem: null };
116
+ return {
117
+ browser: null,
118
+ problem: { id: "browser", message: "No Chrome or Chromium found.", fix: `Install Chrome, or run: ${INSTALL_COMMAND}` },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Check everything a real run needs. Used by `doctor` and by real runs.
124
+ * @param {{ env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, nodeVersion?: string }} [options]
125
+ */
126
+ export async function checkEnvironment({ env = process.env, platform = process.platform, nodeVersion = process.versions.node } = {}) {
127
+ /** @type {Problem[]} */
128
+ const problems = [];
129
+ const major = Number(nodeVersion.split(".")[0]);
130
+ if (!(major >= MIN_NODE_MAJOR)) {
131
+ problems.push({ id: "node", message: `Node ${nodeVersion} is too old. automatica11y needs Node ${MIN_NODE_MAJOR} or newer.`, fix: `Install Node ${MIN_NODE_MAJOR} or newer from https://nodejs.org.` });
132
+ }
133
+ const { browser, problem } = await findBrowser({ env, platform });
134
+ if (problem) problems.push(problem);
135
+ return { ok: problems.length === 0, node: nodeVersion, browser, problems };
136
+ }
@@ -0,0 +1,60 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ /** The packages whose resolved versions every plan and results file records. */
8
+ const TOOL_PACKAGES = {
9
+ "axe-core": "axe-core",
10
+ "ibm-checker-engine": "accessibility-checker-engine",
11
+ "playwright-core": "playwright-core",
12
+ esbuild: "esbuild",
13
+ "guidepup-vsr": "@guidepup/virtual-screen-reader",
14
+ };
15
+
16
+ /**
17
+ * Read an installed package's version without importing the package itself.
18
+ * @param {string} name
19
+ * @returns {string | null} The version, or null when the package isn't installed.
20
+ */
21
+ export function readPackageVersion(name) {
22
+ try {
23
+ return JSON.parse(readFileSync(require.resolve(`${name}/package.json`), "utf8")).version ?? null;
24
+ } catch {
25
+ // The package may hide its package.json behind an exports map. Walk up from its entry file.
26
+ }
27
+ try {
28
+ let dir = dirname(require.resolve(name));
29
+ for (let depth = 0; depth < 6; depth += 1) {
30
+ try {
31
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
32
+ if (pkg.name === name) return pkg.version ?? null;
33
+ } catch {
34
+ // Keep walking.
35
+ }
36
+ dir = dirname(dir);
37
+ }
38
+ } catch {
39
+ // Not installed.
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** Version of automatica11y itself. */
45
+ export function ownVersion() {
46
+ const url = new URL("../../package.json", import.meta.url);
47
+ return JSON.parse(readFileSync(url, "utf8")).version;
48
+ }
49
+
50
+ /**
51
+ * Resolved versions of every tool, keyed the way plan.json and results.json record them.
52
+ * @param {{ chromium?: string | null }} [extra]
53
+ */
54
+ export function readToolVersions(extra = {}) {
55
+ /** @type {Record<string, string | null>} */
56
+ const tools = { node: process.versions.node, automatica11y: ownVersion() };
57
+ for (const [key, pkg] of Object.entries(TOOL_PACKAGES)) tools[key] = readPackageVersion(pkg);
58
+ tools.chromium = extra.chromium ?? null;
59
+ return tools;
60
+ }
@@ -0,0 +1,10 @@
1
+ /** Properties our init scripts and helpers put on the page's window. They only exist inside the browser. */
2
+ interface Window {
3
+ __a11y: any;
4
+ __vsr: any;
5
+ __a11yClicks: number;
6
+ __a11yLast: any;
7
+ __a11yExports?: any;
8
+ __a11yDefined?: string[];
9
+ __a11yClosedShadowHosts?: string[];
10
+ }
@@ -0,0 +1,20 @@
1
+ import { findBrowser } from "../env/browser.js";
2
+
3
+ export const VIEWPORT = { width: 1280, height: 800 };
4
+
5
+ /**
6
+ * Launch the browser we found. Playwright loads here and nowhere else, so commands that never launch a browser never pay for it.
7
+ * @param {{ env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform }} [options]
8
+ */
9
+ export async function launchBrowser({ env, platform } = {}) {
10
+ const { browser: found, problem } = await findBrowser({ env, platform });
11
+ if (!found) throw new Error(problem?.message ?? "No browser found.");
12
+ let chromium;
13
+ try {
14
+ ({ chromium } = await import("playwright-core"));
15
+ } catch {
16
+ throw new Error("playwright-core isn't installed. Run: npm install playwright-core");
17
+ }
18
+ const browser = await chromium.launch({ executablePath: found.path, args: process.env.CI ? ["--no-sandbox"] : [] });
19
+ return { browser, found, version: browser.version() };
20
+ }