mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The opt-in timeline module gets its page (#613). At 768 px and up the record's lanes (life, conditions, allergies, medications, vaccinations, doctor and procedures, labs, documents) are drawn in one SVG on a shared time axis, with a year, month or week grid, open periods ending in an arrow, a dashed lead-in where a start is unknown, dose marks and pauses on the medication they belong to, a Today line and a selection line. Up to three neutral value lines run below as monthly means, chosen in a menu. Recharts stays for every value chart; this surface is plain SVG like the heatmaps. All geometry is pure (timeline-geometry.ts): window, scale, row packing and label placement through one occupancy map, so no label overlaps another label or a bar, none runs past the card edge, and labels that do not fit are left out rather than squeezed. The width comes from a ResizeObserver, so a docked day panel re-runs the layout. The bar under the chart names the selected month's entries as chips, the month's means and "Open 3 Jan.", which puts ?day= in the URL for the day layer. The chart is one tab stop: arrows move the selection by a day, a month or a year with the zoom, Home and End jump to the ends, Enter opens the day. A visually hidden table lists every entry for screen readers. The legend says once that stacked means at the same time, not cause and effect. Below 768 px the timeline is a chronicle, newest first by month or year, with an Ongoing block for what has no meaningful start, rails beside the rows an episode or course spans, and empty stretches named as such. Switching the module on in Settings opens the readiness inventory: each lane carries, is thin or is empty, with one link per gap and the verdict as a count. The same inventory sits as a dismissible card on the timeline while a gap is open, and under Layers as "Data coverage". Life events are entered in a sheet (title, kind, a date to the day, month or year, an optional end, an optional note) from the timeline, the capture picker and the dashboard's add menu; one surface line maps the capture kind to the timeline module, and it is offered in one's own record only. Deleting asks first. Server routes are built in parallel; the e2e journey answers them with the contract's shapes. Copy in all seven locales; the timeline and lifeEvents release-pending prefixes are gone.
393 lines
20 KiB
TypeScript
393 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 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",
|
|
// 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,
|
|
},
|
|
},
|
|
],
|
|
});
|