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.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/astro/index.d.ts +90 -0
- package/dist/astro/index.js +48 -0
- package/dist/audit/runners/worker.d.ts +1 -0
- package/dist/audit/runners/worker.js +27 -0
- package/dist/audit-6gbV0Zjd.js +582 -0
- package/dist/audit-VtuUOuyX.js +2 -0
- package/dist/baseline-DQTnNlc4.js +169 -0
- package/dist/baseline-Itspu3-Y.js +2 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +259 -0
- package/dist/escape-Dm1o_RAk.js +21 -0
- package/dist/fingerprint-DRoneAjj.js +20 -0
- package/dist/html-BLEuzep6.js +337 -0
- package/dist/impact-DvgBjupx.js +32 -0
- package/dist/impact-EEB9ZXmC.d.ts +7 -0
- package/dist/index.d.ts +264 -0
- package/dist/index.js +2 -0
- package/dist/jsdom-BEu6Ra_2.js +163 -0
- package/dist/jsdom-C6dIyaxN.js +3 -0
- package/dist/json-1ESNIiHY.js +139 -0
- package/dist/playwright-BfWuTG_u.js +235 -0
- package/dist/pool-DixLeu8L.js +188 -0
- package/dist/render-K9KxDDSA.js +774 -0
- package/dist/result-2aZPfM8w.js +168 -0
- package/dist/sarif-eSCuI0eX.js +192 -0
- package/dist/statement/templates/at.de.md +127 -0
- package/dist/statement/templates/at.en.md +121 -0
- package/dist/statement/templates/ch.de.md +138 -0
- package/dist/statement/templates/ch.en.md +135 -0
- package/dist/statement/templates/de.de.md +129 -0
- package/dist/statement/templates/de.en.md +125 -0
- package/dist/version-B3v4rNoG.js +15 -0
- package/package.json +95 -0
|
@@ -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,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 };
|