From ca3c599e7c1a0e8c7c3987ea392501ad50a90c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 16:49:22 +0200 Subject: [PATCH] test(e2e): extend axe-core to authenticated surfaces; fix login regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /, /settings/integrations to the WCAG 2.1 AA serious+critical sweep under an authenticated test.use(storageState). /admin is parked as test.fixme() because the overview page surfaces three real critical violations (button-name × N, select-name × 1, colour-contrast × 1) all originating in src/components/admin/**, which Phase 4b is restructuring concurrently — re-enable the audit once 4b ships. Repair pre-existing breakage in login.spec.ts: the "Login with password" copy was renamed to "Sign in with password" months ago, but the regex never tracked it. Also disambiguate the strict-mode violation between the passkey CTA and the password submit (both match /sign in/) by restricting to button[type=submit]. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/a11y.spec.ts | 134 +++++++++++++++++++++++++++++++++++++--------- e2e/login.spec.ts | 12 ++++- 2 files changed, 118 insertions(+), 28 deletions(-) diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 8e78816bd..df787a643 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -1,44 +1,126 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + /** * Accessibility regression gate — fails the build if any WCAG 2.1 AA - * violation lands in the `serious` or `critical` bucket on a public - * surface. Authenticated pages are out of scope here (need seeded - * data); they're covered by the per-flow specs that already touch - * those routes. + * violation lands in the `serious` or `critical` bucket. v1.5 phase-3 + * extends the original public-only sweep to cover the authenticated + * surfaces a real user actually spends time on: + * + * - `/auth/login` (public) + * - `/` (dashboard, authenticated) + * - `/settings/integrations` (authenticated) + * - `/admin` (admin-only — seeded user has ADMIN) + * + * The seeded user (`e2e-tester`) is provisioned with `role: ADMIN` in + * `e2e/setup/global-setup.ts` so the same fixture covers both regular + * and admin surfaces without a second user. The /admin page audit is + * intentionally limited to the overview route — phase 4b is currently + * restructuring the admin sub-routes in parallel and we don't want to + * race that work. */ + +function reportBlocking( + blocking: Awaited>["violations"], +) { + if (blocking.length === 0) return; + // Pretty-print so failures are actionable in CI logs. + console.log( + "axe violations:\n" + + blocking + .map( + (v) => + ` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.length} node(s)\n ${v.helpUrl}`, + ) + .join("\n"), + ); +} + +async function runAxe(page: import("@playwright/test").Page) { + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + return results.violations.filter( + (v) => v.impact === "serious" || v.impact === "critical", + ); +} + test.describe("axe-core public surfaces", () => { for (const path of ["/auth/login"]) { test(`${path} has no serious or critical a11y violations`, async ({ page, }) => { await page.goto(path); - // Wait briefly for any reduced-motion fades to settle. await page.waitForLoadState("networkidle"); - const results = await new AxeBuilder({ page }) - .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) - .analyze(); - - const blocking = results.violations.filter( - (v) => v.impact === "serious" || v.impact === "critical", - ); - - if (blocking.length > 0) { - // Pretty-print so failures are actionable in CI logs. - console.log( - "axe violations:\n" + - blocking - .map( - (v) => - ` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.length} node(s)\n ${v.helpUrl}`, - ) - .join("\n"), - ); - } - + const blocking = await runAxe(page); + reportBlocking(blocking); expect(blocking).toHaveLength(0); }); } }); + +test.describe("axe-core authenticated surfaces", () => { + // Reuse the cookie jar captured by `e2e/setup/global-setup.ts`. Login + // ran once in global-setup so we don't burn auth rate-limit attempts. + test.use({ storageState: STORAGE_STATE_PATH }); + + // Stable mocks so the auth'd pages render deterministically — axe scans + // need fully-painted DOM, and a flaky API would otherwise stall the + // analyse() call until the 30s test timeout. + test.beforeEach(async ({ page }) => { + await page.route("**/api/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} }, + error: null, + }), + }), + ); + await page.route("**/api/mood/analytics", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], summary: { count: 0 } }, + error: null, + }), + }), + ); + }); + + for (const path of ["/", "/settings/integrations"]) { + test(`${path} has no serious or critical a11y violations`, async ({ + page, + }) => { + await page.goto(path, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + const blocking = await runAxe(page); + reportBlocking(blocking); + expect(blocking).toHaveLength(0); + }); + } + + // /admin is being restructured by Phase 4b in parallel — the overview + // page currently surfaces critical button-name + select-name + colour- + // contrast violations that originate in `src/components/admin/**`, + // which is OFF-LIMITS for this phase by explicit instruction. The + // assertion is parked as a fixme so the rest of the a11y suite stays + // green and the regression remains visible. Re-enable once Phase 4b + // ships the new admin shell. + test.fixme("/admin has no serious or critical a11y violations", async ({ + page, + }) => { + await page.goto("/admin", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + const blocking = await runAxe(page); + reportBlocking(blocking); + expect(blocking).toHaveLength(0); + }); +}); diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index a9aea596c..23a29bf63 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -15,7 +15,9 @@ async function openPasswordForm(page: import("@playwright/test").Page) { // mounted only after clicking the "Login with password" / "Mit // Passwort anmelden" outline button. await page - .getByRole("button", { name: /login with password|mit passwort/i }) + .getByRole("button", { + name: /sign in with password|login with password|mit passwort/i, + }) .click(); } @@ -56,7 +58,13 @@ test.describe("login page", () => { }), ); - await page.getByRole("button", { name: /login|anmelden|sign in/i }).click(); + // The form has multiple buttons matching /sign in/ — both the + // passkey CTA and the password submit. Filter to the submit button + // so the click lands on the right one. + await page + .locator('button[type="submit"]') + .filter({ hasText: /login|anmelden|sign in/i }) + .click(); await expect( page.getByText(/invalid credentials|ungültig|falsch/i),