Files
itworx-knowledge-hub-Public/tests/e2e/specs/v2-routing-and-fidelity.spec.ts
T

268 lines
13 KiB
TypeScript

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 <main> 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") });
});
});