mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'feat/v146-ui-e2e' into feat/v146-ui
This commit is contained in:
commit
8a6c6bfdd7
16 files changed
+1610
-295
No files matched your search
@@ -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/`.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user