diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index fd0a7d434..6821cba0d 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -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/`. diff --git a/e2e/backup-restore-journey.spec.ts b/e2e/backup-restore-journey.spec.ts index 9104ec5f6..a5193ca58 100644 --- a/e2e/backup-restore-journey.spec.ts +++ b/e2e/backup-restore-journey.spec.ts @@ -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 { + 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", () => { diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts index 5c7cfa565..f63b54f5c 100644 --- a/e2e/delegated-writes.spec.ts +++ b/e2e/delegated-writes.spec.ts @@ -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"]'), diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 4a0b4ea49..07761eecc 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -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 { 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 { + 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 { + 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 { + 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 { + // 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); + } }); }); diff --git a/e2e/measurement-flow.spec.ts b/e2e/measurement-flow.spec.ts index 414dba6f6..7cc228024 100644 --- a/e2e/measurement-flow.spec.ts +++ b/e2e/measurement-flow.spec.ts @@ -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> { + 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 `
`. 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 `
`. 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); + }); }); }); diff --git a/e2e/medication-compliance-journey.spec.ts b/e2e/medication-compliance-journey.spec.ts index 83f01b3dc..809f7120e 100644 --- a/e2e/medication-compliance-journey.spec.ts +++ b/e2e/medication-compliance-journey.spec.ts @@ -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; diff --git a/e2e/mental-health-check-in.spec.ts b/e2e/mental-health-check-in.spec.ts index 85e4b77bb..399bf49f2 100644 --- a/e2e/mental-health-check-in.spec.ts +++ b/e2e/mental-health-check-in.spec.ts @@ -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, + }, + ]); + }); + } +}); diff --git a/e2e/mood-timestamp.spec.ts b/e2e/mood-timestamp.spec.ts index f157aa674..92f0a43f9 100644 --- a/e2e/mood-timestamp.spec.ts +++ b/e2e/mood-timestamp.spec.ts @@ -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> { + 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 | null = null; - - await page.route("**/api/mood-entries*", async (route) => { - if (route.request().method() === "POST") { - submitted = route.request().postDataJSON() as Record; - 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"); }); }); diff --git a/e2e/pwa-offline.spec.ts b/e2e/pwa-offline.spec.ts index 05701ce0d..28b34c16a 100644 --- a/e2e/pwa-offline.spec.ts +++ b/e2e/pwa-offline.spec.ts @@ -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, }) => { diff --git a/e2e/record-csv-import.spec.ts b/e2e/record-csv-import.spec.ts index 5ad83d964..d1168700a 100644 --- a/e2e/record-csv-import.spec.ts +++ b/e2e/record-csv-import.spec.ts @@ -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> { + 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); + }); }); }); diff --git a/e2e/settings-persistence.spec.ts b/e2e/settings-persistence.spec.ts new file mode 100644 index 000000000..cdf7399de --- /dev/null +++ b/e2e/settings-persistence.spec.ts @@ -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 { + 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(); + } + }); +}); diff --git a/e2e/setup/critical-fixture.ts b/e2e/setup/critical-fixture.ts new file mode 100644 index 000000000..e7ec87b76 --- /dev/null +++ b/e2e/setup/critical-fixture.ts @@ -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 = 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, + testInfo: TestInfo, + ): Promise => { + await use(criticalAccount(journey, testInfo).storageStatePath); + }; +} + +async function withPool(fn: (pool: pg.Pool) => Promise): Promise { + 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 { + 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 { + 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 { + 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), + }; + }); +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index faf10ceb1..493b36f80 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -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 { [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 { `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(); } diff --git a/package.json b/package.json index a8f6354bb..2094a014f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 9a53b27c1..cd8a69347 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 diff --git a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts new file mode 100644 index 000000000..983d71d8b --- /dev/null +++ b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts @@ -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([]); + }); + }); +});