import AxeBuilder from "@axe-core/playwright"; import { expect, test, type Page } from "@playwright/test"; import { authenticatedStatePath } from "../global-setup"; import { expectAuthenticatedShell } from "./support/v2"; /** * V2-R3: infrastructure scale/durable-sync remediation's sibling milestone — * "Stitch fidelity and daily UX" (docs/v2/V2_REMEDIATION_PLAN.md, * docs/adr/ADR-027-v2-r3-context-inspector-and-daily-ux-completeness.md). This spec * is the concrete evidence for the `implementation-fidelity-checklist.md` error/ * degraded-state row (#23): every route's own API-failure path shows a real, * accessible, retry-capable error state, not just the few routes previously proven * directly. */ test.use({ storageState: authenticatedStatePath }); async function forceApiFailure(page: Page, pathPattern: string | RegExp) { await page.route(pathPattern, (route) => route.fulfill({ status: 500, contentType: "application/problem+json", body: JSON.stringify({ title: "Onverwachte serverfout.", status: 500, correlationId: "test-correlation-id" }) }) ); } const errorStateRoutes: Array<{ path: string; apiPattern: RegExp; retryLabel: string }> = [ { path: "/infrastructure/services", apiPattern: /\/api\/v1\/infrastructure\/services(\?.*)?$/, retryLabel: "Opnieuw" }, { path: "/projects", apiPattern: /\/api\/v1\/projects(\?.*)?$/, retryLabel: "Opnieuw" }, { path: "/runbooks", apiPattern: /\/api\/v1\/runbooks(\?.*)?$/, retryLabel: "Opnieuw" } ]; test.describe("V2-R3 error-state sweep: every listed route's own API failure shows a real, accessible, retryable error", () => { for (const { path, apiPattern, retryLabel } of errorStateRoutes) { test(`${path} shows a clear error state with a working retry when its own API call fails`, async ({ page }) => { await forceApiFailure(page, apiPattern); await page.goto(path); await expectAuthenticatedShell(page); const alert = page.getByRole("alert"); await expect(alert).toBeVisible(); const retryButton = alert.getByRole("button", { name: retryLabel }); await expect(retryButton).toBeVisible(); // Never a raw stack trace, exception message or private filesystem path shown // to the user (CLAUDE.md security rules) — only the route's own honest, // generic Dutch failure copy. const alertText = await alert.textContent(); expect(alertText).not.toMatch(/Exception|StackTrace|System\.|at \w+\.\w+\(/); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); // The retry actually re-issues the same request: clearing the forced failure // and clicking retry must recover to the real, working screen, not stay stuck. await page.unroute(apiPattern); await retryButton.click(); await expect(page.getByRole("alert")).toHaveCount(0); }); } }); test.describe("V2-R3 offline banner: a genuine network-level failure is distinguishable from an ordinary server error", () => { test("a request that fails at the network level (not just a 5xx) still surfaces a clear, non-raw error message", async ({ page }) => { await page.route(/\/api\/v1\/infrastructure\/services(\?.*)?$/, (route) => route.abort("connectionrefused")); await page.goto("/infrastructure/services"); await expectAuthenticatedShell(page); const alert = page.getByRole("alert"); await expect(alert).toBeVisible(); const alertText = await alert.textContent(); expect(alertText).not.toMatch(/Failed to fetch|NetworkError|TypeError/); }); }); // implementation-fidelity-checklist.md row #21 ("Empty states"): individually proven // where directly exercised, but with no single dedicated cross-route sweep. Forcing a // genuinely empty `{ items: [], nextCursor: null }` response via route interception // (rather than relying on the shared dev database happening to be empty, which it is // not after a long session's worth of prior specs seeding real content) makes this // deterministic and repeatable regardless of what real data already exists. const emptyStateRoutes: Array<{ path: string; apiPattern: RegExp; heading: string }> = [ { path: "/projects", apiPattern: /\/api\/v1\/projects(\?.*)?$/, heading: "Nog geen projecten" }, { path: "/runbooks", apiPattern: /\/api\/v1\/runbooks(\?.*)?$/, heading: "Nog geen runbooks" } ]; test.describe("V2-R3 empty-state sweep: a genuinely empty collection shows honest, accessible copy, not a blank screen", () => { for (const { path, apiPattern, heading } of emptyStateRoutes) { test(`${path} shows a clear empty state when there is genuinely nothing to list`, async ({ page }) => { await page.route(apiPattern, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [], nextCursor: null }) }) ); await page.goto(path); await expectAuthenticatedShell(page); await expect(page.getByRole("heading", { name: heading, level: 2 })).toBeVisible(); const accessibility = await new AxeBuilder({ page }).analyze(); expect(accessibility.violations).toEqual([]); }); } }); // implementation-fidelity-checklist.md row #22 ("Loading/skeleton states"): the // mid-load page-has-heading-one axe gap is already a known, accepted, purely // transient false positive (every scan in this suite waits past it) — what was never // given a dedicated pass is the skeleton's own markup while it is actually visible. // Holding the route open (never fulfilling it for the duration of this test) keeps // the app in its loading state deterministically, long enough to assert on it. test.describe("V2-R3 loading-state check: the skeleton itself renders sensible, non-empty structure while data is still in flight", () => { test("/runbooks shows a real, non-blank loading skeleton before its data arrives", async ({ page }) => { let releaseRoute: (() => void) | undefined; const held = new Promise((resolve) => { releaseRoute = resolve; }); await page.route(/\/api\/v1\/runbooks(\?.*)?$/, async (route) => { await held; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [], nextCursor: null }) }); }); await page.goto("/runbooks"); await expectAuthenticatedShell(page); // A real, visible loading indicator — not a blank main area — while the request // is deliberately held open. await expect(page.getByRole("status")).toBeVisible(); releaseRoute?.(); await expect(page.getByRole("heading", { name: "Nog geen runbooks", level: 2 })).toBeVisible(); }); });