'use strict';
const { execSync } = require('child_process');
const { chromium } = require('playwright');
const axeSource = require('axe-core').source;
const fs = require('fs');
const path = require('path');
const url = require('url');
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
console.error('Usage: node scripts/axe-compare.js <URL> [--output <file>] [--bin <path>] [--level AA]');
process.exit(1);
}
const targetUrl = args[0];
let outputFile = null;
let binPath = './target/release/auditmysite';
let wcagLevel = 'AA';
for (let i = 1; i < args.length; i++) {
if (args[i] === '--output' && args[i + 1]) outputFile = args[++i];
else if (args[i] === '--bin' && args[i + 1]) binPath = args[++i];
else if (args[i] === '--level' && args[i + 1]) wcagLevel = args[++i].toUpperCase();
}
function runAuditMySite(targetUrl, binPath, level) {
console.error(`[1/3] Running auditmysite on ${targetUrl} ...`);
try {
const cmd = `${binPath} "${targetUrl}" --format json --level ${level}`;
const raw = execSync(cmd, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
const jsonStart = raw.indexOf('{');
if (jsonStart < 0) throw new Error('No JSON found in output: ' + raw.slice(0, 200));
return JSON.parse(raw.slice(jsonStart));
} catch (err) {
const msg = err.stdout || err.stderr || err.message;
console.error('auditmysite failed:', msg.slice(0, 500));
process.exit(1);
}
}
function extractOurFindings(json) {
const findings = json?.report?.findings || json?.report?.raw_wcag?.violations || [];
const warnings = json?.report?.raw_wcag?.warnings || [];
if (!json?.report) {
console.error('No report field in auditmysite JSON output. Re-run with a current binary.');
process.exit(1);
}
const map = new Map();
const add = (v) => {
const axeId = v.axe_id || v.rule_id || `(no-axe-id:${v.wcag_criterion || v.criterion || '?'})`;
const criterion = v.wcag_criterion || v.criterion || v.rule || '?';
const wcagLevel = v.wcag_level || v.level || '?';
if (!map.has(axeId)) {
map.set(axeId, { axeId, criterion, wcagLevel, count: 0, messages: [] });
}
const entry = map.get(axeId);
entry.count += (v.occurrence_count || 1);
const msg = v.description || v.message || '';
if (msg && entry.messages.length < 3) entry.messages.push(msg.slice(0, 80));
};
findings.forEach(add);
warnings.forEach((v) => {
const axeId = v.axe_id || v.rule_id || `(warning:${v.wcag_criterion || v.criterion || '?'})`;
const criterion = v.wcag_criterion || v.criterion || v.rule || '?';
const wcagLevel = v.wcag_level || v.level || '?';
if (!map.has(axeId)) {
map.set(axeId, { axeId, criterion, wcagLevel, count: 0, messages: [], isWarning: true });
}
const entry = map.get(axeId);
entry.count++;
entry.isWarning = true;
});
return map;
}
async function runAxeCore(targetUrl) {
console.error('[2/3] Running axe-core via Playwright ...');
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ bypassCSP: true });
const page = await context.newPage();
try {
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
await page.addScriptTag({ content: axeSource });
const results = await page.evaluate(async () => {
return await window.axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] },
resultTypes: ['violations', 'incomplete'],
});
});
return results;
} finally {
await context.close();
await browser.close();
}
}
function extractAxeFindings(axeResults) {
const map = new Map();
const addGroup = (violations, kind) => {
for (const v of violations) {
const wcagTags = v.tags.filter((t) => /^wcag\d/.test(t));
const criterion = wcagTagsToCriterion(wcagTags);
map.set(v.id, {
axeId: v.id,
criterion,
impact: v.impact || '?',
description: v.description?.slice(0, 100),
count: v.nodes?.length ?? 1,
kind,
});
}
};
addGroup(axeResults.violations || [], 'violation');
addGroup(axeResults.incomplete || [], 'incomplete');
return map;
}
function wcagTagsToCriterion(tags) {
for (const tag of tags) {
const m = tag.match(/^wcag(\d)(\d)(\d+)$/);
if (m) return `${m[1]}.${m[2]}.${m[3]}`;
}
return '?';
}
function buildTable(ourMap, axeMap) {
const allIds = new Set([...ourMap.keys(), ...axeMap.keys()]);
const rows = [];
for (const axeId of allIds) {
const our = ourMap.get(axeId);
const axe = axeMap.get(axeId);
const criterion = our?.criterion || axe?.criterion || '?';
const wcagLevel = our?.wcagLevel || axe?.impact || '?';
let ourResult, axeResult, note;
if (our && axe) {
ourResult = `${our.count} finding${our.count !== 1 ? 's' : ''}${our.isWarning ? ' ⚠' : ''}`;
axeResult = axe.kind === 'incomplete'
? `${axe.count} (needs review)`
: `${axe.count} violation${axe.count !== 1 ? 's' : ''}`;
note = our.count > 0 && axe.count > 0 ? '✓ both' : '~ partial';
} else if (our && !axe) {
ourResult = `${our.count} finding${our.count !== 1 ? 's' : ''}${our.isWarning ? ' ⚠' : ''}`;
axeResult = '–';
note = our.isWarning ? 'only-us (heuristic)' : 'only-us';
} else {
ourResult = '–';
axeResult = axe.kind === 'incomplete'
? `${axe.count} (needs review)`
: `${axe.count} violation${axe.count !== 1 ? 's' : ''}`;
note = axe.kind === 'incomplete' ? 'only-axe (incomplete)' : 'gap ← axe only';
}
rows.push({ axeId, criterion, wcagLevel, ourResult, axeResult, note });
}
rows.sort((a, b) => {
const priority = (n) => (n.startsWith('gap') ? 0 : n.startsWith('only-axe') ? 1 : n === '✓ both' ? 2 : 3);
return priority(a.note) - priority(b.note) || a.axeId.localeCompare(b.axeId);
});
return rows;
}
function renderMarkdown(rows, targetUrl, ourVersion) {
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
const gaps = rows.filter((r) => r.note.startsWith('gap')).length;
const both = rows.filter((r) => r.note === '✓ both').length;
const onlyUs = rows.filter((r) => r.note.startsWith('only-us')).length;
const onlyAxeIncomplete = rows.filter((r) => r.note.startsWith('only-axe (incomplete)')).length;
const lines = [
`# axe-core Comparison — ${targetUrl}`,
``,
`Generated: ${now} `,
`Tool: auditmysite ${ourVersion} · axe-core (wcag2a/aa/21a/21aa/22aa tags)`,
``,
`## Summary`,
``,
`| | Count |`,
`|---|---|`,
`| ✓ Both tools flagged | ${both} |`,
`| gap ← axe-core only | ${gaps} |`,
`| only-us (we flag, axe doesn't) | ${onlyUs} |`,
`| only-axe (incomplete/needs-review) | ${onlyAxeIncomplete} |`,
``,
`## Rule-by-rule comparison`,
``,
`| axe-id | criterion | our result | axe-core result | note |`,
`|--------|-----------|-----------|-----------------|------|`,
...rows.map(
(r) =>
`| \`${r.axeId}\` | ${r.criterion} | ${r.ourResult} | ${r.axeResult} | ${r.note} |`
),
``,
`### Legend`,
``,
`- **✓ both** — both tools flagged this rule on the page`,
`- **gap ← axe only** — axe-core found violations we missed (coverage gap)`,
`- **only-us** — we flag this, axe-core does not (possible false positive or intentional extension)`,
`- **⚠** — our finding is a heuristic warning, not a confirmed violation`,
`- **(needs review)** — axe-core returned this as \`incomplete\` (requires manual confirmation)`,
];
return lines.join('\n');
}
(async () => {
const ourJson = runAuditMySite(targetUrl, binPath, wcagLevel);
const ourVersion = ourJson?.metadata?.tool || 'unknown';
const ourMap = extractOurFindings(ourJson);
const axeResults = await runAxeCore(targetUrl);
const axeMap = extractAxeFindings(axeResults);
console.error(`[3/3] Comparing ${ourMap.size} our rules vs ${axeMap.size} axe rules ...`);
const rows = buildTable(ourMap, axeMap);
const markdown = renderMarkdown(rows, targetUrl, ourVersion);
if (outputFile) {
fs.mkdirSync(path.dirname(path.resolve(outputFile)), { recursive: true });
fs.writeFileSync(outputFile, markdown, 'utf8');
console.error(`Written to ${outputFile}`);
} else {
process.stdout.write(markdown + '\n');
}
})();