mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
test(e2e): extend axe-core to authenticated surfaces; fix login regex
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
a106547e20
commit
ca3c599e7c
2 files changed
+118
-28
No files matched your search
+108
-26
@@ -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<ReturnType<AxeBuilder["analyze"]>>["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);
|
||||
});
|
||||
});
|
||||
+10
-2
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user