Files
govoplan-core/webui/conformance/tests/ui-conformance.spec.ts
T

304 lines
13 KiB
TypeScript

import { createRequire } from "node:module";
import { expect, test } from "@playwright/test";
const require = createRequire(import.meta.url);
const axePath = require.resolve("axe-core/axe.min.js");
async function expectNoAccessibilityViolations(page: import("@playwright/test").Page) {
await page.addScriptTag({ path: axePath });
const violations = await page.evaluate(async () => {
const axe = (window as typeof window & { axe: { run: (options: unknown) => Promise<{ violations: Array<{ id: string; impact?: string | null; help: string; nodes: Array<{ target: unknown; failureSummary?: string }> }> }> } }).axe;
const result = await axe.run({ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] } });
return result.violations.map(({ id, impact, help, nodes }) => ({
id,
impact,
help,
nodes: nodes.map((node: { target: unknown; failureSummary?: string }) => ({ target: node.target, failureSummary: node.failureSummary }))
}));
});
expect(violations).toEqual([]);
}
test("shared components remain accessible and keyboard operable", async ({ page }) => {
await page.goto("/?theme=light");
await expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible();
await expectNoAccessibilityViolations(page);
const editorActions = page.getByRole("toolbar", { name: "Bearbeitungsaktionen" });
await expect(page.locator("[data-page-archetype='overview']")).toBeVisible();
await expect(editorActions).toHaveAttribute("data-page-refreshable", "true");
await expect(editorActions).toHaveAttribute("data-page-dirty", "true");
await expect(editorActions.getByRole("status")).toHaveText("Unsaved changes");
await expect(editorActions.locator("[data-page-action-separation='destructive']")).toHaveCount(1);
await expect(editorActions.locator("[data-page-action-separation='destructive']")).toHaveCSS("border-left-width", "2px");
const actionSlots = await editorActions.locator("[data-page-action-slot]").evaluateAll((elements) => elements.map((element) => element.getAttribute("data-page-action-slot")));
expect(actionSlots).toEqual([
"reload",
"context",
"destructive",
"discard",
"save"
]);
await expect(editorActions.getByRole("button")).toHaveText([
"Neu laden",
"Vorschau öffnen",
"Löschen",
"Verwerfen",
"Änderungen speichern"
]);
await editorActions.getByRole("button", { name: "Neu laden" }).focus();
await page.keyboard.press("Tab");
await expect(editorActions.getByRole("button", { name: "Vorschau öffnen" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(editorActions.locator(".disabled-action-tooltip")).toBeFocused();
await expect(page.getByRole("tooltip")).toContainText("Nur die federführende Stelle");
await page.keyboard.press("Tab");
await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(editorActions).toHaveAttribute("data-page-dirty", "false");
await expect(editorActions.getByRole("status")).toHaveText("Saved");
await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeDisabled();
const metricDrilldown = page.getByRole("button", { name: "Offene Aufgaben prüfen" });
await metricDrilldown.focus();
await page.keyboard.press("Enter");
await expect(page.getByTestId("metric-drilldown-result")).toHaveText("18 offene Aufgaben");
const opener = page.getByTestId("open-dialog");
await opener.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: "Konsequenz vor Ausführung prüfen" })).toBeVisible();
await expectNoAccessibilityViolations(page);
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toBeHidden();
await expect(opener).toBeFocused();
});
test("full-page tool launch preserves a bounded return context", async ({ page }) => {
await page.goto("/?theme=light");
await page.getByTestId("launch-full-page").click();
await expect(page).toHaveURL(/\/files$/);
const returnButton = page.getByRole("button", { name: /RPP-2026-0001.*Anwohnerparkausweis/ });
await expect(returnButton).toBeVisible();
await returnButton.click();
await expect(page).toHaveURL(/\/?\?theme=light$/);
});
test("Quick Access preserves focus and adapts to a narrow viewport", async ({ page }) => {
await page.route("**/api/v1/quick-access/effective*", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify(quickAccessPayload(false))
});
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?theme=light&quick-access=1");
const filesTrigger = page.getByRole("button", { name: "Files" });
await expect(filesTrigger).toBeVisible();
await filesTrigger.click();
const drawer = page.getByRole("dialog", { name: "Files" });
await expect(drawer).toBeVisible();
await expect(drawer.getByRole("button", { name: "Close" })).toBeFocused();
const drawerBounds = await drawer.boundingBox();
expect(drawerBounds?.x).toBeGreaterThanOrEqual(0);
expect((drawerBounds?.x ?? 0) + (drawerBounds?.width ?? 0)).toBeLessThanOrEqual(390);
await page.keyboard.press("Escape");
await expect(drawer).toBeHidden();
await expect(filesTrigger).toBeFocused();
});
test("F1 resolves focused high-risk help and restores focus accessibly", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?theme=light&help=1");
const retentionAction = page.getByTestId("f1-retention-action");
await retentionAction.focus();
await page.keyboard.press("F1");
const dialog = page.getByRole("dialog", { name: "Kontexthilfe" });
await expect(dialog).toBeVisible();
await expect(dialog.locator("[data-help-context]"))
.toHaveAttribute("data-help-context", "policy.retention.action.apply");
await expect(dialog.getByRole("heading", { level: 3 }))
.toHaveText("Aufbewahrungsregeln anwenden");
await expect(dialog).toContainText("topic=policy.admin.retention-and-disposition");
await expect(dialog.getByRole("button", { name: "Administrator-Dokumentation öffnen" }))
.toBeVisible();
await expectNoAccessibilityViolations(page);
const bounds = await dialog.boundingBox();
expect(bounds?.x).toBeGreaterThanOrEqual(0);
expect((bounds?.x ?? 0) + (bounds?.width ?? 0)).toBeLessThanOrEqual(390);
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(retentionAction).toBeFocused();
const fallbackAction = page.getByTestId("f1-fallback-action");
await fallbackAction.focus();
await page.keyboard.press("F1");
const fallbackDialog = page.getByRole("dialog", { name: "Kontexthilfe" });
await expect(fallbackDialog.locator("[data-help-context]"))
.toHaveAttribute("data-help-context", "core.conformance.action.review");
await expect(fallbackDialog).toContainText("fallback_context=campaigns.list");
await page.keyboard.press("Escape");
await expect(fallbackAction).toBeFocused();
});
test("View focus has a deliberate permission-derived all-tools escape", async ({ page }) => {
await page.route("**/api/v1/quick-access/effective*", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify(quickAccessPayload(true))
});
});
await page.goto("/?theme=light&quick-access=focused");
const rail = page.locator(".quick-access-rail");
await expect(rail).toHaveAttribute("data-view-focus-mode", "focused");
await expect(page.getByRole("button", { name: "Files" })).toBeVisible();
await expect(page.getByRole("button", { name: "Messages" })).toHaveCount(0);
const allTools = page.locator(".quick-access-view-mode-button");
await expect(allTools).toHaveAttribute("aria-pressed", "false");
await allTools.click();
await expect(rail).toHaveAttribute("data-view-focus-mode", "all");
await expect(page.getByRole("button", { name: "Messages" })).toBeVisible();
await page.getByRole("button", { name: "Messages" }).click();
const explanation = page.locator(".quick-access-view-mode[role='status']");
await expect(explanation).toHaveAttribute("data-view-focus-mode", "all");
await explanation.getByRole("button").click();
await expect(rail).toHaveAttribute("data-view-focus-mode", "focused");
await expect(page.getByRole("button", { name: "Messages" })).toHaveCount(0);
});
test("a stale View focus falls back safely in a sparse optional-module catalogue", async ({ page }) => {
await page.route("**/api/v1/quick-access/effective*", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify(quickAccessPayload(false))
});
});
await page.goto("/?theme=light&quick-access=stale-focus");
const rail = page.locator(".quick-access-rail");
await expect(rail).toHaveAttribute("data-view-focus-mode", "unavailable");
await expect(page.getByRole("button", { name: "Files" })).toBeVisible();
await expect(page.locator(".quick-access-view-mode-button")).toHaveCount(0);
await page.getByRole("button", { name: "Files" }).click();
await expect(page.locator(".quick-access-view-mode[role='status']"))
.toHaveAttribute("data-view-focus-mode", "unavailable");
});
test("light and dark desktop geometry remains stable", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto("/?theme=light");
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-desktop.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
await page.goto("/?theme=dark");
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-dark-desktop.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
});
test("narrow layout preserves task order without horizontal overflow", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?theme=light");
await expect(page.getByText("Bedingung prüfen")).toBeVisible();
const overflowing = await page.evaluate(() => Array.from(document.querySelectorAll<HTMLElement>("body *"))
.map((element) => {
const rect = element.getBoundingClientRect();
return {
element: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`,
left: Math.round(rect.left),
right: Math.round(rect.right),
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth
};
})
.filter(({ left, right }) => left < -1 || right > window.innerWidth + 1)
.slice(0, 20));
expect(overflowing).toEqual([]);
await expect(page.locator("[data-page-action-archetype='editor'] [data-page-action-group='trailing']"))
.toHaveCSS("justify-content", "flex-end");
const saveRightEdge = await page.locator("[data-page-action-archetype='editor'] [data-page-action-slot='save']").evaluate((element) => element.getBoundingClientRect().right);
const actionBarRightEdge = await page.locator("[data-page-action-archetype='editor']").evaluate((element) => element.getBoundingClientRect().right);
expect(Math.abs(actionBarRightEdge - saveRightEdge)).toBeLessThanOrEqual(1);
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
});
function quickAccessPayload(includeMessages: boolean) {
const files = {
id: "files",
label: "Files",
description: "Files beside the current task",
icon: "files",
order: 10,
enabled: true,
forced: false,
locked_by: null,
tools: [{
contract_version: "1",
id: "files.recent",
module_id: "files",
category_id: "files",
label: "Recent files",
description: "Select an authorized file without leaving this case.",
icon: "files",
surface_id: "files.route.files",
full_page_path: "/files",
required_all: [],
required_any: [],
order: 10,
default_enabled: true,
modes: ["select"],
availability: "global",
accepted_reference_kinds: [],
returned_reference_kinds: ["files.file-version"],
help_context_id: "files.quick_access.files",
enabled: true,
forced: false,
locked_by: null
}]
};
const messages = {
id: "messages",
label: "Messages",
description: "Messages beside the current task",
icon: "messages",
order: 20,
enabled: true,
forced: false,
locked_by: null,
tools: [{
contract_version: "1",
id: "postbox.unread",
module_id: "postbox",
category_id: "messages",
label: "Unread messages",
description: "Open an authorized unread message.",
icon: "messages",
surface_id: "postbox.route.postbox",
full_page_path: "/postbox",
required_all: [],
required_any: [],
order: 10,
default_enabled: true,
modes: ["view"],
availability: "global",
accepted_reference_kinds: [],
returned_reference_kinds: ["postbox.message"],
help_context_id: "postbox.quick_access.messages",
enabled: true,
forced: false,
locked_by: null
}]
};
return {
categories: includeMessages ? [files, messages] : [files],
diagnostics: []
};
}