#!/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);