import AxeBuilder from "@axe-core/playwright"; import { expect, test, type Page } from "@playwright/test"; import { authenticatedStatePath } from "../global-setup"; import { expectAuthenticatedShell, openPrimaryNavigation } from "./support/v2"; async function postWithCsrf(page: Page, url: string, data: unknown) { const csrfResponse = await page.request.get("/api/v1/auth/csrf"); const csrf = (await csrfResponse.json()) as { token: string; headerName: string }; return page.request.post(url, { headers: { [csrf.headerName]: csrf.token }, data }); } /** * V2-M8: accessibility, performance and visual fidelity. * * Closes concrete, previously-unverified gaps found while re-reading the full axe * coverage map across every prior V2 spec (V2_EVIDENCE.md "V2-M8"): the Login screen * itself was never axe-scanned (every prior scan happened only after reaching the * authenticated shell); `/activity` was never axe-scanned at all; the command palette * and mobile primary navigation were only ever scanned in their closed/hidden state, * never open; and no test verified 200% zoom/reflow anywhere, a real WCAG 2.2 AA * requirement (`CLAUDE.md` ยง12) distinct from the axe ruleset itself. * * 200% zoom is simulated by halving the viewport (1280x900 -> 640x450), not via * Chromium's non-standard `document.body.style.zoom`. Verified directly that the * `zoom` CSS property does not change `window.innerWidth`/media-query evaluation at * all (confirmed via a throwaway debug script โ€” scrollWidth/clientWidth stayed * within a few px of each other purely from zoom's own sub-pixel rounding, while the * page's real responsive breakpoints never engaged), so it would not have exercised * this app's actual responsive CSS the way a real user's browser zoom does. A halved * viewport is what a real 200%-zoomed 1280px-wide window is equivalent to in CSS * pixels, and correctly triggers the same breakpoints `responsive.css` already * defines for narrow real viewports. */ test.describe("V2-M8 accessibility: previously unscanned screens and states", () => { test("the login screen itself is axe-clean, both in its default and its error state", async ({ page }) => { await page.goto("/"); await expect(page.getByRole("heading", { name: "Aanmelden" })).toBeVisible(); const defaultAccessibility = await new AxeBuilder({ page }).analyze(); expect(defaultAccessibility.violations).toEqual([]); await page.getByLabel("E-mailadres").fill("onbekend@itworx.e2e"); await page.getByLabel("Wachtwoord", { exact: true }).fill("whatever-password-value"); await page.getByRole("button", { name: "Aanmelden" }).click(); await expect(page.getByRole("alert")).toBeVisible(); const errorAccessibility = await new AxeBuilder({ page }).analyze(); expect(errorAccessibility.violations).toEqual([]); }); test.describe("authenticated screens", () => { test.use({ storageState: authenticatedStatePath }); test("the activity feed is axe-clean in both themes, not yet scanned by any prior milestone", async ({ page }) => { await page.goto("/activity"); await expectAuthenticatedShell(page); await expect(page.getByRole("heading", { name: "Activiteit" })).toBeVisible(); await page.waitForLoadState("networkidle"); const lightAccessibility = await new AxeBuilder({ page }).analyze(); expect(lightAccessibility.violations).toEqual([]); await page.goto("/settings"); await page.getByRole("button", { name: "Donker", exact: true }).click(); await page.goto("/activity"); await page.waitForLoadState("networkidle"); const darkAccessibility = await new AxeBuilder({ page }).analyze(); expect(darkAccessibility.violations).toEqual([]); }); test("the command palette is axe-clean while open, not only reachable", async ({ page }) => { await page.goto("/"); await expectAuthenticatedShell(page); await page.getByRole("button", { name: "Opdrachten en zoeken openen" }).click(); await expect(page.getByRole("dialog")).toBeVisible(); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); test("the mobile primary navigation is axe-clean while open, not only while closed", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/"); await expectAuthenticatedShell(page); await openPrimaryNavigation(page); await expect(page.getByRole("navigation", { name: "Hoofdnavigatie" })).toBeVisible(); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); test("the dashboard causes no horizontal reflow and stays axe-clean at the equivalent of 200% zoom", async ({ page }) => { // 1280x900 is this suite's own reference desktop width elsewhere; halved is // the CSS-pixel equivalent of viewing that same window at 200% zoom. await page.setViewportSize({ width: 640, height: 450 }); await page.goto("/"); await expectAuthenticatedShell(page); // Scanning during the transient RouteStates.LoadingState (no heading yet) is a // real, reproducible "page-has-heading-one" false failure, not an actual // defect โ€” wait for the real heading first, matching every other axe scan in // this suite. await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible(); const overflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth ); expect(overflow).toBeLessThanOrEqual(1); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); test("the storage matrix causes no horizontal reflow at the equivalent of 200% zoom (the one real DataTable-backed route)", async ({ page }) => { await page.setViewportSize({ width: 640, height: 450 }); await page.goto("/infrastructure/storage"); await expect(page.getByRole("heading", { name: "Opslag en capaciteit" })).toBeVisible(); const overflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth ); expect(overflow).toBeLessThanOrEqual(1); }); // Screen-map #11 "Deployment detail" has no dedicated route โ€” it is the // ServiceDetailPage "Deployments" tab (docs/design/v2/screen-map.md). Every other // ServiceDetailPage tab was already axe-scanned (V2-M4's "Live facts" tab, // V2-M4/M5's overview tab); this was the one remaining unscanned tab. test("a service's Deployments tab is axe-clean, the one remaining unscanned ServiceDetailPage tab", async ({ page }) => { const sourceKey = `docker:e2e-m8-deployments-${Date.now()}`; const detectedName = `e2e-m8-deployments-${Date.now()}`; const snapshotResponse = await postWithCsrf(page, "/api/v1/infrastructure/inventory/snapshots", { sourceKey: "inventory-e2e", sourceHash: `hash-m8-${Date.now()}`, capturedAt: new Date().toISOString(), hosts: [ { sourceKey: "host:e2e-m8", hostname: "E2E-M8-Host", operatingSystem: null, kernelVersion: null, architecture: null, logicalCpuCount: 2, totalMemoryBytes: 500_000 } ], services: [ { sourceKey, hostSourceKey: "host:e2e-m8", detectedName, runtimeKind: "container", runtimeStatus: "running", health: "Healthy", imageReference: "example/e2e-m8:1", imageDigest: null, runtimeUser: null, readOnlyRootFilesystem: true, noNewPrivileges: true, privileged: false, confidence: "High", components: null, endpoints: null, networks: null, volumes: null, databases: null, dependencies: null, healthObservations: null, deployments: null, backups: null } ] }); expect(snapshotResponse.ok(), await snapshotResponse.text()).toBeTruthy(); await page.goto("/admin/inventory"); await expectAuthenticatedShell(page); const proposalCard = page.locator(".inventory-proposals article", { hasText: detectedName }); await expect(proposalCard).toBeVisible(); await proposalCard.getByRole("button", { name: "Bevestigen" }).click(); await expect(proposalCard).toBeHidden(); await page.goto("/infrastructure/services"); await page.getByRole("link", { name: new RegExp(detectedName) }).click(); await expect(page.getByRole("heading", { name: detectedName })).toBeVisible(); await page.getByRole("tab", { name: "Deployments" }).click(); await expect(page.getByRole("heading", { name: "Deploymentflow" })).toBeVisible(); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); }); });