Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-a11y-baseline.mjs
T

140 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const BASELINE_PATH = ".eslintrc.a11y-baseline.json";
const DEFAULT_SCOPE = ["apps/web/app/**/*.tsx", "packages/ui/src/stories/**/*.tsx"];
function readBaseline(rootDir) {
return JSON.parse(fs.readFileSync(path.join(rootDir, BASELINE_PATH), "utf8"));
}
function baselineScope(baseline) {
if (Array.isArray(baseline.scope)) return baseline.scope;
if (typeof baseline.scope === "string" && baseline.scope.trim().length > 0) return [baseline.scope];
return DEFAULT_SCOPE;
}
function baselineRules(baseline) {
return Object.keys(baseline.rules ?? {}).sort();
}
function runEslint(rootDir, scope, rules) {
const ruleArgs = rules.flatMap((rule) => ["--rule", `${rule}:error`]);
const result = spawnSync("pnpm", ["exec", "eslint", "--format", "json", "--no-ignore", "--ext", ".tsx", ...ruleArgs, ...scope], {
cwd: rootDir,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 20,
});
if (result.error) {
throw result.error;
}
if (![0, 1].includes(result.status)) {
throw new Error(result.stderr || `eslint exited with status ${result.status}`);
}
try {
return JSON.parse(result.stdout || "[]");
} catch (error) {
throw new Error(`failed to parse ESLint JSON output: ${error.message}`);
}
}
function countRuleMessages(eslintResults, rules) {
const counts = Object.fromEntries(rules.map((rule) => [rule, 0]));
const examples = Object.fromEntries(rules.map((rule) => [rule, []]));
for (const result of eslintResults) {
for (const message of result.messages ?? []) {
if (!rules.includes(message.ruleId)) continue;
counts[message.ruleId] += 1;
if (examples[message.ruleId].length < 3) {
examples[message.ruleId].push(`${path.relative(process.cwd(), result.filePath)}:${message.line}:${message.column}`);
}
}
}
return { counts, examples };
}
export function verifyA11yBaseline(rootDir = process.cwd()) {
const baseline = readBaseline(rootDir);
const scope = baselineScope(baseline);
const rules = baselineRules(baseline);
const eslintResults = runEslint(rootDir, scope, rules);
const { counts, examples } = countRuleMessages(eslintResults, rules);
const failures = [];
const observations = [];
const passes = [];
for (const rule of rules) {
const expected = Number(baseline.rules[rule]?.violations ?? 0);
const actual = counts[rule] ?? 0;
if (actual > expected) {
failures.push(`${rule}: ${actual} violation(s), baseline allows ${expected}`);
continue;
}
if (actual < expected) {
observations.push(`${rule}: ${actual} violation(s), below baseline ${expected}; update ${BASELINE_PATH} when ratcheting`);
} else {
passes.push(`${rule}: ${actual} violation(s) matches baseline`);
}
}
const totalExpected = Number(baseline.totalViolations ?? 0);
const totalActual = Object.values(counts).reduce((sum, count) => sum + count, 0);
if (totalActual > totalExpected) {
failures.push(`totalViolations: ${totalActual}, baseline allows ${totalExpected}`);
} else if (totalActual < totalExpected) {
observations.push(`totalViolations: ${totalActual}, below baseline ${totalExpected}; update ${BASELINE_PATH} when ratcheting`);
} else {
passes.push(`totalViolations: ${totalActual} matches baseline`);
}
return { passes, observations, failures, counts, examples, scope };
}
export function formatA11yBaselineResult(result) {
const status = result.failures.length > 0 ? "FAIL" : "PASS";
const lines = [
`${status} accessibility baseline verification`,
`Scope: ${result.scope.join(", ")}`,
...Object.entries(result.counts).map(([rule, count]) => `COUNT ${rule}: ${count}`),
...result.passes.map((pass) => `PASS ${pass}`),
...result.observations.map((observation) => `OBSERVE ${observation}`),
...result.failures.map((failure) => `FAIL ${failure}`),
];
if (result.failures.length > 0) {
for (const [rule, examples] of Object.entries(result.examples)) {
if (examples.length > 0) {
lines.push(`EXAMPLE ${rule}: ${examples.join(", ")}`);
}
}
}
lines.push(`Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`, "");
return lines.join("\n");
}
const isCli = process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname;
if (isCli) {
try {
const result = verifyA11yBaseline(process.cwd());
const output = formatA11yBaselineResult(result);
if (result.failures.length > 0) {
process.stderr.write(output);
process.exitCode = 1;
} else {
process.stdout.write(output);
}
} catch (error) {
process.stderr.write(`FAIL accessibility baseline verification\n${error.message}\n`);
process.exitCode = 1;
}
}