mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Thirty-one files carried internal build vocabulary and anchors pointing at notes that are not in the repository: a reader of `checklist.ts` in a year would find its rationale keyed to a research id they cannot open, inside a comment that also names an internal build stage. Two test titles carried the same anchors. The version tags and the whole of the reasoning stay; only the vocabulary and the dangling references go, with the few sentences that were built around an anchor rewritten to stand on their own.
232 lines
7.8 KiB
TypeScript
232 lines
7.8 KiB
TypeScript
/**
|
|
* v1.39 (C2) — 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");
|
|
});
|
|
});
|