mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Add the setup flow's browser journeys, axe sweep and string-headroom check
Four journeys, one account each, from the welcome screen to the dashboard: blood pressure and weight typed in, a daily medication added inline, a visit within a month with labs, and a child's managed profile created on the confirm screen. Each asserts what the walk wrote — the module map, the tile order, the checklist, the settled ledger — and the first proves that leaving after Q3 resumes at Q4. Two sweeps on accounts of their own drive the state through the answers route and visit every screen by URL: axe at both widths plus one keyboard-only answer, and every locale with every text node lengthened by 30% before the page, the shell, every clipping box and every control are measured. All six run in one project because each mutates the account it walks. The medication wizard learns to stay where it was mounted after a create, which the first-result screen needs and the medications list does not.
This commit is contained in:
1 parent
02ca15ad6e
commit
2686da2803
12 files changed
+1287
-2
No files matched your search
@@ -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<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",
|
||||
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<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")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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("/");
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await page.locator('[data-slot="onboarding-next"]').click();
|
||||
}
|
||||
|
||||
export async function skip(page: Page): Promise<void> {
|
||||
await page.locator('[data-slot="onboarding-skip"]').click();
|
||||
}
|
||||
|
||||
/** The welcome screen's acknowledgment plus "Set up". */
|
||||
export async function acceptAndSetUp(page: Page): Promise<void> {
|
||||
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<MePayload> {
|
||||
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<string, boolean>;
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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" });
|
||||
}
|
||||
@@ -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<HTMLElement>(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<HTMLElement>("*")]
|
||||
.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<HTMLElement>(
|
||||
"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([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
return hash(password, {
|
||||
memoryCost: 19456,
|
||||
@@ -1038,6 +1135,45 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// 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<void> {
|
||||
`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();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@ function AddMedicationTask({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
mode="create"
|
||||
navigateOnCreate={false}
|
||||
onSuccess={(id) => {
|
||||
setOpen(false);
|
||||
void onComplete(id);
|
||||
|
||||
Reference in new issue
Block a user