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

174 lines
8.8 KiB
TypeScript

import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell, openPrimaryNavigation } from "./support/v2";
async function postWithCsrf(page: Page, url: string, data: unknown) {
const csrfResponse = await page.request.get("/api/v1/auth/csrf");
const csrf = (await csrfResponse.json()) as { token: string; headerName: string };
return page.request.post(url, { headers: { [csrf.headerName]: csrf.token }, data });
}
/**
* V2-M8: accessibility, performance and visual fidelity.
*
* Closes concrete, previously-unverified gaps found while re-reading the full axe
* coverage map across every prior V2 spec (V2_EVIDENCE.md "V2-M8"): the Login screen
* itself was never axe-scanned (every prior scan happened only after reaching the
* authenticated shell); `/activity` was never axe-scanned at all; the command palette
* and mobile primary navigation were only ever scanned in their closed/hidden state,
* never open; and no test verified 200% zoom/reflow anywhere, a real WCAG 2.2 AA
* requirement (`CLAUDE.md` §12) distinct from the axe ruleset itself.
*
* 200% zoom is simulated by halving the viewport (1280x900 -> 640x450), not via
* Chromium's non-standard `document.body.style.zoom`. Verified directly that the
* `zoom` CSS property does not change `window.innerWidth`/media-query evaluation at
* all (confirmed via a throwaway debug script — scrollWidth/clientWidth stayed
* within a few px of each other purely from zoom's own sub-pixel rounding, while the
* page's real responsive breakpoints never engaged), so it would not have exercised
* this app's actual responsive CSS the way a real user's browser zoom does. A halved
* viewport is what a real 200%-zoomed 1280px-wide window is equivalent to in CSS
* pixels, and correctly triggers the same breakpoints `responsive.css` already
* defines for narrow real viewports.
*/
test.describe("V2-M8 accessibility: previously unscanned screens and states", () => {
test("the login screen itself is axe-clean, both in its default and its error state", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Aanmelden" })).toBeVisible();
const defaultAccessibility = await new AxeBuilder({ page }).analyze();
expect(defaultAccessibility.violations).toEqual([]);
await page.getByLabel("E-mailadres").fill("onbekend@itworx.e2e");
await page.getByLabel("Wachtwoord", { exact: true }).fill("whatever-password-value");
await page.getByRole("button", { name: "Aanmelden" }).click();
await expect(page.getByRole("alert")).toBeVisible();
const errorAccessibility = await new AxeBuilder({ page }).analyze();
expect(errorAccessibility.violations).toEqual([]);
});
test.describe("authenticated screens", () => {
test.use({ storageState: authenticatedStatePath });
test("the activity feed is axe-clean in both themes, not yet scanned by any prior milestone", async ({ page }) => {
await page.goto("/activity");
await expectAuthenticatedShell(page);
await expect(page.getByRole("heading", { name: "Activiteit" })).toBeVisible();
await page.waitForLoadState("networkidle");
const lightAccessibility = await new AxeBuilder({ page }).analyze();
expect(lightAccessibility.violations).toEqual([]);
await page.goto("/settings");
await page.getByRole("button", { name: "Donker", exact: true }).click();
await page.goto("/activity");
await page.waitForLoadState("networkidle");
const darkAccessibility = await new AxeBuilder({ page }).analyze();
expect(darkAccessibility.violations).toEqual([]);
});
test("the command palette is axe-clean while open, not only reachable", async ({ page }) => {
await page.goto("/");
await expectAuthenticatedShell(page);
await page.getByRole("button", { name: "Opdrachten en zoeken openen" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
test("the mobile primary navigation is axe-clean while open, not only while closed", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await expectAuthenticatedShell(page);
await openPrimaryNavigation(page);
await expect(page.getByRole("navigation", { name: "Hoofdnavigatie" })).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
test("the dashboard causes no horizontal reflow and stays axe-clean at the equivalent of 200% zoom", async ({ page }) => {
// 1280x900 is this suite's own reference desktop width elsewhere; halved is
// the CSS-pixel equivalent of viewing that same window at 200% zoom.
await page.setViewportSize({ width: 640, height: 450 });
await page.goto("/");
await expectAuthenticatedShell(page);
// Scanning during the transient RouteStates.LoadingState (no heading yet) is a
// real, reproducible "page-has-heading-one" false failure, not an actual
// defect — wait for the real heading first, matching every other axe scan in
// this suite.
await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible();
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
);
expect(overflow).toBeLessThanOrEqual(1);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
test("the storage matrix causes no horizontal reflow at the equivalent of 200% zoom (the one real DataTable-backed route)", async ({ page }) => {
await page.setViewportSize({ width: 640, height: 450 });
await page.goto("/infrastructure/storage");
await expect(page.getByRole("heading", { name: "Opslag en capaciteit" })).toBeVisible();
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
);
expect(overflow).toBeLessThanOrEqual(1);
});
// Screen-map #11 "Deployment detail" has no dedicated route — it is the
// ServiceDetailPage "Deployments" tab (docs/design/v2/screen-map.md). Every other
// ServiceDetailPage tab was already axe-scanned (V2-M4's "Live facts" tab,
// V2-M4/M5's overview tab); this was the one remaining unscanned tab.
test("a service's Deployments tab is axe-clean, the one remaining unscanned ServiceDetailPage tab", async ({ page }) => {
const sourceKey = `docker:e2e-m8-deployments-${Date.now()}`;
const detectedName = `e2e-m8-deployments-${Date.now()}`;
const snapshotResponse = await postWithCsrf(page, "/api/v1/infrastructure/inventory/snapshots", {
sourceKey: "inventory-e2e",
sourceHash: `hash-m8-${Date.now()}`,
capturedAt: new Date().toISOString(),
hosts: [
{
sourceKey: "host:e2e-m8", hostname: "E2E-M8-Host", operatingSystem: null,
kernelVersion: null, architecture: null, logicalCpuCount: 2, totalMemoryBytes: 500_000
}
],
services: [
{
sourceKey, hostSourceKey: "host:e2e-m8", detectedName, runtimeKind: "container",
runtimeStatus: "running", health: "Healthy", imageReference: "example/e2e-m8:1",
imageDigest: null, runtimeUser: null, readOnlyRootFilesystem: true, noNewPrivileges: true,
privileged: false, confidence: "High", components: null, endpoints: null, networks: null,
volumes: null, databases: null, dependencies: null, healthObservations: null,
deployments: null, backups: null
}
]
});
expect(snapshotResponse.ok(), await snapshotResponse.text()).toBeTruthy();
await page.goto("/admin/inventory");
await expectAuthenticatedShell(page);
const proposalCard = page.locator(".inventory-proposals article", { hasText: detectedName });
await expect(proposalCard).toBeVisible();
await proposalCard.getByRole("button", { name: "Bevestigen" }).click();
await expect(proposalCard).toBeHidden();
await page.goto("/infrastructure/services");
await page.getByRole("link", { name: new RegExp(detectedName) }).click();
await expect(page.getByRole("heading", { name: detectedName })).toBeVisible();
await page.getByRole("tab", { name: "Deployments" }).click();
await expect(page.getByRole("heading", { name: "Deploymentflow" })).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
});
});