187 lines
6.6 KiB
JavaScript
187 lines
6.6 KiB
JavaScript
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", "");
|
|
});
|