Files
HealthLog/e2e/setup/global-setup.ts
T
Marc-André Bombeck cacf802b8d test(e2e): the sharing journey, and an honest account of the stale paint
Two browser contexts, two seeded accounts, one database: the owner invites,
the delegate accepts and opens the record, the owner takes it back. Every step
through the shipped UI, because the half of this feature that can go wrong is
the half a person touches.

The stale-paint check is a negative one and deliberately not "the new record's
data arrives" — a check that waits for the new data says nothing about what
was on screen while it arrived. An init script arms a MutationObserver before
any page script on every navigation, including the reload the switch performs,
and latches the delegate's own marker weight. The observer's positive control
runs first on a page that definitely shows it, so its later silence is not the
silence of a broken observer.

Then it was broken on purpose, and the result is written into the spec rather
than assumed. Removing the switch-time cache wipe, the persister's restore
guard and the record-scoped hash — all three — leaves the spec GREEN: the
restore already refuses to clobber a query the live cache holds, and the
dashboard's query is created during render, so the snapshot never reaches that
route's screen at all. Removing the reload turns it red on the banner rather
than on the marker. So this suite is a regression net over the journey, not a
proof of any one control; the controls are proven where they can be broken and
observed, in the unit suites, and the spec's header says so.

The nav assertion turned up a real gap while it was failing: the avatar block
still linked into the account settings from inside a shared record. Those
routes refuse under a switch, so the link led only to an explanation of why it
did not work. It renders as a plain identity block now, and the mobile user
menu drops the same utilities.

Setup gains a second account, a marker weight in each record, a grant reset,
and a clear of the login bucket — this setup signs in twice now, and the
ceiling is five attempts a quarter-hour, so three local runs used to end with a
429 from the fixture rather than a failure from the product.
2026-08-02 15:14:35 +02:00

397 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Playwright globalSetup — seeds the deterministic test user used by the
* authenticated specs AND logs that user in once, persisting the resulting
* `healthlog_session` cookie to a `storageState.json` file. Runs ONCE per
* `pnpm e2e` invocation, before any worker boots.
*
* Why login here instead of in each spec:
* - The login endpoint is rate-limited to 5 attempts per IP per 15min,
* and Playwright runs specs concurrently — even one spec re-logging-in
* per `beforeEach` would burn through the limit on the first 6
* authenticated tests, causing flake.
* - Per-spec login is also slow (argon2 verify ~300ms × N specs).
*
* The Next.js prod server that Playwright's `webServer` starts and the
* seed below both read `process.env.DATABASE_URL`, so they share the
* same Postgres instance. The seed is idempotent — re-runs against an
* existing dev DB rotate the password hash and clear AI/Codex state.
*
* Implementation note: we talk to Postgres via `pg` (raw SQL), NOT the
* Prisma client. Playwright's TS loader does not handle the generated
* Prisma client's `import.meta.url` indirection, and we don't need
* Prisma's type safety for a one-off upsert. The integration suite
* (`tests/integration/`) keeps using Prisma because Vitest's loader
* does support the generated client.
*/
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { hash } from "@node-rs/argon2";
import {
request as playwrightRequest,
type FullConfig,
} from "@playwright/test";
import pg from "pg";
// Resolve relative to the project root rather than __dirname/import.meta.url —
// Playwright's TS loader runs files as CJS without `"type":"module"`, and
// loading either dirname helper at module scope crashes the test runner.
// The repo layout puts this file at e2e/setup/global-setup.ts so we use
// process.cwd() (== project root for `pnpm e2e`).
export const STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageState.json",
);
export const E2E_USER = {
email: "e2e@healthlog.test",
username: "e2e-tester",
password: "ZJ4hN8x!Pq3vMr2C", // 16 chars, passes zxcvbn min-score-3 gate
role: "ADMIN",
} as const;
/**
* v1.36.0 — the SECOND account, so account sharing has two sides to test.
*
* Sharing is the one feature in the product that cannot be exercised by one
* person: somebody has to own a record and somebody else has to be given it.
* This account owns the record; `E2E_USER` is the delegate throughout the
* sharing spec.
*/
export const E2E_OWNER = {
email: "e2e-owner@healthlog.test",
username: "e2e-owner",
password: "Qv7bR2n!Ks9wLd4T",
role: "USER",
} as const;
/**
* Weights seeded one per account, chosen so neither can be mistaken for the
* other in rendered markup and neither collides with a value any other spec
* writes.
*
* The delegate's number is the whole point of the stale-paint check: after
* switching into the owner's record it must appear NOWHERE, at no moment. A
* spec that only waited for the owner's data to arrive would prove nothing
* about what was on screen in between.
*/
export const E2E_DELEGATE_MARKER_KG = 77.7;
export const E2E_OWNER_MARKER_KG = 63.3;
export const OWNER_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateOwner.json",
);
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
}
function cuid(): string {
// Match Prisma's default cuid format closely enough that downstream
// queries that filter on `id LIKE 'c%'` (none currently) keep working.
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let id = "c";
for (let i = 0; i < 24; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
export default async function globalSetup(config: FullConfig): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error(
"[e2e/global-setup] DATABASE_URL is not set — Playwright cannot seed the test user.",
);
}
const pool = new pg.Pool({ connectionString: url });
try {
const passwordHash = await hashPassword(E2E_USER.password);
const now = new Date();
const dob = new Date("1985-06-15T00:00:00.000Z");
// Postgres-native upsert keyed on the unique username column. The
// SET clause clears any leftover Codex / insights state from a
// previous run so the "Settings → AI" spec sees the disconnected
// baseline; we also stamp `onboarding_completed_at` so the auth
// shell does not bounce the user to /onboarding, AND set
// `onboarding_tour_completed = true` so the spotlight tour does
// NOT auto-launch on the dashboard. Without that flag the tour
// mounts a full-viewport `role="dialog"` overlay with `z-index:200`
// that intercepts every pointer event — every authenticated spec
// that tries to click a header / sidebar / quick-add button times
// out (50+ failed CI runs since v1.4.13). Specs that need the
// tour can opt back in by mocking `/api/auth/me`.
// Nutrients is intentionally opt-in in production. The shared fixture
// enables it because the water-capture specs exercise that real gate.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role,
created_at, updated_at,
height_cm, date_of_birth, gender,
codex_connection_status,
insights_privacy_mode,
onboarding_completed_at,
onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, $5,
$6, $6,
180, $7, 'MALE',
'disconnected',
'aggregated',
$6,
true,
'{"nutrients":true}'::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role,
updated_at = EXCLUDED.updated_at,
height_cm = EXCLUDED.height_cm,
date_of_birth = EXCLUDED.date_of_birth,
gender = EXCLUDED.gender,
codex_access_token_encrypted = NULL,
codex_refresh_token_encrypted = NULL,
codex_token_expires_at = NULL,
codex_connected_at = NULL,
codex_connection_status = 'disconnected',
insights_cached_at = NULL,
insights_cached_text = NULL,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json`,
[
cuid(),
E2E_USER.username,
E2E_USER.email,
passwordHash,
E2E_USER.role,
now,
dob,
],
);
// Repeated local E2E runs reuse the same seeded account and database.
// Its owner-scoped share-link bucket lasts an hour, so otherwise the third
// run can start above the 20-operation ceiling and fail before exercising
// the share flow. Reset only this fixture user's bucket; never touch
// unrelated rate-limit state in a developer database.
await pool.query(
`DELETE FROM rate_limits
WHERE key = 'share-link:' || (
SELECT id::text FROM users WHERE username = $1
)`,
[E2E_USER.username],
);
// Opt the seed account into cycle tracking. The user is seeded as
// `MALE`, and v1.18.0 resolves the `cycle` module through
// `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled`
// derives from gender, so a male account with no profile reads as
// cycle-OFF and `/cycle` redirects home. An explicit `true` opts the
// account in regardless of gender, which is what the cycle spec needs.
// Keyed by the username subquery so we never depend on the cuid above
// (the user upsert may have hit the ON CONFLICT path on a re-run).
await pool.query(
`INSERT INTO cycle_profiles
(id, user_id, cycle_tracking_enabled, created_at, updated_at)
SELECT $1, u.id, true, $2, $2
FROM users u
WHERE u.username = $3
ON CONFLICT (user_id) DO UPDATE SET
cycle_tracking_enabled = true,
updated_at = EXCLUDED.updated_at`,
[cuid(), now, E2E_USER.username],
);
// ── v1.36.0 — the sharing fixture ────────────────────────────────────
//
// A second account, one distinctive weight in each record, and a clean
// grant table. The reset matters as much as the seed: grants and switch
// stamps outlive a run, and a second run starting with the delegate
// already inside the owner's record would skip the invitation half of
// the journey while still going green.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role,
created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)
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`,
[
cuid(),
E2E_OWNER.username,
E2E_OWNER.email,
await hashPassword(E2E_OWNER.password),
now,
],
);
// Every grant between the pair, and any switch stamp either of them is
// carrying. Deleted rather than revoked: this is fixture hygiene, not a
// lifecycle transition, and a revoked row would still show in the panel.
await pool.query(
`DELETE FROM account_grants
WHERE grantor_id IN (SELECT id FROM users WHERE username = ANY($1))
OR grantee_id IN (SELECT id FROM users WHERE username = ANY($1))`,
[[E2E_USER.username, E2E_OWNER.username]],
);
await pool.query(
`UPDATE sessions SET acting_as_user_id = NULL
WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1))`,
[[E2E_USER.username, E2E_OWNER.username]],
);
await pool.query(
`DELETE FROM rate_limits
WHERE key LIKE 'sharing:%'`,
);
// The login bucket, for the same reason. This setup signs in TWICE now
// (delegate and owner), and the ceiling is five attempts per IP per
// quarter-hour — so three local runs in a row used to end with a 429 from
// the fixture rather than a failure from the product. Only the auth
// surfaces' own buckets are cleared; nothing else in the table is touched.
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
// One marker weight per record. Re-seeded by delete-then-insert so a
// re-run cannot accumulate duplicates and so an edited constant takes
// effect rather than sitting beside the old value.
for (const [username, kg] of [
[E2E_USER.username, E2E_DELEGATE_MARKER_KG],
[E2E_OWNER.username, E2E_OWNER_MARKER_KG],
] as const) {
await pool.query(
`DELETE FROM measurements
WHERE user_id = (SELECT id FROM users WHERE username = $1)
AND external_id = 'e2e-sharing-marker'`,
[username],
);
await pool.query(
`INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at,
external_id, created_at, updated_at)
SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3,
'e2e-sharing-marker', $3, $3
FROM users u WHERE u.username = $4`,
[cuid(), kg, now, 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
// commit.
console.log(
`[e2e/global-setup] seeded user ${E2E_USER.username} (${E2E_USER.email})`,
);
} finally {
await pool.end();
}
// Login once and persist the resulting cookie jar so every spec can
// reuse it via `playwright.config.ts → use.storageState`. This is the
// documented Playwright pattern (`docs/auth.md`) and the only way to
// avoid the per-spec login + rate-limit dance.
const baseURL =
config.projects[0]?.use.baseURL ??
process.env.E2E_BASE_URL ??
"http://localhost:3000";
const ctx = await playwrightRequest.newContext({ baseURL });
const res = await ctx.post("/api/auth/login", {
data: {
email: E2E_USER.username,
password: E2E_USER.password,
},
});
if (res.status() !== 200) {
const body = await res.text();
throw new Error(
`[e2e/global-setup] login failed: HTTP ${res.status()} — ${body.slice(0, 200)}`,
);
}
const state = await ctx.storageState();
// Strip any cookies we don't recognise so storageState only carries
// what the auth shell needs. v1.4.22 C4 added `hl_onboarding` — the
// login route sets it to "pending" for new users and DELETES it for
// already-onboarded ones, so for our pre-seeded completed e2e user
// the cookie is absent and the proxy passes the dashboard through.
state.cookies = state.cookies.filter((c) =>
["healthlog_session", "healthlog-locale", "hl_onboarding"].includes(c.name),
);
// v1.27.11 — pin the shared auth state to English via an explicit locale
// cookie. The server now honours `User.locale` when the cookie is absent
// (the ITP fix), and the locale-switch spec mirrors its German pick into
// the SHARED e2e user's profile — without this cookie every spec running
// after it would render German and the English-string assertions fail.
// The cookie sits at the top of the resolution ladder, so each spec
// context stays deterministically English; locale-switch overrides it
// inside its own context only.
const base = new URL(baseURL);
state.cookies = state.cookies.filter((c) => c.name !== "healthlog-locale");
state.cookies.push({
name: "healthlog-locale",
value: "en",
domain: base.hostname,
path: "/",
expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
httpOnly: false,
secure: false,
sameSite: "Lax",
});
await writeFile(STORAGE_STATE_PATH, JSON.stringify(state, null, 2));
await ctx.dispose();
console.log(
`[e2e/global-setup] auth state captured to ${STORAGE_STATE_PATH}`,
);
// v1.36.0 — the owner's cookie jar, captured the same way and for the same
// reason: the login endpoint is IP-rate-limited and the sharing spec needs
// both sides of a grant signed in at once.
const ownerCtx = await playwrightRequest.newContext({ baseURL });
const ownerRes = await ownerCtx.post("/api/auth/login", {
data: { email: E2E_OWNER.username, password: E2E_OWNER.password },
});
if (ownerRes.status() !== 200) {
const body = await ownerRes.text();
throw new Error(
`[e2e/global-setup] owner login failed: HTTP ${ownerRes.status()} — ${body.slice(0, 200)}`,
);
}
const ownerState = await ownerCtx.storageState();
ownerState.cookies = ownerState.cookies.filter(
(c) => c.name === "healthlog_session" || c.name === "hl_onboarding",
);
ownerState.cookies.push({
name: "healthlog-locale",
value: "en",
domain: base.hostname,
path: "/",
expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
httpOnly: false,
secure: false,
sameSite: "Lax",
});
await writeFile(
OWNER_STORAGE_STATE_PATH,
JSON.stringify(ownerState, null, 2),
);
await ownerCtx.dispose();
console.log(
`[e2e/global-setup] owner auth state captured to ${OWNER_STORAGE_STATE_PATH}`,
);
}