diff --git a/e2e/setup-flow-a11y.spec.ts b/e2e/setup-flow-a11y.spec.ts new file mode 100644 index 000000000..b1a1a7fc6 --- /dev/null +++ b/e2e/setup-flow-a11y.spec.ts @@ -0,0 +1,220 @@ +/** + * 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 +>["violations"][number]; + +const WCAG_TAGS: Record = { + wcag2a: true, + wcag2aa: true, + wcag21a: true, + wcag21aa: true, +}; +const SEMANTIC_RULES: Record = { + "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; +} + +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", + painted: (page) => page.locator('[data-slot="onboarding-task-connect"]'), + 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 { + 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") + ); + }); +} + +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"); + }); +}); diff --git a/e2e/setup-flow-bp.spec.ts b/e2e/setup-flow-bp.spec.ts new file mode 100644 index 000000000..939449727 --- /dev/null +++ b/e2e/setup-flow-bp.spec.ts @@ -0,0 +1,152 @@ +/** + * v1.39 (C2) — the setup flow, path 1: blood pressure and weight, typed in. + * + * From the welcome screen to the dashboard on one account of its own: the + * questions, leaving after Q3 and coming back to Q4, the units question that + * only appears because weight was ticked, the confirm screen, one blood + * pressure reading entered inline as the first result, and the dashboard + * with the checklist open. Then the four things the design spec's definition + * of done names, read off the API rather than the viewport: the module map, + * the tile order, the checklist, and the resume. + * + * Runs in one project (see `playwright.config.ts`): it resets and mutates its + * own account. + */ +import { + E2E_SETUP_BP, + SETUP_BP_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + acceptAndSetUp, + choose, + expectScreen, + next, + readMe, + readTileOrder, +} from "./setup-flow-helpers"; + +test.describe("setup flow — blood pressure and weight, typed in", () => { + test.use({ storageState: SETUP_BP_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_BP.username); + }); + + test("walks from the welcome screen to a first reading on the dashboard", async ({ + page, + }) => { + test.setTimeout(90_000); + + await page.goto("/onboarding"); + await acceptAndSetUp(page); + + await choose(page, "who", ["me"]); + await next(page); + await expectScreen(page, "areas"); + + await choose(page, "areas", ["blood-pressure", "weight-body"]); + await next(page); + await expectScreen(page, "medication"); + + await choose(page, "medication", ["no"]); + await next(page); + await expectScreen(page, "sources"); + + // Leave after Q3 and come back: the first-run redirect lands on + // /onboarding, and the front door sends the flow to the step it owes. + await page.goto("/"); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe("/onboarding/sources"); + await expectScreen(page, "sources"); + + await choose(page, "sources", ["manual"]); + await next(page); + await expectScreen(page, "visit"); + + await choose(page, "visit", ["no"]); + await next(page); + + // Q6 appears because weight was ticked and the account holds no unit. + await expectScreen(page, "units"); + await expect( + page.locator('[data-slot="onboarding-question-unit-preference"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="onboarding-question-glucose-unit"]'), + ).toHaveCount(0); + await choose(page, "unit-preference", ["metric"]); + await next(page); + + await expectScreen(page, "confirm"); + await expect( + page.locator('[data-slot="onboarding-confirm-modules"]'), + ).toBeVisible(); + await next(page); + + // The first result: one blood-pressure reading, entered inline. + await expectScreen(page, "first-result"); + const screen = page.locator("section[data-task]"); + await expect(screen).toHaveAttribute("data-task", "log-reading"); + await expect(screen).toHaveAttribute("data-target", "blood-pressure"); + await page.locator("#sys").fill("124"); + await page.locator("#dia").fill("78"); + await page.locator("#puls").fill("66"); + await page.getByRole("button", { name: /^save$/i }).click(); + await expect( + page.locator('[data-slot="onboarding-first-result-done"]'), + ).toBeVisible({ timeout: 15_000 }); + await next(page); + + await expectScreen(page, "done"); + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + + // The dashboard, with the checklist open. + const checklist = page.locator('[data-testid="onboarding-card"]'); + await expect(checklist).toBeVisible({ timeout: 15_000 }); + await expect(checklist.locator("#getting-started-body")).toBeVisible(); + + // The module map: nothing the answers did not name is on; the always-on + // trio is. + const me = await readMe(page); + for (const key of [ + "glucose", + "sleep", + "recovery", + "mood", + "mentalHealth", + "labs", + "illness", + "workouts", + "medications", + "doctorReport", + "vaccinations", + ]) { + expect(me.modules[key], `module ${key}`).toBe(false); + } + for (const key of ["insights", "achievements", "inboundDocuments"]) { + expect(me.modules[key], `module ${key}`).not.toBe(false); + } + expect(me.onboarding.completedAt).not.toBeNull(); + expect(me.onboarding.firstResult?.task).toBe("log-reading"); + expect(me.onboarding.firstResult?.completedAt).not.toBeNull(); + expect( + me.onboarding.steps.every((step) => step.status !== "pending"), + "every step settled", + ).toBe(true); + + // The tile order: the two areas' tiles first, in the default layout's + // own order, and the rest behind them. + const order = await readTileOrder(page); + expect(order.slice(0, 5)).toEqual([ + "weight", + "bp", + "pulse", + "bodyFat", + "bpInTarget", + ]); + }); +}); diff --git a/e2e/setup-flow-child.spec.ts b/e2e/setup-flow-child.spec.ts new file mode 100644 index 000000000..e573d0b4a --- /dev/null +++ b/e2e/setup-flow-child.spec.ts @@ -0,0 +1,101 @@ +/** + * v1.39 (C2) — the setup flow, path 4: a child's managed profile. + * + * Q1 "someone I look after" keeps the questions in the guardian's own record + * and creates the profile on the confirm screen, after the questions — the + * maintainer's answer to spec question 2, and the only order the routes + * allow, since the three writes refuse under an acting-account switch. The + * immunization log is on for that answer, the first-result screen is not in + * the order (its tasks would write the wrong record), and the done screen + * offers the new record through the same switch the account menu uses. + * + * Creating the profile resolves `requireFreshMfa`, so the account carries a + * confirmed factor (stamped by global setup after its login) and the fixture + * makes the session's step-up fresh right before the walk. + */ +import { + E2E_SETUP_CHILD, + SETUP_CHILD_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { + clearManagedProfilesOf, + resetSetupFlow, + stampFreshMfa, +} from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + acceptAndSetUp, + choose, + expectScreen, + next, + readMe, +} from "./setup-flow-helpers"; + +const PROFILE_NAME = "Setup journey child"; + +test.describe("setup flow — a child's managed profile", () => { + test.use({ storageState: SETUP_CHILD_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await clearManagedProfilesOf(E2E_SETUP_CHILD.username); + await resetSetupFlow(E2E_SETUP_CHILD.username); + await stampFreshMfa(E2E_SETUP_CHILD.username); + }); + + test("creates the profile on the confirm screen and offers its record", async ({ + page, + }) => { + test.setTimeout(90_000); + + await page.goto("/onboarding"); + await acceptAndSetUp(page); + + await choose(page, "who", ["someone-else"]); + await next(page); + await expectScreen(page, "areas"); + await choose(page, "areas", ["blood-pressure"]); + await next(page); + await expectScreen(page, "medication"); + await choose(page, "medication", ["no"]); + await next(page); + await expectScreen(page, "sources"); + await choose(page, "sources", ["manual"]); + await next(page); + await expectScreen(page, "visit"); + await choose(page, "visit", ["no"]); + await next(page); + + // The confirm screen is the managed-profile form for this answer. + await expectScreen(page, "confirm"); + const managed = page.locator('[data-slot="onboarding-confirm-managed"]'); + await expect(managed).toBeVisible(); + await managed + .locator('[data-slot="managed-profile-name"]') + .fill(PROFILE_NAME); + await managed + .locator('[data-slot="managed-profile-create-submit"]') + .click(); + + // No first-result screen for this answer: straight to done, with the new + // record offered. + await expectScreen(page, "done"); + await expect( + page.locator('[data-slot="onboarding-open-managed-record"]'), + ).toBeVisible({ timeout: 15_000 }); + + const me = await readMe(page); + expect(me.modules.vaccinations).not.toBe(false); + expect(me.modules.glucose).toBe(false); + expect(me.onboarding.completedAt).not.toBeNull(); + expect( + me.onboarding.steps.find((step) => step.id === "first-result")?.status, + ).toBe("skipped"); + const managedEntries = (me.accountAccess?.accounts ?? []).filter( + (entry) => entry.recordKind === "managed", + ); + expect(managedEntries).toHaveLength(1); + + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + }); +}); diff --git a/e2e/setup-flow-helpers.ts b/e2e/setup-flow-helpers.ts new file mode 100644 index 000000000..53973ed4e --- /dev/null +++ b/e2e/setup-flow-helpers.ts @@ -0,0 +1,119 @@ +/** + * v1.39 (C2) — driving the setup flow in a browser, shared by its journeys. + * + * Everything here addresses stable `data-slot` / `data-*` attributes, never + * viewport text: the copy is i18n-driven and one of the specs runs every + * locale. The API helpers ride the page's own cookie jar, so what they write + * lands on the account the browser is signed in as. + */ +import { expect, type Page } from "@playwright/test"; + +export type SetupScreen = + | "welcome" + | "who" + | "areas" + | "medication" + | "sources" + | "visit" + | "units" + | "confirm" + | "first-result" + | "done"; + +export const SHELL = '[data-slot="onboarding-shell"]'; + +export async function expectScreen(page: Page, screen: SetupScreen) { + await expect(page.locator(SHELL)).toHaveAttribute("data-screen", screen, { + timeout: 15_000, + }); +} + +/** Tick one or more chips of the question on screen. */ +export async function choose( + page: Page, + question: string, + values: readonly string[], +): Promise { + for (const value of values) { + await page + .locator( + `[data-slot="onboarding-question-${question}"] [data-slot="onboarding-choice"][data-value="${value}"]`, + ) + .click(); + } +} + +export async function next(page: Page): Promise { + await page.locator('[data-slot="onboarding-next"]').click(); +} + +export async function skip(page: Page): Promise { + await page.locator('[data-slot="onboarding-skip"]').click(); +} + +/** The welcome screen's acknowledgment plus "Set up". */ +export async function acceptAndSetUp(page: Page): Promise { + await expectScreen(page, "welcome"); + const box = page.locator('[data-slot="onboarding-disclaimer"]'); + if ((await box.getAttribute("data-state")) !== "checked") { + await box.click(); + } + await page.locator('[data-slot="onboarding-set-up"]').click(); + await expectScreen(page, "who"); +} + +/** The account payload, as the browser's session sees it. */ +export async function readMe(page: Page): Promise { + const res = await page.request.get("/api/auth/me"); + expect(res.status(), "reading the account payload").toBe(200); + return ((await res.json()) as { data: MePayload }).data; +} + +export interface MePayload { + modules: Record; + onboarding: { + steps: Array<{ id: string; status: string }>; + needs: { recordTarget: string | null; areas: string[] }; + completedAt: string | null; + firstResult: { task: string; completedAt: string | null } | null; + }; + accountAccess?: { + accounts: Array<{ accountId: string; recordKind: string }>; + }; +} + +/** The dashboard layout's tile ids, in order. */ +export async function readTileOrder(page: Page): Promise { + const res = await page.request.get("/api/dashboard/widgets"); + expect(res.status(), "reading the dashboard layout").toBe(200); + const { data } = (await res.json()) as { + data: { widgets: Array<{ id: string; order: number }> }; + }; + return [...data.widgets].sort((a, b) => a.order - b.order).map((w) => w.id); +} + +/** One answer through the answers route, on the browser's own session. */ +export async function answer(page: Page, body: unknown): Promise { + const res = await page.request.patch("/api/onboarding/answers", { + data: body, + }); + expect(res.status(), `answering ${JSON.stringify(body)}`).toBe(200); +} + +export async function complete(page: Page): Promise { + const res = await page.request.post("/api/onboarding/complete", { data: {} }); + expect(res.status(), "completing the flow").toBe(200); +} + +/** + * Answer every question through the API so a later screen can be visited + * directly. The answers name a glucose area (so Q6 applies) and a wearable + * (so the first-result task is a connection the screen can wait for). + */ +export async function answerEverything(page: Page): Promise { + await answer(page, { step: "who", recordTarget: "me" }); + await answer(page, { step: "areas", areas: ["glucose", "weight-body"] }); + await answer(page, { step: "medication", medication: "yes" }); + await answer(page, { step: "sources", sources: ["oura"] }); + await answer(page, { step: "visit", visit: "within-a-month" }); +} diff --git a/e2e/setup-flow-locale-overflow.spec.ts b/e2e/setup-flow-locale-overflow.spec.ts new file mode 100644 index 000000000..44c20e72a --- /dev/null +++ b/e2e/setup-flow-locale-overflow.spec.ts @@ -0,0 +1,186 @@ +/** + * v1.39 (C2) — every setup screen, in every locale, with 30% longer strings. + * + * The design spec asks for 30–50% string headroom in every layout, and the + * seven bundles are the floor, not the ceiling: a German label that fits + * today is one revision from not fitting. So this spec does what + * `locale-switch.spec.ts` does (set the locale cookie, render, refuse raw + * keys) and then what `dialog-horizontal-overflow.spec.ts` does (measure + * real overflow in the page), with one step between them: every text node + * on the screen is lengthened by 30% in place before the measurement, so + * the check is against the headroom rather than against today's copy. + * + * Three claims per screen, locale and width: the page does not scroll + * sideways, nothing that clips its text (`overflow: hidden`) is actually + * clipping, and every control sits inside the viewport. The state is driven + * through the API once per test and every screen is visited by URL, which + * the step machine allows for a flow that reached the end. + */ +import type { Page } from "@playwright/test"; + +import { + E2E_SETUP_LOCALE, + SETUP_LOCALE_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, + SHELL, + type SetupScreen, +} from "./setup-flow-helpers"; + +const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const; + +const VIEWPORTS = [ + { label: "desktop 1280", width: 1280, height: 800 }, + { label: "phone 390", width: 390, height: 844 }, +]; + +const SCREENS: Array<{ screen: SetupScreen; path: string }> = [ + { screen: "who", path: "/onboarding/who" }, + { screen: "areas", path: "/onboarding/areas" }, + { screen: "medication", path: "/onboarding/medication" }, + { screen: "sources", path: "/onboarding/sources" }, + { screen: "visit", path: "/onboarding/visit" }, + { screen: "units", path: "/onboarding/units" }, + { screen: "confirm", path: "/onboarding/confirm" }, + { screen: "first-result", path: "/onboarding/first-result" }, + { screen: "done", path: "/onboarding/done" }, +]; + +/** i18n keys look like `section.subKey`; a rendered one is a lookup that fell through. */ +const RAW_KEY = /\b[a-z]+(?:[A-Z][a-z]+)?\.[a-z][A-Za-z0-9_.-]+\b/; + +/** + * Lengthen every text node under the shell by 30%, then measure. Padding + * appends letters to the LAST word so the longest unbreakable run grows, + * which is the case that overflows; a padding that only added spaces would + * wrap for free and prove nothing. + */ +async function padAndMeasure(page: Page) { + return page.evaluate((shellSelector) => { + const shell = document.querySelector(shellSelector); + if (!shell) return null; + + const walker = document.createTreeWalker(shell, NodeFilter.SHOW_TEXT); + let node: Node | null = walker.nextNode(); + let padded = 0; + while (node) { + const text = node.textContent ?? ""; + const letters = text.replace(/\s+/g, "").length; + if ( + letters >= 3 && + node.parentElement?.closest("script,style") === null + ) { + const extra = Math.ceil(letters * 0.3); + node.textContent = text.replace(/\s*$/, "") + "x".repeat(extra); + padded += 1; + } + node = walker.nextNode(); + } + + const doc = document.documentElement; + const clippers = [...shell.querySelectorAll("*")] + .filter((el) => { + const overflowX = getComputedStyle(el).overflowX; + return overflowX === "hidden" || overflowX === "clip"; + }) + .filter((el) => el.scrollWidth > el.clientWidth + 1) + .map((el) => ({ + slot: el.dataset.slot ?? el.tagName.toLowerCase(), + text: (el.textContent ?? "").trim().slice(0, 40), + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + })); + const outside = [ + ...shell.querySelectorAll( + "button, input, select, textarea, a[href], [tabindex]:not([tabindex='-1'])", + ), + ] + .map((el) => ({ el, r: el.getBoundingClientRect() })) + .filter(({ r }) => r.width > 0 && r.height > 0) + .filter(({ r }) => r.right > window.innerWidth + 1 || r.left < -1) + .map(({ el, r }) => ({ + slot: el.dataset.slot ?? el.tagName.toLowerCase(), + left: Math.round(r.left), + right: Math.round(r.right), + })); + + return { + padded, + pageScrollWidth: doc.scrollWidth, + pageClientWidth: doc.clientWidth, + shellScrollWidth: shell.scrollWidth, + shellClientWidth: shell.clientWidth, + clippers, + outside, + }; + }, SHELL); +} + +test.describe("setup flow — every locale at 30% longer strings", () => { + test.use({ storageState: SETUP_LOCALE_STORAGE_STATE_PATH }); + + for (const locale of LOCALES) { + for (const vp of VIEWPORTS) { + test(`${locale} fits at ${vp.label}`, async ({ page, context }) => { + test.setTimeout(180_000); + await resetSetupFlow(E2E_SETUP_LOCALE.username); + await answerEverything(page); + await answer(page, { + step: "units", + units: { glucoseUnit: "mg/dL", unitPreference: "metric" }, + }); + await complete(page); + await answer(page, { step: "first-result", status: "skipped" }); + + const baseURL = + test.info().project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: locale, url: baseURL }, + ]); + await page.setViewportSize({ width: vp.width, height: vp.height }); + + for (const item of SCREENS) { + await page.goto(item.path, { waitUntil: "domcontentloaded" }); + await expectScreen(page, item.screen); + await expect(page.locator("h1")).toBeVisible(); + + // No raw key before the padding touches the text. + const body = await page.locator(SHELL).innerText(); + const match = body.match(RAW_KEY); + if (match && /\.[a-z][A-Z]/.test(match[0])) { + throw new Error( + `raw i18n key on ${item.screen} in ${locale}: ${match[0]}`, + ); + } + + const result = await padAndMeasure(page); + const label = `${item.screen} ${locale} @${vp.label}`; + expect(result, `${label}: shell not found`).not.toBeNull(); + expect( + result!.padded, + `${label}: nothing was padded, so this measured nothing`, + ).toBeGreaterThan(3); + expect( + result!.pageScrollWidth, + `${label}: the page scrolls sideways`, + ).toBeLessThanOrEqual(result!.pageClientWidth + 1); + expect( + result!.shellScrollWidth, + `${label}: the shell scrolls sideways`, + ).toBeLessThanOrEqual(result!.shellClientWidth + 1); + expect(result!.clippers, `${label}: text is clipped`).toEqual([]); + expect( + result!.outside, + `${label}: control(s) outside the viewport`, + ).toEqual([]); + } + }); + } + } +}); diff --git a/e2e/setup-flow-medication.spec.ts b/e2e/setup-flow-medication.spec.ts new file mode 100644 index 000000000..9f2972597 --- /dev/null +++ b/e2e/setup-flow-medication.spec.ts @@ -0,0 +1,124 @@ +/** + * v1.39 (C2) — the setup flow, path 2: a daily medication with a reminder. + * + * Q3 "yes" is the one answer that makes the first medication the task the + * flow ends on, ahead of a reading; every other question is passed, which is + * the spec's own claim that only Q1 is required. The medication wizard runs + * inline on the first-result screen, and the result stays on that screen + * rather than landing on the medication's page. + */ +import { + E2E_SETUP_MEDICATION, + SETUP_MEDICATION_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + clickNext, + clickSave, + expectStep, + fillStep1Name, + fillStep3Dose, + pickCadenceRow, + pickTreatmentRow, +} from "./medications-wizard-helpers"; +import { + acceptAndSetUp, + choose, + expectScreen, + next, + readMe, + readTileOrder, + skip, +} from "./setup-flow-helpers"; + +test.describe("setup flow — a daily medication with a reminder", () => { + test.use({ storageState: SETUP_MEDICATION_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_MEDICATION.username); + }); + + test("ends on the first medication, added inline", async ({ page }) => { + test.setTimeout(120_000); + + await page.goto("/onboarding"); + await acceptAndSetUp(page); + + await choose(page, "who", ["me"]); + await next(page); + await expectScreen(page, "areas"); + await skip(page); + + await expectScreen(page, "medication"); + await choose(page, "medication", ["yes"]); + await next(page); + + await expectScreen(page, "sources"); + await skip(page); + await expectScreen(page, "visit"); + await skip(page); + + // No unit-bearing area was ticked, so Q6 is not in this flow. + await expectScreen(page, "confirm"); + await next(page); + + await expectScreen(page, "first-result"); + await expect(page.locator("section[data-task]")).toHaveAttribute( + "data-task", + "add-medication", + ); + await page.locator('[data-slot="onboarding-add-medication"]').click(); + await expect( + page.locator('[data-slot="medication-wizard-dialog"]'), + ).toBeVisible({ timeout: 10_000 }); + + await fillStep1Name(page, { name: "Setup journey tablet" }); + await clickNext(page); + await expectStep(page, 2); + await pickTreatmentRow(page, "other"); + await clickNext(page); + await expectStep(page, 3); + await fillStep3Dose(page, { amount: "500" }); + await clickNext(page); + await expectStep(page, 4); + await clickNext(page); + await expectStep(page, 5, 8); + await pickCadenceRow(page, "daily"); + await expectStep(page, 5, 7); + await clickNext(page); + // Times of day arrive with 08:00 already on the plan — one dose a day. + await expectStep(page, 6, 7); + await clickNext(page); + await expectStep(page, 7, 7); + await clickSave(page); + + // The result stays here: the wizard does not navigate away. + await expect( + page.locator('[data-slot="onboarding-first-result-done"]'), + ).toBeVisible({ timeout: 20_000 }); + expect(new URL(page.url()).pathname).toBe("/onboarding/first-result"); + await next(page); + + await expectScreen(page, "done"); + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + await expect(page.locator('[data-testid="onboarding-card"]')).toBeVisible({ + timeout: 15_000, + }); + + const me = await readMe(page); + expect(me.modules.medications).not.toBe(false); + expect(me.modules.glucose).toBe(false); + expect(me.onboarding.firstResult?.task).toBe("add-medication"); + expect(me.onboarding.firstResult?.completedAt).not.toBeNull(); + + // The medication tile leads the dashboard. + const order = await readTileOrder(page); + expect(order[0]).toBe("medications"); + + const meds = await page.request.get("/api/medications"); + const { data } = (await meds.json()) as { data: Array<{ name: string }> }; + expect(data.map((m) => m.name)).toContain("Setup journey tablet"); + }); +}); diff --git a/e2e/setup-flow-visit.spec.ts b/e2e/setup-flow-visit.spec.ts new file mode 100644 index 000000000..5f7bfd710 --- /dev/null +++ b/e2e/setup-flow-visit.spec.ts @@ -0,0 +1,87 @@ +/** + * v1.39 (C2) — the setup flow, path 3: a doctor's visit within a month, with + * lab results. + * + * The visit answer is what switches the doctor report on and puts "prepare + * the visit" on the checklist; the labs area has no inline form, so the + * first-result screen points at the labs page and takes the person's word + * that the first entry was made. + */ +import { + E2E_SETUP_VISIT, + SETUP_VISIT_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import { expect, test } from "./setup/test"; +import { + acceptAndSetUp, + choose, + expectScreen, + next, + readMe, +} from "./setup-flow-helpers"; + +test.describe("setup flow — a visit within a month, with labs", () => { + test.use({ storageState: SETUP_VISIT_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_VISIT.username); + }); + + test("switches the report on and puts the visit on the checklist", async ({ + page, + }) => { + test.setTimeout(90_000); + + await page.goto("/onboarding"); + await acceptAndSetUp(page); + + await choose(page, "who", ["me"]); + await next(page); + await expectScreen(page, "areas"); + await choose(page, "areas", ["labs"]); + await next(page); + await expectScreen(page, "medication"); + await choose(page, "medication", ["no"]); + await next(page); + await expectScreen(page, "sources"); + await choose(page, "sources", ["file"]); + await next(page); + await expectScreen(page, "visit"); + await choose(page, "visit", ["within-a-month"]); + await next(page); + + await expectScreen(page, "confirm"); + await next(page); + + await expectScreen(page, "first-result"); + const screen = page.locator("section[data-task]"); + await expect(screen).toHaveAttribute("data-task", "log-reading"); + await expect(screen).toHaveAttribute("data-target", "labs"); + await expect( + page.locator('[data-slot="onboarding-task-reading-link"]'), + ).toBeVisible(); + await page.locator('[data-slot="onboarding-reading-done"]').click(); + await expect( + page.locator('[data-slot="onboarding-first-result-done"]'), + ).toBeVisible(); + await next(page); + + await expectScreen(page, "done"); + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + + // The checklist opens with the visit row on it. + const checklist = page.locator('[data-testid="onboarding-card"]'); + await expect(checklist).toBeVisible({ timeout: 15_000 }); + await expect(checklist.locator("#getting-started-body")).toBeVisible(); + await expect(checklist.locator('a[href="/checkups"]')).toBeVisible(); + + const me = await readMe(page); + expect(me.modules.labs).not.toBe(false); + expect(me.modules.doctorReport).not.toBe(false); + expect(me.modules.medications).toBe(false); + expect(me.onboarding.firstResult?.task).toBe("log-reading"); + expect(me.onboarding.firstResult?.completedAt).not.toBeNull(); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 8823334ec..13bfccc33 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -424,6 +424,103 @@ export const SEAM_BANNERS_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateSeamBanners.json", ); +/** + * v1.39 (C2) — the setup flow's own accounts, one per journey. + * + * Each journey walks the questions from the welcome screen to the dashboard + * and asserts what the walk wrote: the module map, the tile order, the + * checklist, the reading or medication or profile the first-result step + * produced. Two journeys sharing an account would each read the other's + * answers, so every path has its own — and the two screen sweeps (axe, and + * the string-headroom check across every locale) have theirs, because they + * drive the same state through the API while a journey may be mid-walk. + * + * Seeded with `onboarding_completed_at` NULL, which is what makes the shell + * land them on `/onboarding` in the first place; the fixture puts each one + * back to that state before every test. + */ +export const E2E_SETUP_BP = { + email: "e2e-setup-bp@healthlog.test", + username: "e2e-setup-bp", + password: "Bp7!Wq3nZ9xLm2Kd", + role: "USER", +} as const; + +export const E2E_SETUP_MEDICATION = { + email: "e2e-setup-medication@healthlog.test", + username: "e2e-setup-medication", + password: "Md4!Rt8vC2yPn6Hs", + role: "USER", +} as const; + +export const E2E_SETUP_VISIT = { + email: "e2e-setup-visit@healthlog.test", + username: "e2e-setup-visit", + password: "Vs9!Kq2mX7bTd4Lp", + role: "USER", +} as const; + +/** + * The child-profile journey's account. It creates a managed profile on the + * confirm screen, which resolves `requireFreshMfa`, so like the guardian it + * carries a confirmed factor — stamped AFTER its login capture, because the + * password-only capture cannot complete a TOTP login. + */ +export const E2E_SETUP_CHILD = { + email: "e2e-setup-child@healthlog.test", + username: "e2e-setup-child", + password: "Ch3!Ns6wQ8kZr5Vb", + role: "USER", +} as const; + +export const E2E_SETUP_A11Y = { + email: "e2e-setup-a11y@healthlog.test", + username: "e2e-setup-a11y", + password: "Ay5!Ht9pL3cWn7Xq", + role: "USER", +} as const; + +export const E2E_SETUP_LOCALE = { + email: "e2e-setup-locale@healthlog.test", + username: "e2e-setup-locale", + password: "Lc2!Vm7dR4jYs8Nt", + role: "USER", +} as const; + +export const SETUP_BP_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupBp.json", +); +export const SETUP_MEDICATION_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupMedication.json", +); +export const SETUP_VISIT_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupVisit.json", +); +export const SETUP_CHILD_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupChild.json", +); +export const SETUP_A11Y_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupA11y.json", +); +export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupLocale.json", +); + +const SETUP_FLOW_ACCOUNTS = [ + E2E_SETUP_BP, + E2E_SETUP_MEDICATION, + E2E_SETUP_VISIT, + E2E_SETUP_CHILD, + E2E_SETUP_A11Y, + E2E_SETUP_LOCALE, +] as const; + async function hashPassword(password: string): Promise { return hash(password, { memoryCost: 19456, @@ -1038,6 +1135,45 @@ export default async function globalSetup(config: FullConfig): Promise { // can reuse them via `test.use({ storageState })`. This is the documented // Playwright pattern (`docs/auth.md`) and the only way to avoid the // per-spec login + rate-limit dance. + // v1.39 (C2) — the setup flow's accounts. `onboarding_completed_at` NULL + // is the whole point: it is what lands them on `/onboarding`. The tour + // flag stays true for the reason every other fixture sets it. Whatever a + // previous run wrote — the answers row, the module map, the seeded + // dashboard, a managed profile — is cleared by the journeys' own fixture + // before each test; here the row itself is made to exist. + for (const account of SETUP_FLOW_ACCOUNTS) { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, NULL, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = NULL, + onboarding_tour_completed = true, + totp_confirmed_at = NULL`, + [ + cuid(), + account.username, + account.email, + await hashPassword(account.password), + now, + ], + ); + } + await pool.query( + `DELETE FROM users + WHERE managed_profile_at IS NOT NULL + AND id IN ( + SELECT g.grantor_id FROM account_grants g + JOIN users u ON u.id = g.grantee_id + WHERE u.username = $1 + )`, + [E2E_SETUP_CHILD.username], + ); + const baseURL = config.projects[0]?.use.baseURL ?? process.env.E2E_BASE_URL ?? @@ -1117,6 +1253,19 @@ export default async function globalSetup(config: FullConfig): Promise { `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, [E2E_GUARDIAN.username, now], ); + // v1.39 (C2) — the setup flow's jars, one per journey and one per sweep. + // The child journey's factor is stamped after its capture, for the reason + // the guardian's is: the password-only capture cannot pass a TOTP login. + await capture(E2E_SETUP_BP, SETUP_BP_STORAGE_STATE_PATH); + await capture(E2E_SETUP_MEDICATION, SETUP_MEDICATION_STORAGE_STATE_PATH); + await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH); + await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH); + await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH); + await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); + await pool.query( + `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, + [E2E_SETUP_CHILD.username, now], + ); } finally { await pool.end(); } diff --git a/e2e/setup/setup-flow-fixture.ts b/e2e/setup/setup-flow-fixture.ts new file mode 100644 index 000000000..c8b6954de --- /dev/null +++ b/e2e/setup/setup-flow-fixture.ts @@ -0,0 +1,118 @@ +/** + * v1.39 (C2) — the state the setup-flow journeys start from, and nothing else. + * + * Each journey owns one account and walks it from the welcome screen to the + * dashboard. What a walk leaves behind — the answers row, the completion + * stamp, the module map, the seeded dashboard, the reading or medication the + * first-result step produced, a managed profile — is exactly what the next + * run would then find already done, so every journey resets its own account + * before it starts. Seeded through `pg`, the same transport `global-setup.ts` + * and the other fixtures use, because there is no product endpoint that + * un-completes an account. + * + * Idempotent, and scoped to the one account it is given. + */ +import pg from "pg"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("[setup-flow-fixture] DATABASE_URL is not set"); + } + return new pg.Pool({ connectionString: url }); +} + +/** + * Put one account back before its first run: no answers, no completion, no + * module map, no dashboard seed, no rows the first-result step could have + * written, and a clean write bucket for the answers route. + */ +export async function resetSetupFlow(username: string): Promise { + const pool = connect(); + try { + const { rows } = await pool.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [username], + ); + const id = rows[0]?.id; + if (!id) { + throw new Error(`[setup-flow-fixture] no account named ${username}`); + } + await pool.query(`DELETE FROM onboarding_records WHERE user_id = $1`, [id]); + await pool.query( + `UPDATE users + SET onboarding_completed_at = NULL, + module_preferences_json = NULL, + dashboard_widgets_json = NULL, + glucose_unit = NULL, + unit_preference = NULL + WHERE id = $1`, + [id], + ); + await pool.query(`DELETE FROM measurements WHERE user_id = $1`, [id]); + await pool.query(`DELETE FROM medications WHERE user_id = $1`, [id]); + await pool.query(`DELETE FROM encounters WHERE user_id = $1`, [id]); + await pool.query( + `DELETE FROM rate_limits + WHERE key IN ( + 'onboarding-answers:' || $1, + 'onboarding-restart:' || $1, + 'managed-profile:create:' || $1 + )`, + [id], + ); + } finally { + await pool.end(); + } +} + +/** + * Remove every managed profile this account looks after. A managed profile + * is a real account row, so a journey that created one and left it behind + * would find the guardian looking after two records on the next run. + */ +export async function clearManagedProfilesOf(username: string): Promise { + const pool = connect(); + try { + await pool.query( + `DELETE FROM users + WHERE managed_profile_at IS NOT NULL + AND id IN ( + SELECT g.grantor_id FROM account_grants g + JOIN users u ON u.id = g.grantee_id + WHERE u.username = $1 + )`, + [username], + ); + } finally { + await pool.end(); + } +} + +/** + * Make the account's step-up fresh, exactly as `managed-profile-fixture.ts` + * does for the guardian: creating a managed profile resolves + * `requireFreshMfa`, which needs a confirmed factor on the account and a + * session stamp newer than five minutes. Not a bypass — the route still runs + * its gate; this is the state in which the surface is usable at all. + */ +export async function stampFreshMfa(username: string): Promise { + const pool = connect(); + try { + const stamped = await pool.query( + `UPDATE sessions SET mfa_verified_at = NOW() + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [username], + ); + if ((stamped.rowCount ?? 0) === 0) { + throw new Error(`[setup-flow-fixture] no session for ${username}`); + } + await pool.query( + `UPDATE users SET totp_confirmed_at = COALESCE(totp_confirmed_at, NOW()) + WHERE username = $1`, + [username], + ); + } finally { + await pool.end(); + } +} diff --git a/playwright.config.ts b/playwright.config.ts index 1becdc716..a6231feed 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -222,6 +222,17 @@ export default defineConfig({ // through stable attributes and the account's own API, not a mobile // layout, so it runs in one project. "notification-dispatch-journey.spec.ts", + // v1.39 (C2) — the setup-flow journeys each own one account and walk + // it from the welcome screen to the dashboard, resetting it first. A + // second project would walk the same account from a second browser + // and each would find the other's answers. The two sweeps set their + // own viewports inside the desktop project for the same reason. + "setup-flow-bp.spec.ts", + "setup-flow-medication.spec.ts", + "setup-flow-visit.spec.ts", + "setup-flow-child.spec.ts", + "setup-flow-a11y.spec.ts", + "setup-flow-locale-overflow.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs diff --git a/src/components/medications/wizard/medication-wizard-dialog.tsx b/src/components/medications/wizard/medication-wizard-dialog.tsx index c2f4c0b80..3fcc56182 100644 --- a/src/components/medications/wizard/medication-wizard-dialog.tsx +++ b/src/components/medications/wizard/medication-wizard-dialog.tsx @@ -86,6 +86,12 @@ export interface MedicationWizardDialogProps { initial?: MedicationPayload; /** Fires with the medication id on a successful create / save. */ onSuccess?: (id: string) => void; + /** + * v1.39 (C2) — whether a create lands on the new medication's page. The + * medications list wants that; the setup flow's first-result screen mounts + * the wizard inline and shows the result where it stands, so it opts out. + */ + navigateOnCreate?: boolean; } const STEP_ICONS = { @@ -118,6 +124,7 @@ function WizardDialogShell({ mode, initial, onSuccess, + navigateOnCreate = true, }: MedicationWizardDialogProps) { const { t, locale } = useTranslations(); const router = useRouter(); @@ -331,7 +338,7 @@ function WizardDialogShell({ toast.success(t("common.saved")); onSuccess?.(json.data.id); onOpenChange(false); - if (mode === "create") { + if (mode === "create" && navigateOnCreate) { router.push(`/medications/${json.data.id}`); } return; @@ -342,7 +349,17 @@ function WizardDialogShell({ } finally { setSubmitting(false); } - }, [initial, mode, onOpenChange, onSuccess, payload, queryClient, router, t]); + }, [ + initial, + mode, + navigateOnCreate, + onOpenChange, + onSuccess, + payload, + queryClient, + router, + t, + ]); const Icon = STEP_ICONS[step]; diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index 93d7c96e5..f818654a3 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -325,6 +325,7 @@ function AddMedicationTask({ open={open} onOpenChange={setOpen} mode="create" + navigateOnCreate={false} onSuccess={(id) => { setOpen(false); void onComplete(id);