@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.
- package/CHANGELOG.md +84 -0
- package/README.md +220 -7
- package/assets/engine/cdp-checks.json +30 -0
- package/assets/engine/pa11y-config.json +53 -0
- package/docs/architecture.md +218 -0
- package/docs/cli-handbook.md +237 -0
- package/docs/outputs.md +303 -0
- package/package.json +9 -2
- package/scripts/audit.mjs +3 -0
- package/scripts/core/asset-loader.mjs +4 -0
- package/scripts/engine/analyzer.mjs +8 -1
- package/scripts/engine/dom-scanner.mjs +366 -5
- package/scripts/index.mjs +262 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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] = {
|
|
1014
|
+
results[idx] = {
|
|
1015
|
+
path: routePath,
|
|
1016
|
+
...result,
|
|
1017
|
+
violations: mergedViolations,
|
|
1018
|
+
incomplete: result.incomplete || [],
|
|
1019
|
+
};
|
|
659
1020
|
})(),
|
|
660
1021
|
);
|
|
661
1022
|
}
|