Files
HealthLog/e2e/setup-flow-a11y.spec.ts
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

232 lines
7.8 KiB
TypeScript

/**
* v1.39 — axe over every setup screen, at both viewports, plus one
* keyboard-only pass through a question.
*
* The pattern is `a11y.spec.ts`: a scan waits for something the SCREEN
* renders once its state is there — the question's chips, the confirm
* screen's module card, the first-result task — never for the heading the
* scan then checks. The state is driven through the answers route on the
* page's own session, screen by screen, so every URL is one the step machine
* lets through. Both widths run inside the desktop project, because the
* account is mutated and a second project would drive it from a second
* browser.
*/
import AxeBuilder from "@axe-core/playwright";
import type { Locator, Page } from "@playwright/test";
import {
E2E_SETUP_A11Y,
SETUP_A11Y_STORAGE_STATE_PATH,
} from "./setup/global-setup";
import { resetSetupFlow } from "./setup/setup-flow-fixture";
import { expect, test } from "./setup/test";
import {
answer,
answerEverything,
complete,
expectScreen,
type SetupScreen,
} from "./setup-flow-helpers";
type AxeViolation = Awaited<
ReturnType<AxeBuilder["analyze"]>
>["violations"][number];
const WCAG_TAGS: Record<string, true> = {
wcag2a: true,
wcag2aa: true,
wcag21a: true,
wcag21aa: true,
};
const SEMANTIC_RULES: Record<string, true> = {
"heading-order": true,
"page-has-heading-one": true,
"landmark-one-main": true,
"landmark-unique": true,
region: true,
};
const VIEWPORTS = [
{ label: "desktop 1280", width: 1280, height: 720 },
{ label: "phone 390", width: 390, height: 844 },
];
/** Where to go, and what has to be on screen before axe may look. */
interface ScreenCase {
screen: SetupScreen;
path: string;
painted: (page: Page) => Locator;
/** The state the screen needs, written through the API before the visit. */
prepare?: (page: Page) => Promise<void>;
}
const SCREENS: ScreenCase[] = [
{
screen: "welcome",
path: "/onboarding",
painted: (page) => page.locator('[data-slot="onboarding-set-up"]'),
},
{
screen: "who",
path: "/onboarding/who",
painted: (page) => page.locator('[data-slot="onboarding-question-who"]'),
},
{
screen: "areas",
path: "/onboarding/areas",
painted: (page) => page.locator('[data-slot="onboarding-question-areas"]'),
prepare: (page) => answer(page, { step: "who", recordTarget: "me" }),
},
{
screen: "medication",
path: "/onboarding/medication",
painted: (page) =>
page.locator('[data-slot="onboarding-question-medication"]'),
prepare: (page) =>
answer(page, { step: "areas", areas: ["glucose", "weight-body"] }),
},
{
screen: "sources",
path: "/onboarding/sources",
painted: (page) =>
page.locator('[data-slot="onboarding-question-sources"]'),
prepare: (page) => answer(page, { step: "medication", medication: "yes" }),
},
{
screen: "visit",
path: "/onboarding/visit",
painted: (page) => page.locator('[data-slot="onboarding-question-visit"]'),
prepare: (page) => answer(page, { step: "sources", sources: ["oura"] }),
},
{
screen: "units",
path: "/onboarding/units",
painted: (page) =>
page.locator('[data-slot="onboarding-question-glucose-unit"]'),
prepare: (page) => answer(page, { step: "visit", visit: "within-a-month" }),
},
{
screen: "confirm",
path: "/onboarding/confirm",
painted: (page) => page.locator('[data-slot="onboarding-confirm-modules"]'),
prepare: (page) =>
answer(page, {
step: "units",
units: { glucoseUnit: "mmol/L", unitPreference: "metric" },
}),
},
{
screen: "first-result",
path: "/onboarding/first-result",
// v1.38.19 — wait for the tile the screen SETTLED on, not for
// the connect card. This sweep used to name the connect card, which is
// painted for the few hundred milliseconds before the status envelope
// resolves whatever the account's real state is — so it scanned a frame
// the person may never see, and passed straight through the defect that
// frame was hiding. `data-connect-slot` appears only once the screen has
// decided, and it is on every one of the four tiles.
painted: (page) => page.locator("[data-connect-slot]"),
prepare: (page) => complete(page),
},
{
screen: "done",
path: "/onboarding/done",
painted: (page) => page.locator('[data-slot="onboarding-open-dashboard"]'),
prepare: (page) =>
answer(page, { step: "first-result", status: "skipped" }),
},
];
function reportBlocking(label: string, blocking: AxeViolation[]) {
if (blocking.length === 0) return;
console.log(
`axe violations for ${label}:\n${blocking
.map(
(violation) =>
` - [${violation.impact}] ${violation.id}: ${violation.help}\n` +
violation.nodes
.map((node) => ` ${node.target.join(" ")}: ${node.html}`)
.join("\n"),
)
.join("\n")}`,
);
}
async function runAxe(page: Page): Promise<AxeViolation[]> {
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "best-practice"])
.analyze();
return results.violations.filter((violation) => {
if (SEMANTIC_RULES[violation.id]) {
return (
violation.impact === "moderate" ||
violation.impact === "serious" ||
violation.impact === "critical"
);
}
const isWcagRule = violation.tags.some((tag) => WCAG_TAGS[tag]);
return (
isWcagRule &&
(violation.impact === "serious" || violation.impact === "critical")
);
});
}
// Serial: every test here drives the ONE account through the API, and two
// workers doing that at once would each find the other's answers.
test.describe.configure({ mode: "serial" });
test.describe("setup flow — axe on every screen", () => {
test.use({ storageState: SETUP_A11Y_STORAGE_STATE_PATH });
for (const vp of VIEWPORTS) {
test(`every screen is clean at ${vp.label}`, async ({ page }) => {
test.setTimeout(180_000);
await resetSetupFlow(E2E_SETUP_A11Y.username);
await page.setViewportSize({ width: vp.width, height: vp.height });
for (const item of SCREENS) {
await item.prepare?.(page);
await page.goto(item.path, { waitUntil: "domcontentloaded" });
await expectScreen(page, item.screen);
await expect(item.painted(page)).toBeVisible({ timeout: 15_000 });
// Focus lands on the heading on every screen.
await expect(page.locator("h1")).toBeFocused();
const blocking = await runAxe(page);
reportBlocking(`${item.screen} @${vp.label}`, blocking);
expect(blocking, `${item.screen} @${vp.label}`).toEqual([]);
}
});
}
test("a question can be answered with the keyboard alone", async ({
page,
}) => {
await resetSetupFlow(E2E_SETUP_A11Y.username);
await answerEverything(page);
await page.goto("/onboarding/who", { waitUntil: "domcontentloaded" });
await expectScreen(page, "who");
// Focus is on the heading; Tab reaches the first chip's radio, Space
// picks it, and the keyboard walks to "Next".
await page.keyboard.press("Tab");
const chosen = page.locator(
'[data-slot="onboarding-question-who"] input[type="radio"]:focus',
);
await expect(chosen).toHaveCount(1);
await page.keyboard.press("ArrowDown");
await expect(
page.locator('[data-slot="onboarding-choice"][data-checked="true"]'),
).toHaveAttribute("data-value", "someone-else");
// Tab past the chip group to the action row, then activate "Next".
for (let i = 0; i < 4; i += 1) {
if (await page.locator('[data-slot="onboarding-next"]:focus').count()) {
break;
}
await page.keyboard.press("Tab");
}
await expect(page.locator('[data-slot="onboarding-next"]')).toBeFocused();
await page.keyboard.press("Enter");
await expectScreen(page, "areas");
});
});