@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.
@@ -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
- async function runPa11yChecks(routeUrl, axeTags) {
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 results = await pa11y(routeUrl, {
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/pa11y even if axe is off
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 (conditional)
1248
- if (args.engines.pa11y) {
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 = {