Merge branch 'feat/v146-ui-e2e' into feat/v146-ui

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 14:19:29 +02:00
commit 8a6c6bfdd7
16 files changed
+1610 -295

No files matched your search

+22
View File
@@ -27,6 +27,28 @@ ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock
those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP
(`safeFetch`), APNs, SMTP, the model.
## Critical journeys
A few e2e journeys carry `@critical` in their test titles: signing in with a
password and a passkey and signing out, a reading reaching the dashboard, a
dose moving compliance, a mood entry, a lab CSV import, a questionnaire in
German and Brazilian Portuguese, a setting that persists, a backup coming
back, sharing a record, and the offline page. `pnpm e2e:critical` runs them.
They run against the real server and a seeded Postgres. A spec file that
carries the tag may not intercept anything under `/api`, directly or through
a helper it imports; `src/__tests__/e2e-critical-no-api-stub-guard.test.ts`
refuses it and keeps a floor on the number of tagged tests. Stubbing an
unreachable third-party host that is not the journey's subject is allowed,
with the reason next to the call.
A journey that writes gets an account nobody else writes. The accounts in
`e2e/setup/critical-fixture.ts` are seeded by global setup, one per journey
and per Chromium project, so a journey runs at both widths without the two
projects meeting. Reset what a previous run left with `resetCriticalAccount`
in a `beforeEach`, and read results through the app's own routes rather than
through the database.
## The route test kit
`tests/kit/` holds two modules for tests under `tests/integration/`.
+226 -42
View File
@@ -18,7 +18,11 @@
* 5. what was deleted is back under its ORIGINAL id, and what arrived after
* the snapshot is gone — which is what "the restore replaces the
* account's data tables" in `docs/ops/backup-restore.md` means when it is
* spelled out.
* spelled out;
* 6. a second person downloads their own full backup, their account is
* recreated empty under the same id (the runbook's new host), the admin
* uploads the file and restores it, and the person signs in to the same
* rows under the same ids.
*
* Refusing controls, because a restore path that cannot say no is worse than
* none: a stored copy with one flipped ciphertext byte is refused and changes
@@ -36,19 +40,20 @@
*
* Everything the journey does, it does through the app: the readings and the
* document ride the page's own `fetch`, the archive and the snapshot ride the
* real buttons, the restore rides the typed-confirmation dialog. The only two
* real buttons, the restore rides the typed-confirmation dialog. The only
* things reaching Postgres directly are the ones a browser cannot express —
* resetting the account between repetitions, and corrupting one stored copy so
* the authenticated envelope has something to refuse. Both live in
* `setup/backup-journey-fixture.ts`.
* resetting the accounts between repetitions, corrupting one stored copy so
* the authenticated envelope has something to refuse (both in
* `setup/backup-journey-fixture.ts`), and recreating the second account under
* its own id (`setup/critical-fixture.ts`).
*
* Serial and single-project: the four tests are four acts of one journey, they
* mutate the account destructively, and the last one moves a session's record
* Serial and single-project: the five tests are five acts of one journey, they
* mutate the accounts destructively, and the last one moves a session's record
* selector. Its jar is its own for the reason `global-setup.ts` spells out.
*/
import { readFile } from "node:fs/promises";
import type { Page } from "@playwright/test";
import type { Page, Response } from "@playwright/test";
import { EXPORT_ARGON2_PARAMS } from "@/lib/export/passphrase-archive";
@@ -65,6 +70,13 @@ import {
storedMeasurements,
storeTamperedCopy,
} from "./setup/backup-journey-fixture";
import {
accountRowCounts,
clearLoginBuckets,
criticalAccount,
recreateCriticalAccount,
resetCriticalAccount,
} from "./setup/critical-fixture";
import {
actWithReproof,
completeReproofWithPassword,
@@ -91,6 +103,8 @@ const RESTORED_WEIGHT_KG = 81.4;
const UNTOUCHED_PULSE_BPM = 58;
/** The reading that arrives AFTER the snapshot and must not survive it. */
const POST_SNAPSHOT_PULSE_BPM = 66;
/** The second account's reading, carried through its own backup. */
const FRESH_ACCOUNT_WEIGHT_KG = 67.3;
const MEDICATION_NAME = "E2E Backup Tablet";
@@ -291,6 +305,45 @@ async function restoreThroughDialog(page: Page, id: string) {
return answered;
}
/**
* Wait for a queued restore to finish, and fail with its code if it failed.
*
* The restore runs on the `backup-restore` queue: the request answers 202
* with the job and its status route, and the worker inside the server under
* test does the work. Nothing after this is read until the job says it is
* done, so every assertion is about the finished restore rather than a moment
* in the middle of it.
*/
async function awaitRestore(page: Page, response: Response): Promise<void> {
expect(response.status()).toBe(202);
const queued = (await response.json()) as {
data: { jobId: string; status: string; statusUrl: string };
};
expect(queued.data.status).toBe("queued");
expect(queued.data.statusUrl).toBe(
`/api/admin/backups/restores/${queued.data.jobId}`,
);
await expect
.poll(
async () => {
const res = await page.request.get(queued.data.statusUrl);
const body = (await res.json()) as {
data: {
status: string;
failure: { code: string } | null;
} | null;
};
// A failed job ends the wait with its code in the message instead of
// running the clock out on a state that will never change.
return body.data?.status === "failed"
? `failed: ${body.data.failure?.code ?? "unknown"}`
: body.data?.status;
},
{ timeout: 60_000, intervals: [250, 500, 1_000] },
)
.toBe("succeeded");
}
test.describe("Backup and restore, through the settings surfaces", () => {
test.use({ storageState: BACKUP_STORAGE_STATE_PATH });
@@ -319,7 +372,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
tamperedCopyId = null;
});
test("a record is written, sealed into an HLX1 archive, and snapshotted", async ({
test("a record is written, sealed into an HLX1 archive, and snapshotted @critical", async ({
page,
}) => {
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
@@ -505,7 +558,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
expect(await storedMeasurements()).toEqual(before);
});
test("the snapshot puts back what was deleted and drops what came after", async ({
test("the snapshot puts back what was deleted and drops what came after @critical", async ({
page,
}) => {
const record = requireSeeded();
@@ -563,39 +616,10 @@ test.describe("Backup and restore, through the settings surfaces", () => {
expect(beforeRestore.map((m) => m.id)).toContain(laterId);
await openBackupsConsole(page);
const response = await restoreThroughDialog(page, requireSnapshot());
// The restore runs on the `backup-restore` queue: the request answers 202
// with the job and its status route, and the worker inside the server
// under test does the work. Nothing below is read until the job says it
// is done, so every assertion is about the finished restore rather than
// a moment in the middle of it.
expect(response.status()).toBe(202);
const queued = (await response.json()) as {
data: { jobId: string; status: string; statusUrl: string };
};
expect(queued.data.status).toBe("queued");
expect(queued.data.statusUrl).toBe(
`/api/admin/backups/restores/${queued.data.jobId}`,
await awaitRestore(
page,
await restoreThroughDialog(page, requireSnapshot()),
);
await expect
.poll(
async () => {
const res = await page.request.get(queued.data.statusUrl);
const body = (await res.json()) as {
data: {
status: string;
failure: { code: string } | null;
} | null;
};
// A failed job ends the wait with its code in the message instead
// of running the clock out on a state that will never change.
return body.data?.status === "failed"
? `failed: ${body.data.failure?.code ?? "unknown"}`
: body.data?.status;
},
{ timeout: 60_000, intervals: [250, 500, 1_000] },
)
.toBe("succeeded");
const afterRestore = await storedMeasurements();
@@ -650,6 +674,166 @@ test.describe("Backup and restore, through the settings surfaces", () => {
),
).toHaveCount(0);
});
test("a full backup the operator restores brings the record back into a fresh account @critical", async ({
page,
browser,
}, testInfo) => {
// The second account: a person with a record of their own, who downloads
// the file the export card offers. Its jar is its own; the act below
// gives it a session that needs a re-proof, like the admin's.
const person = criticalAccount("restore", testInfo);
await resetCriticalAccount(person, [
"DELETE FROM measurement_rollups WHERE user_id = $1",
"DELETE FROM measurements WHERE user_id = $1",
"DELETE FROM mood_entry_rollups WHERE user_id = $1",
"DELETE FROM mood_entries WHERE user_id = $1",
"DELETE FROM rate_limits WHERE key = 'export:' || $1",
]);
const context = await browser.newContext({
storageState: person.storageStatePath,
baseURL: testInfo.project.use.baseURL,
});
let backupPath = "";
let written: { weightId: string; moodId: string };
try {
const own = await context.newPage();
const endOwnSession = await useStaleSession(own, person.username);
try {
await own.goto("/measurements", { waitUntil: "domcontentloaded" });
await expect(own.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({
timeout: 30_000,
});
written = await own.evaluate(async (weightKg: number) => {
const post = async (path: string, body: unknown) => {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = (await res.json()) as { data: { id: string } | null };
if (!res.ok || !json.data) {
throw new Error(`${path} answered ${res.status}`);
}
return json.data.id;
};
const at = new Date(Date.now() - 90 * 60_000).toISOString();
return {
weightId: await post("/api/measurements", {
type: "WEIGHT",
value: weightKg,
measuredAt: at,
}),
moodId: await post("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: at,
}),
};
}, FRESH_ACCOUNT_WEIGHT_KG);
await own.goto("/settings/export", { waitUntil: "domcontentloaded" });
await expect(own.getByTestId("export-card-full-backup")).toBeVisible({
timeout: 30_000,
});
const download = own.waitForEvent("download");
await actWithReproof(own, person.password, () =>
own.getByTestId("export-action-full-backup").click(),
);
// Kept past the context: a download's own file goes with it.
backupPath = testInfo.outputPath("full-backup.json");
await (await download).saveAs(backupPath);
} finally {
await endOwnSession();
}
} finally {
await context.close();
}
// ── the account is gone, and comes back empty under the same id ──────
// What a new host is to the runbook (`docs/ops/backup-restore.md`): the
// backup names its owner by id, so the account is recreated under it
// before the upload. A browser cannot express this; it is the one act
// here that goes to Postgres.
await recreateCriticalAccount(person);
expect(await accountRowCounts(person)).toEqual({
measurements: 0,
moodEntries: 0,
});
// ── the operator uploads the file and restores it ───────────────────
await openBackupsConsole(page);
const uploaded = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/admin/backups/upload" &&
res.request().method() === "POST" &&
res.status() !== 401,
);
await actWithReproof(page, E2E_BACKUP_ADMIN.password, () =>
page
.locator('input[type="file"][accept*="application/gzip"]')
.setInputFiles(backupPath),
);
const upload = await uploaded;
expect(upload.status(), (await upload.text()).slice(0, 400)).toBe(201);
const uploadId = ((await upload.json()) as { data: { id: string } }).data
.id;
await expect(snapshotRow(page, uploadId)).toHaveCount(1, {
timeout: 30_000,
});
await awaitRestore(page, await restoreThroughDialog(page, uploadId));
// ── and the person signs in to their record ──────────────────────────
const back = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
});
try {
await clearLoginBuckets();
const login = await back.request.post("/api/auth/login", {
data: { email: person.username, password: person.password },
});
expect(login.status()).toBe(200);
const signedIn = await back.newPage();
// The same rows, under their own ids: a restore, not a re-entry.
const measurements = (
(await (await signedIn.request.get("/api/measurements")).json()) as {
data: { measurements: Array<{ id: string; value: number }> };
}
).data.measurements;
expect(measurements).toEqual([
expect.objectContaining({
id: written.weightId,
value: FRESH_ACCOUNT_WEIGHT_KG,
}),
]);
const moods = (
(await (await signedIn.request.get("/api/mood-entries")).json()) as {
data: { entries: Array<{ id: string; mood: string }> };
}
).data.entries;
expect(moods).toEqual([
expect.objectContaining({ id: written.moodId, mood: "OKAY" }),
]);
await signedIn.goto("/measurements", { waitUntil: "domcontentloaded" });
await expect(
signedIn
.locator(
'[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' +
`[data-measurement-value="${FRESH_ACCOUNT_WEIGHT_KG}"]`,
)
.first(),
).toBeVisible({ timeout: 30_000 });
} finally {
await back.close();
}
// The admin's own record is untouched by a restore aimed at somebody else.
expect(
(await storedMeasurements()).filter((m) => m.deletedAt === null),
).toHaveLength(2);
});
});
test.describe("A read-level delegate inside the record", () => {
+7 -3
View File
@@ -136,7 +136,7 @@ test.describe("delegated writes", () => {
).toBeVisible();
}
test("the owner invites at a level that may add entries", async () => {
test("the owner invites at a level that may add entries @critical", async () => {
await openInviteForm();
const identifier = ownerPage.locator(
@@ -183,7 +183,9 @@ test.describe("delegated writes", () => {
).toBe(WRITE_LEVEL_VALUE);
});
test("the delegate accepts and opens the record", async ({ page }) => {
test("the delegate accepts and opens the record @critical", async ({
page,
}) => {
await page.goto("/settings/access");
await page.locator('[data-slot="grant-accept"]').first().click();
@@ -203,7 +205,9 @@ test.describe("delegated writes", () => {
await expect(banner).not.toContainText("read it, not change it");
});
test("the reading they add lands in the owner's record", async ({ page }) => {
test("the reading they add lands in the owner's record @critical", async ({
page,
}) => {
await page.goto("/measurements");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
+231 -47
View File
@@ -1,24 +1,89 @@
import type { BrowserContext, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
clearLoginBuckets,
criticalAccount,
resetCriticalAccount,
type CriticalAccount,
} from "./setup/critical-fixture";
/**
* Smoke checks on the login surface — must render, must wire the
* password-manager autofill hints (autoComplete="username" /
* "current-password"), must let the user submit.
* Signing in and out, against the real server.
*
* These do NOT log in (no DB seed required); the password-flow
* roundtrip is exercised by the integration test in
* tests/integration/auth-flow.test.ts.
* Every test starts without a session and signs in through the form, so what
* it reads is what the login route, the session table and the shell did, not
* a payload typed into the test. The account is the sign-in journey's own
* (one per project, see `critical-fixture.ts`): it gets no jar from global
* setup, because the tests here end the sessions they open and a jar captured
* once would be dead for a retry.
*
* The passkey half runs through Chromium's virtual authenticator, attached
* over CDP. It is a real authenticator as far as the page and the server are
* concerned: the registration and the assertion are signed, and the server
* verifies both against the origin and RP id the suite runs under.
*/
async function openPasswordForm(page: import("@playwright/test").Page) {
test.use({ storageState: { cookies: [], origins: [] } });
const SESSION_COOKIE = "healthlog_session";
async function openPasswordForm(page: Page): Promise<void> {
await page.goto("/auth/login");
// The login page defaults to passkey mode; the password form is
// mounted only after clicking the "Login with password" / "Mit
// Passwort anmelden" outline button.
await page
.getByRole("button", {
name: /sign in with password|login with password|mit passwort/i,
})
.click();
// The login page opens in passkey mode; the password form mounts after
// this button. The button is server-rendered, so the click is retried
// until the form it mounts is there.
const usePassword = page.getByTestId("login-use-password");
const email = page.getByTestId("login-email");
await expect(async () => {
await usePassword.click({ timeout: 2_000 });
await expect(email).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 30_000 });
}
async function signInWithPassword(
page: Page,
account: CriticalAccount,
): Promise<void> {
await openPasswordForm(page);
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill(account.password);
// The login route allows five attempts per IP per quarter hour and the
// whole suite is one IP; see `clearLoginBuckets`.
await clearLoginBuckets();
await page.getByTestId("login-password-submit").click();
await page.waitForURL((url) => url.pathname === "/", { timeout: 30_000 });
}
async function sessionCookie(context: BrowserContext): Promise<string> {
const cookie = (await context.cookies()).find(
(c) => c.name === SESSION_COOKIE,
);
expect(cookie, "the login answered without a session cookie").toBeTruthy();
return cookie!.value;
}
/** The username `/api/auth/me` answers for this context's session. */
async function signedInAs(page: Page): Promise<string | null> {
const res = await page.request.get("/api/auth/me");
if (res.status() !== 200) return null;
return ((await res.json()) as { data: { username: string } }).data.username;
}
async function signOutThroughTheMenu(page: Page): Promise<void> {
// The desktop sidebar and the phone top bar each carry a user menu; only
// one of them is on screen at a given width.
const trigger = page
.getByRole("button", { name: "User menu" })
.filter({ visible: true })
.first();
const signOut = page.getByRole("menuitem", { name: "Sign out" });
await expect(async () => {
await trigger.click({ timeout: 2_000 });
await expect(signOut).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 30_000 });
await signOut.click();
await page.waitForURL((url) => url.pathname === "/auth/login");
}
test.describe("login page", () => {
@@ -27,47 +92,166 @@ test.describe("login page", () => {
}) => {
await openPasswordForm(page);
const username = page
.getByLabel(/username|benutzername|email|e-mail/i)
.first();
const username = page.getByTestId("login-email");
await expect(username).toBeVisible();
await expect(username).toHaveAttribute("autoComplete", /username|email/);
const password = page.locator('input[type="password"]').first();
const password = page.getByTestId("login-password");
await expect(password).toBeVisible();
await expect(password).toHaveAttribute("autoComplete", "current-password");
});
test("rejects an obviously-wrong credential pair", async ({ page }) => {
test("refuses a wrong password and opens no session", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await openPasswordForm(page);
await page
.getByLabel(/username|benutzername|email|e-mail/i)
.first()
.fill("nobody-here");
await page.locator('input[type="password"]').first().fill("not-the-pw");
// Intercept the API call so the test does not depend on a real
// backend rejecting the credentials. The login form's error
// surfacing is what we actually want to prove here.
await page.route("**/api/auth/login", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Invalid credentials" }),
}),
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill("not-this-account-password");
await clearLoginBuckets();
const answer = page.waitForResponse(
(res) =>
res.url().endsWith("/api/auth/login") &&
res.request().method() === "POST",
);
await page.getByTestId("login-password-submit").click();
// The form has multiple buttons matching /sign in/ — both the
// passkey CTA and the password submit. Filter to the submit button
// so the click lands on the right one.
await page
.locator('button[type="submit"]')
.filter({ hasText: /login|anmelden|sign in/i })
.click();
await expect(
page.getByText(/invalid credentials|ungültig|falsch/i),
).toBeVisible({ timeout: 5_000 });
expect((await answer).status()).toBe(401);
await expect(page.getByText("Invalid credentials")).toBeVisible();
await expect(page).toHaveURL(/\/auth\/login/);
expect(
(await context.cookies()).some((c) => c.name === SESSION_COOKIE),
).toBe(false);
});
});
test.describe("signing in and out", () => {
test("a password sign-in lands on the dashboard as that account @critical", async ({
page,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await signInWithPassword(page, account);
await expect(page.locator("main")).toBeVisible();
expect(await signedInAs(page)).toBe(account.username);
});
test("signing out ends the session on the server, not only in the browser @critical", async ({
page,
context,
playwright,
baseURL,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await signInWithPassword(page, account);
const cookie = await sessionCookie(context);
// A second client holding the same cookie, the way a copied jar or a
// stolen cookie would.
const replay = await playwright.request.newContext({
baseURL,
extraHTTPHeaders: { cookie: `${SESSION_COOKIE}=${cookie}` },
});
try {
// Positive control: the replayed cookie is a working session before
// the sign-out, so the 401 below is about the sign-out.
expect((await replay.get("/api/auth/me")).status()).toBe(200);
await signOutThroughTheMenu(page);
// The browser dropped its cookie; that alone would pass with a server
// that kept the row. The proof is the old cookie being refused.
expect(await signedInAs(page)).toBeNull();
expect((await replay.get("/api/auth/me")).status()).toBe(401);
expect((await replay.get("/api/measurements?limit=1")).status()).toBe(
401,
);
} finally {
await replay.dispose();
}
// And the protected shell sends the browser back to the form.
await page.goto("/");
await page.waitForURL((url) => url.pathname === "/auth/login");
});
test("a passkey registered in settings signs the account back in @critical", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
// A retry, or the last run, may have left a credential on the account;
// the list below has to count only the one this test registers.
await resetCriticalAccount(account, [
"DELETE FROM passkeys WHERE user_id = $1",
]);
const cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send(
"WebAuthn.addVirtualAuthenticator",
{
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
},
);
try {
await signInWithPassword(page, account);
await test.step("register a passkey under the current password", async () => {
await page.goto("/settings/security");
await page.getByRole("button", { name: "Add passkey" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await dialog.locator('input[type="password"]').fill(account.password);
await dialog.getByRole("button", { name: "Verify and add" }).click();
await expect(dialog).toBeHidden();
await expect(
page.getByTestId("passkeys-list").locator("li"),
).toHaveCount(1);
const stored = await cdp.send("WebAuthn.getCredentials", {
authenticatorId,
});
expect(stored.credentials).toHaveLength(1);
expect(stored.credentials[0].isResidentCredential).toBe(true);
});
await signOutThroughTheMenu(page);
expect(await signedInAs(page)).toBeNull();
await test.step("sign in with it", async () => {
await clearLoginBuckets();
await page
.getByRole("button", { name: "Sign in with Passkey" })
.click();
await page.waitForURL((url) => url.pathname === "/", {
timeout: 30_000,
});
expect(await signedInAs(page)).toBe(account.username);
// The server recorded the use against the stored credential.
const res = await page.request.get("/api/auth/passkeys");
expect(res.status()).toBe(200);
const passkeys = (
(await res.json()) as {
data: Array<{ lastUsedAt: string | null }>;
}
).data;
expect(passkeys).toHaveLength(1);
expect(passkeys[0].lastUsedAt).not.toBeNull();
});
} finally {
await cdp
.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId })
.catch(() => undefined);
}
});
});
+111 -148
View File
@@ -1,140 +1,92 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
import { openMenu } from "./open-menu";
/**
* Add-measurement flow — exercises the dashboard's quick-entry dropdown,
* the dialog-mounted MeasurementForm, the POST to /api/measurements, and
* the resulting query invalidation that surfaces the row on
* `/measurements`.
* Add-measurement flow, against the real server: the dashboard's quick-entry
* menu, the dialog-mounted MeasurementForm, the real POST to
* `/api/measurements`, and then every surface that has to agree about the
* reading afterwards: the dashboard tile, the weight chart (through the data
* table that lists exactly the points it drew) and the day view.
*
* Weight is the simplest type (single numeric input + datetime), so it
* keeps the spec focused on the round-trip rather than the BP three-field
* batched submit.
* Until v1.46 this spec answered its own POST and its own list read with a
* route stub, so it proved that the list renders a row the test typed in.
* Nothing here is stubbed now. The account is the measurement journey's own,
* one per project (`critical-fixture.ts`), and is emptied of readings before
* the test, so "the tile shows the weight" can only mean the weight this test
* entered.
*
* Weight is the simplest type (single numeric input + datetime), so it keeps
* the spec on the round trip rather than on the blood-pressure batch.
*/
/** Distinct from anything a fixture seeds, so a match is this reading. */
const WEIGHT_KG = "81.7";
/** Today in the browser's zone (`timezoneId` in `playwright.config.ts`). */
function todayInBerlin(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
/** The account's weight readings, as the list route answers them. */
async function storedWeights(
page: Page,
): Promise<Array<{ value: number; unit: string }>> {
const res = await page.request.get("/api/measurements?type=WEIGHT");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: { measurements: Array<{ value: number; unit: string }> };
};
return data.measurements.map(({ value, unit }) => ({ value, unit }));
}
test.describe("add measurement flow", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.use({ storageState: criticalStorageState("measurement") });
// This journey lands on the dashboard, which is the most expensive page in
// the product to render cold: the route segment suspends behind the server
// render and shows `loading.tsx` until it resolves. On a shared runner with
// the whole suite in flight that has overrun the 30 s default, and the test
// then died on the quick-add trigger rather than on anything it is about.
// The subject here is the add-then-appears round trip, not the dashboard's
// cold-start latency, so the budget is the page's and the assertions stay
// exactly as tight as they were.
// the product to render cold, and then reads three more surfaces. The
// assertions stay as tight as they are; the budget is the pages'.
test.setTimeout(90_000);
test("creating a weight reading surfaces it in the list", async ({
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("measurement", testInfo), [
"DELETE FROM measurement_rollups WHERE user_id = $1",
"DELETE FROM measurements WHERE user_id = $1",
]);
});
test("a weight entered on the dashboard reaches the tile, the chart and the day view @critical", async ({
page,
}) => {
// Capture the POST payload so we can assert against it independently
// of any list-fetch response Playwright might cache. Match both
// `/api/measurements` (POST creates a reading) and the list query
// `/api/measurements?type=...&limit=...&offset=...` (GET).
let postedBody: unknown = null;
await page.route("**/api/measurements*", async (route) => {
const req = route.request();
if (req.method() === "POST") {
postedBody = JSON.parse(req.postData() ?? "null");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: "m_e2e_1", success: true },
error: null,
}),
});
return;
}
// GET — return the just-posted reading so the list view shows it.
// The list expects `data.measurements: Measurement[]` plus
// `data.meta.total`; chart fetches use the same envelope.
const body = postedBody as {
type?: string;
value?: number;
measuredAt?: string;
} | null;
// The list-page `Measurement` shape requires `unit` and `source`
// — without them the row crashes the render and the e2e poll
// never sees "78.4". Mock with the same fields a real reading
// carries.
const measurements = body
? [
{
id: "m_e2e_1",
type: body.type,
value: body.value,
unit: body.type === "WEIGHT" ? "kg" : "",
source: "MANUAL",
measuredAt: body.measuredAt,
notes: null,
},
]
: [];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { measurements, meta: { total: measurements.length } },
error: null,
}),
});
});
// Dashboard's analytics fetch — empty summaries are fine; we only
// need the page to render so we can click "Add".
//
// v1.4.39.3 — match `/api/analytics` AND any sliced variant
// (`?slice=summaries`). The v1.4.39.2 dashboard split fires both
// requests in parallel; the previous string glob missed the slim
// variant which then fell through to the real route and slowed
// the page paint enough that the post-mutation list query never
// surfaced inside the 10 s window.
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
await page.goto("/", { waitUntil: "domcontentloaded" });
// Open the "Add" dropdown — the dashboard's quick-entry trigger sits
// at the top-right of `<main>`. Scope the locator there so we don't
// accidentally match an "Add" button on the sidebar.
const main = page.locator("main");
const addTrigger = main
// The quick-entry trigger sits at the top of `<main>`. Waiting for it,
// rather than for the route skeleton to go, is waiting for the thing the
// next line clicks.
const addTrigger = page
.locator("main")
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
.first();
// Wait for the trigger itself rather than for the route-level skeleton to
// go. The skeleton is also absent while the protected shell holds the
// whole page behind its hydration gate, so its disappearance is not a
// statement about the suspended segment having resolved; the control the
// next line clicks is. `networkidle` says nothing about either.
await expect(addTrigger).toBeVisible({ timeout: 60_000 });
await page.waitForLoadState("networkidle");
// The account holds no weight yet, so whatever the surfaces below show
// can only come from this reading.
expect(await storedWeights(page)).toEqual([]);
await openMenu(page, addTrigger);
// v1.5: the menu items now have distinct labels — the
// measurement entry says "Measurement" / "Messung" instead of "Add",
// so we can target it directly.
// The desktop rail opens a dropdown, the phone shell the capture sheet
// the bottom nav's plus opens; the measurement entry is in either.
await page
@@ -143,52 +95,63 @@ test.describe("add measurement flow", () => {
.first()
.click();
// Form should be visible inside the dialog. Switch type to WEIGHT
// (combo-box defaults to BLOOD_PRESSURE).
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// The Select trigger renders the current label — click to open and
// pick the weight option.
// The type select defaults to blood pressure.
await dialog.getByRole("combobox").first().click();
await page
.getByRole("option", { name: /weight|gewicht/i })
.first()
.click();
await dialog.locator("#value").fill(WEIGHT_KG);
// Weight is single-value mode — fill the value field with 78.4 kg.
const valueInput = dialog.locator("#value");
await valueInput.fill("78.4");
// Save
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/measurements" &&
res.request().method() === "POST",
);
await dialog.getByRole("button", { name: /save|speichern/i }).click();
const response = await created;
expect(response.status()).toBe(201);
await expect(dialog).toBeHidden();
// Dialog closes when the mutation succeeds
await expect(dialog).toBeHidden({ timeout: 5_000 });
// Assert the POST went through with the expected shape
expect(postedBody).toMatchObject({
type: "WEIGHT",
value: 78.4,
await test.step("the server stored exactly that reading", async () => {
expect(await storedWeights(page)).toEqual([
{ value: Number(WEIGHT_KG), unit: "kg" },
]);
});
// Navigate to the list and confirm the entry surfaces. The list
// page renders entries from /api/measurements which we've mocked
// to echo the just-posted row.
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
await test.step("the dashboard tile", async () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const tile = page.locator(
'[data-slot="dashboard-tile-link"][data-tile-id="weight"]',
);
await expect(tile).toBeVisible({ timeout: 60_000 });
await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText(
WEIGHT_KG,
);
});
// The list renders a desktop table AND a mobile card grid (one is
// hidden via `md:hidden`/`hidden md:block`). At least one match
// means our row landed in the DOM regardless of viewport.
await expect
.poll(
async () =>
await page
.locator("main")
.getByText(/78[.,]4/)
.count(),
{ timeout: 10_000 },
)
.toBeGreaterThanOrEqual(1);
await test.step("the weight chart", async () => {
await page.goto("/insights/weight", { waitUntil: "domcontentloaded" });
const table = page.locator('[data-slot="chart-data-table"]').first();
await expect(table).toBeVisible({ timeout: 30_000 });
await table.locator('[data-slot="chart-data-table-toggle"]').click();
const rows = table.locator('[data-slot="chart-data-table-row"]');
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText(WEIGHT_KG);
});
await test.step("the day view", async () => {
await page.goto(`/measurements?day=${todayInBerlin()}`, {
waitUntil: "domcontentloaded",
});
const value = page.locator(
'[data-slot="day-view"] [data-slot="day-value"][data-type="WEIGHT"]',
);
await expect(value).toBeVisible({ timeout: 30_000 });
await expect(value).toContainText(WEIGHT_KG);
});
});
});
+1 -1
View File
@@ -363,7 +363,7 @@ test.describe("medication adherence journey", () => {
});
});
test("a taken and a skipped dose move the card, the history and the dashboard tile", async ({
test("a taken and a skipped dose move the card, the history and the dashboard tile @critical", async ({
page,
}) => {
// The wizard is a seven-step dialog with per-step validation gating Next;
+188 -2
View File
@@ -2,6 +2,11 @@ import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
@@ -37,9 +42,16 @@ const CASES: readonly Case[] = [
async function expectNoSeriousViolations(page: Page, label: string) {
// The pointer rests where the last tap landed; a hover tint under it is
// not what the screen shows at rest.
// not what the screen shows at rest. Moving it away starts the tint's
// transition back, so the scan waits for every transition to settle rather
// than for a guessed number of milliseconds. Transitions only: a spinner's
// or a skeleton's endless animation would never settle.
await page.mouse.move(0, 0);
await page.waitForTimeout(300);
await page.waitForFunction(() =>
document
.getAnimations()
.every((a) => !(a instanceof CSSTransition) || a.playState !== "running"),
);
const results = await new AxeBuilder({ page })
.include(
'[data-slot="mental-health-check-in"], [data-slot="mental-health-result"]',
@@ -346,3 +358,177 @@ test.describe("mental-health check-in, in the reader's language", () => {
).toHaveCount(0);
});
});
/**
* A WHO-5 check-in, word for word, in German and in Brazilian Portuguese,
* on the wellbeing journey's own account (one per project,
* `critical-fixture.ts`) against the real server.
*
* The journeys above anchor on data attributes so they read the same in every
* language; this one reads the words. The German text is the validated WHO
* translation, which the form has to reproduce exactly; the Brazilian one is
* HealthLog's own and has to say so. Either way the stored assessment records
* the language and the kind of wording it was answered in, and the score the
* server computed is the one on screen.
*
* The strings are written out here rather than imported from
* `src/lib/mental-health/wording/`: a test that read the expected text from
* the module it checks would pass on any edit to that module.
*/
const WHO5_WORDING = {
de: {
validated: true,
progress: "Frage 1 von 5",
stem: "In den letzten zwei Wochen …",
first: "… war ich froh und guter Laune",
last: "… war mein Alltag voller Dinge, die mich interessieren",
// In the order the form prints them, best first.
anchors: [
"Die ganze Zeit",
"Meistens",
"Etwas mehr als die Hälfte der Zeit",
"Etwas weniger als die Hälfte der Zeit",
"Ab und zu",
"Zu keinem Zeitpunkt",
],
},
"pt-BR": {
validated: false,
progress: "Pergunta 1 de 5",
stem: "Nas últimas duas semanas …",
first: "Eu me senti alegre e de bom humor",
last: "Meu dia a dia foi preenchido com coisas que me interessam",
anchors: [
"O tempo todo",
"A maior parte do tempo",
"Mais da metade do tempo",
"Menos da metade do tempo",
"Algumas vezes",
"Em nenhum momento",
],
},
} as const;
/** The account's stored assessments, as the list route answers them. */
async function storedAssessments(page: Page): Promise<
Array<{
instrument: string;
locale: string;
wording: string | null;
totalScore: number;
}>
> {
const res = await page.request.get("/api/mental-health/assessments");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: {
assessments: Array<{
instrument: string;
locale: string;
wording: string | null;
totalScore: number;
}>;
};
};
return data.assessments.map(
({ instrument, locale, wording, totalScore }) => ({
instrument,
locale,
wording,
totalScore,
}),
);
}
test.describe("WHO-5 check-in, word for word", () => {
test.use({ storageState: criticalStorageState("wellbeing") });
// Both locales write to the one account of the project, and each test
// empties it first; in order, so neither empties the other's mid-run.
test.describe.configure({ mode: "default" });
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("wellbeing", testInfo), [
"DELETE FROM mental_health_assessments WHERE user_id = $1",
]);
});
for (const [locale, words] of Object.entries(WHO5_WORDING)) {
test(`${locale}: the form reads the ${words.validated ? "validated" : "HealthLog"} translation and the result is stored in it @critical`, async ({
page,
}) => {
test.setTimeout(90_000);
await openInLocale(page, locale);
await expect(page.locator("html")).toHaveAttribute("lang", locale);
await page
.locator('[data-slot="instrument-card"][data-instrument="WHO5"]')
.locator('[data-slot="instrument-card-start"]')
.click();
const wizard = page.locator(
'[data-slot="mental-health-check-in"][data-instrument="WHO5"]',
);
const step = (n: number) =>
wizard.locator(`[data-slot="check-in-step"][data-step="${n}"]`);
await expect(step(1)).toBeVisible();
await expect(
wizard.locator('[data-slot="check-in-progress"]'),
).toContainText(words.progress);
await expect(step(1).locator('[data-slot="check-in-stem"]')).toHaveText(
words.stem,
);
await expect(
step(1).locator('[data-slot="check-in-question"]'),
).toHaveText(words.first);
await expect(step(1).locator('[role="radio"]')).toHaveText([
...words.anchors,
]);
await expect(
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
).toHaveCount(words.validated ? 0 : 1);
// "Meistens" / "A maior parte do tempo" (value 4) on every item.
for (let n = 1; n <= 5; n++) {
await expect(step(n)).toBeVisible();
if (n === 5) {
await expect(
step(n).locator('[data-slot="check-in-question"]'),
).toHaveText(words.last);
}
await step(n).getByRole("radio", { name: words.anchors[1] }).click();
}
await expect(
wizard.locator('[data-slot="check-in-review"]'),
).toBeVisible();
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/mental-health/assessments" &&
res.request().method() === "POST",
);
await wizard.locator('[data-slot="check-in-submit"]').click();
expect((await created).status()).toBe(201);
// Five items at 4, times WHO-5's multiplier of 4.
const result = page.locator(
'[data-slot="mental-health-result"][data-instrument="WHO5"]',
);
await expect(result.locator('[data-slot="result-score"]')).toContainText(
"80",
);
await expect(
result.locator('[data-slot="result-wording-note"]'),
).toHaveCount(words.validated ? 0 : 1);
expect(await storedAssessments(page)).toEqual([
{
instrument: "WHO5",
locale,
wording: words.validated ? "validated" : "unvalidated_translation",
totalScore: 80,
},
]);
});
}
});
+66 -32
View File
@@ -1,39 +1,53 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* Logging a mood through the form, against the real server: the time stays
* visible while the details are collapsed, the instant typed into it is the
* instant stored, and the entry is in the list afterwards.
*
* Until v1.46 this spec answered the POST and the list read itself, so the
* list was always empty and the POST always succeeded. The account is now the
* mood journey's own, one per project (`critical-fixture.ts`), emptied before
* the test; the empty state the form opens from is the account's real one.
*/
/** The account's mood entries, as the list route answers them. */
async function storedMoods(
page: Page,
): Promise<Array<{ mood: string; moodLoggedAt: string }>> {
const res = await page.request.get("/api/mood-entries");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: { entries: Array<{ mood: string; moodLoggedAt: string }> };
};
return data.entries.map(({ mood, moodLoggedAt }) => ({ mood, moodLoggedAt }));
}
test.describe("mood timestamp capture", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.use({ storageState: criticalStorageState("mood") });
test("keeps Time visible while details are collapsed and submits the selected instant", async ({
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("mood", testInfo), [
"DELETE FROM mood_entry_rollups WHERE user_id = $1",
"DELETE FROM mood_entries WHERE user_id = $1",
]);
});
test("a mood logged at a chosen time is stored at that instant and listed @critical", async ({
page,
}) => {
let submitted: Record<string, unknown> | null = null;
await page.route("**/api/mood-entries*", async (route) => {
if (route.request().method() === "POST") {
submitted = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ data: { id: "created-mood" }, error: null }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } },
error: null,
}),
});
});
await page.goto("/mood", { waitUntil: "domcontentloaded" });
// The list is mocked empty, so its empty state carries the add action
// (the header drops its own while there is nothing to list).
// An empty list carries the add action in its empty state (the header
// drops its own while there is nothing to list).
await page.getByRole("button", { name: "Log your first mood" }).click();
await page.getByRole("radio", { name: "Amazing" }).click();
@@ -53,12 +67,32 @@ test.describe("mood timestamp capture", () => {
await timeInput.fill("09:45");
await timeInput.blur();
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/mood-entries" &&
res.request().method() === "POST",
);
await page.getByRole("button", { name: "Save", exact: true }).click();
expect((await created).status()).toBe(201);
await expect.poll(() => submitted).not.toBeNull();
expect(submitted).toMatchObject({
mood: "SUPER_GUT",
moodLoggedAt: "2026-01-15T08:45:00.000Z",
// 09:45 in Berlin in January is 08:45 UTC.
expect(await storedMoods(page)).toEqual([
{ mood: "SUPER_GUT", moodLoggedAt: "2026-01-15T08:45:00.000Z" },
]);
// The list shows it, at either width (the table and the phone cards each
// carry the marker; one of them is on screen).
const rows = page.locator('[data-slot="mood-rows"]').filter({
visible: true,
});
await expect(rows).toBeVisible();
await expect(rows).toContainText("Amazing");
await expect(rows).toContainText("09:45");
// And still after a reload, which reads the list from the server again
// rather than from the cache the mutation updated.
await page.reload({ waitUntil: "domcontentloaded" });
await expect(rows).toContainText("Amazing");
await expect(rows).toContainText("09:45");
});
});
+1 -1
View File
@@ -25,7 +25,7 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("offline, with the service worker", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("an open page says it is offline, a new one opens the offline page, and both recover", async ({
test("an open page says it is offline, a new one opens the offline page, and both recover @critical", async ({
page,
context,
}) => {
+82 -19
View File
@@ -1,29 +1,69 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* Lab results from a spreadsheet (#1215), at phone width, against the real
* server: the mapping is suggested from the headers, the check reports a
* refused row by line and writes nothing, the import writes the rest, and a
* second import of the same file only finds duplicates.
* Lab results from a spreadsheet (#1215), against the real server: the
* mapping is suggested from the headers, the check reports a refused row by
* line and writes nothing, the import writes the rest, the imported row is on
* the labs page, and a second import of the same file only finds duplicates.
*
* The account is the labs journey's own, one per project
* (`critical-fixture.ts`), emptied of results first. Before v1.46 this ran on
* the shared account in both projects, so the "only duplicates" verdict of
* one project could be the other project's first import.
*/
const ANALYTE = "E2E Marker CSV";
function csv(): string {
const day = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10);
return [
"Analyte;Value;Unit;Date",
`E2E Marker CSV;5,4;mmol/L;${day}`,
`E2E Marker CSV;1.234;mmol/L;${day}`,
// A decimal comma, as a German lab export writes it.
`${ANALYTE};5,4;mmol/L;${day}`,
// Either 1.234 or 1234: refused as ambiguous rather than guessed.
`${ANALYTE};1.234;mmol/L;${day}`,
].join("\n");
}
/** The account's lab results, as the list route answers them. */
async function storedResults(
page: Page,
): Promise<Array<{ analyte: string; value: number | null; unit: string }>> {
const res = await page.request.get("/api/labs");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: {
results: Array<{ analyte: string; value: number | null; unit: string }>;
};
};
return data.results.map(({ analyte, value, unit }) => ({
analyte,
value,
unit,
}));
}
test.describe("record CSV import", () => {
test.use({
storageState: STORAGE_STATE_PATH,
viewport: { width: 390, height: 844 },
test.use({ storageState: criticalStorageState("labs") });
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("labs", testInfo), [
"DELETE FROM lab_results WHERE user_id = $1",
]);
});
test("checks, imports and then only finds duplicates", async ({ page }) => {
test("checks, imports, lists the result and then only finds duplicates @critical", async ({
page,
}) => {
await page.goto("/settings/export");
await page.locator('[data-slot="record-import-open-labs"]').click();
const sheet = page.locator('[data-slot="record-import-sheet"]');
@@ -48,19 +88,42 @@ test.describe("record CSV import", () => {
await expect(
page.locator('[data-slot="record-import-error-row"]'),
).toHaveCount(1);
// The check is a dry run.
expect(await storedResults(page)).toEqual([]);
await page.locator('[data-slot="record-import-run"]').click();
await expect(
page.locator('[data-slot="record-import-done"]'),
).toBeVisible();
expect(await storedResults(page)).toEqual([
{ analyte: ANALYTE, value: 5.4, unit: "mmol/L" },
]);
// Second run of the same file.
await page.keyboard.press("Escape");
await page.locator('[data-slot="record-import-open-labs"]').click();
await page.locator('[data-slot="record-import-file"]').setInputFiles(file);
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-run"]'),
).toBeDisabled();
await test.step("the result is on the labs page", async () => {
await page.goto("/labs");
const analyte = page
.locator('[data-slot="lab-list-analyte"]')
.filter({ hasText: ANALYTE })
.filter({ visible: true });
await expect(analyte).toHaveCount(1);
// The cell the name heads carries the latest reading under it.
await expect(analyte.locator("xpath=../..")).toContainText("5.4");
});
await test.step("a second run of the same file", async () => {
await page.goto("/settings/export");
await page.locator('[data-slot="record-import-open-labs"]').click();
await page
.locator('[data-slot="record-import-file"]')
.setInputFiles(file);
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-checked"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="record-import-run"]'),
).toBeDisabled();
expect(await storedResults(page)).toHaveLength(1);
});
});
});
+82
View File
@@ -0,0 +1,82 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* A settings change is the server's, not the tab's.
*
* The time-format choice is changed through the account settings, then read
* back three ways that a client-side cache could not fake: after a reload, in
* a second browser context that shares the session but none of the first
* one's storage, and on `/api/auth/me`, which every client renders from.
*
* The account is the settings journey's own, one per project
* (`critical-fixture.ts`), and is put back to the default first.
*/
async function openTimeFormat(page: Page) {
await page.goto("/settings/account");
const select = page.getByTestId("settings-time-format-select");
await expect(select).toBeVisible({ timeout: 30_000 });
// The control is disabled until the account payload has landed.
await expect(select).toBeEnabled();
return select;
}
async function publishedTimeFormat(page: Page): Promise<string> {
const res = await page.request.get("/api/auth/me");
expect(res.status()).toBe(200);
return ((await res.json()) as { data: { timeFormat: string } }).data
.timeFormat;
}
test.describe("settings persistence", () => {
test.use({ storageState: criticalStorageState("settings") });
test.beforeEach(async ({}, testInfo) => {
await resetCriticalAccount(criticalAccount("settings", testInfo), [
"UPDATE users SET time_format = 'AUTO' WHERE id = $1",
]);
});
test("a time-format change survives a reload and reaches a second browser @critical", async ({
page,
browser,
}, testInfo) => {
const select = await openTimeFormat(page);
await expect(select).toHaveValue("AUTO");
const saved = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/user/profile" &&
res.request().method() === "PATCH",
);
await select.selectOption("H12");
expect((await saved).status()).toBe(200);
expect(await publishedTimeFormat(page)).toBe("H12");
await page.reload();
await expect(page.getByTestId("settings-time-format-select")).toHaveValue(
"H12",
);
// Same session cookie, nothing else: no local storage, no IndexedDB
// query snapshot, no in-memory cache.
const other = await browser.newContext({
storageState: criticalAccount("settings", testInfo).storageStatePath,
baseURL: testInfo.project.use.baseURL,
});
try {
const second = await other.newPage();
await expect(await openTimeFormat(second)).toHaveValue("H12");
} finally {
await other.close();
}
});
});
+253
View File
@@ -0,0 +1,253 @@
/**
* Accounts for the `@critical` journeys: one per journey AND per project.
*
* The `@critical` journeys run against the real server with no `/api` stubs
* (`src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds that line), so
* every verdict they read is a statement about a real record. Two things would
* make those verdicts somebody else's:
*
* - a sibling spec writing into the same account. The shared fixture account
* is read and written by sixty files, so "the dashboard tile shows the
* weight I just entered" is only true of an account nobody else writes;
* - the same journey running in the other project. Every other journey that
* owns its account answers that by running in ONE project (the long list in
* `playwright.config.ts`). These journeys are the release gate and are
* meant to hold at both widths, so instead of dropping a project each
* project gets its own account: `chromium-desktop` and `chromium-mobile`
* run the same file in parallel and never meet.
*
* The accounts are seeded and signed in by `global-setup.ts`, like every
* other fixture account; this module only names them and resets what the
* journeys write. It talks to Postgres through `pg` for the reason
* `global-setup.ts` gives (Playwright's loader cannot load the generated
* Prisma client).
*/
import { resolve } from "node:path";
import type { TestInfo } from "@playwright/test";
import pg from "pg";
/** The journeys that own an account pair. */
export const CRITICAL_JOURNEYS = [
"auth",
"measurement",
"mood",
"labs",
"wellbeing",
"settings",
// The fresh account a full backup is imported into. The backup journey
// runs in the desktop project only (`playwright.config.ts` says why), so
// only the desktop account of this pair is ever written.
"restore",
] as const;
export type CriticalJourney = (typeof CRITICAL_JOURNEYS)[number];
/**
* Journeys that sign in through the form in every test and so get no jar.
* The sign-in journey ends the sessions it opens; a jar captured once in
* global setup would be dead for a retry, and a retry has to start from the
* same place as the first attempt.
*/
export const FORM_SIGN_IN_JOURNEYS: ReadonlySet<CriticalJourney> = new Set([
"auth",
]);
/** The projects the journeys run in; each gets its own account per journey. */
export const CRITICAL_PROJECTS = {
"chromium-desktop": "d",
"chromium-mobile": "m",
} as const;
type CriticalProject = keyof typeof CRITICAL_PROJECTS;
export interface CriticalAccount {
username: string;
email: string;
password: string;
storageStatePath: string;
}
function accountFor(
journey: CriticalJourney,
suffix: (typeof CRITICAL_PROJECTS)[CriticalProject],
): CriticalAccount {
const username = `e2e-critical-${journey}-${suffix}`;
return {
username,
email: `${username}@healthlog.test`,
// Fixture credential for a throwaway test database, like every other
// account in `global-setup.ts`. Long and mixed so it would also clear the
// registration strength gate.
password: `Cq7!${journey}-${suffix}-Wz4pLx`,
storageStatePath: resolve(
process.cwd(),
`e2e/setup/storageStateCritical-${journey}-${suffix}.json`,
),
};
}
/** Every critical account, for `global-setup.ts` to seed and sign in. */
export function allCriticalAccounts(): Array<
CriticalAccount & { journey: CriticalJourney }
> {
return CRITICAL_JOURNEYS.flatMap((journey) =>
Object.values(CRITICAL_PROJECTS).map((suffix) => ({
journey,
...accountFor(journey, suffix),
})),
);
}
/**
* The account a journey uses in the project the test is running in. A project
* outside the map has no account on purpose: a critical journey that grows a
* third project has to say which account it reads.
*/
export function criticalAccount(
journey: CriticalJourney,
testInfo: TestInfo,
): CriticalAccount {
const suffix = CRITICAL_PROJECTS[testInfo.project.name as CriticalProject];
if (!suffix) {
throw new Error(
`[critical-fixture] no ${journey} account for project ${testInfo.project.name}`,
);
}
return accountFor(journey, suffix);
}
/**
* A `test.use({ storageState })` value that picks the jar of the running
* project's account.
*/
export function criticalStorageState(journey: CriticalJourney) {
return async (
{}: object,
use: (path: string) => Promise<void>,
testInfo: TestInfo,
): Promise<void> => {
await use(criticalAccount(journey, testInfo).storageStatePath);
};
}
async function withPool<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[critical-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
return await fn(pool);
} finally {
await pool.end();
}
}
/**
* Run statements against one critical account, with `$1` bound to its id.
* Fixture hygiene only: what a previous run or a retry left behind. Nothing a
* journey asserts is written here.
*/
export async function resetCriticalAccount(
account: CriticalAccount,
statements: readonly string[],
): Promise<void> {
await withPool(async (pool) => {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[account.username],
);
const id = res.rows[0]?.id;
if (!id) {
throw new Error(
`[critical-fixture] ${account.username} not seeded — global-setup must run first`,
);
}
for (const sql of statements) await pool.query(sql, [id]);
});
}
/**
* Clear the login buckets. The ceiling is five attempts per IP per quarter
* hour, the suite shares one IP, and global setup alone signs in more than
* twenty times; a journey that signs in through the form would otherwise meet
* the fixture's 429 rather than the product. Only the auth surfaces' buckets
* are touched, exactly as global setup does between its own logins.
*/
export async function clearLoginBuckets(): Promise<void> {
await withPool(async (pool) => {
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
});
}
/**
* Delete a critical account and create it again, empty, under the same id,
* username and password. The backup journey restores a person's file into
* the account the way the runbook brings one back on a new host, where the
* file names its owner by id; a browser cannot delete and recreate a row
* under a chosen id, so this is the fixture's act, not the journey's.
*/
export async function recreateCriticalAccount(
account: CriticalAccount,
): Promise<void> {
await withPool(async (pool) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const res = await client.query<{
id: string;
password_hash: string;
module_preferences_json: unknown;
}>(
`SELECT id, password_hash, module_preferences_json
FROM users WHERE username = $1`,
[account.username],
);
const row = res.rows[0];
if (!row) {
throw new Error(`[critical-fixture] ${account.username} not seeded`);
}
await client.query("DELETE FROM users WHERE id = $1", [row.id]);
await client.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, $5::jsonb)`,
[
row.id,
account.username,
account.email,
row.password_hash,
JSON.stringify(row.module_preferences_json),
],
);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
});
}
/** How many measurements and mood entries a critical account holds. */
export async function accountRowCounts(
account: CriticalAccount,
): Promise<{ measurements: number; moodEntries: number }> {
return withPool(async (pool) => {
const res = await pool.query<{ measurements: string; mood: string }>(
`SELECT
(SELECT count(*) FROM measurements m
JOIN users u ON u.id = m.user_id WHERE u.username = $1)
AS measurements,
(SELECT count(*) FROM mood_entries e
JOIN users u ON u.id = e.user_id WHERE u.username = $1) AS mood`,
[account.username],
);
return {
measurements: Number(res.rows[0]?.measurements ?? 0),
moodEntries: Number(res.rows[0]?.mood ?? 0),
};
});
}
+51
View File
@@ -33,6 +33,7 @@ import {
} from "@playwright/test";
import pg from "pg";
import { allCriticalAccounts, FORM_SIGN_IN_JOURNEYS } from "./critical-fixture";
import { seedDayDoorsRecord } from "./day-doors-fixture";
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
@@ -1539,6 +1540,49 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
[cuid(), now, E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username],
);
// ── the @critical journeys' accounts ─────────────────────────────────
//
// One per journey and per project; `critical-fixture.ts` says why. Every
// module a journey reaches is switched on (wellbeing and labs are opt-in),
// and the passkeys the sign-in journey registers go with the row's reset,
// so a re-run registers into an empty list rather than beside the last
// run's credential.
for (const account of allCriticalAccounts()) {
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json,
totp_confirmed_at = NULL`,
[
cuid(),
account.username,
account.email,
await hashPassword(account.password),
now,
JSON.stringify({
mood: true,
labs: true,
mentalHealth: true,
medications: true,
}),
],
);
await pool.query(
`DELETE FROM passkeys
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[account.username],
);
}
// Console (instead of structured logging) is intentional here —
// global-setup runs outside the app's logging context, and the
// line is useful when debugging a CI failure where the seed didn't
@@ -1708,6 +1752,13 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
[E2E_SETUP_CHILD.username, now],
);
// The @critical journeys' jars. The sign-in journey signs in through the
// form in every test, so its account gets none.
for (const account of allCriticalAccounts()) {
if (FORM_SIGN_IN_JOURNEYS.has(account.journey)) continue;
await capture(account, account.storageStatePath);
}
} finally {
await pool.end();
}
+1
View File
@@ -46,6 +46,7 @@
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.mts",
"e2e": "playwright test",
"e2e:critical": "playwright test --grep @critical",
"e2e:ui": "playwright test --ui",
"openapi:generate": "npx tsx scripts/generate-openapi.ts",
"openapi:check": "npx tsx scripts/check-openapi.ts",
+8
View File
@@ -27,6 +27,14 @@ import {
*
* To run locally: `pnpm dlx playwright install --with-deps chromium`
* once, then `pnpm e2e`.
*
* The journeys tagged `@critical` (`pnpm e2e:critical`) are the exception to
* the route interception above: they intercept nothing under `/api`, which
* `src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds. Most of them
* run in both Chromium projects on an account per project
* (`e2e/setup/critical-fixture.ts`), so they need no entry in the
* `chromium-mobile` ignore list below; the ones that are on that list are
* there for the reason written beside them.
*/
/**
* Drawn once here, before any server or worker exists, and published through
@@ -0,0 +1,280 @@
/**
* Structural guard: an `@critical` e2e journey talks to the real server.
*
* The `@critical` tag marks the journeys a release is gated on — sign-in,
* a reading reaching the dashboard, a dose moving compliance, a backup coming
* back. They are only worth that role if the server under them is the one
* that ships. A spec that stubs `/api/*` with `page.route(...)` proves that
* the UI renders a payload somebody typed into the test, which is how a broken
* write path stays green: the mock answers 200 whatever the route does.
*
* So a spec FILE that carries `@critical` anywhere may not intercept the app's
* API, and the rule is per file rather than per test because a route armed in
* a `beforeEach` or a `describe`-level hook covers every test beside it.
* What it catches:
*
* - `.route(` / `.routeFromHAR(` in the spec whose first argument mentions
* `api`, or is not a literal at all (a pattern held in a variable cannot be
* shown to miss `/api`, so it is refused rather than trusted);
* - a helper imported from a relative module whose exported function arms
* such a route (`stubDashboardAnalytics`, `mockMedicationsApi`, …). The
* helper is read, its exported functions are cut apart at each top-level
* `export`, and any that intercepts `/api` may not be imported by name.
*
* Stubbing a third-party host that is unreachable from CI and is not the
* journey's subject stays possible: a literal pattern without `api` passes.
* The spec is expected to say why next to the call.
*
* It is a tripwire with a scanner's limits, not a parser: a route armed
* through a helper that is not imported by name (a default export, a
* re-export chain) is not followed. The synthetic cases at the bottom prove
* each matcher fires; the floor proves the sweep found the tagged tests at
* all.
*/
import { existsSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import {
readSourceText,
requireFloor,
walkSourceFiles,
} from "./helpers/source-files";
const E2E = join(process.cwd(), "e2e");
/**
* The `@critical` tests found in `e2e/*.spec.ts`. Pinned at the real count:
* the set is the release gate, so a journey that loses its tag is a change
* somebody should see here. It counts title literals in the source, so a
* title written once inside a loop counts once however many tests it names.
*/
const CRITICAL_TEST_FLOOR = 16;
/** A test whose title literal carries the tag, however the call is spelled. */
const CRITICAL_TEST =
/\btest(?:\.(?:only|skip|fixme|fail|slow))?\(\s*(["'`])((?:(?!\1)[\s\S])*?@critical(?:(?!\1)[\s\S])*?)\1/g;
/** A route interception call; the first argument is examined separately. */
const ROUTE_CALL = /\.route(?:FromHAR)?\(\s*/g;
/**
* The source without its comment LINES: a line whose first non-blank text is
* `//`, and a block that opens at the start of a line. Deliberately not the
* shared `stripComments`, which opens a block at any `/*` — and route globs
* (`"**\/api/day/**"`, `"https://host/**"`) are full of them, so it would
* swallow the code after the first glob, including any `.route(` there. Prose
* about this rule lives in comment lines, which is what has to go.
*/
function stripCommentLines(source: string): string {
return source
.replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, "")
.replace(/^[ \t]*\/\/.*$/gm, "");
}
interface StubFinding {
/** Where the interception lives (the spec, or the helper it imports). */
where: string;
/** What was found. */
detail: string;
}
/**
* The first argument of a call whose open paren ends at `start`: a string,
* template or regex literal is returned whole; anything else is returned up to
* the first comma or closing paren and flagged as not a literal.
*/
function firstArgument(
source: string,
start: number,
): { text: string; literal: boolean } {
const opener = source[start];
if (opener === '"' || opener === "'" || opener === "`") {
const end = source.indexOf(opener, start + 1);
return { text: source.slice(start, end + 1), literal: true };
}
if (opener === "/") {
// A regex literal: up to the next unescaped slash.
let i = start + 1;
while (i < source.length && !(source[i] === "/" && source[i - 1] !== "\\"))
i += 1;
return { text: source.slice(start, i + 1), literal: true };
}
const rest = source.slice(start);
const stop = rest.search(/[,)]/);
return { text: rest.slice(0, stop === -1 ? 80 : stop), literal: false };
}
/** Every `/api` (or unprovable) interception in one source text. */
function apiStubsIn(source: string): string[] {
const code = stripCommentLines(source);
const hits: string[] = [];
for (const m of code.matchAll(ROUTE_CALL)) {
const arg = firstArgument(code, (m.index ?? 0) + m[0].length);
if (!arg.literal) {
hits.push(`route pattern is not a literal: ${arg.text.trim()}`);
} else if (/api/i.test(arg.text)) {
hits.push(`route intercepts the app's API: ${arg.text}`);
}
}
return hits;
}
/** The names a spec imports from each relative module. */
function relativeImports(
source: string,
): Array<{ names: string[]; from: string }> {
const code = stripCommentLines(source);
const out: Array<{ names: string[]; from: string }> = [];
for (const m of code.matchAll(
/import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["'](\.{1,2}\/[^"']+)["']/g,
)) {
const names = m[1]
.split(",")
.map((n) =>
n
.trim()
.replace(/^type\s+/, "")
.split(/\s+as\s+/)[0]
.trim(),
)
.filter(Boolean);
out.push({ names, from: m[2] });
}
return out;
}
/**
* Exported functions of a helper that intercept `/api`, by name. The helper is
* cut at each top-level `export`, so each slice is one export's body.
*/
function stubbingExports(helperSource: string): string[] {
const code = stripCommentLines(helperSource);
const starts = [...code.matchAll(/^export\s+/gm)].map((m) => m.index ?? 0);
const names: string[] = [];
starts.forEach((start, i) => {
const slice = code.slice(start, starts[i + 1] ?? code.length);
const name = slice.match(
/^export\s+(?:async\s+)?(?:function\s*\*?\s*|const\s+|let\s+)([A-Za-z0-9_$]+)/,
)?.[1];
if (name && apiStubsIn(slice).length > 0) names.push(name);
});
return names;
}
function resolveHelper(specRel: string, from: string): string | null {
const base = join(E2E, dirname(specRel), from);
for (const candidate of [`${base}.ts`, `${base}/index.ts`, base]) {
if (candidate.endsWith(".ts") && existsSync(candidate)) return candidate;
}
return null;
}
/** Everything that stubs `/api` in one spec file, its imported helpers included. */
function findApiStubs(
specRel: string,
source: string,
readHelper: (absolute: string) => string = readSourceText,
): StubFinding[] {
const findings: StubFinding[] = apiStubsIn(source).map((detail) => ({
where: specRel,
detail,
}));
for (const { names, from } of relativeImports(source)) {
const helper = resolveHelper(specRel, from);
if (!helper) continue;
const stubbing = new Set(stubbingExports(readHelper(helper)));
for (const name of names) {
if (stubbing.has(name)) {
findings.push({
where: relative(E2E, helper),
detail: `\`${name}\` (imported by ${specRel}) intercepts the app's API`,
});
}
}
}
return findings;
}
function specFiles(): string[] {
return walkSourceFiles(E2E, { floor: 100, extensions: [".ts"] }).filter(
(rel) => !rel.includes("/") && rel.endsWith(".spec.ts"),
);
}
function criticalTests(): Array<{ file: string; title: string }> {
const tests: Array<{ file: string; title: string }> = [];
for (const file of specFiles()) {
const code = stripCommentLines(readSourceText(join(E2E, file)));
for (const m of code.matchAll(CRITICAL_TEST)) {
tests.push({ file, title: m[2] });
}
}
return tests;
}
describe("@critical e2e journeys run against the real API", () => {
it("finds the tagged journeys", () => {
requireFloor("@critical e2e tests", criticalTests(), CRITICAL_TEST_FLOOR);
});
it("no file carrying @critical intercepts /api, directly or through a helper", () => {
const files = new Set(criticalTests().map((t) => t.file));
// A file that mentions the tag outside a test title (a describe title, a
// `grep` option) is held to the same rule.
for (const file of specFiles()) {
if (
stripCommentLines(readSourceText(join(E2E, file))).includes("@critical")
)
files.add(file);
}
const offenders = [...files].flatMap((file) =>
findApiStubs(file, readSourceText(join(E2E, file))).map(
(f) => `${f.where}: ${f.detail}`,
),
);
expect(offenders).toEqual([]);
});
describe("the matchers fire", () => {
it("on a glob, a regex and a non-literal pattern", () => {
expect(
apiStubsIn(`await page.route("**/api/measurements*", f);`),
).toHaveLength(1);
expect(
apiStubsIn(`await page.route(/\\/api\\/analytics(\\?|$)/, f);`),
).toHaveLength(1);
expect(apiStubsIn(`await context.route(PATTERN, f);`)).toHaveLength(1);
expect(
apiStubsIn(`await page.routeFromHAR("api.har", { update: false });`),
).toHaveLength(1);
});
it("not on a third-party host, a comment, or an unrelated call", () => {
expect(
apiStubsIn(`await page.route("https://tiles.example.org/**", f);`),
).toEqual([]);
expect(apiStubsIn(`// page.route("**/api/x", f)`)).toEqual([]);
expect(apiStubsIn(`router.push("/api/x");`)).toEqual([]);
});
it("on a stubbing helper imported by name, and not on its siblings", () => {
const helper = [
`export function mockThings(page) {`,
` void page.route("**/api/things**", (r) => r.fulfill({}));`,
`}`,
`export async function openThing(page) {`,
` await page.goto("/things");`,
`}`,
].join("\n");
expect(stubbingExports(helper)).toEqual(["mockThings"]);
const read = () => helper;
const uses = `import { mockThings, openThing } from "./medications-wizard-helpers";`;
expect(findApiStubs("x.spec.ts", uses, read)).toHaveLength(1);
const clean = `import { openThing } from "./medications-wizard-helpers";`;
expect(findApiStubs("x.spec.ts", clean, read)).toEqual([]);
});
});
});