/** * 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"; import { allCriticalAccounts, FORM_SIGN_IN_JOURNEYS } from "./critical-fixture"; import { seedDayDoorsRecord } from "./day-doors-fixture"; import { seedMobileRoutesRecord } from "./mobile-routes-fixture"; // 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; /** * v1.37.2 — the owner carries a full name, so the switcher journey can assert * that a delegate sees it. Generic on purpose: fixtures hold no real name. */ export const E2E_OWNER_FULL_NAME = "Test Full Name"; /** * The backup/restore journey's own account. * * Its own account, and an administrative one, because the restore path is * admin-only by construction (`requireAdmin()` is cookie-only) and because the * journey is destructive on purpose: it deletes rows, restores an earlier * snapshot over them, and asserts what came back. Pointing that at an account * any other spec reads would make every one of them depend on where this * journey happens to be. */ export const E2E_BACKUP_ADMIN = { email: "e2e-backup-admin@healthlog.test", username: "e2e-backup-admin", password: "Hw3!Tq7vZm2xRb8L", role: "ADMIN", } as const; /** * The read-level delegate on that account's record. * * The refusing half of the journey needs somebody who is inside the record and * still has no business taking or replaying a copy of it. A separate account * rather than one of the existing delegates: this one is granted READ over the * whole record above, and reusing a delegate would change what the scoped * journeys see. */ export const E2E_BACKUP_DELEGATE = { email: "e2e-backup-delegate@healthlog.test", username: "e2e-backup-delegate", password: "Ld6!Yn4kW9pJc3Vt", role: "USER", } as const; /** * The delegated-writes journey's own pair. * * That journey used to invite `E2E_USER` from `E2E_OWNER`, the same pair the * account-sharing journey invites. Only one live grant can stand between two * accounts, so whichever of the two files reached its invitation second met a * 409, and on CI the two can run in parallel. A pair of its own removes the * collision instead of ordering around it. */ export const E2E_WRITE_OWNER = { email: "e2e-write-owner@healthlog.test", username: "e2e-write-owner", password: "Rk5!Wq8tHn3zPv6D", role: "USER", } as const; /** Generic on purpose, like `E2E_OWNER_FULL_NAME`: fixtures hold no real name. */ export const E2E_WRITE_OWNER_FULL_NAME = "Test Write Owner"; export const E2E_WRITE_DELEGATE = { email: "e2e-write-delegate@healthlog.test", username: "e2e-write-delegate", password: "Tm4!Xc9vBs2kLw7J", role: "USER", } as const; /** * v1.37.0 — the account that creates and administers managed profiles. * * Its own account, and not one of the others, for one reason: every route in * the managed-profile family resolves `requireFreshMfa`, which refuses an * account with NO second factor enrolled (`auth.stepup.mfa_not_enrolled`). So * this account carries a confirmed TOTP secret, and an account with a confirmed * secret cannot be logged in by the password-only capture below. The setup * therefore logs it in FIRST and stamps the factor afterwards — see the * enrolment step at the end of `globalSetup`. * * Enrolling the factor on a shared account would have changed behaviour for * every spec that touches a step-up-gated surface, starting with the MANAGE * invitation on the sharing panel. */ export const E2E_GUARDIAN = { email: "e2e-guardian@healthlog.test", username: "e2e-guardian", password: "Rt5!Nm8xQ3wZ6bJp", role: "USER", } as const; /** * The medication-adherence journey's own account. * * The batched compliance read is capped at thirty calls a minute per ACTOR, * and the journey spends that allowance twice over: it writes doses and then * reads the resulting rates back off the same cabinet. On the shared account * the allowance is also being spent by whichever sibling happens to be * mounting `/medications` in the other worker, and the cabinet itself is * shared with them — so the journey's `beforeEach` clear would take rows * somebody else seeded with it. One account of its own makes the rate-limit * bucket, the cabinet and the dashboard tile's denominator private at once. */ export const E2E_MEDICATION = { email: "e2e-medication@healthlog.test", username: "e2e-medication", password: "Hj6!Tw2pV9sQ4nDx", role: "USER", } as const; /** * The module-surfaces journey's own account (`modules-off.spec.ts`). * * It switches mood and medications off and on again, and those switches are * read by every surface of the account. On the shared account a parallel spec * reading a mood chart or the add menu would see them flip underneath it, so * the journey gets a record nobody else reads. */ export const E2E_MODULES = { email: "e2e-modules@healthlog.test", username: "e2e-modules", password: "Qm4!Vr8tK2wZp7Ls", role: "USER", } as const; export const MODULES_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateModules.json", ); /** * The phone-width route sweep's own account (`mobile-route-overflow.spec.ts`). * * It holds one or two of everything a route can render * (`mobile-routes-fixture.ts`), because the sweep is only worth something * against populated pages, and writing that much into the shared account would * change what every other spec reads. An administrator, so the admin console * is swept too; `FEMALE`, so the cycle surfaces render without a switch. */ export const E2E_MOBILE_ROUTES = { email: "e2e-mobile-routes@healthlog.test", username: "e2e-mobile-routes", password: "Wd7!Kp3mTz9qRv2N", role: "ADMIN", } as const; export const MOBILE_ROUTES_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateMobileRoutes.json", ); /** * The AI-optional journeys' own account (`ai-optional-*.spec.ts`). * * The consent journey gives it a provider, a stored briefing and an AI * consent, then withdraws the consent; the Coach-memory journey hides its * Coach and deletes a stored conversation. Both change what every AI surface * of the account shows, so they run on a record nobody else reads. */ export const E2E_AI_OPTIONAL = { email: "e2e-ai-optional@healthlog.test", username: "e2e-ai-optional", password: "Rw5!Nc9pT3xLq6Zb", role: "USER", } as const; /** * The timeline journey's own account (`timeline.spec.ts`). * * It switches the opt-in timeline module on and off, which changes the * navigation, the add menus and Settings for the whole account; on the shared * account a parallel spec would see them move underneath it. The module * starts off here, the way every account starts. */ export const E2E_TIMELINE = { email: "e2e-timeline@healthlog.test", username: "e2e-timeline", password: "Tl6!Wq3zRm8vXn2K", role: "USER", } as const; export const TIMELINE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateTimeline.json", ); /** * The vault specs' own accounts (`documents.spec.ts`, and the documents * journeys of `v142-qol.spec.ts`). * * The vault's layout (cards or a list, months stacked or side by side) is a * per-account preference. On the shared account the layout journey turned it * while the month-grid tests read the default, so either could fail depending * on which ran first. Each file seeds its own vault into its own account * (`ensureVaultFixture(username)`). */ export const E2E_VAULT = { email: "e2e-vault@healthlog.test", username: "e2e-vault", password: "Vt4!Hq8mZr2wKx6N", // The upload-cap test sets the instance's cap through the admin console. role: "ADMIN", } as const; export const VAULT_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateVault.json", ); export const E2E_VAULT_LAYOUT = { email: "e2e-vault-layout@healthlog.test", username: "e2e-vault-layout", password: "Vl7!Bn3qXw9pRt5M", role: "USER", } as const; export const VAULT_LAYOUT_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateVaultLayout.json", ); /** * The day-doors journey's own account (`day-doors.spec.ts`). * * It turns a chart's comparison overlay on, which is a per-account * preference; on a shared account a parallel spec would find its chart * drawing a prior period it never asked for. Its record carries the series * the journey clicks (recovery, sleep, mood, weight, a lab marker). */ export const E2E_DAY_DOORS = { email: "e2e-day-doors@healthlog.test", username: "e2e-day-doors", password: "Dy4!Rk8wQm2vTz6P", role: "USER", } as const; export const DAY_DOORS_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateDayDoors.json", ); /** * The score-history journey's own account (`score-history.spec.ts`). * * It switches the dashboard's top card off and back on and remembers a range * tab per score page, both per-account preferences; on a shared account a * parallel spec would find its dashboard without the card it asserts. */ export const E2E_SCORE_HISTORY = { email: "e2e-score-history@healthlog.test", username: "e2e-score-history", password: "Sh7!Vn2qLp9xWc4R", role: "USER", } as const; export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateScoreHistory.json", ); export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateAiOptional.json", ); /** * The notification-dispatch journey's own account. * * It has one, rather than borrowing the shared fixture, because the journey's * verdicts are counts over ONE account's `push_attempts` ledger — "one email * attempt, no ntfy attempt, the APNs arm skipped and for which reason". * * The direction that matters is the one that is easy to state backwards. The * exposure is not mainly that another spec writes into this account's window; * it is that this journey's own trigger — the admin reminder sweep — dispatches * for whatever accounts it is pointed at, and pointed at the instance it * reaches all of them. A dedicated account gives the sweep something to name, * so the run stays a writer for exactly one ledger and a reader of exactly the * same one. * * ADMIN because the two surfaces the journey reads the dispatch decision * through — the reminder trigger and the notification diagnostic — are * `requireAdmin()`, which is cookie-only by construction. */ export const E2E_NOTIFY = { email: "e2e-notify@healthlog.test", username: "e2e-notify", password: "Xd6!Ct3pW9qBz5Fh", role: "ADMIN", } as const; export const NOTIFY_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateNotify.json", ); /** A separate delegate session for the eight scoped-record browser journeys. */ export const E2E_SCOPE_DELEGATE = { email: "e2e-scope-delegate@healthlog.test", username: "e2e-scope-delegate", password: "Qz8!Vp4rL2nX7mKs", role: "USER", } as const; /** * The doctor-report journey's own account. * * Generating a report WRITES: the route remembers the scope and the practice * name on the account row, and the panel a later visit opens is therefore the * "repeat run" state with the picker collapsed. `settings-export.spec.ts` * asserts the opposite — an expanded picker, an empty summary, a disabled * button — against the shared account, so a journey that generated there would * turn nine of its assertions red the moment the two files happened to run in * the wrong order, and keep them red on every later run against the same * database. * * Its own account also keeps the seeded readings, the emergency profile and * the hourly export bucket off the account sixty other specs read. * * `date_format` is pinned rather than left on AUTO: the spec reads the period * line out of the rendered PDF and has to know which field is the month. */ export const E2E_REPORT_OWNER = { email: "e2e-report-owner@healthlog.test", username: "e2e-report-owner", password: "Hd6!Tw3pV9nQ2xLb", role: "USER", } as const; /** One non-personal target record per closed sharing domain. */ export const E2E_SCOPE_RECORDS = [ { domain: "measurements", username: "e2e-scope-measurements", href: "/measurements", }, { domain: "medications", username: "e2e-scope-medications", href: "/medications", }, { domain: "labs", username: "e2e-scope-labs", href: "/labs" }, { domain: "profile", username: "e2e-scope-profile", href: "/profile" }, { domain: "illness", username: "e2e-scope-illness", href: "/illness" }, { domain: "mind", username: "e2e-scope-mind", href: "/mood" }, { domain: "cycle", username: "e2e-scope-cycle", href: "/cycle" }, { domain: "documents", username: "e2e-scope-documents", href: "/documents" }, ] as const; /** * Whole-record grants used to prove the three adult access levels and the * separate managed-profile boundary in a real browser session. */ export const E2E_LEVEL_RECORDS = [ { username: "e2e-level-read", access: "READ", recordKind: "shared", }, { username: "e2e-level-write", access: "WRITE", recordKind: "shared", }, { username: "e2e-level-manage", access: "MANAGE", recordKind: "shared", }, { username: "e2e-level-managed", access: "MANAGE", recordKind: "managed", }, ] 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", ); export const SCOPE_DELEGATE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateScopeDelegate.json", ); export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateScopeA11y.json", ); /** * v1.38.12 — the managed-record capabilities journey's own jar. Same scope * delegate, its own session row, for the reason every switching spec has * one: it moves the record selector, and two specs driving one row from two * workers would test their interference rather than the controls. */ export const SCOPE_CAPABILITIES_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateScopeCapabilities.json", ); /** The adherence journey's jar — see `E2E_MEDICATION` for why it is its own. */ export const MEDICATION_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateMedication.json", ); /** * v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at * `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as * well. * * The record-session fence made the cost visible. Four specs moved the same * session row's record selector — the two fence specs, the cross-tab journey * and the scoped-sharing journey — and the fence's whole subject is that row's * context and its monotonic epoch. On one worker they never overlapped; on two * they moved the epoch under each other, and the failure was the harness racing * itself. Passing only at `--workers=1` is not a passing suite: CI runs two. * * A separate LOGIN, not a separate account: the selector lives on the session * row, so two jars for one account are two independent contexts. The grants, * the fixtures and the assertions all stay exactly as they were. */ export const FENCE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateFence.json", ); export const FENCE_OFFLINE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateFenceOffline.json", ); /** * The managed-profile journey's own jar. Its own session row, like every other * switching spec, so a run at CI's worker count cannot have two specs moving * the same session's record selector. */ export const GUARDIAN_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateGuardian.json", ); export const CROSS_TAB_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateCrossTab.json", ); /** The delegated-writes journey's jars, one per side of its own pair. */ export const WRITE_OWNER_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateWriteOwner.json", ); export const WRITE_DELEGATE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateWriteDelegate.json", ); /** The backup/restore journey's jar, for the account it owns outright. */ export const BACKUP_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateBackup.json", ); /** * The read-level delegate's jar for that journey. Its own session row, like * every other switching spec's — the refusal control switches into the * journey's record, and the switch is stamped on the row rather than held in * the tab. */ export const BACKUP_DELEGATE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateBackupDelegate.json", ); /** * The doctor-report delegate journey's jar. Its own session row, for the reason * every switching spec has one: it opens somebody else's record to prove the * report refuses a READ-level grant, and two specs driving one row from two * workers would test their interference rather than the refusal. */ export const REPORT_DELEGATE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateReportDelegate.json", ); /** * The doctor-report journey's jar. Its own account, so what generating a report * stamps on the account row cannot reach the shared one — see `E2E_REPORT_OWNER`. */ export const REPORT_OWNER_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateReportOwner.json", ); /** * The banner-geometry spec's jar, and the reason it exists is a release-shaped * one rather than a load-shaped one. * * `chrome-header-seam-banners.spec.ts` needs a session that is INSIDE somebody * else's record, because one of the strips it stacks only paints there. It used * to produce that state by capturing `/api/auth/me` and serving it back with * the sharing block filled — cheaper than a switch, and self-contained, which is * exactly why it was written that way. * * v1.37.0 ended that. The record context is now proved across three places at * once — the payload's own validator (fail-closed, no partial credit), the fence * store's scope, and the transition machine that has to agree with both — so a * payload claiming a shared record while the session row still says `self` is a * state the shell refuses on purpose. A single route mock can no longer make it, * and the honest fix is to stop faking it: switch for real, and take a jar of * its own so moving one session row cannot move anybody else's. */ export const SEAM_BANNERS_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSeamBanners.json", ); /** * v1.39 — the setup flow's own accounts, one per journey. * * Each journey walks the questions from the welcome screen to the dashboard * and asserts what the walk wrote: the module map, the tile order, the * checklist, the reading or medication or profile the first-result step * produced. Two journeys sharing an account would each read the other's * answers, so every path has its own — and the two screen sweeps (axe, and * the string-headroom check across every locale) have theirs, because they * drive the same state through the API while a journey may be mid-walk. * * Seeded with `onboarding_completed_at` NULL, which is what makes the shell * land them on `/onboarding` in the first place; the fixture puts each one * back to that state before every test. */ export const E2E_SETUP_BP = { email: "e2e-setup-bp@healthlog.test", username: "e2e-setup-bp", password: "Bp7!Wq3nZ9xLm2Kd", role: "USER", } as const; export const E2E_SETUP_MEDICATION = { email: "e2e-setup-medication@healthlog.test", username: "e2e-setup-medication", password: "Md4!Rt8vC2yPn6Hs", role: "USER", } as const; export const E2E_SETUP_VISIT = { email: "e2e-setup-visit@healthlog.test", username: "e2e-setup-visit", password: "Vs9!Kq2mX7bTd4Lp", role: "USER", } as const; /** * The child-profile journey's account. It creates a managed profile on the * confirm screen, which resolves `requireFreshMfa`, so like the guardian it * carries a confirmed factor — stamped AFTER its login capture, because the * password-only capture cannot complete a TOTP login. */ export const E2E_SETUP_CHILD = { email: "e2e-setup-child@healthlog.test", username: "e2e-setup-child", password: "Ch3!Ns6wQ8kZr5Vb", role: "USER", } as const; export const E2E_SETUP_A11Y = { email: "e2e-setup-a11y@healthlog.test", username: "e2e-setup-a11y", password: "Ay5!Ht9pL3cWn7Xq", role: "USER", } as const; /** * v1.38.19 — the connect-source journey's account. * * The one arm of the flow that renders a claim about real account state had * no journey that reached it: bp answers `manual`, visit answers `file`, and * the child path ends at confirm. This account answers Q4 with a wearable, so * `/onboarding/first-result` really does render the connect step. */ export const E2E_SETUP_CONNECT = { email: "e2e-setup-connect@healthlog.test", username: "e2e-setup-connect", password: "Cn6!Zx4tB9wQm3Jf", role: "USER", } as const; export const E2E_SETUP_LOCALE = { email: "e2e-setup-locale@healthlog.test", username: "e2e-setup-locale", password: "Lc2!Vm7dR4jYs8Nt", role: "USER", } as const; /** * v1.38.19 — the shared-provider offer's own account. * * Its journey seeds INSTANCE-wide state (the operator key's presence and the * provider-health ledger the tri-state folds) and mints a consent receipt on * the account, so it cannot share one with a journey that asserts a module * map or a tile order. */ export const E2E_SETUP_AI_OFFER = { email: "e2e-setup-ai-offer@healthlog.test", username: "e2e-setup-ai-offer", password: "Ao6!Zd3fB9qMv2Rk", role: "USER", } as const; export const SETUP_BP_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupBp.json", ); export const SETUP_MEDICATION_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupMedication.json", ); export const SETUP_VISIT_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupVisit.json", ); export const SETUP_CHILD_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupChild.json", ); export const SETUP_A11Y_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupA11y.json", ); export const SETUP_CONNECT_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupConnect.json", ); export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupLocale.json", ); export const SETUP_AI_OFFER_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupAiOffer.json", ); const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_BP, E2E_SETUP_MEDICATION, E2E_SETUP_VISIT, E2E_SETUP_CHILD, E2E_SETUP_A11Y, E2E_SETUP_LOCALE, E2E_SETUP_AI_OFFER, E2E_SETUP_CONNECT, ] as const; 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 so the nutrients/hydration display specs render the card. 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_encrypted = NULL, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, module_preferences_json = EXCLUDED.module_preferences_json, report_selection_json = NULL, last_report_practice_name = NULL`, [ cuid(), E2E_USER.username, E2E_USER.email, passwordHash, E2E_USER.role, now, dob, ], ); // v1.37.0 — the managed-profile guardian. Seeded exactly like the other // fixture accounts; its second factor is stamped AFTER the login capture, // because the password-only capture cannot complete a TOTP login. 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, totp_confirmed_at = NULL`, [ cuid(), E2E_GUARDIAN.username, E2E_GUARDIAN.email, await hashPassword(E2E_GUARDIAN.password), now, ], ); // Whatever a previous run created. A managed profile is a real account row, // so a suite that left one behind would find the guardian looking after two // records on the next run and the roster assertions would read the wrong // one. Deleting the profile takes its grants with it. await pool.query( `DELETE FROM users WHERE managed_profile_at IS NOT NULL AND id IN ( SELECT g.grantor_id FROM account_grants g JOIN users u ON u.id = g.grantee_id WHERE u.username = $1 )`, [E2E_GUARDIAN.username], ); // The adherence journey's account. Modules are pinned rather than left to // the defaults: the dashboard's medication tile is one of the surfaces the // journey reads, and it only paints while the module is on. 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`, [ cuid(), E2E_MEDICATION.username, E2E_MEDICATION.email, await hashPassword(E2E_MEDICATION.password), now, JSON.stringify({ medications: true }), ], ); // The module-surfaces journey's account. Its module switches are set by // `e2e/setup/modules-fixture.ts` before every test. 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_MODULES.username, E2E_MODULES.email, await hashPassword(E2E_MODULES.password), now, ], ); // The phone-width route sweep's account. Every opt-in module is on, so // the sweep reaches the glucose, documents, wellbeing, nutrients and // environment surfaces; its record is written after the login capture. await pool.query( `INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, height_cm, date_of_birth, gender, onboarding_completed_at, onboarding_tour_completed, module_preferences_json) VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true, $8::jsonb) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, role = EXCLUDED.role, 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`, [ cuid(), E2E_MOBILE_ROUTES.username, E2E_MOBILE_ROUTES.email, await hashPassword(E2E_MOBILE_ROUTES.password), E2E_MOBILE_ROUTES.role, now, new Date("1988-04-12T00:00:00.000Z"), JSON.stringify({ glucose: true, environment: true, inboundDocuments: true, mentalHealth: true, nutrients: true, }), ], ); // The AI-optional journeys' account. Its provider, stored briefing, // consent and Coach rows are set by `e2e/setup/ai-optional-fixture.ts` // before each journey. 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_AI_OPTIONAL.username, E2E_AI_OPTIONAL.email, await hashPassword(E2E_AI_OPTIONAL.password), now, ], ); // The timeline journey's account. The module starts off; the journey // switches it through the real Settings switch. 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, NULL) 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 = NULL`, [ cuid(), E2E_TIMELINE.username, E2E_TIMELINE.email, await hashPassword(E2E_TIMELINE.password), now, ], ); // The vault specs' accounts — see `E2E_VAULT`. The vault fixture turns // the documents module on and seeds the documents. for (const account of [E2E_VAULT, E2E_VAULT_LAYOUT]) { 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, $6, $5, $5, $5, true) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, role = EXCLUDED.role, updated_at = EXCLUDED.updated_at, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`, [ cuid(), account.username, account.email, await hashPassword(account.password), now, account.role, ], ); } // The day-doors journey's account. Every module the journey reaches is // on; its record is written after the login capture. 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, NULL) 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 = NULL`, [ cuid(), E2E_DAY_DOORS.username, E2E_DAY_DOORS.email, await hashPassword(E2E_DAY_DOORS.password), now, ], ); // The score-history journey's account. Every module on, a fresh layout: // the journey moves its top card and range tabs, so a re-run starts from // the defaults. 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, NULL) 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 = NULL, dashboard_widgets_json = NULL`, [ cuid(), E2E_SCORE_HISTORY.username, E2E_SCORE_HISTORY.email, await hashPassword(E2E_SCORE_HISTORY.password), now, ], ); // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the // journey has to be re-runnable and `--repeat-each` is exactly the case a // once-per-suite reset does not cover. 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, $5, $6, $6, $6, true) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, role = EXCLUDED.role, updated_at = EXCLUDED.updated_at, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`, [ cuid(), E2E_NOTIFY.username, E2E_NOTIFY.email, await hashPassword(E2E_NOTIFY.password), E2E_NOTIFY.role, now, ], ); // The doctor-report journey's account. Seeded like the shared one — a // record with a height, a date of birth and a gender, so the report has a // patient header to print — and with the two report columns cleared, which // is what puts the panel back into its first-run state on every run. await pool.query( `INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, height_cm, date_of_birth, gender, date_format, onboarding_completed_at, onboarding_tour_completed) VALUES ($1, $2, $3, $4, 'USER', $5, $5, 180, $6, 'MALE', 'MDY', $5, true) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, updated_at = EXCLUDED.updated_at, height_cm = EXCLUDED.height_cm, date_of_birth = EXCLUDED.date_of_birth, gender = EXCLUDED.gender, date_format = EXCLUDED.date_format, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, report_selection_json = NULL, last_report_practice_name = NULL`, [ cuid(), E2E_REPORT_OWNER.username, E2E_REPORT_OWNER.email, await hashPassword(E2E_REPORT_OWNER.password), now, dob, ], ); // The scoped-record journey uses a dedicated delegate so its preseeded // grants cannot affect the invitation lifecycle exercised by the sharing // journey above. Its own module preferences are explicitly off: a target // record's resolved scope, rather than the actor's preferences, must keep // each granted doorway visible. 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`, [ cuid(), E2E_SCOPE_DELEGATE.username, E2E_SCOPE_DELEGATE.email, await hashPassword(E2E_SCOPE_DELEGATE.password), now, JSON.stringify({ medications: false, labs: false, illness: false, mood: false, mentalHealth: false, inboundDocuments: false, }), ], ); await pool.query( `DELETE FROM account_grants WHERE grantee_id = (SELECT id FROM users WHERE username = $1)`, [E2E_SCOPE_DELEGATE.username], ); await pool.query( `UPDATE sessions SET acting_as_user_id = NULL WHERE user_id = (SELECT id FROM users WHERE username = $1)`, [E2E_SCOPE_DELEGATE.username], ); for (const record of E2E_SCOPE_RECORDS) { 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, NULL, 'USER', $4, $4, $4, true, $5::jsonb) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, 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`, [ cuid(), record.username, `${record.username}@healthlog.test`, now, JSON.stringify({ medications: true, labs: true, illness: true, mood: true, mentalHealth: true, inboundDocuments: true, }), ], ); await pool.query( `INSERT INTO account_grants (id, grantor_id, grantee_id, access, scope_json, invited_at, accepted_at, created_at) SELECT $1, owner.id, delegate.id, 'READ', $2::jsonb, $3, $3, $3 FROM users owner, users delegate WHERE owner.username = $4 AND delegate.username = $5`, [ cuid(), JSON.stringify([record.domain]), now, record.username, E2E_SCOPE_DELEGATE.username, ], ); } // Whole-record fixtures intentionally sit beside the narrow records // above. They make the browser prove the presentation and mutation // boundaries of READ, WRITE, MANAGE, and a guardian-managed record // without changing the invitation lifecycle fixture below. for (const record of E2E_LEVEL_RECORDS) { await pool.query( `INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, managed_profile_at, onboarding_completed_at, onboarding_tour_completed, module_preferences_json) VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $5, $4, true, $6::jsonb) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = NULL, updated_at = EXCLUDED.updated_at, managed_profile_at = EXCLUDED.managed_profile_at, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, module_preferences_json = EXCLUDED.module_preferences_json, report_selection_json = NULL, last_report_practice_name = NULL`, [ cuid(), record.username, `${record.username}@healthlog.test`, now, record.recordKind === "managed" ? now : null, JSON.stringify({ medications: true, labs: true, illness: true, mood: true, mentalHealth: true, inboundDocuments: true, }), ], ); await pool.query( `INSERT INTO account_grants (id, grantor_id, grantee_id, access, scope_json, invited_at, accepted_at, created_at) SELECT $1, owner.id, delegate.id, $2, NULL, $3, $3, $3 FROM users owner, users delegate WHERE owner.username = $4 AND delegate.username = $5`, [ cuid(), record.access, now, record.username, E2E_SCOPE_DELEGATE.username, ], ); } // Cycle is delegated to its profile gate rather than module preferences. 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-scope-cycle"], ); // 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], ); // The export bucket, for the same reason and with the same shape. Every // export route shares one 10-per-hour bucket keyed on the ACTOR, the // counter outlives a run by a full hour, and the doctor-report journey // spends two of it per pass — so a fourth run inside the hour would meet a // 429 and report a missing artefact rather than a spent quota. Only the two // accounts that generate a report are cleared. await pool.query( `DELETE FROM rate_limits WHERE key IN ( SELECT 'export:' || id::text FROM users WHERE username = ANY($1) )`, [[E2E_REPORT_OWNER.username, E2E_SCOPE_DELEGATE.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, full_name) VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6) 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, full_name = EXCLUDED.full_name`, [ cuid(), E2E_OWNER.username, E2E_OWNER.email, await hashPassword(E2E_OWNER.password), now, E2E_OWNER_FULL_NAME, ], ); // 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 more than twenty times // (the shared jar, the owner, and one jar apiece for every spec that moves // a session's record selector), 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:%'`); // Every other bucket the suite can fill, for the same reason. A bucket is // keyed on the account it meters (`:`, sometimes with a // further segment) or, on an anonymous surface, on the caller's address, // and several of them outlive a run by up to an hour: the check-in // ceiling, the per-account write bucket, the export and import buckets. // A second run on the same database then met the first run's counts. // Cleared are the buckets of the fixture accounts (every one of them is // an `@healthlog.test` address or an `e2e-` record) and those of the // loopback addresses the suite calls from; a developer's own accounts // and their buckets are left alone. await pool.query( `DELETE FROM rate_limits r USING users u WHERE (u.email LIKE '%@healthlog.test' OR u.username LIKE 'e2e-%') AND position(u.id IN r.key) > 0`, ); await pool.query( `DELETE FROM rate_limits WHERE key LIKE '%:127.0.0.1' OR key LIKE '%:::1' OR key LIKE '%:::ffff:127.0.0.1' OR key LIKE '%:unknown'`, ); // 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`, // `measured_at` is `timestamp without time zone` and the driver // serialises a `Date` in the HOST's zone, so a developer machine east // of UTC seeded this row hours into the FUTURE — where every fold // whose window ends "now" skips it, and the type stays uncovered. // Send the instant as UTC, the same shape the app's own writes take. [cuid(), kg, now.toISOString(), username], ); } // v1.37.0 — clear what the browser suite WRITES, so the suite can be run // twice. // // The adult-WRITE journey saves a blood-pressure reading through the real // form, and the form stamps a minute-rounded `measuredAt`. Two runs inside // the same minute therefore collide on the natural key // (user, type, measured_at, source) — the second save is a duplicate, and // the journey asserts its POST succeeded. It has always been that way; it // used to surface as a 500 and now surfaces as the 409 the route learned to // give, which is what made it legible. A suite that cannot be re-run is a // suite whose green is a statement about the clock. // // Only rows the journey itself creates: MANUAL, and carrying no // `external_id`, which is what distinguishes them from the seeded markers // above. await pool.query( `DELETE FROM measurements WHERE user_id = (SELECT id FROM users WHERE username = $1) AND source = 'MANUAL' AND external_id IS NULL`, ["e2e-level-write"], ); // ── the delegated-writes journey's pair ────────────────────────────── // // Seeded like the sharing pair above and reset the same way: no grant // between them, no switch stamp on either, so the journey always starts // from the invitation. The spec revokes its grant when it ends; the delete // here covers a run that died before getting there. for (const [account, fullName] of [ [E2E_WRITE_OWNER, E2E_WRITE_OWNER_FULL_NAME], [E2E_WRITE_DELEGATE, null], ] as const) { await pool.query( `INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, onboarding_completed_at, onboarding_tour_completed, full_name) VALUES ($1, $2, $3, $4, $5, $6, $6, $6, true, $7) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, role = EXCLUDED.role, updated_at = EXCLUDED.updated_at, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, full_name = EXCLUDED.full_name, totp_confirmed_at = NULL`, [ cuid(), account.username, account.email, await hashPassword(account.password), account.role, now, fullName, ], ); } 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_WRITE_OWNER.username, E2E_WRITE_DELEGATE.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_WRITE_OWNER.username, E2E_WRITE_DELEGATE.username]], ); // The reading the delegate saves into the owner's record carries a // minute-rounded `measuredAt`, so a second run inside the same minute would // meet the natural-key 409 — the same reason `e2e-level-write` is cleared // above. await pool.query( `DELETE FROM measurements WHERE user_id = (SELECT id FROM users WHERE username = $1) AND source = 'MANUAL' AND external_id IS NULL`, [E2E_WRITE_OWNER.username], ); // ── the backup/restore journey's pair ──────────────────────────────── // // The account the journey owns, with the three modules its readings need // switched on, and the delegate that holds READ over the whole record. // Both are re-seeded here rather than in the spec so the journey never // spends a login on a surface the setup already owns. for (const account of [E2E_BACKUP_ADMIN, E2E_BACKUP_DELEGATE] as const) { 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, $5, $6, $6, $6, true, $7::jsonb) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, role = EXCLUDED.role, 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), account.role, now, JSON.stringify({ medications: true, mood: true, inboundDocuments: true, }), ], ); } // Grant hygiene before the grant, for the reason the sharing fixture // states above: a grant and a switch stamp both outlive a run. 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_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.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_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username]], ); // Whole-record READ: `scope_json` NULL is the un-narrowed grant, which is // the strongest read a delegate can hold and therefore the honest subject // for "and still cannot take or replay a copy". await pool.query( `INSERT INTO account_grants (id, grantor_id, grantee_id, access, scope_json, invited_at, accepted_at, created_at) SELECT $1, owner.id, delegate.id, 'READ', NULL, $2, $2, $2 FROM users owner, users delegate WHERE owner.username = $3 AND delegate.username = $4`, [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 // commit. console.log( `[e2e/global-setup] seeded user ${E2E_USER.username} (${E2E_USER.email})`, ); // 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. // v1.39 — the setup flow's accounts. `onboarding_completed_at` NULL // is the whole point: it is what lands them on `/onboarding`. The tour // flag stays true for the reason every other fixture sets it. Whatever a // previous run wrote — the answers row, the module map, the seeded // dashboard, a managed profile — is cleared by the journeys' own fixture // before each test; here the row itself is made to exist. for (const account of SETUP_FLOW_ACCOUNTS) { 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, NULL, true) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, updated_at = EXCLUDED.updated_at, onboarding_completed_at = NULL, onboarding_tour_completed = true, totp_confirmed_at = NULL`, [ cuid(), account.username, account.email, await hashPassword(account.password), now, ], ); } await pool.query( `DELETE FROM users WHERE managed_profile_at IS NOT NULL AND id IN ( SELECT g.grantor_id FROM account_grants g JOIN users u ON u.id = g.grantee_id WHERE u.username = $1 )`, [E2E_SETUP_CHILD.username], ); const baseURL = config.projects[0]?.use.baseURL ?? process.env.E2E_BASE_URL ?? "http://localhost:3000"; /** * Log one account in, clearing the login bucket first. * * The ceiling is FIVE attempts per IP per quarter-hour and this setup now * signs in more than twenty times, so clearing once before the batch is no longer * enough — the sixth would be answered by the fixture's own 429 rather * than by the product. Only the auth surfaces' buckets are touched, and * only between logins this setup is itself performing. */ const capture = async ( account: { username: string; password: string }, path: string, ): Promise => { await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); await captureAuthState(baseURL, account, path); }; // The shared jar every authenticated spec reads. await capture(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 capture(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 capture(E2E_USER, DELEGATE_STORAGE_STATE_PATH); // The adherence journey's jar. Its own account, so nothing it writes and // nothing it clears is visible to the shared one. await capture(E2E_MEDICATION, MEDICATION_STORAGE_STATE_PATH); // The notification-dispatch journey's jar. Its own account, so its own // login — see `E2E_NOTIFY` for why the ledger counts need one. await capture(E2E_NOTIFY, NOTIFY_STORAGE_STATE_PATH); // The module-surfaces journey's jar — see `E2E_MODULES`. await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH); // The phone-width route sweep's jar, then its record — see // `E2E_MOBILE_ROUTES`. await capture(E2E_MOBILE_ROUTES, MOBILE_ROUTES_STORAGE_STATE_PATH); await seedMobileRoutesRecord(baseURL, MOBILE_ROUTES_STORAGE_STATE_PATH); // The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`. await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH); // The timeline journey's jar — see `E2E_TIMELINE`. await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH); // The vault specs' jars — see `E2E_VAULT`. await capture(E2E_VAULT, VAULT_STORAGE_STATE_PATH); await capture(E2E_VAULT_LAYOUT, VAULT_LAYOUT_STORAGE_STATE_PATH); // The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`. await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH); await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH); // The score-history journey's jar — see `E2E_SCORE_HISTORY`. await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); // v1.37.0 — one jar per switching spec. See the block comment on // FENCE_STORAGE_STATE_PATH: these three used to share the scope delegate's // row with the scoped-sharing journey, which is why the suite only passed // on a single worker. await capture(E2E_SCOPE_DELEGATE, FENCE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, FENCE_OFFLINE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SEAM_BANNERS_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, REPORT_DELEGATE_STORAGE_STATE_PATH); // The doctor-report journey's own account, for the reason written on // E2E_REPORT_OWNER: generating a report writes to the account row. await capture(E2E_REPORT_OWNER, REPORT_OWNER_STORAGE_STATE_PATH); // The delegated-writes journey's pair. await capture(E2E_WRITE_OWNER, WRITE_OWNER_STORAGE_STATE_PATH); await capture(E2E_WRITE_DELEGATE, WRITE_DELEGATE_STORAGE_STATE_PATH); // The backup/restore journey's pair. await capture(E2E_BACKUP_ADMIN, BACKUP_STORAGE_STATE_PATH); await capture(E2E_BACKUP_DELEGATE, BACKUP_DELEGATE_STORAGE_STATE_PATH); // v1.37.0 — the guardian's jar, and only then its second factor. // // Order is the whole of it. `requireFreshMfa` refuses an account with no // factor enrolled, so the managed-profile routes are unreachable without // this; and `captureAuthState` posts a password to `/api/auth/login`, which // for a TOTP account answers with a challenge rather than a session. Log in // first, enrol second. The freshness stamp on the session is refreshed by // the spec immediately before it acts — the window is five minutes and a // full suite run is longer than that. await capture(E2E_GUARDIAN, GUARDIAN_STORAGE_STATE_PATH); await pool.query( `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, [E2E_GUARDIAN.username, now], ); // v1.39 — the setup flow's jars, one per journey and one per sweep. // The child journey's factor is stamped after its capture, for the reason // the guardian's is: the password-only capture cannot pass a TOTP login. await capture(E2E_SETUP_BP, SETUP_BP_STORAGE_STATE_PATH); await capture(E2E_SETUP_MEDICATION, SETUP_MEDICATION_STORAGE_STATE_PATH); await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH); await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH); await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH); await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH); await capture(E2E_SETUP_AI_OFFER, SETUP_AI_OFFER_STORAGE_STATE_PATH); await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); await pool.query( `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(); } } /** * 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(); } }