import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import AxeBuilder from "@axe-core/playwright"; import { expect, test, type Page } from "@playwright/test"; import { authenticatedStatePath } from "../global-setup"; import { followPrimaryNavigation, openPrimaryNavigation } from "./support/v2"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * V2-M2 routing, application-shell and visual-fidelity audit. * * This spec is the concrete evidence behind docs/v2/V2_EVIDENCE.md's "V2-M2" section * and docs/v2/V2_ACCEPTANCE_MATRIX.md's routing/shell/fidelity rows — every assertion * here corresponds to a specific requirement in that milestone's audit brief, not * generic smoke coverage. Kept as permanent regression coverage, not a throwaway * script, since real-router deep-link/back-forward/focus-management behavior is easy * to silently regress. */ test.use({ storageState: authenticatedStatePath }); const screenshotDir = path.resolve(__dirname, "../../../artifacts/v2-m2/screenshots"); fs.mkdirSync(screenshotDir, { recursive: true }); async function setTheme(page: Page, theme: "Licht" | "Donker") { await page.goto("/settings"); await page.getByRole("button", { name: theme, exact: true }).click(); await expect(page.locator("html")).toHaveAttribute("data-theme", theme === "Licht" ? "light" : "dark"); } test.describe("V2-M2 routing audit: deep links, reload, back/forward, titles", () => { const routes: Array<{ path: string; heading?: string; title: string }> = [ { path: "/", heading: "Operationeel overzicht", title: "Home · ItWorx Knowledge Hub" }, { path: "/infrastructure", title: "Infrastructuur · ItWorx Knowledge Hub" }, { path: "/infrastructure/storage", title: "Opslag en capaciteit · ItWorx Knowledge Hub" }, { path: "/infrastructure/services", title: "Services · ItWorx Knowledge Hub" }, { path: "/projects", heading: "Projecten", title: "Projecten · ItWorx Knowledge Hub" }, { path: "/runbooks", title: "Runbooks · ItWorx Knowledge Hub" }, { path: "/knowledge", heading: "Kennisbank", title: "Kennisbank · ItWorx Knowledge Hub" }, { path: "/search", title: "Zoeken · ItWorx Knowledge Hub" }, { path: "/activity", title: "Activiteit · ItWorx Knowledge Hub" }, { path: "/settings", heading: "Instellingen", title: "Instellingen · ItWorx Knowledge Hub" }, { path: "/admin", title: "Beheer · ItWorx Knowledge Hub" } ]; for (const route of routes) { test(`${route.path} is a real deep link: reachable directly, survives reload, has a distinct document title`, async ({ page }) => { // Deep link: navigate straight to the URL, not via in-app clicks. await page.goto(route.path); if (route.heading) { await expect(page.getByRole("heading", { name: route.heading })).toBeVisible(); } await expect(page).toHaveTitle(route.title); await expect(page).toHaveURL(new RegExp(route.path.replace(/[/]/g, "\\/") + "$")); // Reload on the exact same URL must restore the same route, not bounce to "/". await page.reload(); if (route.heading) { await expect(page.getByRole("heading", { name: route.heading })).toBeVisible(); } await expect(page).toHaveURL(new RegExp(route.path.replace(/[/]/g, "\\/") + "$")); }); } test("an unknown route renders the not-found page instead of a blank screen or a silent redirect", async ({ page }) => { await page.goto("/this-route-does-not-exist"); await expect(page).toHaveURL(/\/this-route-does-not-exist$/); await expect(page.getByRole("heading")).toBeVisible(); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); test("browser back and forward move between two real routes, not just app-internal state", async ({ page }) => { await page.goto("/infrastructure"); await page.goto("/projects"); await page.goto("/runbooks"); await page.goBack(); await expect(page).toHaveURL(/\/projects$/); await page.goBack(); await expect(page).toHaveURL(/\/infrastructure$/); await page.goForward(); await expect(page).toHaveURL(/\/projects$/); }); test("a non-Administrator deep-linking to /admin is redirected away, not shown admin content", async ({ page, baseURL }) => { // Create a throwaway Reader account via the real admin API (the same account // creation form AdminPage.tsx exposes), then verify the route guard against it in // a fresh, separately authenticated context — not by trusting client-side hiding. // page.request shares the authenticated owner's cookies with page's own context // (per the storageState applied above), matching attachments.spec.ts's convention. await page.goto("/"); const csrfResponse = await page.request.get("/api/v1/auth/csrf"); const csrf = (await csrfResponse.json()) as { token: string; headerName: string }; const readerEmail = `e2e-reader-${Date.now()}@itworx.e2e`; const readerPassword = "correct-horse-battery-staple-reader"; const createResponse = await page.request.post("/api/v1/admin/users", { headers: { [csrf.headerName]: csrf.token }, data: { displayName: "E2E Reader", email: readerEmail, password: readerPassword, role: "Reader" } }); expect(createResponse.ok(), await createResponse.text()).toBeTruthy(); // A manually created browser context does not inherit the project's `baseURL` // fixture — pass it explicitly, otherwise `goto("/")` resolves against no base. // storageState is forced empty so this context never inherits the owner's // session, regardless of the file-level `test.use({ storageState: ... })`. const readerContext = await page.context().browser()!.newContext({ baseURL, storageState: { cookies: [], origins: [] } }); const readerPage = await readerContext.newPage(); await readerPage.goto("/"); await readerPage.getByLabel("E-mailadres").fill(readerEmail); await readerPage.getByLabel("Wachtwoord", { exact: true }).fill(readerPassword); await readerPage.getByRole("button", { name: "Aanmelden" }).click(); await expect(readerPage.getByRole("button", { name: "Accountmenu" })).toBeVisible(); await readerPage.goto("/admin"); await expect(readerPage).toHaveURL(/\/$/); await expect(readerPage.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible(); await readerContext.close(); }); }); test.describe("V2-M2 application shell audit", () => { test("a skip link is the first focusable element and jumps into the page's main content", async ({ page }) => { // The skip link itself lives in index.html (outside the React root, so it works // identically on the pre-auth login/setup screens too) and targets #main-content, // which every route's own top-level
provides. await page.goto("/infrastructure"); await page.keyboard.press("Tab"); await expect(page.getByRole("link", { name: "Ga naar de inhoud" })).toBeFocused(); await page.keyboard.press("Enter"); await expect(page.locator("#main-content")).toBeFocused(); }); test("focus moves to the route content on in-app navigation, not just on skip-link activation", async ({ page }) => { await page.goto("/"); await followPrimaryNavigation(page, "Projecten"); await expect(page).toHaveURL(/\/projects$/); await expect(page.locator("#route-focus-target")).toBeFocused(); }); test("the active route is marked in the primary navigation for assistive technology, not just visually", async ({ page }) => { await page.goto("/projects"); await openPrimaryNavigation(page); await expect( page.getByRole("navigation", { name: "Hoofdnavigatie" }).getByRole("link", { name: "Projecten", exact: true }) ).toHaveAttribute("aria-current", "page"); await expect( page.getByRole("navigation", { name: "Hoofdnavigatie" }).getByRole("link", { name: "Home", exact: true }) ).not.toHaveAttribute("aria-current", "page"); }); test("the command palette entry point is reachable and keyboard-operable", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "Opdrachten en zoeken openen" }).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog")).toBeHidden(); }); test("no primary route causes page-level horizontal overflow at 1280px", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); for (const routePath of ["/", "/infrastructure", "/projects", "/runbooks", "/knowledge"]) { await page.goto(routePath); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow, `${routePath} caused ${overflow}px of horizontal overflow at 1280px`).toBeLessThanOrEqual(1); } }); }); test.describe("V2-M2 accessibility (axe) on primary routes, both themes", () => { const routes = ["/", "/infrastructure", "/infrastructure/services", "/projects", "/runbooks", "/knowledge", "/settings"]; for (const theme of ["Licht", "Donker"] as const) { test(`primary routes have zero axe violations in ${theme === "Licht" ? "light" : "dark"} mode`, async ({ page }) => { // Seven complete route loads and axe scans share this test's total budget. test.slow(); await setTheme(page, theme); for (const routePath of routes) { await page.goto(routePath); // Every route shows a transient RouteStates.LoadingState (role="status", no // heading) while its own data fetch is in flight; scanning during that window // is a real, reproducible false failure ("page-has-heading-one"), not an // actual accessibility defect — wait for the fetch to settle first, matching // how every route's own spec (e.g. project-hub.spec.ts) waits for its // known heading before scanning, generalized here since routes vary. await page.waitForLoadState("networkidle"); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations, `${routePath} (${theme}): ${JSON.stringify(accessibility.violations, null, 2)}`).toEqual([]); } }); } test("reduced motion renders visualizations in their final state without violating axe", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/"); await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible(); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); }); test.describe("V2-M2 visual fidelity capture (compared against artifacts/stitch/v2/screens by hand)", () => { test("capture implementation screenshots for the required routes/breakpoints/themes", async ({ page }) => { // Eleven captures plus persisted theme changes form one visual evidence set. test.slow(); await setTheme(page, "Donker"); await page.setViewportSize({ width: 1600, height: 1000 }); await page.goto("/"); await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible(); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "02-home-dashboard-dark-1600.png") }); await page.goto("/infrastructure"); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "04-infrastructure-overview-dark-1600.png") }); await page.goto("/infrastructure/services"); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "06-services-catalog-dark-1600.png") }); await page.goto("/projects"); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "09-projects-overview-dark-1600.png") }); await page.goto("/knowledge"); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "14-knowledge-tree-dark-1600.png") }); await setTheme(page, "Licht"); await page.setViewportSize({ width: 1600, height: 1000 }); await page.goto("/"); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "02-home-dashboard-light-1600.png") }); await page.goto("/projects"); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "09-projects-overview-light-1600.png") }); await page.setViewportSize({ width: 1024, height: 768 }); await page.goto("/infrastructure"); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "04-infrastructure-1024.png") }); await page.setViewportSize({ width: 768, height: 1024 }); await page.goto("/infrastructure/services"); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "27-tablet-service-workspace-768.png") }); await setTheme(page, "Donker"); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/"); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "24-mobile-navigation-dark-390.png") }); await setTheme(page, "Licht"); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/knowledge"); await page.waitForLoadState("networkidle"); await page.screenshot({ path: path.join(screenshotDir, "25-mobile-knowledge-light-390.png") }); }); });