Files
HealthLog/e2e/setup-flow-helpers.ts
T
Marc-André Bombeck 99ddf0db5d test(e2e): give an unnavigated page an origin before a session call
The setup flow specs answer questions through these helpers before their
first real navigation. That case still went through page.request, which
shares the runner's keep-alive agent, and a stale socket failed an answer
with read ECONNRESET on the dependency update branches. The page now opens
the public version endpoint first and sends the call with its own fetch.
2026-09-13 20:58:04 +02:00

155 lines
5.3 KiB
TypeScript

/**
* 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");
}
/**
* 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" });
}