import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; const AXE_TAGS = [ "wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa", ]; const MANUAL_AXE_TAGS = AXE_TAGS.filter((tag) => !tag.startsWith("wcag22")); let fixtureProcess; let surfaces; function startFixture() { const python = process.env.DOCFORGE_PYTHON || ".venv/bin/python"; const child = spawn(python, ["tools/accessibility_fixture.py"], { cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"], }); let stderr = ""; child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk) => { stderr += chunk; }); const lines = createInterface({ input: child.stdout }); const ready = new Promise((resolve, reject) => { let settled = false; lines.once("line", (line) => { settled = true; try { const payload = JSON.parse(line); if ( payload.schema_version !== 1 || typeof payload.manual_html !== "string" || typeof payload.portable_html !== "string" || typeof payload.live_url !== "string" ) { throw new Error("Accessibility fixture returned an invalid payload"); } resolve(payload); } catch (error) { reject(error); } finally { lines.close(); } }); child.once("exit", (code, signal) => { if (!settled) { reject( new Error( `Accessibility fixture exited before readiness ` + `(code=${code}, signal=${signal}):\n${stderr}`, ), ); } }); }); return { child, ready }; } async function stopFixture(child) { if (child.exitCode !== null || child.signalCode !== null) { return; } const exited = new Promise((resolve) => child.once("exit", resolve)); child.stdin.end(); await Promise.race([ exited, new Promise((_, reject) => { setTimeout(() => reject(new Error("Accessibility fixture did not stop")), 5_000); }), ]); } function violationReport(violations) { return violations.map((violation) => { const targets = violation.nodes .flatMap((node) => node.target) .join(", "); return `${violation.id} (${violation.impact}): ${violation.help}\n ${targets}`; }).join("\n"); } async function expectNoAxeViolations(page, tags = AXE_TAGS) { const results = await new AxeBuilder({ page }).withTags(tags).analyze(); expect(results.violations, violationReport(results.violations)).toEqual([]); } async function tabTo(page, selector, maximumTabs = 40) { for (let count = 0; count < maximumTabs; count += 1) { await page.keyboard.press("Tab"); if (await page.evaluate((target) => document.activeElement?.matches(target), selector)) { return page.locator(selector).filter({ visible: true }).first(); } } throw new Error(`Keyboard focus did not reach ${selector}`); } test.beforeAll(async () => { const fixture = startFixture(); fixtureProcess = fixture.child; surfaces = await fixture.ready; }); test.afterAll(async () => { await stopFixture(fixtureProcess); }); test("generated manual has no axe violations and its navigation works by keyboard", async ({ page, }) => { await page.setContent(surfaces.manual_html, { waitUntil: "load" }); await expect(page.locator("main section")).not.toHaveCount(0); // The generic renderer owns structure, while this frozen project template owns target sizing. await expectNoAxeViolations(page, MANUAL_AXE_TAGS); await page.keyboard.press("Tab"); const firstNavigationLink = page.locator("nav[aria-label='Documentation'] a").first(); await expect(firstNavigationLink).toBeFocused(); const target = await firstNavigationLink.getAttribute("href"); expect(target).toMatch(/^#[A-Za-z0-9_.-]+$/); await page.keyboard.press("Enter"); await expect.poll(() => page.evaluate(() => window.location.hash)).toBe(target); }); test("portable graph supports skip, filter, view, and dialog keyboard flows", async ({ page }) => { await page.goto("about:blank"); await page.setContent(surfaces.portable_html, { waitUntil: "load" }); await expect(page.locator("#status")).toContainText("nodes and"); await expectNoAxeViolations(page); await page.keyboard.press("Tab"); await expect(page.locator("a.skip")).toBeFocused(); await page.keyboard.press("Enter"); await expect(page.locator("main#main")).toBeFocused(); await page.goto("about:blank"); await page.setContent(surfaces.portable_html, { waitUntil: "load" }); await page.keyboard.press("Tab"); await page.keyboard.press("Tab"); await expect(page.locator("#filter")).toBeFocused(); await page.keyboard.type("guide.workflow"); await expect(page.locator("#status")).toContainText("1 nodes and"); await page.keyboard.press("Tab"); await expect(page.locator("#mode")).toBeFocused(); await page.keyboard.press("ArrowDown"); await expect(page.locator("main#main")).toHaveAttribute("data-mode", "flow"); await page.keyboard.press("ArrowUp"); await expect(page.locator("main#main")).toHaveAttribute("data-mode", "nodes"); await page.keyboard.press("Tab"); const nodeButton = page.locator("#node-list button").first(); await expect(nodeButton).toBeFocused(); await page.keyboard.press("Enter"); await expect(page.locator("#node-dialog")).toHaveAttribute("open", ""); await expect(page.locator("#close-dialog")).toBeFocused(); await expectNoAxeViolations(page); await page.keyboard.press("Escape"); await expect(page.locator("#node-dialog")).not.toHaveAttribute("open", ""); await expect(nodeButton).toBeFocused(); }); test("live viewer passes axe and exposes keyboard graph and resize controls", async ({ page }) => { await page.goto(surfaces.live_url); await expect(page.locator("#status")).toContainText("nodes ยท"); await expect(page.locator("#graph g.node[role='button']").first()).toBeVisible(); await expectNoAxeViolations(page); const resizer = await tabTo(page, "#left-resizer"); const originalWidth = Number(await resizer.getAttribute("aria-valuenow")); await page.keyboard.press("ArrowRight"); await expect(resizer).toHaveAttribute("aria-valuenow", String(originalWidth + 16)); await tabTo(page, "#graph g.node[role='button']"); await page.keyboard.press("Shift+Enter"); await expect(page.locator("#node-dialog")).toHaveAttribute("open", ""); await expect(page.locator("#close-node-dialog")).toBeFocused(); await expectNoAxeViolations(page); await page.keyboard.press("Escape"); await expect(page.locator("#node-dialog")).not.toHaveAttribute("open", ""); });