Files
tested/scripts/a11y.mjs
T
joshuaandClaude Opus 5.5 54ae9c49a1 Tested: static Astro site answering hard Bible questions
Built from the kit specs (CLAUDE.md, docs/01-05): question index import,
status seals, connections graph, Pagefind search, Scripture margin,
and 157 evidence articles (drafts unless published) with sources and
verified "Learn more" links. Includes link, owner-notes, a11y and
publish scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:46:19 +02:00

100 lines
3.4 KiB
JavaScript

#!/usr/bin/env node
// Accessibility check: runs axe-core against the built site in light and dark themes, on desktop
// and phone widths, using the Chrome already installed on this machine (no browser download).
// Run with: npm run a11y (after npm run build)
// Fails if axe reports any serious or critical issue.
import { spawn } from "node:child_process";
import { chromium } from "playwright-core";
import { AxeBuilder } from "@axe-core/playwright";
import data from "../src/data/questions.json" with { type: "json" };
const PORT = 4329;
const BASE = `http://localhost:${PORT}`;
const q = (id) => `/questions/${data.questions.find((x) => x.id === id).slug}/`;
const pages = [
"/",
"/questions/",
"/questions/?status=red",
q("3.1"), // links to a whole group
q("2.12"), // italics and curly quotes in the answer
q("3B.14"), // a "missing book" row (no domains)
q("7.9"),
`/groups/${data.groups[0].slug}/`,
"/groups/missing-books/",
"/about/",
"/connections/",
"/this-page-does-not-exist/",
];
const viewports = [
{ name: "desktop", width: 1280, height: 900 },
{ name: "phone", width: 390, height: 844 },
];
// Astro allows one preview server per project; --force replaces any that's already running.
const server = spawn("npx", ["astro", "preview", "--force", "--port", String(PORT)], {
shell: true,
stdio: "ignore",
});
async function waitForServer() {
for (let i = 0; i < 60; i++) {
try {
if ((await fetch(BASE)).ok) return;
} catch {}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error("Preview server did not start");
}
let failures = 0;
const browser = await chromium.launch({ channel: "chrome" });
try {
await waitForServer();
for (const theme of ["light", "dark"]) {
for (const vp of viewports) {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
colorScheme: theme,
reducedMotion: "reduce",
});
const page = await context.newPage();
for (const path of pages) {
await page.goto(BASE + path, { waitUntil: "networkidle" });
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"])
.analyze();
const serious = violations.filter((v) => ["serious", "critical"].includes(v.impact));
const minor = violations.filter((v) => !serious.includes(v));
const label = `${theme.padEnd(5)} ${vp.name.padEnd(7)} ${path}`;
if (!violations.length) {
console.log(`ok ${label}`);
continue;
}
failures += serious.length;
console.log(`${serious.length ? "FAIL" : "note"} ${label}`);
for (const v of [...serious, ...minor]) {
console.log(` [${v.impact}] ${v.id}: ${v.help} (${v.nodes.length} element(s))`);
for (const n of v.nodes.slice(0, 3)) console.log(` ${n.target.join(" ")}`);
}
}
await context.close();
}
}
} finally {
await browser.close();
// Killing the npx wrapper doesn't reliably stop the server on Windows; Astro's own command does.
await new Promise((resolve) =>
spawn("npx", ["astro", "preview", "stop"], { shell: true, stdio: "ignore" }).on(
"exit",
resolve,
),
);
server.kill();
}
console.log(failures ? `\n${failures} serious issue(s).` : "\nNo serious accessibility issues.");
process.exit(failures ? 1 : 0);