diff --git a/.github/workflows/bundle-budget-refresh.yml b/.github/workflows/bundle-budget-refresh.yml new file mode 100644 index 0000000000..77499f8119 --- /dev/null +++ b/.github/workflows/bundle-budget-refresh.yml @@ -0,0 +1,191 @@ +# Weekly bundle-budget baseline measurement. Surfaces the current production / +# mockups / per-route gzip weight, and how far it has drifted from the committed +# baseline in bundle-budget.json, into a rolling issue. REPORT ONLY — it never +# commits, pushes, or opens a PR; acting on the report means refreshing the +# baseline by hand in a normal reviewed PR. +# +# Why this exists (outstanding issue #QSHHGK): nothing scheduled a baseline +# measurement, so accumulated growth from many merged PRs stayed invisible until +# it crossed the 10% tolerance and failed whichever unrelated PR happened to land +# last. This job makes the accumulation visible early and attributable, without +# silently absorbing it. +name: Bundle Budget Refresh + +on: + workflow_dispatch: {} + schedule: + # 04:40 UTC on Wednesdays. Deliberately off the crowded Sunday-evening slot: + # ci.yml, docker-image.yml and eval-canary.yml all fire at "0 18 * * 0" and + # live-drift.yml at "30 18 * * 0", and a cold full `npm run build` here would + # queue behind them. Midweek also means the measurement lands while work is + # in flight rather than after the weekend batch. The :40 minute avoids + # ops-digest.yml (hourly at :20), ingestion-autopilot.yml (6-hourly at :00) + # and live-domain-monitor.yml (6-hourly at :23). 04:40 UTC is off-peak for + # GitHub-hosted runners and is midday in Perth (UTC+8), so the owner sees a + # fresh report during the working day. + - cron: "40 4 * * 3" + +concurrency: + group: bundle-budget-refresh + # Never cancel a measurement in flight: a half-finished build produces no + # numbers at all, and the next run is a week away. + cancel-in-progress: false + +permissions: + contents: read + +jobs: + bundle-budget-refresh: + runs-on: ubuntu-24.04 + permissions: + contents: read + # Only for the rolling report issue below. This workflow writes nothing to + # the repository itself. + issues: write + # Generous: this is a deliberately COLD full Next.js build (no .next cache + # restore, see below), which is far slower than ci.yml's incremental Build. + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history: --refresh-baseline validates baseline provenance with + # git (the source SHA must be a real commit and an ancestor of HEAD, + # and its distance from HEAD must be resolvable). A shallow clone makes + # that check fail closed. + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node and dependencies + uses: ./.github/actions/setup-node-cached + + # No .next cache restore here, and .next is removed outright before the + # build. AGENTS.md ("Bundle budget" → Measuring): `npm run build` reuses a + # cached .next, and check-bundle-budget.mjs then reads STALE output and + # reports byte-identical numbers — it will say the budget passes when it + # does not. A scheduled measurement whose numbers cannot be trusted is + # worse than no measurement, so this job always pays for a cold build. + - name: Measure bundle weight against the committed baseline + id: refresh + env: + # Matches ci.yml's Build step; parallelises static generation only. + NEXT_BUILD_CPUS: "4" + run: | + set -o pipefail + rm -rf .next + npm run build + node scripts/check-bundle-budget.mjs --refresh-baseline --json | tee bundle-budget-refresh.json + + # DELIBERATELY REPORT ONLY. This workflow must never commit the refreshed + # bundle-budget.json, push a branch, or open a PR: + # * scripts/check-github-action-pins.mjs fails the build on any + # workflow-authored branch mutation, because bot-authored heads leave + # required checks awaiting approval and cannot be merged. + # * a baseline moved by a bot is a baseline nobody reviewed — the growth + # it absorbs becomes unattributable, which is the exact failure mode + # #QSHHGK exists to prevent. + # The refreshed file ships as an artifact instead; a human applies it in a + # normal PR after deciding the growth is legitimate. + - name: Upload refreshed baseline and measurement + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: bundle-budget-refresh + path: | + bundle-budget.json + bundle-budget-refresh.json + retention-days: 30 + # A missing file means the measurement silently produced nothing; fail + # rather than publish a report with no evidence behind it. + if-no-files-found: error + + - name: Publish to rolling issue + if: ${{ !cancelled() }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + # Pass step results via env (not inline template expansion) to avoid + # the GitHub Actions template-injection pattern. The measurement itself + # is read from the JSON file on disk for the same reason. + REFRESH_OUTCOME: ${{ steps.refresh.outcome }} + MEASUREMENT_FILE: bundle-budget-refresh.json + with: + script: | + const fs = require("fs"); + const label = "bundle-budget-refresh"; + const title = "Bundle budget baseline measurement"; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const outcome = process.env.REFRESH_OUTCOME || "unknown"; + + const pct = (value) => (typeof value === "number" ? `${value >= 0 ? "+" : ""}${value.toFixed(2)}%` : "n/a"); + const kb = (value) => (typeof value === "number" ? `${(value / 1024).toFixed(1)} kB` : "n/a"); + + let body; + if (outcome === "success") { + const measurement = JSON.parse(fs.readFileSync(process.env.MEASUREMENT_FILE, "utf8")); + const rows = [ + `| production | ${kb(measurement.production?.gzipBytes)} | ${kb(measurement.production?.previousGzipBytes)} | ${pct(measurement.production?.diffPct)} |`, + `| mockups (scratch) | ${kb(measurement.mockups?.gzipBytes)} | ${kb(measurement.mockups?.previousGzipBytes)} | ${pct(measurement.mockups?.diffPct)} |`, + ...Object.entries(measurement.routes ?? {}).map( + ([route, r]) => `| route \`${route}\` | ${kb(r.gzipBytes)} | ${kb(r.previousGzipBytes)} | ${pct(r.diffPct)} |`, + ), + ]; + body = [ + `_Updated ${measurement.updatedAt} — [run](${runUrl})._`, + "", + "Cold-build measurement of current bundle weight against the committed", + "`bundle-budget.json` baseline. **Report only** — no baseline was committed by this run.", + "", + "| bucket | measured (gzip) | committed baseline | drift |", + "| --- | --- | --- | --- |", + ...rows, + "", + `Total: ${kb(measurement.totalGzipBytes)} gzip across ${measurement.files} chunk(s).`, + `Measured at \`${String(measurement.baselineSource ?? "").slice(0, 12)}\` (${measurement.baselineCommitDistance ?? "?"} commit(s) behind HEAD).`, + "", + "Production and mockup tolerances are 10% and 25% respectively. When production", + "drift approaches its ceiling, either find the regression or refresh the baseline", + "deliberately in a reviewed PR:", + "", + "```", + "rm -rf .next && npm run build", + "npm run check:bundle-budget -- --refresh-baseline", + "```", + "", + "The refreshed `bundle-budget.json` from this run is attached to the run above as", + "the `bundle-budget-refresh` artifact.", + ].join("\n"); + } else { + body = [ + `_Updated ${new Date().toISOString()} — [run](${runUrl})._`, + "", + `⚠ The scheduled measurement did not complete (\`${outcome}\`), so the numbers below are absent`, + "rather than reassuring. This is **not** evidence that the bundle budget is healthy.", + "", + `Check the build and bundle-budget steps in the [run log](${runUrl}).`, + ].join("\n"); + core.warning(`Bundle budget measurement did not complete: ${outcome}`); + } + + const { data: existing } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + labels: label, + }); + if (existing.length > 0) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing[0].number, + body, + }); + core.info(`Updated rolling issue #${existing[0].number}.`); + } else { + const created = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + labels: [label], + body, + }); + core.info(`Opened rolling issue #${created.data.number}.`); + } diff --git a/docs/outstanding-issues-inbox/5fa04c6e-4d27-4e95-ace6-a037aaccba1a.json b/docs/outstanding-issues-inbox/5fa04c6e-4d27-4e95-ace6-a037aaccba1a.json new file mode 100644 index 0000000000..a11c117944 --- /dev/null +++ b/docs/outstanding-issues-inbox/5fa04c6e-4d27-4e95-ace6-a037aaccba1a.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "5fa04c6e-4d27-4e95-ace6-a037aaccba1a", + "createdOn": "2026-09-02", + "action": "add", + "payload": { + "pri": "P3", + "type": "issue", + "summary": "The scheduled bundle-budget refresh workflow is never parsed by GitHub until its first scheduled run, so a YAML fault would surface in a non-blocking run rather than at merge", + "detail": "Raised while landing PR #2527, which added .github/workflows/bundle-budget-refresh.yml for #QSHHGK. The workflow is schedule- plus workflow_dispatch-only, so no pull_request CI run ever invokes it. It is covered offline by tests/bundle-budget-refresh-workflow.test.ts (17 assertions on schedule, cold-build ordering, the refresh flag, permissions, and the absence of any push or branch mutation) and by check:github-actions for pins and runs-on. None of those is GitHub's own workflow-syntax parser, which runs only when a workflow first executes. The known failure shape is the one recorded in tests/ci-cache-safety.test.ts around line 551: a workflow that fails to parse is named after its file path, creates ZERO jobs, and reports a bare failure that nothing local catches. Here that would land in an isolated scheduled run that blocks nothing, so it could sit unnoticed until someone wondered why no baseline report had appeared. Cheap close: trigger it once by workflow_dispatch after merge and confirm it creates jobs and posts to the rolling issue. That single dispatch also produces the first measurement with a resolvable baselineSource that #QSHHGK is waiting on, so the two close together.", + "source": "session 2026-09-02, PR #2527", + "issueUlid": "01M1G9AQS08TKTV69DHJ1TAGY4" + } +} diff --git a/docs/outstanding-issues-inbox/62fb8f5c-2d49-4e48-8352-0b17b742944c.json b/docs/outstanding-issues-inbox/62fb8f5c-2d49-4e48-8352-0b17b742944c.json new file mode 100644 index 0000000000..5c29afe439 --- /dev/null +++ b/docs/outstanding-issues-inbox/62fb8f5c-2d49-4e48-8352-0b17b742944c.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "62fb8f5c-2d49-4e48-8352-0b17b742944c", + "createdOn": "2026-09-02", + "action": "done", + "payload": { + "id": "#B0530F", + "outcome": "Resolved 2026-09-02: the automation this row asked for already exists on main. PR #2474 (commit 79a824e) added the caring-contacts-db job at .github/workflows/ci.yml:1115 with exactly the shape requested - a digest-pinned postgres:17 service container on host port 54329, POSTGRES_HOST_AUTH_METHOD trust and a pg_isready health check - and runs npm run caring-contacts:db:test at ci.yml:1150, so the row-level-security and cross-team-isolation files are now collected by an automated gate rather than by nobody. It is wired into the required pr-required aggregate (ci.yml:1167, :1198, :1317, :1319), and the static contract test this row asked for exists at tests/ci-cache-safety.test.ts:123-135, pinning the container, the environment variable, the command and the pr-required membership, and asserting db-reset-verify does not duplicate the same run. The offline chain was left untouched, so the concern about breaking every offline run does not arise. Verified locally: the suite was run in this container against a real PostgreSQL cluster on port 54329 and reported 2 test files passed and 213 tests passed.", + "baseRowFingerprint": "b3165cd370886608f0e13fd1cbb943f041f8baa07655cf8bc3951a4964e4fcdc" + } +} diff --git a/docs/outstanding-issues-inbox/c994c7a9-2c0a-41fc-a58d-5de479d8e823.json b/docs/outstanding-issues-inbox/c994c7a9-2c0a-41fc-a58d-5de479d8e823.json new file mode 100644 index 0000000000..a427b7186e --- /dev/null +++ b/docs/outstanding-issues-inbox/c994c7a9-2c0a-41fc-a58d-5de479d8e823.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "id": "c994c7a9-2c0a-41fc-a58d-5de479d8e823", + "createdOn": "2026-09-02", + "action": "update", + "payload": { + "id": "#QSHHGK", + "detail": "UPDATE 2026-09-02 (PR #2527): the missing TRIGGER now exists. .github/workflows/bundle-budget-refresh.yml runs weekly (Wednesday 04:40 UTC, deliberately off the crowded Sunday-evening cluster) plus workflow_dispatch, removes .next before building so the measurement is not read from a stale cache, and runs check-bundle-budget.mjs --refresh-baseline. It is REPORT ONLY: it publishes to a rolling bundle-budget-refresh issue and uploads the refreshed bundle-budget.json as an artifact, and never commits, pushes or opens a PR, because check-github-action-pins.mjs bans workflow-authored branch mutation and a baseline moved by a bot is one nobody reviewed. WHAT REMAINS, and why this row stays open: (1) no named refresh OWNER — the apply step is still a human action on the artifact the run produces; (2) the recorded baselineSource 0764fb58 STILL does not resolve, re-checked 2026-09-02 after deepening a container clone to 3419 commits, so the standing +5.2% remains unattributable to any reviewed change set; (3) the baseline numbers were deliberately NOT refreshed in PR #2527, because doing so would have silently absorbed that unattributable growth. The first run — scheduled, or dispatched once after merge — produces a measurement whose source resolves, and that is the point at which a refresh becomes reviewable and this row can close.", + "source": "session 2026-09-02, PR #2527", + "baseRowFingerprint": "2ebd6a3a17bb295805363f8c1e1c99920dc4a539313b102dcbcb8dc4a8acb3dd" + } +} diff --git a/docs/outstanding-issues-inbox/cd053a9a-584a-4046-b2e4-3eb0548c3ccf.json b/docs/outstanding-issues-inbox/cd053a9a-584a-4046-b2e4-3eb0548c3ccf.json new file mode 100644 index 0000000000..c360d00a6c --- /dev/null +++ b/docs/outstanding-issues-inbox/cd053a9a-584a-4046-b2e4-3eb0548c3ccf.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "cd053a9a-584a-4046-b2e4-3eb0548c3ccf", + "createdOn": "2026-09-02", + "action": "done", + "payload": { + "id": "#27TWKM", + "outcome": "Resolved 2026-09-02: this is already done on main. PR #2474 (commit 79a824e) added the caring-contacts-db CI job at .github/workflows/ci.yml:1115, which brings up a digest-pinned postgres:17 service container on host port 54329 and runs the whole Postgres project via npm run caring-contacts:db:test at ci.yml:1150, so every row-level-security and cross-team-isolation assertion now fires in CI rather than only on a developer's machine. The job is wired into the required pr-required aggregate (ci.yml:1167, :1198, :1317, :1319) and pinned by a contract test at tests/ci-cache-safety.test.ts:123-135, which also asserts db-reset-verify does not duplicate the same command; there is no coverage hole, because every caring-contacts path classifies to static_heavy_changed=true, which the job's if: condition covers. The suite was re-run in this container against a real local PostgreSQL cluster on port 54329 and reported 2 test files passed and 213 tests passed. The placement half of the row is likewise settled: the guards that need no database live in tests/caring-contacts-domain-isolation.test.ts, which the default npm run test collects.", + "baseRowFingerprint": "846921835f9f53a8eb54c5dc5f8f016ef4bdae5e0a842854e1a4b1aafaf51d27" + } +} diff --git a/docs/scripts-index.md b/docs/scripts-index.md index c53033d8ee..7c03349816 100644 --- a/docs/scripts-index.md +++ b/docs/scripts-index.md @@ -1,6 +1,6 @@ # Scripts index -Curated map of `scripts/` (284 files) and the `package.json` script surface (287 entries), +Curated map of `scripts/` (284 files) and the `package.json` script surface (288 entries), grouped by purpose. This is orientation, not an exhaustive per-file listing — the authoritative command list is `package.json`, and `npm run docs:check-scripts` verifies every `npm run ` referenced in docs resolves to a real script. `npm run docs:update` refreshes the exact counts above. diff --git a/package.json b/package.json index 9dcf147960..c4299a034a 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "test:coverage": "node scripts/run-vitest.mjs run --coverage", "test:coverage:node": "node scripts/run-vitest.mjs run --project=node --coverage", "test:coverage:ui": "node scripts/run-vitest.mjs run --project=jsdom --coverage", - "test:ci-workflows": "node scripts/run-vitest.mjs run tests/ci-cache-safety.test.ts tests/authenticated-live-workflow.test.ts tests/browser-test-plan.test.ts tests/codex-autofix-workflow.test.ts tests/codex-run-pr-operator-workflow.test.ts tests/eval-canary-workflow.test.ts tests/live-drift-workflow.test.ts tests/ops-digest.test.ts tests/container-ci-contract.test.ts tests/test-runner-safety.test.ts tests/installed-lock-parity.test.ts tests/railway-config.test.ts tests/ingestion-autopilot.test.ts tests/ingestion-autopilot-workflow.test.ts tests/check-lighthouse-budget.test.ts tests/live-web-vitals-inputs.test.ts tests/offline-release-profile.test.ts", + "test:ci-workflows": "node scripts/run-vitest.mjs run tests/ci-cache-safety.test.ts tests/authenticated-live-workflow.test.ts tests/browser-test-plan.test.ts tests/codex-autofix-workflow.test.ts tests/codex-run-pr-operator-workflow.test.ts tests/eval-canary-workflow.test.ts tests/live-drift-workflow.test.ts tests/ops-digest.test.ts tests/container-ci-contract.test.ts tests/test-runner-safety.test.ts tests/installed-lock-parity.test.ts tests/railway-config.test.ts tests/ingestion-autopilot.test.ts tests/ingestion-autopilot-workflow.test.ts tests/check-lighthouse-budget.test.ts tests/live-web-vitals-inputs.test.ts tests/offline-release-profile.test.ts tests/bundle-budget-refresh-workflow.test.ts", "test:cc-guards": "node scripts/run-vitest.mjs run --reporter=dot tests/caring-contacts-plan-draft.dom.test.tsx tests/caring-contacts-plan-patient-detail.test.ts tests/caring-contacts-plan-activation.test.ts tests/caring-contacts-plan-wizard.dom.test.tsx tests/caring-contacts-schedule.test.ts tests/caring-contacts-schedule-view.test.ts tests/caring-contacts-schedule-route.test.ts tests/caring-contacts-schedule-screen.dom.test.tsx tests/caring-contacts-schedule-page.dom.test.tsx tests/caring-contacts-clock.test.ts tests/caring-contacts-new-plan-page.dom.test.tsx tests/caring-contacts-explained-automation.dom.test.tsx tests/caring-contacts-workspace-shell.dom.test.tsx tests/caring-contacts-patients-directory.dom.test.tsx tests/caring-contacts-patient-overview.dom.test.tsx tests/caring-contacts-patients-page.dom.test.tsx tests/caring-contacts-domain-isolation.test.ts tests/caring-contacts-interface-vocabulary.test.ts tests/caring-contacts-retention.test.ts tests/caring-contacts-repository.test.ts tests/caring-contacts-overlay-definitions.test.ts tests/caring-contacts-overlay-trigger-inventory.test.ts tests/caring-contacts-workspace-screens.test.ts tests/route-reachability.test.ts tests/design-system-adoption.test.ts tests/caring-contacts-contact-time-adjustment.dom.test.tsx tests/caring-contacts-contact-route.test.ts tests/caring-contacts-overlay-trigger.dom.test.tsx tests/caring-contacts-overlay-host.dom.test.tsx tests/source-control-bytes.test.ts tests/caring-contacts-demo-seed.test.ts tests/caring-contacts-pathway-versions.test.ts tests/caring-contacts-templates-library.dom.test.tsx tests/caring-contacts-templates-page.dom.test.tsx tests/caring-contacts-template-detail.dom.test.tsx tests/caring-contacts-template-detail-page.dom.test.tsx tests/caring-contacts-reporting.test.ts tests/caring-contacts-guidance-reports-pages.dom.test.tsx tests/caring-contacts-team-workload.test.ts tests/caring-contacts-team-route.test.ts tests/caring-contacts-team-roster.dom.test.tsx tests/caring-contacts-team-page.dom.test.tsx", "test:e2e": "node scripts/run-playwright.mjs", "test:e2e:all": "node scripts/run-playwright.mjs", @@ -63,7 +63,7 @@ "test:e2e:pwa": "node scripts/run-playwright.mjs tests/ui-pwa.spec.ts --project=chromium", "test:e2e:critical": "node scripts/run-playwright.mjs --project=chromium --grep @critical", "test:e2e:regression": "node scripts/run-playwright.mjs --project=chromium --grep-invert \"@critical|@quarantine|@mockup\"", - "test:e2e:pr": "node scripts/run-playwright.mjs --project=chromium --grep-invert \"@quarantine|@mockup\"", + "test:e2e:pr": "node scripts/run-playwright.mjs --project=chromium --project=chromium-caring-contacts-seeded --grep-invert \"@quarantine|@mockup\"", "test:e2e:pr:shard": "node scripts/playwright-pr-shards.mjs", "check:playwright-pr-shards": "node scripts/playwright-pr-shards.mjs --validate", "check:playwright-browser-revision": "node scripts/check-playwright-browser-revision.mjs", @@ -72,6 +72,7 @@ "test:e2e:mockups": "node scripts/run-playwright.mjs --project=chromium-mockups", "test:e2e:care-plan-mockup": "node scripts/run-playwright.mjs --project=chromium-mockups tests/ui-care-plan-mockup.spec.ts", "test:e2e:caring-contact-mockup": "node scripts/run-playwright.mjs --project=chromium-mockups tests/ui-caring-contact-mockup.spec.ts", + "test:e2e:caring-contacts-activation": "node scripts/run-playwright.mjs --project=chromium-caring-contacts-seeded tests/ui-caring-contacts-activation.spec.ts", "test:e2e:advisory": "node scripts/run-playwright.mjs --project=chromium --project=chromium-mockups --grep \"@quarantine|@mockup\" --pass-with-no-tests", "test:e2e:chromium": "node scripts/run-playwright.mjs --project=chromium --project=chromium-mockups", "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", diff --git a/playwright.config.ts b/playwright.config.ts index 01dbb3141b..407bba7301 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -28,10 +28,24 @@ const mockupSpecPattern = /.*ui-(answer-chat-perfected-mockup|care-plan-mockup|caring-contact-mockup|document-image-status-mockup|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|ward-management|ward-coordinator|ward-roles|ward-discharges)\.spec\.ts/; const mockupTag = /@mockup/; +// The ONE production journey that needs a populated Caring Contacts store, and therefore the one +// that runs against `run-playwright.mjs`'s SECOND server (`CARING_CONTACTS_DEMO_SEED=on`) rather +// than the primary one. It is deliberately absent from `productionSpecPattern` above: that matcher +// names `caring-contacts-workspace` explicitly, so `ui-caring-contacts-activation` cannot leak into +// the projects pointed at the unseeded server — where its referral would not exist and the wizard +// would render the same "not visible" notice the workspace spec already pins. Keep it that way; +// `tests/playwright-project-isolation.test.ts` fails if it drifts either direction. +const seededSpecPattern = /.*ui-caring-contacts-activation\.spec\.ts/; + +// Published by `scripts/run-playwright.mjs` when it starts the seeded server. Falling back to the +// primary `baseURL` would silently point the journey at the EMPTY store, so the spec itself refuses +// to run without this value rather than trusting a fallback (see its own head comment). +const seededBaseURL = process.env.PLAYWRIGHT_SEEDED_BASE_URL; + export default defineConfig({ testDir: "./tests", testMatch: - /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|answer-chat-perfected-mockup|care-plan-mockup|caring-contact-mockup|caring-contacts-workspace|clinical-ask|dictionary|document-canvas|document-image-status-mockup|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|ward-(?:management|coordinator|roles|discharges)|overlap|universal-search|specifiers|sources|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/, + /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|answer-chat-perfected-mockup|care-plan-mockup|caring-contact-mockup|caring-contacts-activation|caring-contacts-workspace|clinical-ask|dictionary|document-canvas|document-image-status-mockup|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|ward-(?:management|coordinator|roles|discharges)|overlap|universal-search|specifiers|sources|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/, timeout: 60_000, retries: 0, // Fail the run if a stray `test.only` is committed: otherwise it silently @@ -97,6 +111,20 @@ export default defineConfig({ ...(chromiumExecutablePath ? { launchOptions: { executablePath: chromiumExecutablePath } } : {}), }, }, + { + // The Caring Contacts activation journey, and the ONLY project pointed at the seeded server. + // Chromium-only on purpose: this is the workspace's one Client Component, so the evidence it + // buys is hydration plus a two-write recovery path, neither of which is a cross-engine + // question — and a second engine would need a third server for the same population. + name: "chromium-caring-contacts-seeded", + testMatch: seededSpecPattern, + grepInvert: mockupTag, + use: { + ...devices["Desktop Chrome"], + ...(chromiumExecutablePath ? { launchOptions: { executablePath: chromiumExecutablePath } } : {}), + baseURL: seededBaseURL ?? baseURL, + }, + }, { name: "firefox", testMatch: productionSpecPattern, diff --git a/scripts/playwright-browser-preflight.mjs b/scripts/playwright-browser-preflight.mjs index dd1128c446..401486bc03 100644 --- a/scripts/playwright-browser-preflight.mjs +++ b/scripts/playwright-browser-preflight.mjs @@ -14,6 +14,10 @@ export const playwrightProjectNames = Object.freeze({ chromium: "chromium", chromiumMockups: "chromium-mockups", chromiumArtifacts: "chromium-artifacts", + // Runs against `run-playwright.mjs`'s second, demo-seeded server. A different server, but the + // same browser binary: the project uses `devices["Desktop Chrome"]`, so it belongs to the + // chromium family below like any other Chromium project. + chromiumCaringContactsSeeded: "chromium-caring-contacts-seeded", firefox: "firefox", webkit: "webkit", }); @@ -22,6 +26,7 @@ const DEFAULT_CONFIG_PROJECTS = Object.freeze({ "playwright.config.ts": [ playwrightProjectNames.chromium, playwrightProjectNames.chromiumMockups, + playwrightProjectNames.chromiumCaringContactsSeeded, playwrightProjectNames.firefox, playwrightProjectNames.webkit, ], @@ -32,6 +37,7 @@ const PROJECT_BROWSER_FAMILIES = Object.freeze({ [playwrightProjectNames.chromium]: "chromium", [playwrightProjectNames.chromiumMockups]: "chromium", [playwrightProjectNames.chromiumArtifacts]: "chromium", + [playwrightProjectNames.chromiumCaringContactsSeeded]: "chromium", [playwrightProjectNames.firefox]: "firefox", [playwrightProjectNames.webkit]: "webkit", }); diff --git a/scripts/playwright-pr-shards.mjs b/scripts/playwright-pr-shards.mjs index 5cc00b2de4..e9730da79c 100644 --- a/scripts/playwright-pr-shards.mjs +++ b/scripts/playwright-pr-shards.mjs @@ -21,6 +21,20 @@ import { childProcessExitCode } from "./child-process-result.mjs"; export const productionSpecFilePattern = /^(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:smoke|stress|accessibility|caring-contacts-workspace|clinical-ask|dictionary|document-canvas|tools|overlap|universal-search|specifiers|sources|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|therapy-pathways|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts$/; +/** + * Same matcher as playwright.config.ts `seededSpecPattern` (keep in sync). + * + * These specs run in `chromium-caring-contacts-seeded`, against `run-playwright.mjs`'s SECOND + * server, so they are deliberately NOT in `productionSpecFilePattern` above — that list is held + * byte-for-byte against `productionSpecPattern`, and adding them there would point the journey at + * the unseeded server. They still belong to a shard: a spec wired into no gate is a spec that + * silently never runs, which is the defect these groups exist to make impossible. + */ +export const seededSpecFilePattern = /^ui-caring-contacts-activation\.spec\.ts$/; + +/** The project each shard file must be collected by. Production files use `chromium`. */ +export const SEEDED_PR_UI_PROJECT = "chromium-caring-contacts-seeded"; + /** * One source of truth for shard membership and its latest hosted timing sample. * Durations are summed from the list reporter in CI run 31658845383 (2026-08-13). @@ -49,6 +63,20 @@ export const prUiSpecProfiles = Object.freeze([ // Added with the Therapy Pathways mobile picker redesign; measured locally at // ~2 tests. Placed on shard 1 to keep post-critical spread within the 10s ceiling. { file: "tests/ui-therapy-pathways.spec.ts", shard: 1, fullSeconds: 2.0, criticalSeconds: 0 }, + // The seeded Caring Contacts activation journey (#JZA0XK). It runs in + // `chromium-caring-contacts-seeded` rather than `chromium`, which is why this entry carries a + // `project` and the others do not. Placed on shard 1 because it is the shard with the smallest + // post-critical total, and the seeded server it needs is started once per shard run. + // ESTIMATE, not a measurement: this container could not launch a browser when the spec landed. + // Replace with hosted evidence at the next timing refresh — the balance guards in + // `tests/playwright-pr-shards.test.ts` hold either way. + { + file: "tests/ui-caring-contacts-activation.spec.ts", + shard: 1, + fullSeconds: 8.0, + criticalSeconds: 0, + project: SEEDED_PR_UI_PROJECT, + }, { file: "tests/ui-phone-scroll-routes.spec.ts", shard: 2, fullSeconds: 129.6, criticalSeconds: 0 }, { file: "tests/ui-phone-scroll.spec.ts", shard: 2, fullSeconds: 66.3, criticalSeconds: 0 }, @@ -117,7 +145,25 @@ export function listProductionSpecFiles(testsDir = path.join(process.cwd(), "tes .sort(); } -export function validatePrUiShardGroups(groups = prUiShardGroups, { listFiles = listProductionSpecFiles } = {}) { +export function listSeededSpecFiles(testsDir = path.join(process.cwd(), "tests")) { + return readdirSync(testsDir) + .filter((file) => seededSpecFilePattern.test(file)) + .map((file) => `tests/${file}`) + .sort(); +} + +/** + * Every spec the required Production UI shards must cover, whichever server it runs against. + * + * The union, not `listProductionSpecFiles` alone: an on-disk seeded spec missing from the groups + * has to be a shard-parity FAILURE, or the one gate that catches an unrun journey stops seeing the + * seeded lane at all. + */ +export function listPrUiSpecFiles(testsDir = path.join(process.cwd(), "tests")) { + return [...listProductionSpecFiles(testsDir), ...listSeededSpecFiles(testsDir)].sort(); +} + +export function validatePrUiShardGroups(groups = prUiShardGroups, { listFiles = listPrUiSpecFiles } = {}) { const onDisk = listFiles(); const assigned = []; const duplicates = []; @@ -154,9 +200,35 @@ export function filesForPrUiShard(shard, groups = prUiShardGroups) { return files; } +/** + * The projects a shard must select, in a stable order. + * + * A file list alone is not enough: Playwright collects a file only in a project whose `testMatch` + * accepts it, so a seeded spec passed to a `--project=chromium` run contributes ZERO tests and the + * run still exits 0. Naming the seeded project alongside `chromium` is what makes the shard + * actually run it — and it is named only when the shard holds such a file, so no other shard pays + * for the second server `run-playwright.mjs` starts for it. + */ +export function projectsForPrUiShard(shard, groups = prUiShardGroups, profiles = prUiSpecProfiles) { + const files = filesForPrUiShard(shard, groups); + const projects = ["chromium"]; + for (const profile of profiles) { + if (profile.project && files.includes(profile.file) && !projects.includes(profile.project)) { + projects.push(profile.project); + } + } + return projects; +} + export function playwrightArgsForPrUiShard(shard, { excludeCritical = false } = {}) { const grepInvert = excludeCritical ? "@critical|@quarantine|@mockup" : "@quarantine|@mockup"; - return ["scripts/run-playwright.mjs", ...filesForPrUiShard(shard), "--project=chromium", "--grep-invert", grepInvert]; + return [ + "scripts/run-playwright.mjs", + ...filesForPrUiShard(shard), + ...projectsForPrUiShard(shard).map((project) => `--project=${project}`), + "--grep-invert", + grepInvert, + ]; } function parseArgs(args) { diff --git a/scripts/run-playwright.mjs b/scripts/run-playwright.mjs index 977cf89632..120d9bc5e2 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -36,7 +36,29 @@ const routeSmokePaths = [ "/documents/search?mode=documents", "/forms/transport-crisis-form", ]; +/** + * The Playwright project whose journeys need a POPULATED Caring Contacts store. + * + * `demoSeedRequested()` in `src/lib/caring-contacts-server/demo-seed.ts` excludes this runner's + * server unless `CARING_CONTACTS_DEMO_SEED=on`, and that exclusion is deliberate: the empty + * caseload, the "No referral named" wizard notice and the empty schedule day that + * `tests/ui-caring-contacts-workspace.spec.ts` asserts are real production states, not fixtures, + * and switching the seed on for THAT server would delete those observations rather than add one. + * So the activation journey gets a SECOND server on a second port, from the same isolated build, + * with the seed on — and the primary server's environment is left exactly as it was. + */ +const SEEDED_PROJECT_NAME = "chromium-caring-contacts-seeded"; + +/** + * What `playwright test` receives, exactly as the caller wrote it, and what the browser preflight + * reads. `playwright-browser-preflight.mjs` holds `chromium-caring-contacts-seeded` in its own + * project -> browser-family table, so the seeded project needs no translation here. + */ const playwrightArgs = process.argv.slice(2); +/** Whether `args[index]` is the token that NAMES the seeded project, in either CLI spelling. */ +const namesSeededProject = (args, index) => + args[index] === `--project=${SEEDED_PROJECT_NAME}` || + (args[index] === SEEDED_PROJECT_NAME && args[index - 1] === "--project"); const explicitProjectRequested = playwrightArgs.some( (argument) => argument === "--project" || argument.startsWith("--project="), ); @@ -47,6 +69,8 @@ const mockupProjectRequested = argument === "--project=chromium-mockups" || (argument === "--project" && playwrightArgs[index + 1] === "chromium-mockups"), ); +const seededServerRequested = + !explicitProjectRequested || playwrightArgs.some((_argument, index) => namesSeededProject(playwrightArgs, index)); // Fail loud on missing browser binaries before the heavy lock or production build. // Otherwise launch failures surface as "N failed" product tests and are easy to misread @@ -192,8 +216,11 @@ function isVerifiedProjectPayload(payload) { async function waitForServer(baseUrl, server) { const startedAt = Date.now(); while (Date.now() - startedAt < startupTimeoutMs) { - if (serverLaunchError) { - throw new Error(`Playwright-owned Next server failed to launch: ${serverLaunchError.message}`); + // Per-child rather than one module-level slot: this runner owns two servers whenever the + // seeded project runs, and a shared slot would report the primary's launch failure against + // the seeded server (or the reverse) and send a reader to the wrong process. + if (server.launchError) { + throw new Error(`Playwright-owned Next server failed to launch: ${server.launchError.message}`); } if (server.exitCode !== null || server.signalCode) { throw new Error( @@ -219,6 +246,26 @@ async function waitForServer(baseUrl, server) { throw new Error(`Timed out waiting for the Playwright-owned PsychSift server at ${baseUrl}.`); } +/** + * One `next start` from this run's isolated build, on one port, with one environment. + * + * Both servers go through here so the launch shape — detached process group, inherited stdio, and + * the per-child `launchError` `waitForServer` reads — cannot drift between them. + */ +function startIsolatedServer(serverPort, env) { + const child = spawn(process.execPath, [nextBin, "start", "--hostname", "0.0.0.0", "--port", String(serverPort)], { + cwd: projectRoot, + detached: process.platform !== "win32", + env, + stdio: ["ignore", "inherit", "inherit"], + windowsHide: true, + }); + child.once("error", (error) => { + child.launchError = error; + }); + return child; +} + function stopOwnedProcessTree(child) { if (!child?.pid || child.exitCode !== null) return; if (process.platform === "win32") { @@ -233,12 +280,16 @@ function stopOwnedProcessTree(child) { } let server; -let serverLaunchError; +/** The second `next start`, from the same build, holding the Caring Contacts demo population. */ +let seededServer; let cleaned = false; function cleanup() { if (cleaned) return; cleaned = true; try { + // BOTH servers, on every exit path. A seeded server left listening holds the heavy-run port + // and a populated store past the run that owned it. + stopOwnedProcessTree(seededServer); stopOwnedProcessTree(server); if (!keepBuildRoot) { removePathSync(absoluteRunRoot, { recursive: true }); @@ -343,21 +394,34 @@ try { console.log(`Starting isolated production Playwright server at ${baseUrl} (${relativeRunRoot})`); - server = spawn(process.execPath, [nextBin, "start", "--hostname", "0.0.0.0", "--port", String(port)], { - cwd: projectRoot, - detached: process.platform !== "win32", - env: offlineEnv, - stdio: ["ignore", "inherit", "inherit"], - windowsHide: true, - }); - server.once("error", (error) => { - serverLaunchError = error; - }); - + server = startIsolatedServer(port, offlineEnv); await waitForServer(baseUrl, server); + + // The seeded server, and NOTHING about the primary one above changes to make it exist: it is a + // second `next start` from the same `dist/`, on its own port, with `CARING_CONTACTS_DEMO_SEED=on` + // in its own environment. Started only when the seeded project is actually selected, so an + // ordinary `--project=chromium` run pays neither the port nor the startup for it. + const testEnv = { ...offlineEnv }; + if (seededServerRequested) { + const seededPort = await findFreePort(stableProjectPort(projectRoot)); + const seededBaseUrl = `http://localhost:${seededPort}`; + console.log(`Starting seeded Caring Contacts Playwright server at ${seededBaseUrl} (${relativeRunRoot})`); + seededServer = startIsolatedServer(seededPort, { + ...offlineEnv, + PORT: String(seededPort), + PLAYWRIGHT_BASE_URL: seededBaseUrl, + CARING_CONTACTS_DEMO_SEED: "on", + }); + // The same readiness probe as the primary server: identity, then the route smoke set. A + // seeded server that answered before its store was built would hand the wizard journey an + // empty caseload and fail as though the wizard were broken. + await waitForServer(seededBaseUrl, seededServer); + testEnv.PLAYWRIGHT_SEEDED_BASE_URL = seededBaseUrl; + } + const result = spawnSync(process.execPath, [playwrightBin, "test", ...playwrightArgs], { cwd: projectRoot, - env: offlineEnv, + env: testEnv, stdio: "inherit", }); const exitCode = childProcessExitCode(result); diff --git a/tests/bundle-budget-refresh-workflow.test.ts b/tests/bundle-budget-refresh-workflow.test.ts new file mode 100644 index 0000000000..4f9c658627 --- /dev/null +++ b/tests/bundle-budget-refresh-workflow.test.ts @@ -0,0 +1,168 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { sourceFrom, sourceSegment } from "./helpers/source-contract"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const workflowPath = path.join(repoRoot, ".github", "workflows", "bundle-budget-refresh.yml"); + +describe("bundle-budget refresh workflow exists", () => { + it("ships the scheduled baseline measurement workflow (outstanding issue #QSHHGK)", () => { + // Before this file existed nothing measured bundle growth on a schedule, so + // accumulation only surfaced as a failure on whichever unrelated PR landed + // last. If the workflow is deleted, that regression returns silently. + expect(existsSync(workflowPath), `expected a workflow at ${workflowPath}`).toBe(true); + }); +}); + +const workflow = readFileSync(workflowPath, "utf8").replace(/\r\n/g, "\n"); +const triggers = sourceSegment(workflow, "\non:\n", "\nconcurrency:\n", { label: "bundle-budget-refresh triggers" }); +const job = sourceFrom(workflow, " bundle-budget-refresh:\n", { label: "bundle-budget-refresh job" }); + +describe("bundle-budget refresh workflow triggers and privileges", () => { + it("runs on a weekly schedule and on manual dispatch", () => { + expect(triggers).toContain("workflow_dispatch: {}"); + expect(triggers).toMatch(/^\s+- cron: "[^"]+"$/m); + // Weekly: a day-of-week field, not the every-day wildcard. + const cron = /- cron: "([^"]+)"/.exec(triggers)?.[1] ?? ""; + expect(cron.split(/\s+/)).toHaveLength(5); + expect(cron.split(/\s+/)[4], `cron "${cron}" must pin a weekday`).not.toBe("*"); + }); + + it("stays clear of the crowded Sunday 18:00 UTC slot other weekly workflows use", () => { + // ci.yml, docker-image.yml and eval-canary.yml all fire at "0 18 * * 0"; + // live-drift.yml at "30 18 * * 0". A cold full build queued behind those is + // the slot this workflow must not take. + const cron = /- cron: "([^"]+)"/.exec(triggers)?.[1] ?? ""; + expect(cron).not.toBe("0 18 * * 0"); + expect(cron).not.toBe("30 18 * * 0"); + }); + + it("never cancels a measurement already in flight", () => { + expect(workflow).toContain("group: bundle-budget-refresh"); + expect(workflow).toContain("cancel-in-progress: false"); + }); + + it("keeps workflow-level permissions read-only and grants issues: write only on the job", () => { + expect(workflow).toMatch(/^permissions:\n {2}contents: read\n/m); + expect(sourceSegment(workflow, "\npermissions:\n", "\njobs:\n", { label: "workflow permissions" })).not.toContain( + "issues:", + ); + expect(job).toMatch(/^ {4}permissions:\n {6}contents: read\n(?: {6}#[^\n]*\n)* {6}issues: write$/m); + }); + + it("pins the runner and sets an explicit generous timeout for the cold build", () => { + expect(job).toContain("runs-on: ubuntu-24.04"); + expect(job).not.toContain("ubuntu-latest"); + const timeout = /^ {4}timeout-minutes: (\d+)$/m.exec(job)?.[1]; + expect(timeout, "the job must set an explicit timeout-minutes").toBeDefined(); + expect(Number(timeout)).toBeGreaterThanOrEqual(30); + }); + + it("checks out full history without credentials, because provenance validation needs it", () => { + // --refresh-baseline validates that the baseline source SHA is a real commit, + // an ancestor of HEAD, and a resolvable distance from it. A shallow clone + // makes that check fail closed. + expect(job).toContain("uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1"); + expect(job).toContain("fetch-depth: 0"); + expect(job).toContain("persist-credentials: false"); + }); + + it("installs dependencies through the shared cached-setup composite action", () => { + expect(job).toContain("uses: ./.github/actions/setup-node-cached"); + }); +}); + +describe("bundle-budget refresh measurement", () => { + const measureStep = sourceSegment(job, " - name: Measure bundle weight", " - name: Upload", { + label: "measure step", + }); + + it("removes .next before building, so the check cannot read stale output", () => { + // AGENTS.md "Bundle budget" → Measuring: `npm run build` reuses a cached + // .next and the check then reports byte-identical numbers — it will say the + // budget passes when it does not. + const removeIndex = measureStep.indexOf("rm -rf .next"); + const buildIndex = measureStep.indexOf("npm run build"); + expect(removeIndex, "expected `rm -rf .next` in the measurement step").toBeGreaterThanOrEqual(0); + expect(buildIndex, "expected `npm run build` in the measurement step").toBeGreaterThanOrEqual(0); + expect(removeIndex).toBeLessThan(buildIndex); + }); + + it("does not restore a Next.js build cache that would defeat the cold build", () => { + expect(job).not.toContain("path: .next/cache"); + }); + + it("runs the budget check in refresh mode and captures machine-readable output", () => { + expect(measureStep).toContain("scripts/check-bundle-budget.mjs --refresh-baseline --json"); + expect(measureStep.indexOf("npm run build")).toBeLessThan(measureStep.indexOf("check-bundle-budget.mjs")); + expect(measureStep).toContain("set -o pipefail"); + }); + + it("uploads the refreshed baseline and fails rather than reporting with no evidence", () => { + const uploadStep = sourceSegment(job, " - name: Upload refreshed baseline", " - name: Publish", { + label: "upload step", + }); + expect(uploadStep).toContain("uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7"); + expect(uploadStep).toContain("if-no-files-found: error"); + expect(uploadStep).toContain("bundle-budget.json"); + }); +}); + +describe("bundle-budget refresh stays report-only", () => { + it("never commits, pushes, or otherwise mutates the repository", () => { + // scripts/check-github-action-pins.mjs fails the build on workflow-authored + // branch mutation: bot-authored heads leave required checks awaiting + // approval. A bot-moved baseline is also a baseline nobody reviewed. + expect(workflow).not.toMatch(/\bgit\s+push\b/); + expect(workflow).not.toMatch(/\bgit\s+commit\b/); + expect(workflow).not.toMatch(/\bgit\s+(?:tag|branch)\b/); + expect(workflow).not.toContain("create-pull-request"); + expect(workflow).not.toMatch(/\bgh\s+pr\s+create\b/); + expect(workflow).not.toMatch(/github\s*\.\s*rest\s*\.\s*pulls\b/); + expect(workflow).not.toMatch(/github\s*\.\s*rest\s*\.\s*git\b/); + expect(workflow).not.toMatch(/createOrUpdateFileContents/); + expect(workflow).not.toMatch(/\bgh\s+pr\s+update-branch\b/); + expect(workflow).not.toContain("sync:pr-branches"); + }); + + it("records in the file itself why it must never push, so nobody re-adds it", () => { + expect(workflow).toContain("REPORT ONLY"); + expect(workflow).toContain("awaiting approval"); + }); + + it("publishes into a single rolling labelled issue instead of stacking duplicates", () => { + const publishStep = sourceFrom(job, " - name: Publish to rolling issue", { label: "publish step" }); + expect(publishStep).toContain("uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0"); + expect(publishStep).toContain('const label = "bundle-budget-refresh";'); + expect(publishStep).toContain("github.rest.issues.listForRepo"); + expect(publishStep).toContain("github.rest.issues.update"); + expect(publishStep).toContain("github.rest.issues.create"); + }); + + it("passes step results into github-script via env, never inline template expansion", () => { + // Inline `${{ }}` interpolation into a script body is the GitHub Actions + // template-injection pattern; dependency-report.yml uses the same env shape. + const publishStep = sourceFrom(job, " - name: Publish to rolling issue", { label: "publish step" }); + const scriptBody = sourceFrom(publishStep, " script: |\n", { label: "github-script body" }); + expect(publishStep).toContain("REFRESH_OUTCOME: ${{ steps.refresh.outcome }}"); + expect(scriptBody).toContain("process.env.REFRESH_OUTCOME"); + expect(scriptBody).not.toContain("${{"); + }); + + it("never puts a status-check function anywhere but an `if:` line", () => { + // A status function inside an env: value is valid YAML and an invalid Actions + // schema: the whole workflow fails to parse and creates ZERO jobs. Repo-wide + // scan lives in tests/ci-cache-safety.test.ts; this is the local guard. + const offenders = workflow + .split("\n") + .map((line, index) => ({ line, number: index + 1 })) + .filter(({ line }) => /\$\{\{[^}]*\b(?:success|failure|cancelled|always)\s*\(/.test(line)) + .filter(({ line }) => !/^\s*(-\s+)?if\s*:/.test(line)) + .map(({ line, number }) => `${number}: ${line.trim()}`); + expect(offenders).toEqual([]); + }); +}); diff --git a/tests/caring-contacts-domain-isolation.test.ts b/tests/caring-contacts-domain-isolation.test.ts index 5793ce9dbf..254da0a645 100644 --- a/tests/caring-contacts-domain-isolation.test.ts +++ b/tests/caring-contacts-domain-isolation.test.ts @@ -99,10 +99,11 @@ describe("caring-contacts domain isolation", () => { * They live here, in an offline source-scanning file the default `npm run test` collects, for a * reason found the hard way in review round 1. The first of them was originally written in * `caring-contacts-postgres-repository.test.ts`, which `vitest.config.mts` lists in - * `caringContactsDbTestFiles` and excludes from the `node` project outright -- and no workflow under - * `.github/workflows/` runs the database suite at all. So the guard was real, correct, and could - * fire only when a human happened to have a Postgres container up. Neither property needs a - * database: both are a file read and a regular expression. + * `caringContactsDbTestFiles` and excludes from the offline `node` project outright. CI does run the + * database suite now, in its own `caring-contacts-db` job against a Postgres service container, but + * a guard living there still fires only where a database is configured -- never in the default + * offline run. So the guard was real, correct, and reachable in one place too few. Neither property + * needs a database: both are a file read and a regular expression. * * Both carry a positive control. A scan whose pattern stops matching after a rename goes GREEN, not * red, so a scan without one is a check that cannot fail -- which is the same defect in a different diff --git a/tests/playwright-browser-preflight.test.ts b/tests/playwright-browser-preflight.test.ts index e6f8011034..575cc5ae4a 100644 --- a/tests/playwright-browser-preflight.test.ts +++ b/tests/playwright-browser-preflight.test.ts @@ -16,6 +16,7 @@ describe("playwright browser preflight", () => { const configuredProjects = [ playwrightProjectNames.chromium, playwrightProjectNames.chromiumMockups, + playwrightProjectNames.chromiumCaringContactsSeeded, playwrightProjectNames.firefox, playwrightProjectNames.webkit, ]; diff --git a/tests/playwright-project-isolation.test.ts b/tests/playwright-project-isolation.test.ts index e8f0ec34b7..53ea3ab177 100644 --- a/tests/playwright-project-isolation.test.ts +++ b/tests/playwright-project-isolation.test.ts @@ -2,6 +2,8 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { playwrightArgsForPrUiShard, prUiShardGroups } from "../scripts/playwright-pr-shards.mjs"; + /** * Pull a named regex literal out of playwright.config.ts and rebuild it. * @@ -83,6 +85,97 @@ describe("Playwright production-project isolation", () => { expect(mockupSpecPattern.test(spec), `${spec} leaked into the advisory mockup project`).toBe(false); }); + /** + * The Caring Contacts activation journey (#JZA0XK) is the repository's ONE spec that runs against + * a different server: `run-playwright.mjs` starts a second `next start` with + * `CARING_CONTACTS_DEMO_SEED=on`, and `chromium-caring-contacts-seeded` is the only project + * pointed at it. + * + * Both directions matter and both fail silently rather than loudly: + * + * - Missed by `seededSpecPattern` or the top-level `testMatch`, it never runs, and the wizard + * goes back to having zero browser evidence on a green pull request. + * - Caught by `productionSpecPattern`, it runs in the projects aimed at the UNSEEDED server, + * where `demo-seed-referral-wren` does not exist. The wizard would render the same + * `PlanStartStateNotice` the workspace spec already pins, and the journey would fail for a + * reason that has nothing to do with the wizard. + * + * The gate wiring is pinned here too: a spec collected by a project no gate ever selects is the + * same defect as a spec collected by nothing. + */ + it("collects the seeded caring-contacts activation spec only in the seeded project", () => { + const source = readFileSync(resolve(process.cwd(), "playwright.config.ts"), "utf8"); + const productionSpecPattern = configPattern(source, "productionSpecPattern"); + const mockupSpecPattern = configPattern(source, "mockupSpecPattern"); + const seededSpecPattern = configPattern(source, "seededSpecPattern"); + const testMatch = source.match(/testMatch:\s*(\/.*\/),/); + expect(testMatch, "playwright.config.ts: could not read the top-level testMatch regex").not.toBeNull(); + const testMatchPattern = new RegExp(testMatch![1].slice(1, -1)); + + const spec = "tests/ui-caring-contacts-activation.spec.ts"; + expect(existsSync(resolve(process.cwd(), spec)), `${spec} is missing`).toBe(true); + expect(testMatchPattern.test(spec), `${spec} is not collected by top-level testMatch`).toBe(true); + expect( + seededSpecPattern.test(spec), + `${spec} is not collected by seededSpecPattern, so the activation wizard has no browser gate at all`, + ).toBe(true); + expect( + productionSpecPattern.test(spec), + `${spec} leaked into the projects pointed at the UNSEEDED server, where its referral does not exist`, + ).toBe(false); + expect(mockupSpecPattern.test(spec), `${spec} leaked into the advisory mockup project`).toBe(false); + + // The seeded project exists, matches only that pattern, and reads its own base URL from the + // second server rather than inheriting the primary one. + expect(source).toMatch( + /name: ["']chromium-caring-contacts-seeded["'],\s+testMatch: seededSpecPattern,\s+grepInvert: mockupTag,/m, + ); + expect(source).toContain("const seededBaseURL = process.env.PLAYWRIGHT_SEEDED_BASE_URL;"); + expect(source).toContain("baseURL: seededBaseURL ?? baseURL,"); + + // No production spec may be pulled into the seeded project either: it would then run against a + // populated store while its own assertions describe an empty one. + for (const file of readdirSync(resolve(process.cwd(), "tests")).filter((entry) => entry.endsWith(".spec.ts"))) { + if (`tests/${file}` === spec) continue; + expect(seededSpecPattern.test(`tests/${file}`), `${file} was pulled into the seeded project`).toBe(false); + } + + // GATE WIRING. A focused script, the `verify:ui` PR gate, and CI's Production UI shards. + const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf8")) as { + scripts?: Record; + }; + expect(packageJson.scripts?.["test:e2e:caring-contacts-activation"]).toBe( + "node scripts/run-playwright.mjs --project=chromium-caring-contacts-seeded tests/ui-caring-contacts-activation.spec.ts", + ); + expect(packageJson.scripts?.["test:e2e:pr"]).toContain("--project=chromium-caring-contacts-seeded"); + expect(prUiShardGroups[1]).toContain(spec); + expect(playwrightArgsForPrUiShard(1)).toContain("--project=chromium-caring-contacts-seeded"); + // ...and only the shard that holds it pays for the second server. + expect(playwrightArgsForPrUiShard(2)).not.toContain("--project=chromium-caring-contacts-seeded"); + }); + + /** + * `run-playwright.mjs` owns both servers, and the primary one must stay EMPTY: the workspace + * spec's empty-caseload assertions (including its wizard count of 0) are observations of a real + * production state, and seeding that server would delete them rather than add anything. + */ + it("starts the seeded server separately and leaves the primary server's environment alone", () => { + const runner = readFileSync(resolve(process.cwd(), "scripts/run-playwright.mjs"), "utf8"); + + expect(runner).toContain('CARING_CONTACTS_DEMO_SEED: "on"'); + // Exactly ONE environment object sets it, and it is the seeded server's. A second assignment + // would be the primary server's, which is how the workspace spec's empty-caseload assertions + // would start observing a fixture instead of a state. + expect(runner.match(/CARING_CONTACTS_DEMO_SEED\s*:/g)?.length).toBe(1); + expect(runner).toContain("testEnv.PLAYWRIGHT_SEEDED_BASE_URL = seededBaseUrl;"); + // Started only when the seeded project is selected, and torn down with the primary server on + // every exit path — `cleanup()` is registered for exit, SIGINT and SIGTERM above. + expect(runner).toContain("if (seededServerRequested) {"); + expect(runner).toContain("stopOwnedProcessTree(seededServer);"); + // The same readiness probe, not a second weaker one. + expect(runner).toContain("await waitForServer(seededBaseUrl, seededServer);"); + }); + /** * The Care Plan prototype's only browser proof. Ten tasks of structural * checking ran under `css: false` in jsdom, so this spec is the first and diff --git a/tests/ui-caring-contacts-activation.spec.ts b/tests/ui-caring-contacts-activation.spec.ts new file mode 100644 index 0000000000..68040bcc29 --- /dev/null +++ b/tests/ui-caring-contacts-activation.spec.ts @@ -0,0 +1,344 @@ +import { expect, test, type Page } from "playwright/test"; + +import { PLAN_DRAFT_STORAGE_KEY } from "../src/components/caring-contacts/workspace/plan-wizard/plan-draft"; +import { DESIGNATED_FICTIONAL_PATIENT_MOBILE_NUMBERS } from "../src/lib/caring-contacts/synthetic-contacts"; + +/** + * The Caring Contacts activation wizard, driven end to end in a real browser (#JZA0XK). + * + * WHY THIS FILE EXISTS AND WHY IT IS NOT IN `ui-caring-contacts-workspace.spec.ts` + * ------------------------------------------------------------------------------- + * The workspace spec runs against `run-playwright.mjs`'s PRIMARY server, whose Caring Contacts + * store is deliberately EMPTY: `demoSeedRequested()` excludes any process carrying + * `PLAYWRIGHT_OFFLINE_MODE` unless `CARING_CONTACTS_DEMO_SEED=on`, and that exclusion is what keeps + * the empty-caseload observations in that file honest — including its assertion that the wizard has + * count 0 on `/caring-contacts/plans/new`. Those are real production states (a newly onboarded team + * has no patients on day one), not fixtures, and switching the seed on for that server would delete + * them rather than add anything. + * + * So this journey runs against a SECOND server: same isolated build, second port, seed on, started + * by `run-playwright.mjs` and published as `PLAYWRIGHT_SEEDED_BASE_URL`. The + * `chromium-caring-contacts-seeded` project in `playwright.config.ts` is the only project pointed + * at it, and `seededSpecPattern` there names only this file. The design is the one recorded in + * `docs/caring-contacts/phase-2b-sdd-archive/task-seed-report.md`; nothing here invents it. + * + * WHAT IT PROVES THAT NOTHING ELSE CAN + * ------------------------------------ + * 1. The wizard MOUNTS. It is this workspace's only Client Component (Ruling [109]) and it reads + * its draft through `useSyncExternalStore` with a `getServerSnapshot`, so a server render that + * never hydrates looks identical in markup to one that did. Only a browser can tell them apart, + * and the tell is that the interface responds. + * 2. A draft SURVIVES A RELOAD — the owner decision the client boundary was spent on, held in this + * tab's `sessionStorage` under `PLAN_DRAFT_STORAGE_KEY`. jsdom can exercise the module; only a + * browser can exercise an actual page reload restoring it. + * 3. The sensitive inputs and the mobile caution RENDER AND MEET THE TAP FLOOR at 320px, in dark, + * and under forced colours — measured from the rendered box, never from a class string, which is + * the weakness the issue names. + * 4. THE TWO-WRITE MIDDLE STATE. Stage 4 creates the plan and then starts it, and + * `created-not-started` is the state between them: the plan exists, it has not started, and the + * draft is kept precisely so the next press finishes THE SAME plan instead of creating a second + * one for this patient. It is reached here by blocking only the activation request, so the + * create genuinely succeeds and the activation genuinely fails. + * + * ONE CREATING JOURNEY, AND THE ORDER IS FIXED. `demo-seed-patient-wren` has no other plan, so a + * second creating run against one server is correctly refused as `duplicateActivePlan`. Case 4 is + * that one journey, and `mode: "serial"` keeps it the last thing this file does. + */ +test.describe.configure({ mode: "serial" }); + +/** + * The seeded referral the wizard starts from. + * + * The literal, not an import: `demo-seed.ts` carries `import "server-only"`, which throws the + * moment a non-server module loads it, so a spec cannot read `DEMO_SEED_UNSTARTED_REFERRAL_ID` + * from where it is declared. It is exported there for exactly this purpose and is the wizard's only + * entry point — no screen lists referrals yet. If it is ever renamed, this file's first navigation + * lands on `referral-not-visible` and every case below fails loudly rather than quietly proving + * nothing. + */ +const SEEDED_REFERRAL_ID = "demo-seed-referral-wren"; +/** The patient that referral names. Same source, same reason for the literal. */ +const SEEDED_PATIENT_ID = "demo-seed-patient-wren"; +/** The one approved pathway version the seed publishes; stage 2's single option. */ +const SEEDED_PATHWAY_VERSION_ID = "demo-seed-pathway-version-1"; + +const WIZARD_ROUTE = `/caring-contacts/plans/new?referral=${SEEDED_REFERRAL_ID}`; +const WIZARD_TESTID = "caring-contacts-plan-wizard"; +const MOBILE_CAUTION_TESTID = "caring-contacts-patient-mobile-caution"; + +/** The create collection, and the lifecycle endpoint for one plan. Both from `plan-wizard.tsx`. */ +const CREATE_PLAN_PATH = "/api/caring-contacts/plans"; +const activationPathPattern = /^\/api\/caring-contacts\/plans\/[^/]+$/; + +/** + * Production's tap floor in this repository is 48px (`--spacing-tap`, `min-h-tap`), which exceeds + * both WCAG 2.5.8 (24px) and 2.5.5 (44px). Generic checklist guidance teaches 44; asserting that + * here would license a regression the repository has already refused, so the number is read off the + * design token's own value. + */ +const TAP_FLOOR_PX = 48; + +/** A reserved fictional number the wizard states in place, and one that is deliberately not. */ +const RESERVED_MOBILE = DESIGNATED_FICTIONAL_PATIENT_MOBILE_NUMBERS[0]; +const UNRESERVED_MOBILE = "0400 111 222"; + +/** + * The seeded server's URL, and a hard refusal to run without it. + * + * `playwright.config.ts` falls back to the primary `baseURL` when this is unset, because a config + * that threw would break collection for every other project. That fallback must never be reached + * silently HERE: the primary server holds no referral, so the wizard would render a + * `PlanStartStateNotice` and each case below would fail with a confusing "wizard not found" instead + * of "nobody started the seeded server". + */ +const seededBaseUrl = process.env.PLAYWRIGHT_SEEDED_BASE_URL; + +test.beforeAll(() => { + expect( + seededBaseUrl, + "PLAYWRIGHT_SEEDED_BASE_URL is unset. This spec needs the seeded Caring Contacts server that " + + "scripts/run-playwright.mjs starts for the chromium-caring-contacts-seeded project; run it " + + "through `npm run test:e2e:caring-contacts-activation` rather than a bare `playwright test`.", + ).toBeTruthy(); +}); + +/** Opens the wizard for the seeded referral and waits for the Client Component to be on screen. */ +async function openWizard(page: Page) { + await page.goto(WIZARD_ROUTE); + await expect(page.getByTestId(WIZARD_TESTID)).toBeVisible(); +} + +/** Stage 1: both confirmations, then on to the pathway stage. */ +async function completeAgreement(page: Page) { + const assurances = page.getByRole("group", { name: "Assurances you are confirming" }); + const boxes = assurances.getByRole("checkbox"); + await expect(boxes).toHaveCount(2); + for (const box of await boxes.all()) await box.check(); + await page.getByRole("button", { name: /Continue to pathway/ }).click(); + await expect(page.getByRole("region", { name: "Pathway" })).toBeVisible(); +} + +/** Stage 2: the one approved version the seed publishes, then on to personalisation. */ +async function choosePathway(page: Page) { + const chooser = page.getByRole("group", { name: "Choose a governed pathway version" }); + await chooser.getByRole("radio", { name: SEEDED_PATHWAY_VERSION_ID }).check(); + await page.getByRole("button", { name: /Continue to personalisation/ }).click(); + await expect(page.getByRole("region", { name: "Personalisation" })).toBeVisible(); +} + +/** Stage 3: the details a referral does not carry, then on to review and activation. */ +async function completePersonalisation(page: Page, { mobile = RESERVED_MOBILE } = {}) { + await page.getByLabel("Patient’s name").fill("Wren Example"); + await page.getByLabel("What should we call them in messages?").fill("Wren"); + await page.getByLabel("Mobile number this plan will use").fill(mobile); + await page + .getByRole("group", { name: "When in the day messages go out" }) + .getByRole("radio", { name: "Morning" }) + .check(); + await page.getByRole("button", { name: /^Continue to review/ }).click(); + await expect(page.getByRole("region", { name: "Review and activation" })).toBeVisible(); +} + +/** + * Stage 4's discharge day. + * + * Taken from the clock rather than pinned to a literal: nothing in this domain refuses a past + * discharge day today, and a hardcoded date would be the kind of fixture that keeps passing for a + * year and then starts asserting something nobody meant. The first-contact day is left at the + * default the screen offers (discharge + 1). + */ +function todayCalendarDay(): string { + return new Date().toISOString().slice(0, 10); +} + +/** Opens the final-activation confirmation overlay and presses its own decision control. */ +async function confirmActivation(page: Page) { + await page.locator('[data-testid="workspace-overlay-trigger"][data-overlay-trigger="final-activation"]').click(); + const action = page.getByTestId("workspace-overlay-action"); + await expect(action).toBeVisible(); + await action.click(); +} + +test.describe("caring contacts activation wizard (seeded server)", () => { + test("mounts as a hydrated Client Component for the seeded referral", async ({ page }) => { + await openWizard(page); + + // The wizard rendered rather than a `PlanStartStateNotice`, which is the whole difference the + // seeded server buys: on the unseeded server this route renders the notice and nothing else. + const wizard = page.getByTestId(WIZARD_TESTID); + await expect(wizard).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Sign-up stages" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Agreement" })).toBeVisible(); + + // HYDRATION, not markup. The server render cannot tick a box or enable a control, so a wizard + // whose client bundle never took over fails here while looking identical in the HTML. + const forward = page.getByRole("button", { name: /Continue to pathway/ }); + await expect(forward).toBeDisabled(); + const boxes = page.getByRole("group", { name: "Assurances you are confirming" }).getByRole("checkbox"); + for (const box of await boxes.all()) await box.check(); + await expect(forward).toBeEnabled(); + + // ...and the draft store the hydrated component writes through is this tab's sessionStorage. + const stored = await page.evaluate((key) => window.sessionStorage.getItem(key), PLAN_DRAFT_STORAGE_KEY); + expect(stored, "the hydrated wizard wrote no draft").not.toBeNull(); + }); + + test("keeps a typed draft across a page reload", async ({ page }) => { + await openWizard(page); + await completeAgreement(page); + await choosePathway(page); + + const name = page.getByLabel("Patient’s name"); + await name.fill("Wren Example"); + await expect(name).toHaveValue("Wren Example"); + + await page.reload(); + + // The stage AND the value come back: a reload that restarted the sign-up would land on stage 1 + // with an empty form, which is the outcome Ruling [110] spent the client boundary to prevent. + await expect(page.getByTestId(WIZARD_TESTID)).toBeVisible(); + await expect(page.getByRole("region", { name: "Personalisation" })).toBeVisible(); + await expect(page.getByLabel("Patient’s name")).toHaveValue("Wren Example"); + }); + + test("renders the sensitive inputs and the mobile caution at 320px, in dark, under forced colours", async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 800 }); + await page.emulateMedia({ colorScheme: "dark", forcedColors: "active" }); + + await openWizard(page); + await completeAgreement(page); + await choosePathway(page); + + const name = page.getByLabel("Patient’s name"); + const preferred = page.getByLabel("What should we call them in messages?"); + const mobile = page.getByLabel("Mobile number this plan will use"); + + for (const field of [name, preferred, mobile]) { + await expect(field).toBeVisible(); + // MEASURED FROM THE RENDERED BOX. Reading `min-h-tap` off the class attribute would pass for + // a field whose own rule was overridden, or clipped by a 320px parent, and that is the + // weakness #JZA0XK names. 48px is this repository's floor; never assert 44 here. + const box = await field.boundingBox(); + expect(box, "the field has no rendered box").not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(TAP_FLOOR_PX); + // Nothing may spill sideways at 320px — a field wider than the viewport is unreachable. + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(320); + } + + // The statement that nothing typed here is ever sent, naming the reserved numbers in place + // (Ruling [115]). It is the whole protection on this field, so it has to actually arrive. + const neverSent = page.getByRole("group", { name: "Nothing typed here is ever sent to any number" }); + await expect(neverSent).toBeVisible(); + for (const reserved of DESIGNATED_FICTIONAL_PATIENT_MOBILE_NUMBERS) { + await expect(neverSent).toContainText(reserved); + } + + // The live region is on the page BEFORE it has anything to say — inserting a live region along + // with its content is what stops it being announced (round 1, finding I-2) — and it is named by + // the input it is about. + const caution = page.getByTestId(MOBILE_CAUTION_TESTID); + await expect(caution).toBeAttached(); + await expect(caution).toHaveAttribute("role", "status"); + await expect(mobile).toHaveAttribute("aria-describedby", new RegExp(MOBILE_CAUTION_TESTID)); + + await mobile.fill(RESERVED_MOBILE); + await expect(caution).toHaveText(""); + + await mobile.fill(UNRESERVED_MOBILE); + await expect(caution).toBeVisible(); + await expect(caution).toContainText("not one of the reserved fictional numbers"); + + const forward = page.getByRole("button", { name: /^Continue to review/ }); + const forwardBox = await forward.boundingBox(); + expect(forwardBox, "the forward control has no rendered box").not.toBeNull(); + expect(forwardBox!.height).toBeGreaterThanOrEqual(TAP_FLOOR_PX); + }); + + test("recovers a created-but-not-started plan by finishing the same plan (Ruling [123])", async ({ page }) => { + /** + * The two writes, observed. Bodies rather than counts alone: what makes the second press a + * retry rather than a second plan for this patient is that it sends the SAME `planId` and the + * SAME idempotency keys (Ruling [120]), so the service replays the first answer. + */ + const createBodies: string[] = []; + const activationPaths: string[] = []; + page.on("request", (request) => { + if (request.method() !== "POST") return; + const { pathname } = new URL(request.url()); + if (pathname === CREATE_PLAN_PATH) createBodies.push(request.postData() ?? ""); + else if (activationPathPattern.test(pathname)) activationPaths.push(pathname); + }); + + // ONLY the activation is blocked. The create must genuinely succeed, or this proves a refused + // create — which is `refused`, a different state with a different vocabulary, and the exact + // collapse the five-state machine exists to prevent. + let blockActivation = true; + await page.route( + (url) => activationPathPattern.test(url.pathname), + async (route) => { + if (blockActivation) return route.abort("failed"); + return route.continue(); + }, + ); + + await openWizard(page); + await completeAgreement(page); + await choosePathway(page); + await completePersonalisation(page); + await page.getByLabel("Day the patient was discharged").fill(todayCalendarDay()); + await expect(page.getByTestId("caring-contacts-activation-schedule-summary")).toBeVisible(); + + await confirmActivation(page); + + // THE MIDDLE STATE, as the screen actually renders it: the plan exists, it has not started, and + // both the named statement and the live status say so. + const notStarted = page.getByRole("group", { + name: "The plan was created, and the request to start it did not arrive", + }); + await expect(notStarted).toBeVisible(); + await expect(notStarted).toContainText("The plan was created"); + await expect( + page.getByText( + "The plan was created and has not started. This sign-up is still on this computer, so confirming again finishes the same plan.", + ), + ).toBeVisible(); + + expect(createBodies).toHaveLength(1); + expect(activationPaths).toHaveLength(1); + const firstCreate = JSON.parse(createBodies[0]) as { planId: string; idempotencyKey: string }; + expect(firstCreate.idempotencyKey, "the create carried no idempotency key").toBeTruthy(); + expect(firstCreate.planId, "the create did not name a plan id").toBeTruthy(); + expect(activationPaths[0]).toBe(`${CREATE_PLAN_PATH}/${encodeURIComponent(firstCreate.planId)}`); + + // The draft is KEPT in this state — it holds the plan id and both keys, which is the only thing + // that makes the next press a retry rather than a duplicate. + const heldDraft = await page.evaluate((key) => window.sessionStorage.getItem(key), PLAN_DRAFT_STORAGE_KEY); + expect(heldDraft, "the half-done state discarded the draft").toContain(firstCreate.planId); + + blockActivation = false; + await confirmActivation(page); + + // THE SAME PLAN, FINISHED. Not a second one: the second create carries a byte-identical body, so + // it is a replay of the first under the same idempotency key, and the plan the screen navigates + // to is the one the first press created. + await expect(page).toHaveURL( + `${seededBaseUrl}/caring-contacts/patients/${SEEDED_PATIENT_ID}?plan=${encodeURIComponent(firstCreate.planId)}`, + ); + expect(createBodies).toHaveLength(2); + expect(createBodies[1]).toBe(createBodies[0]); + expect(activationPaths).toHaveLength(2); + expect(activationPaths[1]).toBe(activationPaths[0]); + + // And the patient's own screen reads back that one plan, by the id the first press minted. + const summary = page.getByTestId("caring-contacts-plan-summary"); + await expect(summary).toBeVisible(); + await expect(summary).toContainText(firstCreate.planId); + + // Both writes are confirmed, so — and only so — the draft is gone. + const clearedDraft = await page.evaluate((key) => window.sessionStorage.getItem(key), PLAN_DRAFT_STORAGE_KEY); + expect(clearedDraft, "a finished sign-up left the patient's details in tab storage").toBeNull(); + }); +});