174 lines
8.8 KiB
TypeScript
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([]);
|
|
});
|
|
});
|
|
});
|