@diegovelasquezweb/a11y-engine 0.10.2 → 0.11.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/CHANGELOG.md +16 -0
- package/assets/remediation/intelligence.mjs +4818 -1
- package/assets/scanning/cdp-checks.mjs +60 -1
- package/docs/api-reference.md +22 -2
- package/docs/architecture.md +24 -1
- package/docs/intelligence.md +2 -2
- package/docs/outputs.md +5 -1
- package/package.json +1 -1
- package/src/ai/claude.mjs +66 -5
- package/src/enrichment/analyzer.mjs +30 -0
- package/src/index.d.mts +56 -1
- package/src/index.mjs +10 -1
- package/src/pipeline/dom-scanner.mjs +242 -21
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { chromium } from "playwright";
|
|
10
10
|
import AxeBuilder from "@axe-core/playwright";
|
|
11
11
|
import pa11y from "pa11y";
|
|
12
|
+
import puppeteer from "puppeteer";
|
|
12
13
|
import { log, DEFAULTS, writeJson, getInternalPath } from "../core/utils.mjs";
|
|
13
14
|
import { ASSET_PATHS, loadAssetJson } from "../core/asset-loader.mjs";
|
|
14
15
|
import path from "node:path";
|
|
@@ -97,6 +98,8 @@ function parseArgs(argv) {
|
|
|
97
98
|
viewport: null,
|
|
98
99
|
axeTags: null,
|
|
99
100
|
engines: { axe: true, cdp: true, pa11y: true },
|
|
101
|
+
clearCache: false,
|
|
102
|
+
serverMode: false,
|
|
100
103
|
};
|
|
101
104
|
|
|
102
105
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -104,6 +107,8 @@ function parseArgs(argv) {
|
|
|
104
107
|
if (!key.startsWith("--")) continue;
|
|
105
108
|
|
|
106
109
|
if (key === "--headed") { args.headless = false; continue; }
|
|
110
|
+
if (key === "--clear-cache") { args.clearCache = true; continue; }
|
|
111
|
+
if (key === "--server-mode") { args.serverMode = true; continue; }
|
|
107
112
|
|
|
108
113
|
const value = argv[i + 1];
|
|
109
114
|
if (value === undefined) continue;
|
|
@@ -574,11 +579,21 @@ async function analyzeRoute(
|
|
|
574
579
|
maxRetries = 2,
|
|
575
580
|
waitUntil = "domcontentloaded",
|
|
576
581
|
axeTags = null,
|
|
582
|
+
clearCache = false,
|
|
577
583
|
) {
|
|
578
584
|
let lastError;
|
|
579
585
|
|
|
580
586
|
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
|
581
587
|
try {
|
|
588
|
+
// Clear cache before navigation when requested — ensures fresh results
|
|
589
|
+
// on repeated scans of the same domain within the same browser session.
|
|
590
|
+
if (clearCache) {
|
|
591
|
+
const cdpClient = await page.context().newCDPSession(page);
|
|
592
|
+
await cdpClient.send("Network.clearBrowserCache");
|
|
593
|
+
await cdpClient.send("Network.setCacheDisabled", { cacheDisabled: true });
|
|
594
|
+
await cdpClient.detach();
|
|
595
|
+
}
|
|
596
|
+
|
|
582
597
|
await page.goto(routeUrl, {
|
|
583
598
|
waitUntil,
|
|
584
599
|
timeout: timeoutMs,
|
|
@@ -622,6 +637,7 @@ async function analyzeRoute(
|
|
|
622
637
|
violations: axeResults.violations,
|
|
623
638
|
incomplete: axeResults.incomplete,
|
|
624
639
|
passes: axeResults.passes.map((p) => p.id),
|
|
640
|
+
inapplicable: axeResults.inapplicable.map((p) => p.id),
|
|
625
641
|
metadata,
|
|
626
642
|
};
|
|
627
643
|
} catch (error) {
|
|
@@ -643,6 +659,7 @@ async function analyzeRoute(
|
|
|
643
659
|
error: lastError.message,
|
|
644
660
|
violations: [],
|
|
645
661
|
passes: [],
|
|
662
|
+
inapplicable: [],
|
|
646
663
|
metadata: {},
|
|
647
664
|
};
|
|
648
665
|
}
|
|
@@ -790,6 +807,136 @@ async function runCdpChecks(page) {
|
|
|
790
807
|
}
|
|
791
808
|
}
|
|
792
809
|
|
|
810
|
+
// DOM-eval checks: use page.evaluate() for checks that require direct DOM inspection
|
|
811
|
+
// rather than the accessibility tree.
|
|
812
|
+
|
|
813
|
+
// Check: autoplay media (WCAG 1.4.2, 2.2.2)
|
|
814
|
+
try {
|
|
815
|
+
const autoplayMedia = await page.evaluate(() => {
|
|
816
|
+
const elements = Array.from(document.querySelectorAll("video[autoplay], audio[autoplay]"));
|
|
817
|
+
return elements.map((el, index) => ({
|
|
818
|
+
html: el.outerHTML.substring(0, 200),
|
|
819
|
+
selector: el.id
|
|
820
|
+
? `${el.tagName.toLowerCase()}#${el.id}`
|
|
821
|
+
: `${el.tagName.toLowerCase()}:nth-of-type(${index + 1})`,
|
|
822
|
+
}));
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
if (autoplayMedia.length > 0) {
|
|
826
|
+
const rule = CDP_CHECKS.rules.find((r) => r.id === "cdp-autoplay-media");
|
|
827
|
+
if (rule) {
|
|
828
|
+
violations.push({
|
|
829
|
+
id: rule.id,
|
|
830
|
+
impact: rule.impact,
|
|
831
|
+
tags: rule.tags,
|
|
832
|
+
description: `${rule.description} (${autoplayMedia.length} element${autoplayMedia.length > 1 ? "s" : ""} found)`,
|
|
833
|
+
help: rule.help,
|
|
834
|
+
helpUrl: rule.helpUrl,
|
|
835
|
+
source: "cdp",
|
|
836
|
+
nodes: autoplayMedia.map((media) => ({
|
|
837
|
+
any: [],
|
|
838
|
+
all: [{
|
|
839
|
+
id: "cdp-autoplay-media",
|
|
840
|
+
data: { selector: media.selector },
|
|
841
|
+
relatedNodes: [],
|
|
842
|
+
impact: rule.impact,
|
|
843
|
+
message: rule.failureMessage,
|
|
844
|
+
}],
|
|
845
|
+
none: [],
|
|
846
|
+
impact: rule.impact,
|
|
847
|
+
html: media.html,
|
|
848
|
+
target: [media.selector],
|
|
849
|
+
failureSummary: `Fix all of the following:\n ${rule.failureMessage}`,
|
|
850
|
+
})),
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
} catch (err) {
|
|
855
|
+
log.warn(`CDP autoplay-media check failed (non-fatal): ${err.message}`);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Check: missing main landmark (WCAG 1.3.1)
|
|
859
|
+
try {
|
|
860
|
+
const hasMainLandmark = await page.evaluate(() => {
|
|
861
|
+
return document.querySelector("main, [role=\"main\"]") !== null;
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
if (!hasMainLandmark) {
|
|
865
|
+
const rule = CDP_CHECKS.rules.find((r) => r.id === "cdp-missing-main-landmark");
|
|
866
|
+
if (rule) {
|
|
867
|
+
violations.push({
|
|
868
|
+
id: rule.id,
|
|
869
|
+
impact: rule.impact,
|
|
870
|
+
tags: rule.tags,
|
|
871
|
+
description: rule.description,
|
|
872
|
+
help: rule.help,
|
|
873
|
+
helpUrl: rule.helpUrl,
|
|
874
|
+
source: "cdp",
|
|
875
|
+
nodes: [{
|
|
876
|
+
any: [],
|
|
877
|
+
all: [{
|
|
878
|
+
id: "cdp-missing-main-landmark",
|
|
879
|
+
data: {},
|
|
880
|
+
relatedNodes: [],
|
|
881
|
+
impact: rule.impact,
|
|
882
|
+
message: rule.failureMessage,
|
|
883
|
+
}],
|
|
884
|
+
none: [],
|
|
885
|
+
impact: rule.impact,
|
|
886
|
+
html: "<body>",
|
|
887
|
+
target: ["body"],
|
|
888
|
+
failureSummary: `Fix all of the following:\n ${rule.failureMessage}`,
|
|
889
|
+
}],
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
} catch (err) {
|
|
894
|
+
log.warn(`CDP missing-main-landmark check failed (non-fatal): ${err.message}`);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Check: missing skip link (WCAG 2.4.1)
|
|
898
|
+
try {
|
|
899
|
+
const hasSkipLink = await page.evaluate(() => {
|
|
900
|
+
const firstFocusable = document.querySelector("a[href], button, input, select, textarea");
|
|
901
|
+
if (!firstFocusable) return false;
|
|
902
|
+
const href = firstFocusable.getAttribute("href") || "";
|
|
903
|
+
const text = (firstFocusable.textContent || "").toLowerCase();
|
|
904
|
+
return href.startsWith("#") && (text.includes("skip") || text.includes("main") || text.includes("content"));
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
if (!hasSkipLink) {
|
|
908
|
+
const rule = CDP_CHECKS.rules.find((r) => r.id === "cdp-missing-skip-link");
|
|
909
|
+
if (rule) {
|
|
910
|
+
violations.push({
|
|
911
|
+
id: rule.id,
|
|
912
|
+
impact: rule.impact,
|
|
913
|
+
tags: rule.tags,
|
|
914
|
+
description: rule.description,
|
|
915
|
+
help: rule.help,
|
|
916
|
+
helpUrl: rule.helpUrl,
|
|
917
|
+
source: "cdp",
|
|
918
|
+
nodes: [{
|
|
919
|
+
any: [],
|
|
920
|
+
all: [{
|
|
921
|
+
id: "cdp-missing-skip-link",
|
|
922
|
+
data: {},
|
|
923
|
+
relatedNodes: [],
|
|
924
|
+
impact: rule.impact,
|
|
925
|
+
message: rule.failureMessage,
|
|
926
|
+
}],
|
|
927
|
+
none: [],
|
|
928
|
+
impact: rule.impact,
|
|
929
|
+
html: "<body>",
|
|
930
|
+
target: ["body"],
|
|
931
|
+
failureSummary: `Fix all of the following:\n ${rule.failureMessage}`,
|
|
932
|
+
}],
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
} catch (err) {
|
|
937
|
+
log.warn(`CDP missing-skip-link check failed (non-fatal): ${err.message}`);
|
|
938
|
+
}
|
|
939
|
+
|
|
793
940
|
await cdp.detach();
|
|
794
941
|
} catch (err) {
|
|
795
942
|
log.warn(`CDP checks failed (non-fatal): ${err.message}`);
|
|
@@ -805,7 +952,16 @@ async function runCdpChecks(page) {
|
|
|
805
952
|
* @param {string[]} [axeTags] - WCAG level tags for standard filtering.
|
|
806
953
|
* @returns {Promise<Object[]>} Array of pa11y-sourced violations in axe-compatible format.
|
|
807
954
|
*/
|
|
808
|
-
|
|
955
|
+
/**
|
|
956
|
+
* Runs pa11y (HTML CodeSniffer) against the already-loaded page URL.
|
|
957
|
+
* @param {string} routeUrl - The URL to scan.
|
|
958
|
+
* @param {string[]} [axeTags] - WCAG level tags for standard filtering.
|
|
959
|
+
* @param {import('puppeteer').Browser|null} [sharedBrowser] - Optional shared Puppeteer browser.
|
|
960
|
+
* When provided, pa11y reuses this instance instead of launching a new Chrome per call.
|
|
961
|
+
* The browser is NOT closed by this function — the caller is responsible for lifecycle.
|
|
962
|
+
* @returns {Promise<Object[]>} Array of pa11y-sourced violations in axe-compatible format.
|
|
963
|
+
*/
|
|
964
|
+
async function runPa11yChecks(routeUrl, axeTags, sharedBrowser = null) {
|
|
809
965
|
const violations = [];
|
|
810
966
|
const equivalenceMap = PA11Y_CONFIG.equivalenceMap || {};
|
|
811
967
|
const impactMap = {};
|
|
@@ -824,15 +980,26 @@ async function runPa11yChecks(routeUrl, axeTags) {
|
|
|
824
980
|
// Build ignore list with dynamic standard prefix
|
|
825
981
|
const ignoreList = (PA11Y_CONFIG.ignoreByPrinciple || []).map((r) => `${standard}.${r}`);
|
|
826
982
|
|
|
827
|
-
const
|
|
983
|
+
const pa11yOptions = {
|
|
828
984
|
standard,
|
|
829
985
|
timeout: 30000,
|
|
830
986
|
wait: 2000,
|
|
831
|
-
chromeLaunchConfig: {
|
|
832
|
-
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
833
|
-
},
|
|
834
987
|
ignore: ignoreList,
|
|
835
|
-
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
if (sharedBrowser) {
|
|
991
|
+
// Reuse the shared Puppeteer browser — avoids Chrome cold-start per route.
|
|
992
|
+
// pa11y will open a new page, run its checks, and close the page.
|
|
993
|
+
// It will NOT close the browser (autoClose = false when browser is provided).
|
|
994
|
+
pa11yOptions.browser = sharedBrowser;
|
|
995
|
+
} else {
|
|
996
|
+
// Fallback: let pa11y launch its own Chrome (original behavior).
|
|
997
|
+
pa11yOptions.chromeLaunchConfig = {
|
|
998
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const results = await pa11y(routeUrl, pa11yOptions);
|
|
836
1003
|
|
|
837
1004
|
for (const issue of results.issues || []) {
|
|
838
1005
|
if (issue.type === "notice") continue;
|
|
@@ -986,6 +1153,8 @@ export async function runDomScanner(options = {}, callbacks = {}) {
|
|
|
986
1153
|
cdp: options.engines?.cdp !== false,
|
|
987
1154
|
pa11y: options.engines?.pa11y !== false,
|
|
988
1155
|
},
|
|
1156
|
+
clearCache: options.clearCache ?? false,
|
|
1157
|
+
serverMode: options.serverMode ?? false,
|
|
989
1158
|
};
|
|
990
1159
|
|
|
991
1160
|
if (!args.baseUrl) throw new Error("Missing required option: baseUrl");
|
|
@@ -1012,8 +1181,22 @@ async function _runDomScannerInternal(args) {
|
|
|
1012
1181
|
height: DEFAULTS.viewports[0].height,
|
|
1013
1182
|
};
|
|
1014
1183
|
|
|
1184
|
+
// Server/EC2 Chrome flags — prevents crashes in Docker and headless server environments.
|
|
1185
|
+
// Equivalent to the flags used by @wondersauce/a11y-scanner in AI-11y.
|
|
1186
|
+
const serverArgs = args.serverMode
|
|
1187
|
+
? [
|
|
1188
|
+
"--no-sandbox",
|
|
1189
|
+
"--disable-setuid-sandbox",
|
|
1190
|
+
"--disable-dev-shm-usage",
|
|
1191
|
+
"--disable-gpu",
|
|
1192
|
+
"--no-zygote",
|
|
1193
|
+
"--disable-accelerated-2d-canvas",
|
|
1194
|
+
]
|
|
1195
|
+
: [];
|
|
1196
|
+
|
|
1015
1197
|
const browser = await chromium.launch({
|
|
1016
1198
|
headless: args.headless,
|
|
1199
|
+
args: serverArgs,
|
|
1017
1200
|
});
|
|
1018
1201
|
const context = await browser.newContext({
|
|
1019
1202
|
viewport: primaryViewport,
|
|
@@ -1024,6 +1207,23 @@ async function _runDomScannerInternal(args) {
|
|
|
1024
1207
|
});
|
|
1025
1208
|
const page = await context.newPage();
|
|
1026
1209
|
|
|
1210
|
+
// Shared Puppeteer browser for pa11y — launched once, reused for all routes.
|
|
1211
|
+
// Eliminates Chrome cold-start overhead (1-3s) per route.
|
|
1212
|
+
// Falls back to per-route launch if puppeteer is unavailable.
|
|
1213
|
+
let pa11yBrowser = null;
|
|
1214
|
+
if (args.engines?.pa11y !== false) {
|
|
1215
|
+
try {
|
|
1216
|
+
pa11yBrowser = await puppeteer.launch({
|
|
1217
|
+
headless: true,
|
|
1218
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
1219
|
+
});
|
|
1220
|
+
log.info("pa11y: shared Puppeteer browser ready");
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
log.warn(`pa11y: shared browser launch failed (non-fatal), will launch per-route: ${err.message}`);
|
|
1223
|
+
pa11yBrowser = null;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1027
1227
|
let routes = [];
|
|
1028
1228
|
let projectContext = { framework: null, cms: null, uiLibraries: [] };
|
|
1029
1229
|
try {
|
|
@@ -1199,10 +1399,29 @@ async function _runDomScannerInternal(args) {
|
|
|
1199
1399
|
emittedDone.add("page");
|
|
1200
1400
|
}
|
|
1201
1401
|
|
|
1202
|
-
let result = { url: targetUrl, violations: [], incomplete: [], passes: [], metadata: {} };
|
|
1402
|
+
let result = { url: targetUrl, violations: [], incomplete: [], passes: [], inapplicable: [], metadata: {} };
|
|
1203
1403
|
let cdpViolations = [];
|
|
1204
1404
|
let pa11yViolations = [];
|
|
1205
1405
|
|
|
1406
|
+
// pa11y is fully independent (own browser, receives only URL string).
|
|
1407
|
+
// Start it in parallel with the axe→CDP sequence to hide its latency.
|
|
1408
|
+
// axe and CDP must remain sequential: CDP depends on axe's page navigation.
|
|
1409
|
+
let pa11yPromise = Promise.resolve([]);
|
|
1410
|
+
if (args.engines.pa11y) {
|
|
1411
|
+
if (!emittedDone.has("pa11y")) writeProgress("pa11y", "running");
|
|
1412
|
+
pa11yPromise = runPa11yChecks(targetUrl, args.axeTags, pa11yBrowser)
|
|
1413
|
+
.then((violations) => {
|
|
1414
|
+
if (!emittedDone.has("pa11y")) {
|
|
1415
|
+
writeProgress("pa11y", "done", { found: violations.length });
|
|
1416
|
+
emittedDone.add("pa11y");
|
|
1417
|
+
}
|
|
1418
|
+
log.info(`pa11y: ${violations.length} issue(s) found`);
|
|
1419
|
+
return violations;
|
|
1420
|
+
});
|
|
1421
|
+
} else {
|
|
1422
|
+
log.info("pa11y: skipped (disabled)");
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1206
1425
|
// Step 1: axe-core (conditional)
|
|
1207
1426
|
if (args.engines.axe) {
|
|
1208
1427
|
if (!emittedDone.has("axe")) writeProgress("axe", "running");
|
|
@@ -1216,6 +1435,7 @@ async function _runDomScannerInternal(args) {
|
|
|
1216
1435
|
2,
|
|
1217
1436
|
args.waitUntil,
|
|
1218
1437
|
args.axeTags,
|
|
1438
|
+
args.clearCache || false,
|
|
1219
1439
|
);
|
|
1220
1440
|
const axeViolationCount = result.violations?.length || 0;
|
|
1221
1441
|
if (!emittedDone.has("axe")) {
|
|
@@ -1224,14 +1444,20 @@ async function _runDomScannerInternal(args) {
|
|
|
1224
1444
|
}
|
|
1225
1445
|
log.info(`axe-core: ${axeViolationCount} violation(s) found`);
|
|
1226
1446
|
} else {
|
|
1227
|
-
// Navigate for CDP
|
|
1447
|
+
// Navigate for CDP even if axe is off
|
|
1448
|
+
if (args.clearCache) {
|
|
1449
|
+
const cdpClient = await tabPage.context().newCDPSession(tabPage);
|
|
1450
|
+
await cdpClient.send("Network.clearBrowserCache");
|
|
1451
|
+
await cdpClient.send("Network.setCacheDisabled", { cacheDisabled: true });
|
|
1452
|
+
await cdpClient.detach();
|
|
1453
|
+
}
|
|
1228
1454
|
await tabPage.goto(targetUrl, { waitUntil: args.waitUntil, timeout: args.timeoutMs });
|
|
1229
1455
|
await tabPage.waitForLoadState("networkidle", { timeout: args.waitMs }).catch(() => {});
|
|
1230
1456
|
result.metadata = await tabPage.evaluate(() => ({ title: document.title }));
|
|
1231
1457
|
log.info("axe-core: skipped (disabled)");
|
|
1232
1458
|
}
|
|
1233
1459
|
|
|
1234
|
-
// Step 2: CDP checks (conditional)
|
|
1460
|
+
// Step 2: CDP checks (conditional) — sequential after axe (shares tabPage)
|
|
1235
1461
|
if (args.engines.cdp) {
|
|
1236
1462
|
if (!emittedDone.has("cdp")) writeProgress("cdp", "running");
|
|
1237
1463
|
cdpViolations = await runCdpChecks(tabPage);
|
|
@@ -1244,18 +1470,8 @@ async function _runDomScannerInternal(args) {
|
|
|
1244
1470
|
log.info("CDP checks: skipped (disabled)");
|
|
1245
1471
|
}
|
|
1246
1472
|
|
|
1247
|
-
// Step 3: pa11y (
|
|
1248
|
-
|
|
1249
|
-
if (!emittedDone.has("pa11y")) writeProgress("pa11y", "running");
|
|
1250
|
-
pa11yViolations = await runPa11yChecks(targetUrl, args.axeTags);
|
|
1251
|
-
if (!emittedDone.has("pa11y")) {
|
|
1252
|
-
writeProgress("pa11y", "done", { found: pa11yViolations.length });
|
|
1253
|
-
emittedDone.add("pa11y");
|
|
1254
|
-
}
|
|
1255
|
-
log.info(`pa11y: ${pa11yViolations.length} issue(s) found`);
|
|
1256
|
-
} else {
|
|
1257
|
-
log.info("pa11y: skipped (disabled)");
|
|
1258
|
-
}
|
|
1473
|
+
// Step 3: Await pa11y (started in parallel before axe — may already be done)
|
|
1474
|
+
pa11yViolations = await pa11yPromise;
|
|
1259
1475
|
|
|
1260
1476
|
// Step 4: Merge results
|
|
1261
1477
|
const axeViolationCount = result.violations?.length || 0;
|
|
@@ -1295,6 +1511,11 @@ async function _runDomScannerInternal(args) {
|
|
|
1295
1511
|
}
|
|
1296
1512
|
} finally {
|
|
1297
1513
|
await browser.close();
|
|
1514
|
+
if (pa11yBrowser) {
|
|
1515
|
+
await pa11yBrowser.close().catch((err) =>
|
|
1516
|
+
log.warn(`pa11y browser close failed (non-fatal): ${err.message}`),
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1298
1519
|
}
|
|
1299
1520
|
|
|
1300
1521
|
const payload = {
|