mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
232 lines
7.8 KiB
TypeScript
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");
|
|
});
|
|
});
|