268 lines
13 KiB
TypeScript
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") });
|
|
});
|
|
});
|