/** * 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", ); /** * A SECOND cookie jar for the same delegate account, and the reason it exists * is the one property that makes account switching work at all: the switch is * stamped on the session row, not held in the tab. Every session opened from * `STORAGE_STATE_PATH` is therefore the same session — so a spec that switches * that jar into somebody else's record switches it for every spec that * authenticates with it, whichever of them happens to be mid-navigation on the * other worker. * * The symptom is a wandering one: `/settings/ai` painting "Not part of shared * access", an axe scan reading a banner nobody asked for, a `waitForResponse` * on a request the refused surface never sends. Nothing in the failing spec is * wrong and nothing in its file explains it. * * So the journey that switches gets its own login, and the shared jar is never * pointed anywhere. Same account, different session row, no overlap. */ export const DELEGATE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateDelegate.json", ); async function hashPassword(password: string): Promise { 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 { 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 THREE times // now (the shared jar, the owner, and the sharing journey's own delegate // jar), and the ceiling is five attempts per IP per quarter-hour — so two // local runs in a row would otherwise 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(); } // Log the fixture accounts in here and persist their cookie jars so specs // can reuse them via `test.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"; // The shared jar every authenticated spec reads. await captureAuthState(baseURL, E2E_USER, STORAGE_STATE_PATH); // v1.36.0 — the owner's jar. The sharing journey needs both sides of a grant // signed in at once, and the login endpoint is IP-rate-limited, so the owner // is logged in here rather than inside the spec. await captureAuthState(baseURL, E2E_OWNER, OWNER_STORAGE_STATE_PATH); // The delegate's own jar for that journey. Same account as the shared one, // deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for // what happens when the journey switches the shared row instead. await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH); } /** * Log one account in and write its cookie jar to `path`. * * Each call mints a fresh session row, so two jars for the same account are * two independent browser sessions — which is the whole point of the delegate * jar above. * * Only the cookies the auth shell needs survive the filter. v1.4.22 C4 added * `hl_onboarding`: the login route sets it to "pending" for a new account and * DELETES it for an already-onboarded one, so for these pre-seeded accounts it * is absent and the proxy passes the dashboard through. * * v1.27.11 — the locale cookie is pinned to English rather than inherited. The * server honours `User.locale` when the cookie is absent (the ITP fix), and * the locale-switch spec mirrors its German pick into the SHARED account's * profile — without this cookie every spec running after it would render * German and the English-string assertions would 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. */ async function captureAuthState( baseURL: string, account: { username: string; password: string }, path: string, ): Promise { const ctx = await playwrightRequest.newContext({ baseURL }); try { const res = await ctx.post("/api/auth/login", { data: { email: account.username, password: account.password }, }); if (res.status() !== 200) { const body = await res.text(); throw new Error( `[e2e/global-setup] login failed for ${account.username}: HTTP ${res.status()} — ${body.slice(0, 200)}`, ); } const state = await ctx.storageState(); state.cookies = state.cookies.filter((c) => ["healthlog_session", "hl_onboarding"].includes(c.name), ); state.cookies.push({ name: "healthlog-locale", value: "en", domain: new URL(baseURL).hostname, path: "/", expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365, httpOnly: false, secure: false, sameSite: "Lax", }); await writeFile(path, JSON.stringify(state, null, 2)); console.log(`[e2e/global-setup] auth state captured to ${path}`); } finally { await ctx.dispose(); } }