eaa-kit 0.1.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.
@@ -0,0 +1,163 @@
1
+ import { i as shapeResults, n as failedPage, r as runOptions, t as DEFAULT_TAGS } from "./result-2aZPfM8w.js";
2
+ import { pathToFileURL } from "node:url";
3
+ import { Script } from "node:vm";
4
+ import axe from "axe-core";
5
+ import { JSDOM, VirtualConsole } from "jsdom";
6
+ //#region src/audit/runners/jsdom.ts
7
+ /**
8
+ * Rules this engine structurally cannot decide.
9
+ *
10
+ * jsdom has no layout: every element reports a 0x0 box, computed style is
11
+ * limited to the inline cascade, and nothing is ever fetched. axe-core does not
12
+ * know that, so it happily returns `pass` for some of these — `target-size`
13
+ * passes on any page because a 0x0 target gets measured against nothing, and
14
+ * `color-contrast` passes on pages whose colours were never computed. Both were
15
+ * observed on real sites. Reporting either as a pass would be a false clean
16
+ * bill of health, so every rule listed here is force-reported as incomplete no
17
+ * matter which bucket axe-core put it in.
18
+ *
19
+ * The browser runner passes an empty map instead: with real layout these rules
20
+ * are exactly the ones it exists to answer.
21
+ *
22
+ * Rules tagged `experimental` are listed for completeness but filtered out at
23
+ * scope time: axe-core does not run them by default, so a browser run would not
24
+ * evaluate them either and telling the user to re-run with --browser would be
25
+ * misleading.
26
+ */
27
+ const ENGINE_BLIND_RULES = {
28
+ "color-contrast": {
29
+ detail: "needs rendered foreground and background colours",
30
+ applicabilityUnreliable: false
31
+ },
32
+ "color-contrast-enhanced": {
33
+ detail: "needs rendered foreground and background colours",
34
+ applicabilityUnreliable: false
35
+ },
36
+ "target-size": {
37
+ detail: "needs element geometry; every box is 0x0 without layout",
38
+ applicabilityUnreliable: false
39
+ },
40
+ "scrollable-region-focusable": {
41
+ detail: "needs computed overflow",
42
+ applicabilityUnreliable: true
43
+ },
44
+ "link-in-text-block": {
45
+ detail: "needs rendered colours and text decoration",
46
+ applicabilityUnreliable: true
47
+ },
48
+ "no-autoplay-audio": {
49
+ detail: "needs media duration, and media is never loaded",
50
+ applicabilityUnreliable: true
51
+ },
52
+ "avoid-inline-spacing": {
53
+ detail: "needs computed spacing after the full cascade",
54
+ applicabilityUnreliable: false
55
+ },
56
+ "p-as-heading": {
57
+ detail: "needs computed font size and weight",
58
+ applicabilityUnreliable: false
59
+ },
60
+ "css-orientation-lock": {
61
+ detail: "needs CSS media query evaluation",
62
+ applicabilityUnreliable: true
63
+ }
64
+ };
65
+ /** Per-page ceiling; one pathological document must not stall a CI run. */
66
+ const DEFAULT_TIMEOUT_MS = 3e4;
67
+ /**
68
+ * Audit collected pages with axe-core inside jsdom.
69
+ *
70
+ * Pages are processed sequentially: jsdom parsing and axe-core are both
71
+ * CPU-bound on the main thread, so concurrency buys nothing here. A page that
72
+ * throws or times out is recorded with an `error` and the run continues.
73
+ */
74
+ async function runJsdomAudit(pages, options = {}) {
75
+ const audits = [];
76
+ for (const page of pages) audits.push(await auditPage(page, options));
77
+ return audits;
78
+ }
79
+ async function auditPage(page, options = {}) {
80
+ const tags = options.tags ?? DEFAULT_TAGS;
81
+ const url = pageUrl(page, options.baseUrl);
82
+ const startedAt = Date.now();
83
+ const identity = {
84
+ relativePath: page.relativePath,
85
+ absolutePath: page.absolutePath,
86
+ url,
87
+ engine: "jsdom"
88
+ };
89
+ let dom;
90
+ try {
91
+ dom = createDom(page.html, url);
92
+ injectAxe(dom);
93
+ const { axe: pageAxe } = dom.window;
94
+ const results = await withTimeout(pageAxe.run(dom.window.document, runOptions(tags)), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
95
+ return shapeResults(results, {
96
+ ...identity,
97
+ durationMs: Date.now() - startedAt,
98
+ blind: blindRulesInScope(tags)
99
+ });
100
+ } catch (cause) {
101
+ return failedPage({
102
+ ...identity,
103
+ durationMs: Date.now() - startedAt
104
+ }, cause instanceof Error ? cause.message : String(cause));
105
+ } finally {
106
+ dom?.window.close();
107
+ }
108
+ }
109
+ function createDom(html, url) {
110
+ const virtualConsole = new VirtualConsole();
111
+ virtualConsole.on("jsdomError", () => {});
112
+ return new JSDOM(html, {
113
+ url,
114
+ virtualConsole,
115
+ runScripts: "outside-only",
116
+ pretendToBeVisual: true
117
+ });
118
+ }
119
+ /**
120
+ * axe-core is 1.3 MB of source. Compiling it once and re-running the compiled
121
+ * script in each window's context avoids re-parsing it for every page.
122
+ */
123
+ let axeScript;
124
+ function injectAxe(dom) {
125
+ axeScript ??= new Script(axe.source, { filename: "axe-core.js" });
126
+ axeScript.runInContext(dom.getInternalVMContext());
127
+ }
128
+ const blindScopeCache = /* @__PURE__ */ new Map();
129
+ /**
130
+ * Blind rules the requested tag filter would actually have run. Experimental
131
+ * rules are excluded: axe-core leaves them off by default, so a browser run
132
+ * would not have evaluated them either.
133
+ */
134
+ function blindRulesInScope(tags) {
135
+ const key = [...tags].sort().join(",");
136
+ const cached = blindScopeCache.get(key);
137
+ if (cached) return cached;
138
+ const inScope = /* @__PURE__ */ new Map();
139
+ for (const rule of axe.getRules([...tags])) {
140
+ if (rule.tags.includes("experimental")) continue;
141
+ const blind = ENGINE_BLIND_RULES[rule.ruleId];
142
+ if (blind) inScope.set(rule.ruleId, blind);
143
+ }
144
+ blindScopeCache.set(key, inScope);
145
+ return inScope;
146
+ }
147
+ function pageUrl(page, baseUrl) {
148
+ if (!baseUrl) return pathToFileURL(page.absolutePath).href;
149
+ return new URL(page.relativePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).href;
150
+ }
151
+ async function withTimeout(promise, ms) {
152
+ promise.catch(() => {});
153
+ let timer;
154
+ try {
155
+ return await Promise.race([promise, new Promise((_resolve, reject) => {
156
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`axe-core timed out after ${ms}ms`)), ms);
157
+ })]);
158
+ } finally {
159
+ clearTimeout(timer);
160
+ }
161
+ }
162
+ //#endregion
163
+ export { runJsdomAudit as i, auditPage as n, blindRulesInScope as r, ENGINE_BLIND_RULES as t };
@@ -0,0 +1,3 @@
1
+ import "./result-2aZPfM8w.js";
2
+ import { i as runJsdomAudit } from "./jsdom-BEu6Ra_2.js";
3
+ export { runJsdomAudit };
@@ -0,0 +1,139 @@
1
+ import { i as isImpactLevel, r as countAtOrAbove } from "./impact-DvgBjupx.js";
2
+ import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
3
+ import axe from "axe-core";
4
+ /**
5
+ * Build the machine-readable report.
6
+ *
7
+ * This is a published contract, so it deliberately excludes things the internal
8
+ * PageAudit carries: absolute filesystem paths (machine-specific, and they leak
9
+ * into anything committed), per-page timings (non-deterministic, which would
10
+ * make two reports of the same build differ), and raw axe-core tags (they move
11
+ * between axe-core releases, and promising them would tie this schema to
12
+ * theirs). Everything present here is meant to survive.
13
+ */
14
+ function buildJsonReport(audits, options) {
15
+ const generatedAt = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
16
+ return {
17
+ schemaVersion: 1,
18
+ tool: {
19
+ name: "eaa-kit",
20
+ version: TOOL_VERSION,
21
+ axeCore: axe.version
22
+ },
23
+ generatedAt,
24
+ engine: audits[0]?.engine ?? "jsdom",
25
+ target: {
26
+ directory: options.directory,
27
+ baseUrl: options.baseUrl ?? null
28
+ },
29
+ summary: buildSummary(audits, options.failOn),
30
+ rules: buildRuleIndex(audits),
31
+ pages: audits.map(toJsonPage)
32
+ };
33
+ }
34
+ /** Every rule mentioned by any page, keyed by id and sorted for stable diffs. */
35
+ function buildRuleIndex(audits) {
36
+ const index = /* @__PURE__ */ new Map();
37
+ for (const audit of audits) {
38
+ const outcomes = [
39
+ ...audit.violations,
40
+ ...audit.accepted ?? [],
41
+ ...audit.incomplete,
42
+ ...audit.passes,
43
+ ...audit.inapplicable
44
+ ];
45
+ for (const outcome of outcomes) {
46
+ if (index.has(outcome.ruleId)) continue;
47
+ index.set(outcome.ruleId, {
48
+ help: outcome.help,
49
+ helpUrl: outcome.helpUrl,
50
+ successCriteria: outcome.successCriteria,
51
+ en301549: outcome.enClauses
52
+ });
53
+ }
54
+ }
55
+ return Object.fromEntries([...index].sort(([a], [b]) => a.localeCompare(b)));
56
+ }
57
+ /** Serialised form written to stdout or to --output, with a trailing newline. */
58
+ function serialiseJsonReport(report) {
59
+ return `${JSON.stringify(report, null, 2)}\n`;
60
+ }
61
+ function buildSummary(audits, failOn) {
62
+ const byImpact = {
63
+ critical: 0,
64
+ serious: 0,
65
+ moderate: 0,
66
+ minor: 0,
67
+ unclassified: 0
68
+ };
69
+ let violations = 0;
70
+ let violatingElements = 0;
71
+ let needsReview = 0;
72
+ let notEvaluated = 0;
73
+ let passes = 0;
74
+ let inapplicable = 0;
75
+ let accepted = 0;
76
+ for (const audit of audits) {
77
+ for (const finding of audit.accepted ?? []) accepted += finding.nodes.length;
78
+ for (const finding of audit.violations) {
79
+ violations += 1;
80
+ violatingElements += finding.nodes.length;
81
+ const impact = finding.impact;
82
+ byImpact[impact && isImpactLevel(impact) ? impact : "unclassified"] += 1;
83
+ }
84
+ for (const finding of audit.incomplete) if (finding.reason === "engine-limitation") notEvaluated += 1;
85
+ else needsReview += 1;
86
+ passes += audit.passes.length;
87
+ inapplicable += audit.inapplicable.length;
88
+ }
89
+ return {
90
+ pages: audits.length,
91
+ pagesWithViolations: audits.filter((audit) => audit.violations.length > 0).length,
92
+ pagesNotAudited: audits.filter((audit) => audit.error).length,
93
+ violations,
94
+ violatingElements,
95
+ byImpact,
96
+ needsReview,
97
+ notEvaluated,
98
+ passes,
99
+ inapplicable,
100
+ failOn,
101
+ failing: countAtOrAbove(audits, failOn),
102
+ accepted
103
+ };
104
+ }
105
+ function toJsonPage(audit) {
106
+ return {
107
+ path: audit.relativePath,
108
+ url: audit.url,
109
+ violations: [...audit.violations].sort(byRuleId).map(toJsonFinding),
110
+ ...audit.accepted ? { accepted: [...audit.accepted].sort(byRuleId).map(toJsonFinding) } : {},
111
+ incomplete: [...audit.incomplete].sort(byRuleId).map(toJsonIncomplete),
112
+ passes: [...audit.passes].sort(byRuleId).map((outcome) => outcome.ruleId),
113
+ inapplicable: [...audit.inapplicable].sort(byRuleId).map((outcome) => outcome.ruleId),
114
+ error: audit.error ?? null
115
+ };
116
+ }
117
+ function toJsonFinding(finding) {
118
+ return {
119
+ ruleId: finding.ruleId,
120
+ impact: finding.impact && isImpactLevel(finding.impact) ? finding.impact : null,
121
+ nodes: finding.nodes.map((node) => ({
122
+ html: node.html,
123
+ target: node.target,
124
+ failureSummary: node.failureSummary ?? null
125
+ }))
126
+ };
127
+ }
128
+ function toJsonIncomplete(finding) {
129
+ return {
130
+ ...toJsonFinding(finding),
131
+ reason: finding.reason,
132
+ reasonDetail: finding.reasonDetail
133
+ };
134
+ }
135
+ function byRuleId(a, b) {
136
+ return a.ruleId.localeCompare(b.ruleId);
137
+ }
138
+ //#endregion
139
+ export { buildJsonReport, serialiseJsonReport };
@@ -0,0 +1,235 @@
1
+ import { i as shapeResults, n as failedPage, r as runOptions, t as DEFAULT_TAGS } from "./result-2aZPfM8w.js";
2
+ import { stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import axe from "axe-core";
6
+ import { createReadStream } from "node:fs";
7
+ import { createServer } from "node:http";
8
+ //#region src/audit/serve.ts
9
+ /**
10
+ * Content types for what a static build actually contains. Anything else is
11
+ * served as an octet-stream, which is fine: the browser only needs to parse
12
+ * HTML, CSS and fonts for an audit to be meaningful.
13
+ */
14
+ const CONTENT_TYPES = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".htm": "text/html; charset=utf-8",
17
+ ".css": "text/css; charset=utf-8",
18
+ ".js": "text/javascript; charset=utf-8",
19
+ ".mjs": "text/javascript; charset=utf-8",
20
+ ".json": "application/json; charset=utf-8",
21
+ ".svg": "image/svg+xml",
22
+ ".png": "image/png",
23
+ ".jpg": "image/jpeg",
24
+ ".jpeg": "image/jpeg",
25
+ ".gif": "image/gif",
26
+ ".webp": "image/webp",
27
+ ".avif": "image/avif",
28
+ ".ico": "image/x-icon",
29
+ ".woff": "font/woff",
30
+ ".woff2": "font/woff2",
31
+ ".ttf": "font/ttf",
32
+ ".otf": "font/otf",
33
+ ".txt": "text/plain; charset=utf-8",
34
+ ".xml": "application/xml; charset=utf-8"
35
+ };
36
+ /**
37
+ * Serve a build directory over loopback for the browser runner.
38
+ *
39
+ * A local server rather than file:// URLs, because root-absolute asset paths —
40
+ * `/assets/site.css`, which every static site generator emits — do not resolve
41
+ * under file://. Measured on a page whose stylesheet sets `color: #ccc`: the
42
+ * computed colour is the default black over file:// and the real value over
43
+ * http://. Auditing colour contrast against an unstyled page would be worse
44
+ * than not auditing it at all.
45
+ *
46
+ * Bound to 127.0.0.1 on an ephemeral port, so it is not reachable off the
47
+ * machine and cannot collide with a dev server.
48
+ */
49
+ async function serveDirectory(root) {
50
+ const absoluteRoot = path.resolve(root);
51
+ const server = createServer((request, response) => {
52
+ handle(absoluteRoot, request.url ?? "/", response).catch(() => {
53
+ if (!response.headersSent) response.writeHead(500);
54
+ response.end();
55
+ });
56
+ });
57
+ await new Promise((resolve, reject) => {
58
+ server.once("error", reject);
59
+ server.listen(0, "127.0.0.1", resolve);
60
+ });
61
+ const address = server.address();
62
+ if (address === null || typeof address === "string") throw new Error("Static server did not bind to a port");
63
+ return {
64
+ origin: `http://127.0.0.1:${address.port}`,
65
+ close: () => closeServer(server)
66
+ };
67
+ }
68
+ async function handle(root, requestUrl, response) {
69
+ const file = resolveFile(root, requestUrl);
70
+ if (!file) {
71
+ response.writeHead(404).end();
72
+ return;
73
+ }
74
+ try {
75
+ const target = (await stat(file)).isDirectory() ? path.join(file, "index.html") : file;
76
+ if (!await isReadableFile(target)) {
77
+ response.writeHead(404).end();
78
+ return;
79
+ }
80
+ const type = CONTENT_TYPES[path.extname(target).toLowerCase()] ?? "application/octet-stream";
81
+ response.writeHead(200, { "content-type": type });
82
+ createReadStream(target).on("error", () => {
83
+ if (!response.headersSent) response.writeHead(404);
84
+ response.end();
85
+ }).pipe(response);
86
+ } catch {
87
+ response.writeHead(404).end();
88
+ }
89
+ }
90
+ async function isReadableFile(candidate) {
91
+ try {
92
+ return (await stat(candidate)).isFile();
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+ /** Resolves a request path inside the root, or nothing if it escapes it. */
98
+ function resolveFile(root, requestUrl) {
99
+ const { pathname } = new URL(requestUrl, "http://127.0.0.1");
100
+ let decoded;
101
+ try {
102
+ decoded = decodeURIComponent(pathname);
103
+ } catch {
104
+ return;
105
+ }
106
+ const candidate = path.resolve(root, `.${path.posix.normalize(decoded)}`);
107
+ const relative = path.relative(root, candidate);
108
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return void 0;
109
+ return candidate;
110
+ }
111
+ function closeServer(server) {
112
+ return new Promise((resolve) => {
113
+ server.closeAllConnections();
114
+ server.close(() => resolve());
115
+ });
116
+ }
117
+ //#endregion
118
+ //#region src/audit/runners/playwright.ts
119
+ /** Per-page ceiling. Real pages fetch real resources, so it is looser. */
120
+ const DEFAULT_TIMEOUT_MS = 6e4;
121
+ var BrowserUnavailableError = class extends Error {
122
+ name = "BrowserUnavailableError";
123
+ };
124
+ /** Desktop-ish default; large enough that nothing collapses to a mobile layout. */
125
+ const DEFAULT_VIEWPORT = {
126
+ width: 1280,
127
+ height: 720
128
+ };
129
+ /**
130
+ * Audit a built site in real Chromium.
131
+ *
132
+ * This is the engine that can answer the rules jsdom is blind to: colour
133
+ * contrast, target size, computed overflow. Nothing is force-reported as
134
+ * unevaluated here, because with layout and CSS there is no reason to.
135
+ *
136
+ * Two differences from the browserless path are deliberate and worth knowing:
137
+ * the page's own JavaScript runs, so client-rendered content is audited as a
138
+ * visitor would see it; and the build is served over loopback rather than
139
+ * opened as a file, so absolute asset paths resolve.
140
+ */
141
+ async function runBrowserAudit(directory, pages, options = {}) {
142
+ if (pages.length === 0) return [];
143
+ const chromium = await loadChromium();
144
+ const tags = options.tags ?? DEFAULT_TAGS;
145
+ const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
146
+ const viewport = options.viewport ?? DEFAULT_VIEWPORT;
147
+ const server = await serveDirectory(directory);
148
+ const browser = await chromium.launch({ headless: true });
149
+ try {
150
+ const context = await browser.newContext({
151
+ viewport,
152
+ bypassCSP: true
153
+ });
154
+ const audits = [];
155
+ for (const page of pages) audits.push(await auditOne(context, server.origin, page, {
156
+ tags,
157
+ timeout,
158
+ ...options
159
+ }));
160
+ await context.close();
161
+ return audits;
162
+ } finally {
163
+ await browser.close();
164
+ await server.close();
165
+ }
166
+ }
167
+ async function auditOne(context, origin, page, options) {
168
+ const startedAt = Date.now();
169
+ const identity = {
170
+ relativePath: page.relativePath,
171
+ absolutePath: page.absolutePath,
172
+ url: reportedUrl(page, options.baseUrl),
173
+ engine: "browser"
174
+ };
175
+ const tab = await context.newPage();
176
+ try {
177
+ tab.setDefaultTimeout(options.timeout);
178
+ const target = servedUrl(origin, page.relativePath);
179
+ const response = await tab.goto(target, {
180
+ waitUntil: "load",
181
+ timeout: options.timeout
182
+ });
183
+ if (response && response.status() >= 400) throw new Error(`Served ${page.relativePath} with HTTP ${response.status()}`);
184
+ await tab.addScriptTag({ content: axe.source });
185
+ const results = await tab.evaluate(([runnerOptions]) => globalThis.axe.run(runnerOptions), [{
186
+ ...runOptions(options.tags),
187
+ preload: true
188
+ }]);
189
+ return shapeResults(results, {
190
+ ...identity,
191
+ durationMs: Date.now() - startedAt,
192
+ blind: /* @__PURE__ */ new Map()
193
+ });
194
+ } catch (cause) {
195
+ return failedPage({
196
+ ...identity,
197
+ durationMs: Date.now() - startedAt
198
+ }, cause instanceof Error ? cause.message : String(cause));
199
+ } finally {
200
+ await tab.close();
201
+ }
202
+ }
203
+ /**
204
+ * Where the local server will hand this page over.
205
+ *
206
+ * Encoded per segment: a build with `#` or `?` in a filename would otherwise
207
+ * have the browser read the rest of the path as a fragment or a query and audit
208
+ * the wrong page, or none at all. The browserless engine gets this for free
209
+ * from pathToFileURL; this path has to do it by hand.
210
+ */
211
+ function servedUrl(origin, relativePath) {
212
+ return `${origin}/${relativePath.split("/").map(encodeURIComponent).join("/")}`;
213
+ }
214
+ function reportedUrl(page, baseUrl) {
215
+ if (!baseUrl) return pathToFileURL(page.absolutePath).href;
216
+ return new URL(page.relativePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).href;
217
+ }
218
+ /**
219
+ * Playwright is an optional peer dependency: the browserless path must never
220
+ * pay for a Chromium download. Both failure modes get their own instruction,
221
+ * because "install playwright" and "install the browser binary" are different
222
+ * problems with different fixes.
223
+ */
224
+ async function loadChromium() {
225
+ let module;
226
+ try {
227
+ module = await import("playwright");
228
+ } catch {
229
+ throw new BrowserUnavailableError("Browser mode needs Playwright, which is an optional peer dependency.\n Install it with: pnpm add -D playwright\n Then the browser: npx playwright install chromium");
230
+ }
231
+ if (!module.chromium) throw new BrowserUnavailableError("Playwright is installed but exports no chromium launcher");
232
+ return module.chromium;
233
+ }
234
+ //#endregion
235
+ export { BrowserUnavailableError, runBrowserAudit };