133 lines
6.5 KiB
TypeScript
133 lines
6.5 KiB
TypeScript
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<void>((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();
|
|
});
|
|
});
|