@diegovelasquezweb/a11y-engine 0.1.2 → 0.1.4

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.
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { chromium } from "playwright";
10
10
  import AxeBuilder from "@axe-core/playwright";
11
+ import pa11y from "pa11y";
11
12
  import { log, DEFAULTS, writeJson, getInternalPath } from "../core/utils.mjs";
12
13
  import { ASSET_PATHS, loadAssetJson } from "../core/asset-loader.mjs";
13
14
  import path from "node:path";
@@ -24,6 +25,14 @@ const STACK_DETECTION = loadAssetJson(
24
25
  ASSET_PATHS.discovery.stackDetection,
25
26
  "assets/discovery/stack-detection.json",
26
27
  );
28
+ const CDP_CHECKS = loadAssetJson(
29
+ ASSET_PATHS.engine.cdpChecks,
30
+ "assets/engine/cdp-checks.json",
31
+ );
32
+ const PA11Y_CONFIG = loadAssetJson(
33
+ ASSET_PATHS.engine.pa11yConfig,
34
+ "assets/engine/pa11y-config.json",
35
+ );
27
36
  const AXE_TAGS = [
28
37
  "wcag2a",
29
38
  "wcag2aa",
@@ -85,6 +94,7 @@ function parseArgs(argv) {
85
94
  onlyRule: null,
86
95
  crawlDepth: DEFAULTS.crawlDepth,
87
96
  viewport: null,
97
+ axeTags: null,
88
98
  };
89
99
 
90
100
  for (let i = 0; i < argv.length; i += 1) {
@@ -110,6 +120,7 @@ function parseArgs(argv) {
110
120
  args.excludeSelectors = value.split(",").map((s) => s.trim());
111
121
  if (key === "--color-scheme") args.colorScheme = value;
112
122
  if (key === "--screenshots-dir") args.screenshotsDir = value;
123
+ if (key === "--axe-tags") args.axeTags = value.split(",").map((s) => s.trim());
113
124
  if (key === "--viewport") {
114
125
  const [w, h] = value.split("x").map(Number);
115
126
  if (w && h) args.viewport = { width: w, height: h };
@@ -398,6 +409,7 @@ async function analyzeRoute(
398
409
  timeoutMs = 30000,
399
410
  maxRetries = 2,
400
411
  waitUntil = "domcontentloaded",
412
+ axeTags = null,
401
413
  ) {
402
414
  let lastError;
403
415
 
@@ -417,7 +429,8 @@ async function analyzeRoute(
417
429
  log.info(`Targeted Audit: Only checking rule "${onlyRule}"`);
418
430
  builder.withRules([onlyRule]);
419
431
  } else {
420
- builder.withTags(AXE_TAGS);
432
+ const tagsToUse = axeTags || AXE_TAGS;
433
+ builder.withTags(tagsToUse);
421
434
  }
422
435
 
423
436
  if (Array.isArray(excludeSelectors)) {
@@ -470,6 +483,306 @@ async function analyzeRoute(
470
483
  };
471
484
  }
472
485
 
486
+ /**
487
+ * Writes scan progress to a JSON file for real-time UI updates.
488
+ * @param {string} step - Current step identifier.
489
+ * @param {"pending"|"running"|"done"|"error"} status - Step status.
490
+ * @param {Object} [extra={}] - Additional metadata.
491
+ */
492
+ function writeProgress(step, status, extra = {}) {
493
+ const progressPath = getInternalPath("progress.json");
494
+ let progress = {};
495
+ try {
496
+ if (fs.existsSync(progressPath)) {
497
+ progress = JSON.parse(fs.readFileSync(progressPath, "utf-8"));
498
+ }
499
+ } catch { /* ignore */ }
500
+ progress.steps = progress.steps || {};
501
+ progress.steps[step] = { status, updatedAt: new Date().toISOString(), ...extra };
502
+ progress.currentStep = step;
503
+ fs.mkdirSync(path.dirname(progressPath), { recursive: true });
504
+ fs.writeFileSync(progressPath, JSON.stringify(progress, null, 2));
505
+ }
506
+
507
+ /**
508
+ * Runs CDP (Chrome DevTools Protocol) accessibility checks using Playwright's CDP session.
509
+ * Catches issues axe-core may miss: missing accessible names, broken focus order,
510
+ * aria-hidden on focusable elements, and missing form labels.
511
+ * @param {import("playwright").Page} page - The Playwright page object.
512
+ * @returns {Promise<Object[]>} Array of CDP-sourced violations in axe-compatible format.
513
+ */
514
+ async function runCdpChecks(page) {
515
+ const violations = [];
516
+ const interactiveRoles = CDP_CHECKS.interactiveRoles || [];
517
+ const rulesById = {};
518
+ for (const rule of CDP_CHECKS.rules || []) {
519
+ rulesById[rule.condition] = rule;
520
+ }
521
+
522
+ try {
523
+ const cdp = await page.context().newCDPSession(page);
524
+ const { nodes } = await cdp.send("Accessibility.getFullAXTree");
525
+
526
+ for (const node of nodes) {
527
+ const role = node.role?.value || "";
528
+ const name = node.name?.value || "";
529
+ const properties = node.properties || [];
530
+ const ignored = node.ignored || false;
531
+
532
+ if (ignored) continue;
533
+
534
+ const focusable = properties.find((p) => p.name === "focusable")?.value?.value === true;
535
+ const hidden = properties.find((p) => p.name === "hidden")?.value?.value === true;
536
+
537
+ if (interactiveRoles.includes(role) && !name.trim()) {
538
+ const rule = rulesById["interactive-no-name"];
539
+ if (!rule) continue;
540
+ const backendId = node.backendDOMNodeId;
541
+ let selector = "";
542
+ try {
543
+ if (backendId) {
544
+ const { object } = await cdp.send("DOM.resolveNode", { backendNodeId: backendId });
545
+ if (object?.objectId) {
546
+ const result = await cdp.send("Runtime.callFunctionOn", {
547
+ objectId: object.objectId,
548
+ functionDeclaration: `function() {
549
+ if (this.id) return '#' + this.id;
550
+ if (this.className && typeof this.className === 'string') return this.tagName.toLowerCase() + '.' + this.className.trim().split(/\\s+/).join('.');
551
+ return this.tagName.toLowerCase();
552
+ }`,
553
+ returnByValue: true,
554
+ });
555
+ selector = result.result?.value || "";
556
+ }
557
+ }
558
+ } catch { /* fallback: no selector */ }
559
+
560
+ const desc = (rule.description || "").replace(/\{\{role\}\}/g, role);
561
+ const msg = (rule.failureMessage || "").replace(/\{\{role\}\}/g, role);
562
+ violations.push({
563
+ id: rule.id,
564
+ impact: rule.impact,
565
+ tags: rule.tags,
566
+ description: desc,
567
+ help: rule.help,
568
+ helpUrl: rule.helpUrl,
569
+ source: "cdp",
570
+ nodes: [{
571
+ any: [],
572
+ all: [{
573
+ id: "cdp-accessible-name",
574
+ data: { role, name: "(empty)" },
575
+ relatedNodes: [],
576
+ impact: rule.impact,
577
+ message: msg,
578
+ }],
579
+ none: [],
580
+ impact: rule.impact,
581
+ html: `<${role} aria-role="${role}">`,
582
+ target: selector ? [selector] : [`[role="${role}"]`],
583
+ failureSummary: `Fix all of the following:\n ${msg}`,
584
+ }],
585
+ });
586
+ }
587
+
588
+ if (hidden && focusable) {
589
+ const rule = rulesById["hidden-focusable"];
590
+ if (!rule) continue;
591
+ const desc = (rule.description || "").replace(/\{\{role\}\}/g, role);
592
+ const msg = (rule.failureMessage || "").replace(/\{\{role\}\}/g, role);
593
+ violations.push({
594
+ id: rule.id,
595
+ impact: rule.impact,
596
+ tags: rule.tags,
597
+ description: desc,
598
+ help: rule.help,
599
+ helpUrl: rule.helpUrl,
600
+ source: "cdp",
601
+ nodes: [{
602
+ any: [],
603
+ all: [{
604
+ id: "cdp-hidden-focusable",
605
+ data: { role },
606
+ relatedNodes: [],
607
+ impact: rule.impact,
608
+ message: msg,
609
+ }],
610
+ none: [],
611
+ impact: rule.impact,
612
+ html: `<element role="${role}" aria-hidden="true">`,
613
+ target: [`[role="${role}"]`],
614
+ failureSummary: `Fix all of the following:\n ${msg}`,
615
+ }],
616
+ });
617
+ }
618
+ }
619
+
620
+ await cdp.detach();
621
+ } catch (err) {
622
+ log.warn(`CDP checks failed (non-fatal): ${err.message}`);
623
+ }
624
+ return violations;
625
+ }
626
+
627
+ /**
628
+ * Runs pa11y (HTML CodeSniffer) against the already-loaded page URL.
629
+ * Catches WCAG violations that axe-core may miss, particularly around
630
+ * heading hierarchy, link purpose, and form associations.
631
+ * @param {string} routeUrl - The URL to scan.
632
+ * @param {string[]} [axeTags] - WCAG level tags for standard filtering.
633
+ * @returns {Promise<Object[]>} Array of pa11y-sourced violations in axe-compatible format.
634
+ */
635
+ async function runPa11yChecks(routeUrl, axeTags) {
636
+ const violations = [];
637
+ const equivalenceMap = PA11Y_CONFIG.equivalenceMap || {};
638
+ const impactMap = {};
639
+ for (const [k, v] of Object.entries(PA11Y_CONFIG.impactMap || {})) {
640
+ impactMap[Number(k)] = v;
641
+ }
642
+
643
+ try {
644
+ let standard = "WCAG2AA";
645
+ if (axeTags) {
646
+ if (axeTags.includes("wcag2aaa")) standard = "WCAG2AAA";
647
+ else if (axeTags.includes("wcag2aa") || axeTags.includes("wcag21aa") || axeTags.includes("wcag22aa")) standard = "WCAG2AA";
648
+ else if (axeTags.includes("wcag2a")) standard = "WCAG2A";
649
+ }
650
+
651
+ // Build ignore list with dynamic standard prefix
652
+ const ignoreList = (PA11Y_CONFIG.ignoreByPrinciple || []).map((r) => `${standard}.${r}`);
653
+
654
+ const results = await pa11y(routeUrl, {
655
+ standard,
656
+ timeout: 30000,
657
+ wait: 2000,
658
+ chromeLaunchConfig: {
659
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
660
+ },
661
+ ignore: ignoreList,
662
+ });
663
+
664
+ for (const issue of results.issues || []) {
665
+ if (issue.type === "notice") continue;
666
+
667
+ const impact = impactMap[issue.typeCode] || "moderate";
668
+
669
+ let wcagCriterion = "";
670
+ const wcagMatch = issue.code?.match(/Guideline(\d+)_(\d+)\.(\d+)_(\d+)_(\d+)/);
671
+ if (wcagMatch) {
672
+ wcagCriterion = `${wcagMatch[3]}.${wcagMatch[4]}.${wcagMatch[5]}`;
673
+ }
674
+
675
+ // Resolve axe-equivalent rule ID from equivalence map
676
+ const codeWithoutStandard = (issue.code || "").replace(/^WCAG2(A{1,3})\./, "");
677
+ let axeEquivId = null;
678
+ for (const [pattern, axeId] of Object.entries(equivalenceMap)) {
679
+ if (codeWithoutStandard.startsWith(pattern)) {
680
+ axeEquivId = axeId;
681
+ break;
682
+ }
683
+ }
684
+
685
+ const ruleId = axeEquivId || `pa11y-${(issue.code || "unknown").replace(/\./g, "-").toLowerCase().slice(0, 60)}`;
686
+ const originalCode = issue.code || "unknown";
687
+
688
+ violations.push({
689
+ id: ruleId,
690
+ impact,
691
+ tags: ["pa11y-check", ...(wcagCriterion ? [`wcag${wcagCriterion.replace(/\./g, "")}`] : [])],
692
+ description: issue.message || "pa11y detected an accessibility issue",
693
+ help: issue.message?.split(".")[0] || "Accessibility issue detected by HTML CodeSniffer",
694
+ helpUrl: wcagCriterion
695
+ ? `https://www.w3.org/WAI/WCAG21/Understanding/${wcagCriterion.replace(/\./g, "")}`
696
+ : "https://squizlabs.github.io/HTML_CodeSniffer/",
697
+ source: "pa11y",
698
+ source_rule_id: originalCode,
699
+ nodes: [{
700
+ any: [],
701
+ all: [{
702
+ id: "pa11y-check",
703
+ data: { code: originalCode, context: issue.context?.slice(0, 200) },
704
+ relatedNodes: [],
705
+ impact,
706
+ message: issue.message || "",
707
+ }],
708
+ none: [],
709
+ impact,
710
+ html: issue.context || "",
711
+ target: issue.selector ? [issue.selector] : [],
712
+ failureSummary: `Fix all of the following:\n ${issue.message || "Accessibility issue"}`,
713
+ }],
714
+ });
715
+ }
716
+ } catch (err) {
717
+ log.warn(`pa11y checks failed (non-fatal): ${err.message}`);
718
+ }
719
+ return violations;
720
+ }
721
+
722
+ /**
723
+ * Merges violations from multiple sources (axe-core, CDP, pa11y) and deduplicates.
724
+ * Deduplication is based on rule ID + first target selector combination.
725
+ * @param {Object[]} axeViolations - Violations from axe-core.
726
+ * @param {Object[]} cdpViolations - Violations from CDP checks.
727
+ * @param {Object[]} pa11yViolations - Violations from pa11y.
728
+ * @returns {Object[]} Merged and deduplicated violations array.
729
+ */
730
+ function mergeViolations(axeViolations, cdpViolations, pa11yViolations) {
731
+ const seen = new Set();
732
+ const seenRuleTargets = new Map(); // rule -> Set<target> for cross-engine dedup
733
+ const merged = [];
734
+
735
+ // Build CDP equivalence map from JSON config
736
+ const cdpAxeEquiv = {};
737
+ for (const rule of CDP_CHECKS.rules || []) {
738
+ cdpAxeEquiv[rule.id] = rule.axeEquivalents || [];
739
+ }
740
+
741
+ // Step 1: axe findings (baseline)
742
+ for (const v of axeViolations) {
743
+ const target = v.nodes?.[0]?.target?.[0] || "";
744
+ const key = `${v.id}::${target}`;
745
+ seen.add(key);
746
+ if (!seenRuleTargets.has(v.id)) seenRuleTargets.set(v.id, new Set());
747
+ seenRuleTargets.get(v.id).add(target);
748
+ merged.push(v);
749
+ }
750
+
751
+ // Step 2: CDP findings — check against axe equivalents from JSON
752
+ for (const v of cdpViolations) {
753
+ const equivRules = cdpAxeEquiv[v.id] || [];
754
+ const target = v.nodes?.[0]?.target?.[0] || "";
755
+ const isDuplicate = equivRules.some((r) => seen.has(`${r}::${target}`));
756
+ if (!isDuplicate) {
757
+ const key = `${v.id}::${target}`;
758
+ if (!seen.has(key)) {
759
+ seen.add(key);
760
+ merged.push(v);
761
+ }
762
+ }
763
+ }
764
+
765
+ // Step 3: pa11y findings — check via canonical rule ID (axe-equivalent) + selector
766
+ for (const v of pa11yViolations) {
767
+ const target = v.nodes?.[0]?.target?.[0] || "";
768
+ const key = `${v.id}::${target}`;
769
+
770
+ // If pa11y was mapped to an axe rule ID, check if that rule already covers this target
771
+ const isAxeEquivDuplicate = v.id && seenRuleTargets.has(v.id) && target && seenRuleTargets.get(v.id).has(target);
772
+ // Also check if any existing finding covers this exact target (broader dedup)
773
+ const selectorCovered = target && [...seen].some((k) => k.endsWith(`::${target}`));
774
+
775
+ if (!seen.has(key) && !isAxeEquivDuplicate && (!selectorCovered || !target)) {
776
+ seen.add(key);
777
+ if (!seenRuleTargets.has(v.id)) seenRuleTargets.set(v.id, new Set());
778
+ seenRuleTargets.get(v.id).add(target);
779
+ merged.push(v);
780
+ }
781
+ }
782
+
783
+ return merged;
784
+ }
785
+
473
786
  /**
474
787
  * The main execution function for the accessibility scanner.
475
788
  * Coordinates browser setup, crawling/discovery, parallel scanning, and result saving.
@@ -545,6 +858,7 @@ async function main() {
545
858
  * @type {Set<string>}
546
859
  */
547
860
  const SKIP_SELECTORS = new Set(["html", "body", "head", ":root", "document"]);
861
+ const SKIP_SELECTOR_PREFIXES = ["meta", "link", "style", "script", "title", "base"];
548
862
 
549
863
  /**
550
864
  * Captures a screenshot of an element associated with an accessibility violation.
@@ -557,7 +871,9 @@ async function main() {
557
871
  const firstNode = violation.nodes?.[0];
558
872
  if (!firstNode || firstNode.target.length > 1) return;
559
873
  const selector = firstNode.target[0];
560
- if (!selector || SKIP_SELECTORS.has(selector.toLowerCase())) return;
874
+ const lowerSelector = (selector || "").toLowerCase();
875
+ if (!selector || SKIP_SELECTORS.has(lowerSelector)) return;
876
+ if (SKIP_SELECTOR_PREFIXES.some((p) => lowerSelector.startsWith(p))) return;
561
877
  try {
562
878
  fs.mkdirSync(args.screenshotsDir, { recursive: true });
563
879
  const safeRuleId = violation.id.replace(/[^a-z0-9-]/g, "-");
@@ -630,6 +946,8 @@ async function main() {
630
946
  tabPages.push(await context.newPage());
631
947
  }
632
948
 
949
+ writeProgress("page", "running");
950
+
633
951
  for (let i = 0; i < routes.length; i += tabPages.length) {
634
952
  const batch = [];
635
953
  for (let j = 0; j < tabPages.length && i + j < routes.length; j++) {
@@ -640,6 +958,11 @@ async function main() {
640
958
  const routePath = routes[idx];
641
959
  log.info(`[${idx + 1}/${total}] Scanning: ${routePath}`);
642
960
  const targetUrl = new URL(routePath, baseUrl).toString();
961
+
962
+ writeProgress("page", "done");
963
+
964
+ // Step 1: axe-core
965
+ writeProgress("axe", "running");
643
966
  const result = await analyzeRoute(
644
967
  tabPage,
645
968
  targetUrl,
@@ -649,13 +972,51 @@ async function main() {
649
972
  args.timeoutMs,
650
973
  2,
651
974
  args.waitUntil,
975
+ args.axeTags,
976
+ );
977
+ const axeViolationCount = result.violations?.length || 0;
978
+ writeProgress("axe", "done", { found: axeViolationCount });
979
+ log.info(`axe-core: ${axeViolationCount} violation(s) found`);
980
+
981
+ // Step 2: CDP checks
982
+ writeProgress("cdp", "running");
983
+ const cdpViolations = await runCdpChecks(tabPage);
984
+ writeProgress("cdp", "done", { found: cdpViolations.length });
985
+ log.info(`CDP checks: ${cdpViolations.length} issue(s) found`);
986
+
987
+ // Step 3: pa11y
988
+ writeProgress("pa11y", "running");
989
+ const pa11yViolations = await runPa11yChecks(targetUrl, args.axeTags);
990
+ writeProgress("pa11y", "done", { found: pa11yViolations.length });
991
+ log.info(`pa11y: ${pa11yViolations.length} issue(s) found`);
992
+
993
+ // Step 4: Merge results
994
+ writeProgress("merge", "running");
995
+ const mergedViolations = mergeViolations(
996
+ result.violations || [],
997
+ cdpViolations,
998
+ pa11yViolations,
652
999
  );
653
- if (args.screenshotsDir && result.violations) {
654
- for (const violation of result.violations) {
1000
+ writeProgress("merge", "done", {
1001
+ axe: axeViolationCount,
1002
+ cdp: cdpViolations.length,
1003
+ pa11y: pa11yViolations.length,
1004
+ merged: mergedViolations.length,
1005
+ });
1006
+ log.info(`Merged: ${mergedViolations.length} total unique violations (axe: ${axeViolationCount}, cdp: ${cdpViolations.length}, pa11y: ${pa11yViolations.length})`);
1007
+
1008
+ // Screenshots for merged violations
1009
+ if (args.screenshotsDir && mergedViolations) {
1010
+ for (const violation of mergedViolations) {
655
1011
  await captureElementScreenshot(tabPage, violation, idx);
656
1012
  }
657
1013
  }
658
- results[idx] = { path: routePath, ...result };
1014
+ results[idx] = {
1015
+ path: routePath,
1016
+ ...result,
1017
+ violations: mergedViolations,
1018
+ incomplete: result.incomplete || [],
1019
+ };
659
1020
  })(),
660
1021
  );
661
1022
  }