mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The delegated-writes spec checked for the invitation form's level control right after page load, before the form had painted, found nothing and skipped every test on every run, CI included. - Give the journey its own account pair, seeded and reset in global setup, so it no longer invites the same pair as the account-sharing journey (a second live grant between two accounts is refused with 409). - Wait for the form through its data-slot and fail when the WRITE level control is missing instead of skipping. - Revoke the journey's grant in afterAll. - Keep it out of the mobile project in the config rather than through a runtime skip. Add two checks so a skip cannot hide a spec again: - e2e-conditional-skip-guard.test.ts requires every conditional test.skip/test.fixme in e2e/ to carry a reason and refuses a bare test.skip(). - scripts/check-e2e-skipped-specs.mjs reads the Playwright JSON reports of both shards in the e2e gate job and fails when a spec file skipped all of its tests across every project, apart from an allowlist with reasons.
388 lines
20 KiB
TypeScript
388 lines
20 KiB
TypeScript
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`.
|
|
*/
|
|
/**
|
|
* 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 `<ThemeProvider>` 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"],
|
|
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, and it is the only
|
|
// spec in this project.
|
|
name: "chromium-service-worker",
|
|
testMatch: ["v137-record-session-fence-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 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",
|
|
// 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 (C2) — 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",
|
|
],
|
|
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",
|
|
},
|
|
},
|
|
],
|
|
|
|
// 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,
|
|
},
|
|
},
|
|
],
|
|
});
|