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("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: [] }; }