Complete independent projection runtime
This commit is contained in:
parent
1134c2d375
commit
f1fabaf0ca
38 changed files with 4907 additions and 87 deletions
187
tests/accessibility.spec.mjs
Normal file
187
tests/accessibility.spec.mjs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
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", "");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue