import { defineConfig, devices } from "@playwright/test"; import { NOTIFICATION_BASE_URL, NOTIFICATION_PORT, } from "./e2e/setup/notification-server"; import { SSR_PREFETCH_BASE_URL, SSR_PREFETCH_PORT, } from "./e2e/setup/ssr-prefetch-server"; import { resolveSmtpStubPort, SMTP_STUB_FROM, SMTP_STUB_HOST, } from "./e2e/setup/smtp-stub"; /** * Playwright configuration for the HealthLog E2E suite. * * The suite covers the smoke-level user paths (auth redirect, login * form, public version endpoint, locale switch, axe-core) without * needing seeded data — every spec either runs against an unauthed * surface or uses route interception to stub out the API. Specs that * need a logged-in user are kept narrow and flagged in their describe * block; CI runs them against a worker that seeds a deterministic test * user on startup. * * To run locally: `pnpm dlx playwright install --with-deps chromium` * once, then `pnpm e2e`. * * The journeys tagged `@critical` (`pnpm e2e:critical`) are the exception to * the route interception above: they intercept nothing under `/api`, which * `src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds. Most of them * run in both Chromium projects on an account per project * (`e2e/setup/critical-fixture.ts`), so they need no entry in the * `chromium-mobile` ignore list below; the ones that are on that list are * there for the reason written beside them. */ /** * Drawn once here, before any server or worker exists, and published through * `process.env.SMTP_STUB_PORT` — which the web server inherits and which every * worker process re-reads when it loads this config, so all three agree on one * number without a constant to collide on. */ const smtpStubPort = resolveSmtpStubPort(); export default defineConfig({ testDir: "./e2e", testIgnore: ["setup/**"], globalSetup: "./e2e/setup/global-setup.ts", timeout: 30_000, expect: { timeout: 10_000 }, fullyParallel: true, forbidOnly: !!process.env.CI, failOnFlakyTests: !!process.env.CI, // Two retries on CI: under a loaded shared runner, DOM-settle-gated // assertions (wizard step transitions, list refetch → card provenance, // disclosure toggles) intermittently sample a mid-transition frame. A // retry distinguishes transient contention from a persistent break, while // failOnFlakyTests keeps either outcome visible to CI. The default expect // timeout is lifted from 5s → 10s for the same settle headroom. retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, // The JSON report feeds `scripts/check-e2e-skipped-specs.mjs`, which the // e2e workflow's gate job runs over every shard's report to fail a spec file // that skipped all of its tests. Set E2E_JSON_REPORT to get one locally. reporter: [ ...(process.env.CI ? ([["github"], ["html", { open: "never" }]] as const) : ([["list"], ["html", { open: "never" }]] as const)), ...(process.env.CI || process.env.E2E_JSON_REPORT ? ([ [ "json", { outputFile: process.env.E2E_JSON_REPORT ?? "playwright-json/results.json", }, ], ] as const) : []), ], use: { baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000", trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure", // v1.18.6 — block the service worker in the test context. The // production build registers `/sw.js`, whose v1.18.6 data branch // serves allowlisted `/api/*` GET reads network-first. A worker- // originated `fetch` is NOT subject to Playwright's `page.route` // interception, so the SW would re-fetch the real (empty) backend // and serve that — bypassing every per-spec `route.fulfill` mock and // breaking read-after-write assertions (a just-created row never // surfaced). Blocking the worker keeps the route mocks authoritative // without weakening the shipped SW behaviour. Real users keep the // offline data cache; only the test harness opts out. serviceWorkers: "block", // HealthLog ships dark mode as the default (Dracula theme) — `globals.css` // sets `color-scheme: dark` on the root and the `` defaults // to "system". Playwright's stock context is `colorScheme: "light"`, which // means axe-core was scanning a layout users never actually see (Dracula // greens on a light card → 1.18 contrast). Forcing the test theme to dark // matches what real users render on first paint and is the only honest // a11y baseline for this app. colorScheme: "dark", // Issue #490 — pin the browser timezone. Without this the context // inherits the HOST zone (UTC on CI, Europe/Berlin on a local Mac), // so any surface that renders a clock or a day boundary could pass // locally and skew two hours / a calendar day on CI. Berlin matches // the app's own display fallback, so CI and local render identically. timezoneId: "Europe/Berlin", }, projects: [ { name: "chromium-desktop", // The disk-layer spec needs a live service worker, which the shared // `use` block blocks for every other spec. It runs in its own project. testIgnore: [ "v137-record-session-fence-offline.spec.ts", "pwa-offline.spec.ts", // Runs only in the WebKit phone project. "bottom-sheet.spec.ts", // Runs only in the two tablet projects. "tablet-shell.spec.ts", ], use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, colorScheme: "dark", }, }, { // v1.37.0 — the one project that lets the service worker run. // // Every other project blocks it, and the reason is written in the `use` // block above: a worker-originated `fetch` is not subject to // `page.route`, so the SW would bypass the per-spec route mocks. The // record-session fence's disk-layer case is the exact inverse — its whole // claim is about what the `healthlog-data-*` cache can and cannot serve // across a switch, and with the worker blocked that cache is never // populated, so its positive control could never pass. A check that // cannot pass is worse than no check, so the spec gets a project rather // than a caveat. // // It uses no route mocks for exactly this reason. v1.42 — the offline // page journey (`pwa-offline.spec.ts`) joined it on the same terms: // what the worker itself serves without a connection, no mocks. name: "chromium-service-worker", testMatch: [ "v137-record-session-fence-offline.spec.ts", "pwa-offline.spec.ts", ], use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, colorScheme: "dark", serviceWorkers: "allow", }, }, { name: "chromium-mobile", // The scoped-sharing journeys each carry a persistent server-side // session stamp. They use isolated desktop jars and are not responsive // layout checks, so running a second project against the same stamps // would test session interference rather than the record journeys. testIgnore: [ "v137-sharing-managed-profiles.spec.ts", "v137-sharing-managed-profiles-a11y.spec.ts", "v138-managed-record-capabilities.spec.ts", // v1.37.0 — every spec that MOVES a session's record selector runs in // exactly one project. // // Each of these now has its own login and its own session row (see // `FENCE_STORAGE_STATE_PATH` in `e2e/setup/global-setup.ts`), which is // what lets them run beside each other at any worker count. A second // PROJECT is the one thing a separate jar cannot fix: it would drive // the very same row from two browsers at once, moving the epoch under // itself. The scoped-sharing journeys above are excluded for the same // reason, one release earlier. "v137-record-session-fence.spec.ts", "v137-sharing-cross-tab-session.spec.ts", // The banner-geometry spec joined that list in v1.37.0, because it // stopped faking the shared-record strip and started switching for // real. It is the one entry here that measures a MOBILE property, so // it does not simply lose it: it measures both widths inside the // desktop project instead, where the shell below `md` renders from the // same width-driven rules. Its file comment carries the reasoning. "chrome-header-seam-banners.spec.ts", // The incidental-linking journey creates a visit and files a document // against it, then asserts a GLOBAL verdict — one candidate in the // document's ±7-day window. Two projects mutating the one shared // account in parallel would leave two visits in that window and flip // the single-candidate branch to the picker. It proves a flow through // stable data-slots, not a mobile layout, so it runs in one project. "visits.spec.ts", // The immunization-log journey transcribes doses, mints a booster // reminder and logs a next dose against the one shared account, then // reads grouped counts back off it. Two projects mutating that account // in parallel would leave a stale group on the page and a second row in // an antigen bucket, flipping the grouped verdicts. It proves flows // through stable data-slots, not a mobile layout, so it runs in one // project. "vaccinations.spec.ts", // The timeline journey moves its account's timeline module on and // off; two projects on that one account would flip it under each // other. It checks the phone chronicle itself by resizing inside the // desktop project. "timeline.spec.ts", // The blood-glucose journey writes readings to the one shared account // and then reads GLOBAL verdicts off it — three rows in the list, one // tile per meal-time context — while also moving that account's // display-unit column. Two projects mutating it in parallel would // leave a fourth row in the count and could flip the unit under the // other one mid-navigation. It proves a flow through stable // data-slots, not a mobile layout, so it runs in one project. "glucose-journey.spec.ts", // The adherence journey walks the wizard, records doses on its own // account and then reads RATES back off it — and the dashboard tile // sums the expected doses of every active medication that account // holds. The account keeps siblings out; two PROJECTS running this // same file in parallel would still write the one cabinet and each // sit in the other's denominator, so the percentages would be answers // to a question neither test asked. It proves flows through stable // data-slots, not a mobile layout, so it runs in one project. "medication-compliance-journey.spec.ts", // The doctor-report journey seeds a few weeks of readings into its own // account, presses Generate and reads the PDF back off the download. // Two projects doing that in parallel would leave a second set of rows // in the window one of them is asserting an empty report for, and would // spend the shared hourly export bucket twice over. The panel's mobile // layout is covered by `settings-export.spec.ts`, which measures its // tap targets there; this journey is about the artefact. "doctor-report.spec.ts", // Its delegate half MOVES the session's record selector, so it runs in // exactly one project for the reason written above the fence specs. "doctor-report-delegate.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", "pwa-offline.spec.ts", // Runs only in the WebKit phone project. "bottom-sheet.spec.ts", // Runs only in the two tablet projects. "tablet-shell.spec.ts", // The Apple Health import journey uploads an archive into the one // shared account and its refusal control asserts that the account's // Apple-Health row count did not move. A second project running the // same journey in parallel would import beside it and flip that // verdict — and both would be spending the kick-off route's // three-uploads-per-minute budget on each other. It proves a flow // through stable data attributes, not a mobile layout, so it runs in // one project. "apple-health-import.spec.ts", // The backup/restore journey, for both reasons already on this list // at once: it restores a snapshot over its own account, so a second // project would be two browsers rewriting one record's tables in // parallel, and its last act switches a session into that record. "backup-restore-journey.spec.ts", // The notification-dispatch journey binds a local SMTP responder and // reads verdicts off one account's delivery ledger — "one email // attempt, no ntfy attempt, the APNs arm skipped for this reason". A // second project would run the file in a second worker process, whose // stub would find the port already bound, and would add attempts to // the window the first one counts. It proves a dispatch decision // through stable attributes and the account's own API, not a mobile // layout, so it runs in one project. "notification-dispatch-journey.spec.ts", // v1.39 — the setup-flow journeys each own one account and walk // it from the welcome screen to the dashboard, resetting it first. A // second project would walk the same account from a second browser // and each would find the other's answers. The two sweeps set their // own viewports inside the desktop project for the same reason. "setup-flow-bp.spec.ts", "setup-flow-medication.spec.ts", "setup-flow-visit.spec.ts", "setup-flow-child.spec.ts", "setup-flow-a11y.spec.ts", "setup-flow-connect.spec.ts", "setup-flow-locale-overflow.spec.ts", // The shared-provider offer journey states INSTANCE-wide facts to // reason about: it writes the operator key into `app_settings` and // replaces every `provider_health` row for both shared tags, then // puts both back. Serial mode only orders tests inside one file, so // a second project would hold and clear that state underneath the // first — and underneath any other spec that reads AI availability. "onboarding-ai-offer.spec.ts", // The delegated-writes journey invites, accepts and revokes between // its own two accounts. A second project would send a second invitation // between the same pair while the first is live, and meet the 409. "delegated-writes.spec.ts", // The invitation journey mints its token and registers from a // cookie-less window. Two projects registering from one address // share the five-per-fifteen-minutes budget on `auth:register`, so // the second one meets a rate limit rather than the form. "invite-registration.spec.ts", // The module-surfaces journey switches its account's mood and // medications modules off and on; a second project would flip them // under the first mid-assertion. It sets the mobile width itself for // the add menu, so it runs in one project. "modules-off.spec.ts", // The AI-optional account journeys withdraw their account's consent // and delete its stored Coach rows; a second project would reset the // account under the first. They prove server state through stable // data-slots, not a mobile layout, so they run in one project. "ai-optional-account.spec.ts", // The score-history journey moves its account's top card and range // tabs; two projects on that one account would flip them under each // other. It checks the phone width by resizing inside the desktop // project. "score-history.spec.ts", // The header-height sweep moves its account's language through every // locale; a second project would switch it under the first between // the switch and the measurement. It sets the 390 px phone width // itself inside the desktop project. "header-height-locales.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs // `playwright install chromium` instead of also pulling // webkit. iPhone-13 is intentionally avoided here; the // mobile-Safari smoke is exercised by the iOS app suite. ...devices["Pixel 5"], colorScheme: "dark", }, }, // The bottom sheet's drag, on the engine it is for: iOS Safari is // WebKit, and Chromium's touch scrolling and pointer capture behave // differently enough that a pass there proves little about an iPhone. // CI runs it in its own job inside the Playwright image, where WebKit's // system libraries are already present; the Chromium shards set // E2E_SKIP_WEBKIT so they neither install WebKit nor pick the spec up. ...(process.env.E2E_SKIP_WEBKIT ? [] : [ { name: "webkit-iphone", testMatch: ["bottom-sheet.spec.ts"], use: { ...devices["iPhone 13"], colorScheme: "dark" as const, }, }, // The app shell on an iPad (`tablet-shell.spec.ts`): the rail // under a finger, `
` to its last line, scroll after a modal. // In CI's WebKit job beside `webkit-iphone`. The spec sets each // iPad size itself; the profile supplies touch and `isMobile`. { name: "webkit-tablet", testMatch: ["tablet-shell.spec.ts"], use: { ...devices["iPad Pro 11 landscape"], colorScheme: "dark" as const, }, }, ]), // The same spec on an Android tablet, in the Chromium shards. { name: "chromium-tablet", testMatch: ["tablet-shell.spec.ts"], use: { ...devices["Galaxy Tab S4 landscape"], colorScheme: "dark" as const, }, }, ], // Spin up the production build for E2E. `pnpm build` is run // separately by CI before the suite — locally, set E2E_SKIP_WEB_SERVER=1 // to point at an already-running dev server. webServer: process.env.E2E_SKIP_WEB_SERVER ? undefined : [ { command: `mkdir -p .next/standalone/.next/static .next/standalone/public && cp -R .next/static/. .next/standalone/.next/static && cp -R public/. .next/standalone/public && PORT=3000 HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`, url: "http://localhost:3000/api/version", timeout: 60_000, reuseExistingServer: !process.env.CI, stdout: "ignore", stderr: "pipe", env: { ...process.env, // Native document rendering is fail-soft and outside the browser // suite. Disable it here so queued local thumbnail jobs cannot // crash the shared server inside Skia during unrelated scenarios. NATIVE_CANVAS: "off", // The dashboard RSC wrapper server-prefetches the snapshot into // the first HTML (HydrationBoundary). Playwright's route mocks // only see CLIENT fetches, so an SSR-embedded snapshot would // bypass `mockDashboardSnapshot` and every dashboard spec would // assert against the seeded account instead of its fixture. // Disable the prefetch for the e2e server — the suite keeps the // deterministic client-fetch path. DASHBOARD_SSR_PREFETCH: "false", }, }, // The SHIPPED configuration, on its own port. // // Every spec above runs against a server with the dashboard's RSC // prefetch disabled, which is the one thing self-hosters never run: // the flag defaults ON. That gap let a React #418 hydration bailout // reach production on `/` for a signed-in account, on both viewports, // on every cold load — the client's first render read query cells the // server never had, so React discarded the streamed dashboard and // rebuilt it. The suite could not see it, because with the prefetch // off both sides paint the same skeleton. // // One extra server on 3100 closes that. It asserts the one thing the // mocked specs cannot: that the page the server streamed is the page // the browser keeps (`dashboard-ssr-prefetch-hydration.spec.ts`). // // It also carries `APP_URL`, as a real deployment does. The MCP OAuth // surface fails closed without a configured origin, so the consent // journey (`mcp-oauth-consent-redirect.spec.ts`) runs here. The shared // server stays without it, because `APP_URL` also narrows the accepted // passkey origins and shapes invite links for every other spec. { command: `PORT=${SSR_PREFETCH_PORT} HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`, url: `${SSR_PREFETCH_BASE_URL}/api/version`, timeout: 60_000, reuseExistingServer: !process.env.CI, stdout: "ignore", stderr: "pipe", env: { ...process.env, NATIVE_CANVAS: "off", DASHBOARD_SSR_PREFETCH: "true", APP_URL: SSR_PREFETCH_BASE_URL, }, }, // The mail-configured, scheduler-free server, on its own port. // // Only `notification-dispatch-journey.spec.ts` talks to it, and // `e2e/setup/notification-server.ts` carries the whole reasoning: why // the SMTP env must not reach the shared server (it would render the // Email card for every spec that opens `/settings/integrations`), why // this process must not run the reminder scheduler (a tick inside the // journey's window is a writer no spec can see), and what the shared // server keeping "all" still leaves standing. { command: `PORT=${NOTIFICATION_PORT} HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`, url: `${NOTIFICATION_BASE_URL}/api/version`, timeout: 60_000, reuseExistingServer: !process.env.CI, stdout: "ignore", stderr: "pipe", env: { ...process.env, NATIVE_CANVAS: "off", DASHBOARD_SSR_PREFETCH: "false", HEALTHLOG_PROCESS_TYPE: "web", // The one delivery channel a notification journey can exercise for // real on one machine. Its transport is operator config, not // account input, so it never crosses the SSRF floor that refuses a // local host for ntfy / webhook / Web Push. The responder these // point at is started by the journey itself; see // `e2e/setup/smtp-stub.ts` for why no other channel can be. SMTP_HOST: SMTP_STUB_HOST, SMTP_PORT: String(smtpStubPort), SMTP_FROM: SMTP_STUB_FROM, }, }, ], });