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

155 lines
5.3 KiB
TypeScript

/**
* v1.39 — 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");
}
/**
* One call on the browser's own session, sent with the page's own fetch.
*
* Every Playwright API context in the runner shares one keep-alive agent,
* and it can hand out a socket the server already closed on its idle
* timeout; the call then dies with `read ECONNRESET` and CI counts the retry
* as a failure. The page opens its own connection. A page that has not
* navigated yet has no origin to fetch against, so it first opens the public
* version endpoint: the specs call these helpers before their first real
* navigation, and routing that case back through the API context is what
* kept the flake alive.
*/
async function sessionCall(
page: Page,
method: "GET" | "POST" | "PATCH",
path: string,
body?: unknown,
): Promise<{ status: number; json: unknown }> {
if (!page.url().startsWith("http")) {
await page.goto("/api/version", { waitUntil: "domcontentloaded" });
}
return page.evaluate(
async ({ method, path, body }) => {
const res = await fetch(path, {
method,
headers:
body === undefined
? undefined
: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, json: await res.json().catch(() => null) };
},
{ method, path, body },
);
}
/** The account payload, as the browser's session sees it. */
export async function readMe(page: Page): Promise<MePayload> {
const res = await sessionCall(page, "GET", "/api/auth/me");
expect(res.status, "reading the account payload").toBe(200);
return (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 sessionCall(page, "GET", "/api/dashboard/widgets");
expect(res.status, "reading the dashboard layout").toBe(200);
const { data } = 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 sessionCall(page, "PATCH", "/api/onboarding/answers", body);
expect(res.status, `answering ${JSON.stringify(body)}`).toBe(200);
}
export async function complete(page: Page): Promise<void> {
const res = await sessionCall(page, "POST", "/api/onboarding/complete", {});
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" });
}