@diegovelasquezweb/a11y-engine 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 +20 -0
- package/assets/discovery/crawler-config.json +11 -0
- package/assets/discovery/stack-detection.json +33 -0
- package/assets/remediation/axe-check-maps.json +31 -0
- package/assets/remediation/code-patterns.json +109 -0
- package/assets/remediation/guardrails.json +24 -0
- package/assets/remediation/intelligence.json +4166 -0
- package/assets/remediation/source-boundaries.json +46 -0
- package/assets/reporting/compliance-config.json +173 -0
- package/assets/reporting/manual-checks.json +944 -0
- package/assets/reporting/wcag-reference.json +588 -0
- package/package.json +37 -0
- package/scripts/audit.mjs +326 -0
- package/scripts/core/asset-loader.mjs +54 -0
- package/scripts/core/toolchain.mjs +102 -0
- package/scripts/core/utils.mjs +105 -0
- package/scripts/engine/analyzer.mjs +1022 -0
- package/scripts/engine/dom-scanner.mjs +685 -0
- package/scripts/engine/source-scanner.mjs +300 -0
- package/scripts/reports/builders/checklist.mjs +307 -0
- package/scripts/reports/builders/html.mjs +766 -0
- package/scripts/reports/builders/md.mjs +96 -0
- package/scripts/reports/builders/pdf.mjs +259 -0
- package/scripts/reports/renderers/findings.mjs +188 -0
- package/scripts/reports/renderers/html.mjs +452 -0
- package/scripts/reports/renderers/md.mjs +595 -0
- package/scripts/reports/renderers/pdf.mjs +551 -0
- package/scripts/reports/renderers/utils.mjs +42 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file dom-scanner.mjs
|
|
3
|
+
* @description Accessibility scanner core.
|
|
4
|
+
* Responsible for crawling the target website, discovering routes,
|
|
5
|
+
* and performing the automated axe-core analysis on identified pages
|
|
6
|
+
* using Playwright for browser orchestration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { chromium } from "playwright";
|
|
10
|
+
import AxeBuilder from "@axe-core/playwright";
|
|
11
|
+
import { log, DEFAULTS, writeJson, getInternalPath } from "../core/utils.mjs";
|
|
12
|
+
import { ASSET_PATHS, loadAssetJson } from "../core/asset-loader.mjs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
const CRAWLER_CONFIG = loadAssetJson(
|
|
20
|
+
ASSET_PATHS.discovery.crawlerConfig,
|
|
21
|
+
"assets/discovery/crawler-config.json",
|
|
22
|
+
);
|
|
23
|
+
const STACK_DETECTION = loadAssetJson(
|
|
24
|
+
ASSET_PATHS.discovery.stackDetection,
|
|
25
|
+
"assets/discovery/stack-detection.json",
|
|
26
|
+
);
|
|
27
|
+
const AXE_TAGS = [
|
|
28
|
+
"wcag2a",
|
|
29
|
+
"wcag2aa",
|
|
30
|
+
"wcag21a",
|
|
31
|
+
"wcag21aa",
|
|
32
|
+
"wcag22a",
|
|
33
|
+
"wcag22aa",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Prints the CLI usage instructions and available options to the console.
|
|
38
|
+
*/
|
|
39
|
+
function printUsage() {
|
|
40
|
+
log.info(`Usage:
|
|
41
|
+
node scripts/engine/dom-scanner.mjs --base-url <url> [options]
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
--routes <csv|newline> Optional route list (same-origin paths/urls)
|
|
45
|
+
--output <path> Output JSON path (default: internal)
|
|
46
|
+
--max-routes <number> Max routes to analyze (default: 10)
|
|
47
|
+
--wait-ms <number> Time to wait after load (default: 2000)
|
|
48
|
+
--timeout-ms <number> Request timeout (default: 30000)
|
|
49
|
+
--headless <boolean> Run headless (default: true)
|
|
50
|
+
--color-scheme <value> Emulate color scheme: "light" or "dark" (default: "light")
|
|
51
|
+
--screenshots-dir <path> Directory to save element screenshots (optional)
|
|
52
|
+
--exclude-selectors <csv> Selectors to exclude from scan
|
|
53
|
+
--only-rule <id> Only check for this specific rule ID (ignores tags)
|
|
54
|
+
--crawl-depth <number> How deep to follow links during discovery (1-3, default: 2)
|
|
55
|
+
--wait-until <value> Page load strategy: domcontentloaded|load|networkidle (default: domcontentloaded)
|
|
56
|
+
--viewport <WxH> Viewport dimensions as WIDTHxHEIGHT (e.g., 375x812)
|
|
57
|
+
-h, --help Show this help
|
|
58
|
+
`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parses command-line arguments into a structured configuration object.
|
|
63
|
+
* @param {string[]} argv - Array of command-line arguments (process.argv.slice(2)).
|
|
64
|
+
* @returns {Object} A configuration object for the scanner.
|
|
65
|
+
* @throws {Error} If the required --base-url argument is missing.
|
|
66
|
+
*/
|
|
67
|
+
function parseArgs(argv) {
|
|
68
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
69
|
+
printUsage();
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const args = {
|
|
74
|
+
baseUrl: "",
|
|
75
|
+
routes: "",
|
|
76
|
+
output: getInternalPath("a11y-scan-results.json"),
|
|
77
|
+
maxRoutes: DEFAULTS.maxRoutes,
|
|
78
|
+
waitMs: DEFAULTS.waitMs,
|
|
79
|
+
timeoutMs: DEFAULTS.timeoutMs,
|
|
80
|
+
headless: DEFAULTS.headless,
|
|
81
|
+
waitUntil: DEFAULTS.waitUntil,
|
|
82
|
+
colorScheme: null,
|
|
83
|
+
screenshotsDir: null,
|
|
84
|
+
excludeSelectors: [],
|
|
85
|
+
onlyRule: null,
|
|
86
|
+
crawlDepth: DEFAULTS.crawlDepth,
|
|
87
|
+
viewport: null,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
91
|
+
const key = argv[i];
|
|
92
|
+
if (!key.startsWith("--")) continue;
|
|
93
|
+
|
|
94
|
+
if (key === "--headed") { args.headless = false; continue; }
|
|
95
|
+
|
|
96
|
+
const value = argv[i + 1];
|
|
97
|
+
if (value === undefined) continue;
|
|
98
|
+
|
|
99
|
+
if (key === "--base-url") args.baseUrl = value;
|
|
100
|
+
if (key === "--routes") args.routes = value;
|
|
101
|
+
if (key === "--output") args.output = value;
|
|
102
|
+
if (key === "--max-routes") args.maxRoutes = Number.parseInt(value, 10);
|
|
103
|
+
if (key === "--wait-ms") args.waitMs = Number.parseInt(value, 10);
|
|
104
|
+
if (key === "--timeout-ms") args.timeoutMs = Number.parseInt(value, 10);
|
|
105
|
+
if (key === "--headless") args.headless = value !== "false";
|
|
106
|
+
if (key === "--only-rule") args.onlyRule = value;
|
|
107
|
+
if (key === "--crawl-depth") args.crawlDepth = Number.parseInt(value, 10);
|
|
108
|
+
if (key === "--wait-until") args.waitUntil = value;
|
|
109
|
+
if (key === "--exclude-selectors")
|
|
110
|
+
args.excludeSelectors = value.split(",").map((s) => s.trim());
|
|
111
|
+
if (key === "--color-scheme") args.colorScheme = value;
|
|
112
|
+
if (key === "--screenshots-dir") args.screenshotsDir = value;
|
|
113
|
+
if (key === "--viewport") {
|
|
114
|
+
const [w, h] = value.split("x").map(Number);
|
|
115
|
+
if (w && h) args.viewport = { width: w, height: h };
|
|
116
|
+
}
|
|
117
|
+
i += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
args.crawlDepth = Math.min(Math.max(args.crawlDepth, 1), 3);
|
|
121
|
+
if (!args.baseUrl) throw new Error("Missing required --base-url");
|
|
122
|
+
return args;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const BLOCKED_EXTENSIONS = new RegExp(
|
|
126
|
+
"\\.(" + CRAWLER_CONFIG.blockedExtensions.join("|") + ")$",
|
|
127
|
+
"i",
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const PAGINATION_PARAMS = new RegExp(
|
|
131
|
+
"^(" + CRAWLER_CONFIG.paginationParams.join("|") + ")$",
|
|
132
|
+
"i",
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Attempts to discover additional routes by fetching and parsing the sitemap.xml.
|
|
137
|
+
* @param {string} origin - The origin (protocol + domain) of the target site.
|
|
138
|
+
* @returns {Promise<string[]>} A list of discovered route paths/URLs.
|
|
139
|
+
*/
|
|
140
|
+
async function discoverFromSitemap(origin) {
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(`${origin}/sitemap.xml`, {
|
|
143
|
+
signal: AbortSignal.timeout(5000),
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) return [];
|
|
146
|
+
const xml = await res.text();
|
|
147
|
+
const locs = [...xml.matchAll(/<loc>([^<]+)<\/loc>/gi)].map((m) =>
|
|
148
|
+
m[1].trim(),
|
|
149
|
+
);
|
|
150
|
+
const routes = new Set();
|
|
151
|
+
for (const loc of locs) {
|
|
152
|
+
const normalized = normalizePath(loc, origin);
|
|
153
|
+
if (normalized && normalized !== "/") routes.add(normalized);
|
|
154
|
+
}
|
|
155
|
+
return [...routes];
|
|
156
|
+
} catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Fetches and parses robots.txt to identify paths disallowed for crawlers.
|
|
163
|
+
* @param {string} origin - The origin of the target site.
|
|
164
|
+
* @returns {Promise<Set<string>>} A set of disallowed path prefixes.
|
|
165
|
+
*/
|
|
166
|
+
async function fetchDisallowedPaths(origin) {
|
|
167
|
+
const disallowed = new Set();
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetch(`${origin}/robots.txt`, {
|
|
170
|
+
signal: AbortSignal.timeout(3000),
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) return disallowed;
|
|
173
|
+
const text = await res.text();
|
|
174
|
+
let inUserAgentAll = false;
|
|
175
|
+
for (const raw of text.split("\n")) {
|
|
176
|
+
const line = raw.trim();
|
|
177
|
+
if (/^User-agent:\s*\*/i.test(line)) {
|
|
178
|
+
inUserAgentAll = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (/^User-agent:/i.test(line)) {
|
|
182
|
+
inUserAgentAll = false;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (inUserAgentAll) {
|
|
186
|
+
const match = line.match(/^Disallow:\s*(.+)/i);
|
|
187
|
+
if (match) {
|
|
188
|
+
const p = match[1].trim();
|
|
189
|
+
if (p) disallowed.add(p);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// silent — robots.txt is optional
|
|
195
|
+
}
|
|
196
|
+
return disallowed;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Checks if a specific route path matches any of the disallowed patterns from robots.txt.
|
|
201
|
+
* @param {string} routePath - The path to check.
|
|
202
|
+
* @param {Set<string>} disallowedPaths - Set of disallowed patterns/prefixes.
|
|
203
|
+
* @returns {boolean} True if the path is disallowed, false otherwise.
|
|
204
|
+
*/
|
|
205
|
+
function isDisallowedPath(routePath, disallowedPaths) {
|
|
206
|
+
for (const rule of disallowedPaths) {
|
|
207
|
+
if (routePath.startsWith(rule)) return true;
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Normalizes a URL or path to a relative hashless path if it belongs to the same origin.
|
|
214
|
+
* @param {string} rawValue - The raw URL or path string to normalize.
|
|
215
|
+
* @param {string} origin - The origin of the target site.
|
|
216
|
+
* @returns {string} The normalized relative path, or an empty string if invalid/external.
|
|
217
|
+
*/
|
|
218
|
+
export function normalizePath(rawValue, origin) {
|
|
219
|
+
if (!rawValue) return "";
|
|
220
|
+
try {
|
|
221
|
+
const u = new URL(rawValue, origin);
|
|
222
|
+
if (u.origin !== origin) return "";
|
|
223
|
+
if (BLOCKED_EXTENSIONS.test(u.pathname)) return "";
|
|
224
|
+
const hashless = `${u.pathname || "/"}${u.search || ""}`;
|
|
225
|
+
return hashless === "" ? "/" : hashless;
|
|
226
|
+
} catch {
|
|
227
|
+
return "";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Parses the --routes CLI argument (CSV or newline-separated) into a list of normalized paths.
|
|
233
|
+
* @param {string} routesArg - The raw string from the --routes argument.
|
|
234
|
+
* @param {string} origin - The origin of the target site.
|
|
235
|
+
* @returns {string[]} A list of unique, normalized route paths.
|
|
236
|
+
*/
|
|
237
|
+
export function parseRoutesArg(routesArg, origin) {
|
|
238
|
+
if (!routesArg.trim()) return [];
|
|
239
|
+
const entries = routesArg
|
|
240
|
+
.split(/[,\n]/)
|
|
241
|
+
.map((v) => v.trim())
|
|
242
|
+
.filter(Boolean);
|
|
243
|
+
|
|
244
|
+
const uniq = new Set();
|
|
245
|
+
for (const value of entries) {
|
|
246
|
+
const normalized = normalizePath(value, origin);
|
|
247
|
+
if (normalized) uniq.add(normalized);
|
|
248
|
+
}
|
|
249
|
+
return [...uniq];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Crawls the website to discover additional routes starting from the base URL.
|
|
254
|
+
* @param {import("playwright").Page} page - The Playwright page object.
|
|
255
|
+
* @param {string} baseUrl - The starting URL for discovery.
|
|
256
|
+
* @param {number} maxRoutes - Maximum number of routes to discover.
|
|
257
|
+
* @param {number} crawlDepth - How deep to follow links (1-3).
|
|
258
|
+
* @returns {Promise<string[]>} A list of discovered route paths.
|
|
259
|
+
*/
|
|
260
|
+
export async function discoverRoutes(page, baseUrl, maxRoutes, crawlDepth = 2) {
|
|
261
|
+
const origin = new URL(baseUrl).origin;
|
|
262
|
+
const routes = new Set(["/"]);
|
|
263
|
+
const seenPathnames = new Set(["/"]);
|
|
264
|
+
const visited = new Set();
|
|
265
|
+
let frontier = ["/"];
|
|
266
|
+
|
|
267
|
+
function extractLinks(hrefs) {
|
|
268
|
+
const newRoutes = [];
|
|
269
|
+
for (const href of hrefs) {
|
|
270
|
+
if (routes.size >= maxRoutes) break;
|
|
271
|
+
const normalized = normalizePath(href, origin);
|
|
272
|
+
if (!normalized) continue;
|
|
273
|
+
try {
|
|
274
|
+
const u = new URL(normalized, origin);
|
|
275
|
+
const hasPagination = [...new URLSearchParams(u.search).keys()].some(
|
|
276
|
+
(k) => PAGINATION_PARAMS.test(k),
|
|
277
|
+
);
|
|
278
|
+
if (hasPagination && seenPathnames.has(u.pathname)) continue;
|
|
279
|
+
seenPathnames.add(u.pathname);
|
|
280
|
+
} catch {
|
|
281
|
+
// keep non-parseable normalized paths as-is
|
|
282
|
+
}
|
|
283
|
+
if (!routes.has(normalized)) {
|
|
284
|
+
routes.add(normalized);
|
|
285
|
+
newRoutes.push(normalized);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return newRoutes;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (let depth = 0; depth < crawlDepth && frontier.length > 0; depth++) {
|
|
292
|
+
const nextFrontier = [];
|
|
293
|
+
|
|
294
|
+
for (const routePath of frontier) {
|
|
295
|
+
if (routes.size >= maxRoutes) break;
|
|
296
|
+
if (visited.has(routePath)) continue;
|
|
297
|
+
visited.add(routePath);
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const targetUrl = new URL(routePath, origin).toString();
|
|
301
|
+
if (page.url() !== targetUrl) {
|
|
302
|
+
await page.goto(targetUrl, {
|
|
303
|
+
waitUntil: "domcontentloaded",
|
|
304
|
+
timeout: 10000,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const hrefs = await page.$$eval("a[href]", (elements) =>
|
|
309
|
+
elements.map((el) => el.getAttribute("href")),
|
|
310
|
+
);
|
|
311
|
+
nextFrontier.push(...extractLinks(hrefs));
|
|
312
|
+
} catch (error) {
|
|
313
|
+
log.warn(`Discovery skip ${routePath}: ${error.message}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
frontier = nextFrontier;
|
|
318
|
+
if (routes.size >= maxRoutes) break;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
log.info(
|
|
322
|
+
`Crawl depth ${Math.min(crawlDepth, 3)}: ${routes.size} route(s) discovered (visited ${visited.size} page(s))`,
|
|
323
|
+
);
|
|
324
|
+
return [...routes].slice(0, maxRoutes);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Detects the web framework and UI libraries used by analyzing package.json and file structure.
|
|
329
|
+
* @returns {Object} An object containing detected framework and UI libraries.
|
|
330
|
+
*/
|
|
331
|
+
function detectProjectContext() {
|
|
332
|
+
const uiLibraries = [];
|
|
333
|
+
let pkgFramework = null;
|
|
334
|
+
let fileFramework = null;
|
|
335
|
+
|
|
336
|
+
const projectDir = process.env.A11Y_PROJECT_DIR || process.cwd();
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const pkgPath = path.join(projectDir, "package.json");
|
|
340
|
+
if (fs.existsSync(pkgPath)) {
|
|
341
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
342
|
+
const allDeps = Object.keys({
|
|
343
|
+
...(pkg.dependencies || {}),
|
|
344
|
+
...(pkg.devDependencies || {}),
|
|
345
|
+
});
|
|
346
|
+
for (const [dep, fw] of STACK_DETECTION.frameworkPackageDetectors) {
|
|
347
|
+
if (allDeps.some((d) => d === dep || d.startsWith(`${dep}/`))) {
|
|
348
|
+
pkgFramework = fw;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const [prefix, name] of STACK_DETECTION.uiLibraryPackageDetectors) {
|
|
353
|
+
if (allDeps.some((d) => d === prefix || d.startsWith(`${prefix}/`))) {
|
|
354
|
+
uiLibraries.push(name);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} catch { /* package.json unreadable */ }
|
|
359
|
+
|
|
360
|
+
if (!pkgFramework) {
|
|
361
|
+
for (const [fw, files] of STACK_DETECTION.platformStructureDetectors || []) {
|
|
362
|
+
if (files.some((f) => fs.existsSync(path.join(projectDir, f)))) {
|
|
363
|
+
fileFramework = fw;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const resolvedFramework = pkgFramework || fileFramework;
|
|
370
|
+
|
|
371
|
+
if (resolvedFramework) {
|
|
372
|
+
const source = pkgFramework ? "(from package.json)" : "(from file structure)";
|
|
373
|
+
log.info(`Detected framework: ${resolvedFramework} ${source}`);
|
|
374
|
+
}
|
|
375
|
+
if (uiLibraries.length) log.info(`Detected UI libraries: ${uiLibraries.join(", ")}`);
|
|
376
|
+
|
|
377
|
+
return { framework: resolvedFramework, uiLibraries };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Navigates to a route and performs an axe-core accessibility analysis.
|
|
382
|
+
* @param {import("playwright").Page} page - The Playwright page object.
|
|
383
|
+
* @param {string} routeUrl - The full URL of the route to analyze.
|
|
384
|
+
* @param {number} waitMs - Time to wait after page load.
|
|
385
|
+
* @param {string[]} excludeSelectors - CSS selectors to exclude from the scan.
|
|
386
|
+
* @param {string|null} onlyRule - Specific rule ID to check (optional).
|
|
387
|
+
* @param {number} timeoutMs - Navigation and analysis timeout.
|
|
388
|
+
* @param {number} maxRetries - Number of retries on failure.
|
|
389
|
+
* @param {string} waitUntil - Playwright load state strategy.
|
|
390
|
+
* @returns {Promise<Object>} The analysis results for the route.
|
|
391
|
+
*/
|
|
392
|
+
async function analyzeRoute(
|
|
393
|
+
page,
|
|
394
|
+
routeUrl,
|
|
395
|
+
waitMs,
|
|
396
|
+
excludeSelectors,
|
|
397
|
+
onlyRule,
|
|
398
|
+
timeoutMs = 30000,
|
|
399
|
+
maxRetries = 2,
|
|
400
|
+
waitUntil = "domcontentloaded",
|
|
401
|
+
) {
|
|
402
|
+
let lastError;
|
|
403
|
+
|
|
404
|
+
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
|
405
|
+
try {
|
|
406
|
+
await page.goto(routeUrl, {
|
|
407
|
+
waitUntil,
|
|
408
|
+
timeout: timeoutMs,
|
|
409
|
+
});
|
|
410
|
+
await page
|
|
411
|
+
.waitForLoadState("networkidle", { timeout: waitMs })
|
|
412
|
+
.catch(() => {});
|
|
413
|
+
|
|
414
|
+
const builder = new AxeBuilder({ page });
|
|
415
|
+
|
|
416
|
+
if (onlyRule) {
|
|
417
|
+
log.info(`Targeted Audit: Only checking rule "${onlyRule}"`);
|
|
418
|
+
builder.withRules([onlyRule]);
|
|
419
|
+
} else {
|
|
420
|
+
builder.withTags(AXE_TAGS);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (Array.isArray(excludeSelectors)) {
|
|
424
|
+
for (const selector of excludeSelectors) {
|
|
425
|
+
builder.exclude(selector);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const axeResults = await builder.analyze();
|
|
430
|
+
|
|
431
|
+
if (!Array.isArray(axeResults?.violations)) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
"axe-core returned an unexpected response — violations array missing.",
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const metadata = await page.evaluate(() => {
|
|
438
|
+
return {
|
|
439
|
+
title: document.title,
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
url: routeUrl,
|
|
445
|
+
violations: axeResults.violations,
|
|
446
|
+
incomplete: axeResults.incomplete,
|
|
447
|
+
passes: axeResults.passes.map((p) => p.id),
|
|
448
|
+
metadata,
|
|
449
|
+
};
|
|
450
|
+
} catch (error) {
|
|
451
|
+
lastError = error;
|
|
452
|
+
if (attempt <= maxRetries) {
|
|
453
|
+
log.warn(
|
|
454
|
+
`[attempt ${attempt}/${maxRetries + 1}] Retrying ${routeUrl}: ${error.message}`,
|
|
455
|
+
);
|
|
456
|
+
await page.waitForTimeout(1000 * attempt);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
log.error(
|
|
462
|
+
`Failed to analyze ${routeUrl} after ${maxRetries + 1} attempts: ${lastError.message}`,
|
|
463
|
+
);
|
|
464
|
+
return {
|
|
465
|
+
url: routeUrl,
|
|
466
|
+
error: lastError.message,
|
|
467
|
+
violations: [],
|
|
468
|
+
passes: [],
|
|
469
|
+
metadata: {},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The main execution function for the accessibility scanner.
|
|
475
|
+
* Coordinates browser setup, crawling/discovery, parallel scanning, and result saving.
|
|
476
|
+
* @throws {Error} If navigation to the base URL fails or browser setup issues occur.
|
|
477
|
+
*/
|
|
478
|
+
async function main() {
|
|
479
|
+
const args = parseArgs(process.argv.slice(2));
|
|
480
|
+
const baseUrl = new URL(args.baseUrl).toString();
|
|
481
|
+
const origin = new URL(baseUrl).origin;
|
|
482
|
+
|
|
483
|
+
log.info(`Starting accessibility audit for ${baseUrl}`);
|
|
484
|
+
|
|
485
|
+
const primaryViewport = args.viewport || {
|
|
486
|
+
width: DEFAULTS.viewports[0].width,
|
|
487
|
+
height: DEFAULTS.viewports[0].height,
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const browser = await chromium.launch({
|
|
491
|
+
headless: args.headless,
|
|
492
|
+
});
|
|
493
|
+
const context = await browser.newContext({
|
|
494
|
+
viewport: primaryViewport,
|
|
495
|
+
reducedMotion: "no-preference",
|
|
496
|
+
colorScheme: args.colorScheme || DEFAULTS.colorScheme,
|
|
497
|
+
forcedColors: "none",
|
|
498
|
+
locale: "en-US",
|
|
499
|
+
});
|
|
500
|
+
const page = await context.newPage();
|
|
501
|
+
|
|
502
|
+
let routes = [];
|
|
503
|
+
let projectContext = { framework: null, uiLibraries: [] };
|
|
504
|
+
try {
|
|
505
|
+
await page.goto(baseUrl, {
|
|
506
|
+
waitUntil: args.waitUntil,
|
|
507
|
+
timeout: args.timeoutMs,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
projectContext = detectProjectContext();
|
|
511
|
+
|
|
512
|
+
const cliRoutes = parseRoutesArg(args.routes, origin);
|
|
513
|
+
|
|
514
|
+
if (cliRoutes.length > 0) {
|
|
515
|
+
routes = cliRoutes.slice(0, args.maxRoutes);
|
|
516
|
+
} else if (baseUrl.startsWith("file://")) {
|
|
517
|
+
routes = [""];
|
|
518
|
+
} else {
|
|
519
|
+
log.info("Autodiscovering routes...");
|
|
520
|
+
const sitemapRoutes = await discoverFromSitemap(origin);
|
|
521
|
+
if (sitemapRoutes.length > 0) {
|
|
522
|
+
routes = [...new Set(["/", ...sitemapRoutes])].slice(0, args.maxRoutes);
|
|
523
|
+
log.info(
|
|
524
|
+
`Sitemap: ${routes.length} route(s) discovered from /sitemap.xml`,
|
|
525
|
+
);
|
|
526
|
+
} else {
|
|
527
|
+
const crawled = await discoverRoutes(
|
|
528
|
+
page,
|
|
529
|
+
baseUrl,
|
|
530
|
+
args.maxRoutes,
|
|
531
|
+
args.crawlDepth,
|
|
532
|
+
);
|
|
533
|
+
routes = [...crawled];
|
|
534
|
+
}
|
|
535
|
+
if (routes.length === 0) routes = ["/"];
|
|
536
|
+
}
|
|
537
|
+
} catch (err) {
|
|
538
|
+
log.error(`Fatal: Could not load base URL ${baseUrl}: ${err.message}`);
|
|
539
|
+
await browser.close();
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Selectors that should never be targeted for element screenshots.
|
|
545
|
+
* @type {Set<string>}
|
|
546
|
+
*/
|
|
547
|
+
const SKIP_SELECTORS = new Set(["html", "body", "head", ":root", "document"]);
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Captures a screenshot of an element associated with an accessibility violation.
|
|
551
|
+
* @param {import("playwright").Page} tabPage - The Playwright page object.
|
|
552
|
+
* @param {Object} violation - The axe-core violation object.
|
|
553
|
+
* @param {number} routeIndex - The index of the current route (used for filenames).
|
|
554
|
+
*/
|
|
555
|
+
async function captureElementScreenshot(tabPage, violation, routeIndex) {
|
|
556
|
+
if (!args.screenshotsDir) return;
|
|
557
|
+
const firstNode = violation.nodes?.[0];
|
|
558
|
+
if (!firstNode || firstNode.target.length > 1) return;
|
|
559
|
+
const selector = firstNode.target[0];
|
|
560
|
+
if (!selector || SKIP_SELECTORS.has(selector.toLowerCase())) return;
|
|
561
|
+
try {
|
|
562
|
+
fs.mkdirSync(args.screenshotsDir, { recursive: true });
|
|
563
|
+
const safeRuleId = violation.id.replace(/[^a-z0-9-]/g, "-");
|
|
564
|
+
const filename = `${routeIndex}-${safeRuleId}.png`;
|
|
565
|
+
const screenshotPath = path.join(args.screenshotsDir, filename);
|
|
566
|
+
await tabPage
|
|
567
|
+
.locator(selector)
|
|
568
|
+
.first()
|
|
569
|
+
.scrollIntoViewIfNeeded({ timeout: 3000 });
|
|
570
|
+
await tabPage.evaluate((sel) => {
|
|
571
|
+
const el = document.querySelector(sel);
|
|
572
|
+
if (!el) return;
|
|
573
|
+
const rect = el.getBoundingClientRect();
|
|
574
|
+
const overlay = document.createElement("div");
|
|
575
|
+
overlay.id = "__a11y_highlight__";
|
|
576
|
+
Object.assign(overlay.style, {
|
|
577
|
+
position: "fixed",
|
|
578
|
+
top: `${rect.top}px`,
|
|
579
|
+
left: `${rect.left}px`,
|
|
580
|
+
width: `${rect.width || 40}px`,
|
|
581
|
+
height: `${rect.height || 20}px`,
|
|
582
|
+
outline: "3px solid #ef4444",
|
|
583
|
+
outlineOffset: "2px",
|
|
584
|
+
backgroundColor: "rgba(239,68,68,0.12)",
|
|
585
|
+
zIndex: "2147483647",
|
|
586
|
+
pointerEvents: "none",
|
|
587
|
+
boxSizing: "border-box",
|
|
588
|
+
});
|
|
589
|
+
document.body.appendChild(overlay);
|
|
590
|
+
}, selector);
|
|
591
|
+
await tabPage.screenshot({ path: screenshotPath });
|
|
592
|
+
violation.screenshot_path = `screenshots/${filename}`;
|
|
593
|
+
await tabPage.evaluate(() =>
|
|
594
|
+
document.getElementById("__a11y_highlight__")?.remove(),
|
|
595
|
+
);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
log.warn(
|
|
598
|
+
`Screenshot skipped for "${violation.id}" (${selector}): ${err.message}`,
|
|
599
|
+
);
|
|
600
|
+
await tabPage
|
|
601
|
+
.evaluate(() => document.getElementById("__a11y_highlight__")?.remove())
|
|
602
|
+
.catch(() => {});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** @const {number} Default concurrency level for parallel scanning tabs. */
|
|
607
|
+
const TAB_CONCURRENCY = 3;
|
|
608
|
+
let results = [];
|
|
609
|
+
let total = 0;
|
|
610
|
+
|
|
611
|
+
try {
|
|
612
|
+
const disallowed = await fetchDisallowedPaths(origin);
|
|
613
|
+
if (disallowed.size > 0) {
|
|
614
|
+
const before = routes.length;
|
|
615
|
+
routes = routes.filter((r) => !isDisallowedPath(r, disallowed));
|
|
616
|
+
const skipped = before - routes.length;
|
|
617
|
+
if (skipped > 0)
|
|
618
|
+
log.info(`robots.txt: ${skipped} route(s) excluded (Disallow rules)`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
results = new Array(routes.length);
|
|
622
|
+
total = routes.length;
|
|
623
|
+
|
|
624
|
+
log.info(
|
|
625
|
+
`Targeting ${routes.length} routes (${Math.min(TAB_CONCURRENCY, routes.length)} parallel tabs): ${routes.join(", ")}`,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
const tabPages = [page];
|
|
629
|
+
for (let t = 1; t < Math.min(TAB_CONCURRENCY, routes.length); t++) {
|
|
630
|
+
tabPages.push(await context.newPage());
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
for (let i = 0; i < routes.length; i += tabPages.length) {
|
|
634
|
+
const batch = [];
|
|
635
|
+
for (let j = 0; j < tabPages.length && i + j < routes.length; j++) {
|
|
636
|
+
const idx = i + j;
|
|
637
|
+
const tabPage = tabPages[j];
|
|
638
|
+
batch.push(
|
|
639
|
+
(async () => {
|
|
640
|
+
const routePath = routes[idx];
|
|
641
|
+
log.info(`[${idx + 1}/${total}] Scanning: ${routePath}`);
|
|
642
|
+
const targetUrl = new URL(routePath, baseUrl).toString();
|
|
643
|
+
const result = await analyzeRoute(
|
|
644
|
+
tabPage,
|
|
645
|
+
targetUrl,
|
|
646
|
+
args.waitMs,
|
|
647
|
+
args.excludeSelectors,
|
|
648
|
+
args.onlyRule,
|
|
649
|
+
args.timeoutMs,
|
|
650
|
+
2,
|
|
651
|
+
args.waitUntil,
|
|
652
|
+
);
|
|
653
|
+
if (args.screenshotsDir && result.violations) {
|
|
654
|
+
for (const violation of result.violations) {
|
|
655
|
+
await captureElementScreenshot(tabPage, violation, idx);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
results[idx] = { path: routePath, ...result };
|
|
659
|
+
})(),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
await Promise.all(batch);
|
|
663
|
+
}
|
|
664
|
+
} finally {
|
|
665
|
+
await browser.close();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const payload = {
|
|
669
|
+
generated_at: new Date().toISOString(),
|
|
670
|
+
base_url: baseUrl,
|
|
671
|
+
onlyRule: args.onlyRule || null,
|
|
672
|
+
projectContext,
|
|
673
|
+
routes: results,
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
writeJson(args.output, payload);
|
|
677
|
+
log.success(`Routes scan complete. Results saved to ${args.output}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
681
|
+
main().catch((error) => {
|
|
682
|
+
log.error(`Scanner Execution Error: ${error.message}`);
|
|
683
|
+
process.exit(1);
|
|
684
|
+
});
|
|
685
|
+
}
|