From 3d1692204b0aecb0b40c860f2d6c4d8a82d933fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:50:42 +0200 Subject: [PATCH 1/8] test: add a browser Vitest project for interactive components Run *.browser.test.tsx in headless Chromium through the Playwright provider. The shared helper in tests/browser/ mounts the app's own providers, stubs only the fetch boundary, and shims next/navigation onto the page's real location and history. --- package.json | 4 +- pnpm-lock.yaml | 155 ++++++++++++++++- .../ui/__tests__/segmented.browser.test.tsx | 31 ++++ tests/browser/fetch-stub.ts | 158 ++++++++++++++++++ tests/browser/next-navigation.ts | 89 ++++++++++ tests/browser/render.tsx | 101 +++++++++++ tests/browser/setup.ts | 33 ++++ tests/browser/url.ts | 89 ++++++++++ vitest.config.mts | 108 +++++++++--- 9 files changed, 736 insertions(+), 32 deletions(-) create mode 100644 src/components/ui/__tests__/segmented.browser.test.tsx create mode 100644 tests/browser/fetch-stub.ts create mode 100644 tests/browser/next-navigation.ts create mode 100644 tests/browser/render.tsx create mode 100644 tests/browser/setup.ts create mode 100644 tests/browser/url.ts diff --git a/package.json b/package.json index a8f6354bb..10db88d81 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@types/web-push": "^3.6.4", + "@vitest/browser-playwright": "^5.0.5", "@vitest/coverage-v8": "^5.0.5", "ajv": "^8.20.0", "eslint": "^9", @@ -136,6 +137,7 @@ "testcontainers": "^12.2.1", "tw-animate-css": "^1.4.0", "typescript": "^6", - "vitest": "^5.0.5" + "vitest": "^5.0.5", + "vitest-browser-react": "^2.3.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67cd8344b..2cafabae0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,9 +241,12 @@ importers: '@types/web-push': specifier: ^3.6.4 version: 3.6.4 + '@vitest/browser-playwright': + specifier: ^5.0.5 + version: 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5) '@vitest/coverage-v8': specifier: ^5.0.5 - version: 5.0.5(vitest@5.0.5) + version: 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5) ajv: specifier: ^8.20.0 version: 8.20.0 @@ -291,7 +294,10 @@ importers: version: 6.0.3 vitest: specifier: ^5.0.5 - version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + vitest-browser-react: + specifier: ^2.3.0 + version: 2.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5) packages: @@ -708,6 +714,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} @@ -3407,6 +3416,17 @@ packages: '@visx/vendor@4.0.0-alpha.0': resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + '@vitest/browser-playwright@5.0.5': + resolution: {integrity: sha512-lcdThFUUcMV9mGGV1SUiZXPxfjgW4CssA7o3STxzh8WK0tT2r526kc12tZIBRZfoLMwkhxjLmMXkWXONzdsDQw==} + peerDependencies: + playwright: '*' + vitest: 5.0.5 + + '@vitest/browser@5.0.5': + resolution: {integrity: sha512-IDMoZX/iZ8rkilw7weaSgN8tJ7iZ9hUv0wYoKXyiyjWjWFwLSWAY707DcVTgxTYOudWC1E3HA9LYn7kVa9a7uQ==} + peerDependencies: + vitest: 5.0.5 + '@vitest/coverage-v8@5.0.5': resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==} peerDependencies: @@ -3435,9 +3455,20 @@ packages: vite: optional: true + '@vitest/pretty-format@5.0.5': + resolution: {integrity: sha512-24sSdIlMA9DVPXnYiB9siwU0bTd887Y9jX3+vabztCSsvHwo7ipvJ/a2L3Rljw8+2rK1f5fCj3h5U9I/Jsqgbw==} + '@vitest/spy@5.0.5': resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + '@vitest/ui@5.0.5': + resolution: {integrity: sha512-dCoGUOdgmCaSB2g+WQ/spPCBkytAWdNUkFSXY4z+eYp2d8JEOY8v0uDRaJTG9qf/kTlTVZ4CuYMYMz/f1HS/qQ==} + peerDependencies: + vitest: 5.0.5 + + '@vitest/utils@5.0.5': + resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -5981,6 +6012,10 @@ packages: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -6529,6 +6564,10 @@ packages: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -7065,6 +7104,20 @@ packages: yaml: optional: true + vitest-browser-react@2.3.0: + resolution: {integrity: sha512-S5vdGHHA93j2Z6dbmIJI1wtxT3AXPTRlmC1Meo+ekG011yNnEutMAnHrwyj8qFlGhgBYA8ENGK6+o1Q+vnFz6A==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + vitest: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + vitest@5.0.5: resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} @@ -7197,6 +7250,18 @@ packages: utf-8-validate: optional: true + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@1.0.1: resolution: {integrity: sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==} engines: {node: '>=20'} @@ -7885,6 +7950,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.10.0': {} + '@cfworker/json-schema@4.1.1': optional: true @@ -10055,7 +10122,7 @@ snapshots: '@stryker-mutator/util': 10.0.0 semver: 7.8.5 tslib: 2.8.1 - vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) '@swc/helpers@0.5.23': dependencies: @@ -10562,7 +10629,38 @@ snapshots: d3-time-format: 4.1.0 internmap: 2.0.3 - '@vitest/coverage-v8@5.0.5(vitest@5.0.5)': + '@vitest/browser-playwright@5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)': + dependencies: + '@vitest/browser': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + playwright: 1.64.1 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + '@vitest/ui': 5.0.5(vitest@5.0.5) + '@vitest/utils': 5.0.5 + magic-string: 1.4.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/istanbul-lib-coverage': 1.0.2 @@ -10572,7 +10670,9 @@ snapshots: obug: 3.0.0 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + optionalDependencies: + '@vitest/browser': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5) '@vitest/istanbul-lib-coverage@1.0.2': {} @@ -10590,8 +10690,28 @@ snapshots: msw: 2.14.6(@types/node@26.6.5)(typescript@6.0.3) vite: 8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) + '@vitest/pretty-format@5.0.5': + dependencies: + tinyrainbow: 3.2.0 + '@vitest/spy@5.0.5': {} + '@vitest/ui@5.0.5(vitest@5.0.5)': + dependencies: + '@vitest/utils': 5.0.5 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + + '@vitest/utils@5.0.5': + dependencies: + '@vitest/pretty-format': 5.0.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -13216,6 +13336,8 @@ snapshots: pngjs@5.0.0: {} + pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.6: @@ -13897,6 +14019,12 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} smart-buffer@4.2.0: {} @@ -14483,7 +14611,16 @@ snapshots: jiti: 2.7.0 yaml: 2.9.1 - vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)): + vitest-browser-react@2.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) @@ -14502,7 +14639,9 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 26.6.5 - '@vitest/coverage-v8': 5.0.5(vitest@5.0.5) + '@vitest/browser-playwright': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5) + '@vitest/coverage-v8': 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5) + '@vitest/ui': 5.0.5(vitest@5.0.5) transitivePeerDependencies: - msw @@ -14625,6 +14764,8 @@ snapshots: ws@7.5.13: {} + ws@8.22.0: {} + wsl-utils@1.0.1: dependencies: is-wsl: 3.1.1 diff --git a/src/components/ui/__tests__/segmented.browser.test.tsx b/src/components/ui/__tests__/segmented.browser.test.tsx new file mode 100644 index 000000000..c351c2047 --- /dev/null +++ b/src/components/ui/__tests__/segmented.browser.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { renderWithApp } from "../../../../tests/browser/render"; +import { Segmented } from "../segmented"; + +const OPTIONS = [ + { value: "day", label: "Day" }, + { value: "week", label: "Week" }, + { value: "month", label: "Month" }, +] as const; + +describe(" smoke", () => { + it("moves with the arrow key", async () => { + const onChange = vi.fn(); + const { screen } = await renderWithApp( + , + ); + await screen.getByRole("radio", { name: "Day" }).click(); + await userEvent.keyboard("{ArrowRight}"); + expect(onChange).toHaveBeenLastCalledWith("week"); + await expect + .element(screen.getByRole("radio", { name: "Week" })) + .toHaveFocus(); + }); +}); diff --git a/tests/browser/fetch-stub.ts b/tests/browser/fetch-stub.ts new file mode 100644 index 000000000..de5a8cfbc --- /dev/null +++ b/tests/browser/fetch-stub.ts @@ -0,0 +1,158 @@ +/** + * The network boundary for browser tests. + * + * Components talk to the server through `@/lib/api/api-fetch`, which calls + * the global `fetch`. Replacing `fetch` and nothing else keeps everything + * above it real: the envelope unwrapping, the idempotency header, TanStack + * Query's caching and retries, the component's own pending and error states. + * Never mock `@tanstack/react-query` or the API wrapper in this project; a + * test that does asserts its own stand-in. + * + * Every request is recorded with its parsed JSON body, so a form test asserts + * the payload the server would receive. A route can answer later than the + * request (`deferred`), which is how a test holds a form in its pending + * state. A request no route matches fails with a 404 envelope and is recorded + * too, so an unexpected call is visible rather than hanging. + * + * `setup.ts` restores the real `fetch` after every test. + */ +export interface RecordedRequest { + method: string; + /** Path and query, without the origin. */ + path: string; + headers: Headers; + /** The parsed JSON body, or the raw text when it is not JSON. */ + body: unknown; +} + +export interface StubResponse { + status?: number; + /** The envelope's `data`. Ignored when `error` is set. */ + data?: unknown; + error?: string; +} + +type Responder = ( + request: RecordedRequest, +) => StubResponse | Promise; + +interface Route { + method: string; + path: string | RegExp; + respond: Responder; +} + +export interface FetchStub { + /** Answer `method path` (exact path, or a pattern) with `response`. */ + on( + method: string, + path: string | RegExp, + response: StubResponse | Responder, + ): FetchStub; + /** + * Answer `method path` only when `release` is called, for asserting the + * pending state in between. + */ + deferred( + method: string, + path: string | RegExp, + ): { release: (response?: StubResponse) => void }; + /** Every request so far, in order. */ + requests: RecordedRequest[]; + /** The requests that hit `method path`. */ + calls(method: string, path: string | RegExp): RecordedRequest[]; +} + +let realFetch: typeof fetch | null = null; + +function matches(route: Route, method: string, path: string): boolean { + if (route.method !== method) return false; + const bare = path.split("?")[0]; + return typeof route.path === "string" + ? route.path === bare + : route.path.test(path); +} + +function envelope({ status = 200, data = null, error }: StubResponse) { + const body = error ? { data: null, error } : { data, error: null }; + return new Response(JSON.stringify(body), { + status: error && status === 200 ? 400 : status, + headers: { "content-type": "application/json" }, + }); +} + +async function record( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const request = new Request(input, init); + const url = new URL(request.url, window.location.href); + const text = request.method === "GET" ? "" : await request.text(); + let body: unknown = text === "" ? undefined : text; + try { + if (text !== "") body = JSON.parse(text); + } catch { + // Keep the raw text. + } + return { + method: request.method, + path: `${url.pathname}${url.search}`, + headers: request.headers, + body, + }; +} + +/** Replace `fetch` for the rest of the test. */ +export function stubFetch(): FetchStub { + realFetch ??= window.fetch; + const routes: Route[] = []; + const requests: RecordedRequest[] = []; + + window.fetch = async (input, init) => { + const request = await record(input, init); + requests.push(request); + // The newest route wins, so a test can override a default it set up. + const route = [...routes] + .reverse() + .find((r) => matches(r, request.method, request.path)); + if (!route) { + return envelope({ + status: 404, + error: `no stubbed route for ${request.method} ${request.path}`, + }); + } + return envelope(await route.respond(request)); + }; + + const stub: FetchStub = { + requests, + on(method, path, response) { + routes.push({ + method, + path, + respond: typeof response === "function" ? response : () => response, + }); + return stub; + }, + deferred(method, path) { + let release: (response?: StubResponse) => void = () => {}; + const answer = new Promise((resolve) => { + release = (response = {}) => resolve(response); + }); + routes.push({ method, path, respond: () => answer }); + return { release: (response) => release(response) }; + }, + calls(method, path) { + return requests.filter((r) => + matches({ method, path, respond: () => ({}) }, r.method, r.path), + ); + }, + }; + return stub; +} + +/** Called by `setup.ts` after each test. */ +export function restoreFetch(): void { + if (realFetch) window.fetch = realFetch; + realFetch = null; +} diff --git a/tests/browser/next-navigation.ts b/tests/browser/next-navigation.ts new file mode 100644 index 000000000..4c1084968 --- /dev/null +++ b/tests/browser/next-navigation.ts @@ -0,0 +1,89 @@ +/** + * `next/navigation` for the browser project, aliased in `vitest.config.mts`. + * + * The App Router's hooks throw unless a router is mounted, and mounting one + * means booting Next. What a component needs from them is smaller: the + * current path and query, and a way to change them. This module answers both + * from the page's real `location` and `history`, so `@/lib/url-state` writes + * through the same `pushState` / `replaceState` it uses in the app. + * + * `useSearchParams` follows the address bar the way the router does: it + * re-reads on `popstate` and on the URL-state event, one task after the write + * rather than synchronously, which is the "adopted a render later" gap the + * helper's queue exists for. A test that only asserts what the page shows + * never sees the gap; one that writes twice in a row exercises it. + * + * `redirect` and `notFound` throw like Next's do, so a component that calls + * one fails its test loudly instead of rendering past it. + */ +import { useMemo, useSyncExternalStore } from "react"; + +import { URL_STATE_EVENT } from "@/lib/url-state"; + +function subscribe(onChange: () => void): () => void { + // The router adopts a write after the event loop turns, not inside the + // dispatch that announced it. + const deferred = () => setTimeout(onChange, 0); + window.addEventListener("popstate", deferred); + window.addEventListener(URL_STATE_EVENT, deferred); + return () => { + window.removeEventListener("popstate", deferred); + window.removeEventListener(URL_STATE_EVENT, deferred); + }; +} + +const readSearch = () => window.location.search; +const readPathname = () => window.location.pathname; + +export function useSearchParams(): URLSearchParams { + const search = useSyncExternalStore(subscribe, readSearch, readSearch); + return useMemo(() => new URLSearchParams(search), [search]); +} + +export function usePathname(): string { + return useSyncExternalStore(subscribe, readPathname, readPathname); +} + +function navigate(mode: "push" | "replace", href: string): void { + const url = new URL(href, window.location.href); + if (mode === "push") window.history.pushState(null, "", url); + else window.history.replaceState(null, "", url); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +const router = { + push: (href: string) => navigate("push", href), + replace: (href: string) => navigate("replace", href), + back: () => window.history.back(), + forward: () => window.history.forward(), + refresh: () => {}, + prefetch: () => {}, +}; + +export function useRouter(): typeof router { + return router; +} + +export function useParams(): Record { + return {}; +} + +export function useSelectedLayoutSegment(): string | null { + return null; +} + +export function useSelectedLayoutSegments(): string[] { + return []; +} + +export function redirect(href: string): never { + throw new Error(`redirect(${href}) called in a browser test`); +} + +export function permanentRedirect(href: string): never { + throw new Error(`permanentRedirect(${href}) called in a browser test`); +} + +export function notFound(): never { + throw new Error("notFound() called in a browser test"); +} diff --git a/tests/browser/render.tsx b/tests/browser/render.tsx new file mode 100644 index 000000000..dc692a03d --- /dev/null +++ b/tests/browser/render.tsx @@ -0,0 +1,101 @@ +/** + * Render a component the way the app mounts it, in a real browser. + * + * The providers are the app's own: `I18nProvider` with a real catalog and a + * real TanStack `QueryClient`. The signed-in account is seeded into that + * client under `queryKeys.authMe()`, exactly where `useAuth` reads it once + * `/api/auth/me` has answered, so a component that asks for the account gets + * it without a request. Everything else a component fetches goes through the + * network and is answered by `stubFetch()` from `./fetch-stub`. + * + * The app stylesheet is loaded by `setup.ts` for every browser test, so + * `hidden`, `pointer-events-none`, popper placement and the breakpoints lay + * out as they do in the app. + * + * const { screen, queryClient } = await renderWithApp(, { + * user: { unitPreference: "imperial" }, + * }); + * await screen.getByLabelText("Value (lb)").fill("210"); + */ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, type RenderResult } from "vitest-browser-react"; + +import type { AuthUser } from "@/hooks/use-auth"; +import type { Locale } from "@/lib/i18n/config"; +import { I18nProvider } from "@/lib/i18n/context"; +import { loadMessages } from "@/lib/i18n/load-locale"; +import { queryKeys } from "@/lib/query-keys"; + +/** A signed-in account with every preference at its default. */ +export function authUser(overrides: Partial = {}): AuthUser { + return { + id: "user-1", + username: "user", + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "UTC", + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + onboardingTourCompleted: true, + onboardingTourProgress: null, + avatarUrl: null, + glucoseUnit: null, + unitPreference: "metric", + timeFormat: "AUTO", + dateFormat: "AUTO", + disableCoach: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumber: null, + lastReportPracticeName: null, + reportSelection: null, + cycleTrackingEnabled: false, + ...overrides, + }; +} + +export interface RenderWithAppOptions { + /** The reader's language. Its catalog is loaded before the first render. */ + locale?: Locale; + /** + * The signed-in account, merged over `authUser()`. `null` leaves the + * account unanswered, as before `/api/auth/me` returns. + */ + user?: Partial | null; + /** Bring a client to seed more reads before rendering. */ + queryClient?: QueryClient; +} + +export interface AppRender { + screen: RenderResult; + queryClient: QueryClient; +} + +/** A client configured like a test should want it: no retries, no refetch. */ +export function testQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + mutations: { retry: false }, + }, + }); +} + +export async function renderWithApp( + ui: ReactNode, + { locale = "en", user = {}, queryClient }: RenderWithAppOptions = {}, +): Promise { + const client = queryClient ?? testQueryClient(); + if (user !== null) client.setQueryData(queryKeys.authMe(), authUser(user)); + if (locale !== "en") await loadMessages(locale); + const screen = await render( + + {ui} + , + ); + return { screen, queryClient: client }; +} diff --git a/tests/browser/setup.ts b/tests/browser/setup.ts new file mode 100644 index 000000000..2be7c1b36 --- /dev/null +++ b/tests/browser/setup.ts @@ -0,0 +1,33 @@ +/** + * Setup for the `browser` project (`*.browser.test.tsx`, headless Chromium). + * + * - The app stylesheet, compiled by Vite through the project's PostCSS + * config, so components lay out and hide as they do in the app. + * - The EN catalog, the fallback floor of every `t()` chain, loaded through + * the production loader. `renderWithApp` loads any other locale it is + * asked for. + * - After every test: the real `fetch` and the page's starting address come + * back, so a stub or a URL write never reaches the next test. Rendered + * trees are unmounted by `vitest-browser-react` itself. + */ +import "@/app/globals.css"; + +import { afterEach, beforeAll, beforeEach } from "vitest"; + +import { loadMessages } from "@/lib/i18n/load-locale"; + +import { restoreFetch } from "./fetch-stub"; +import { rememberAddress, restoreAddress } from "./url"; + +beforeAll(async () => { + await loadMessages("en"); +}); + +beforeEach(() => { + rememberAddress(); +}); + +afterEach(() => { + restoreFetch(); + restoreAddress(); +}); diff --git a/tests/browser/url.ts b/tests/browser/url.ts new file mode 100644 index 000000000..55540b193 --- /dev/null +++ b/tests/browser/url.ts @@ -0,0 +1,89 @@ +/** + * The page's address, for URL-state tests in the browser project. + * + * Each test file runs in its own page, and `setup.ts` puts the address the + * page started on back after every test, so a test may write the URL freely. + * Only the query and hash are ever changed: the path is the test runner's + * own, and a component that writes state keeps the path it found. + */ +import { resetUrlStateForTests } from "@/lib/url-state"; + +export interface HistoryRecord { + /** Every `pushState` and `replaceState` since `recordHistory()`, in order. */ + writes: Array<{ mode: "push" | "replace"; search: string }>; + pushes: () => string[]; + replaces: () => string[]; +} + +let restoreHistory: (() => void) | null = null; + +/** + * Record history writes without changing them: the real methods still run, + * so the page's location moves exactly as it would in the app. + */ +export function recordHistory(): HistoryRecord { + restoreHistory?.(); + const { history } = window; + const push = history.pushState.bind(history); + const replace = history.replaceState.bind(history); + const writes: HistoryRecord["writes"] = []; + const searchOf = (url: string | URL | null | undefined) => + url == null + ? window.location.search + : new URL(url, window.location.href).search; + history.pushState = (data, unused, url) => { + push(data, unused, url); + writes.push({ mode: "push", search: searchOf(url) }); + }; + history.replaceState = (data, unused, url) => { + replace(data, unused, url); + writes.push({ mode: "replace", search: searchOf(url) }); + }; + restoreHistory = () => { + history.pushState = push; + history.replaceState = replace; + restoreHistory = null; + }; + return { + writes, + pushes: () => writes.filter((w) => w.mode === "push").map((w) => w.search), + replaces: () => + writes.filter((w) => w.mode === "replace").map((w) => w.search), + }; +} + +/** Start the test on `search` (`""` for a bare path). */ +export function setSearch(search: string): void { + const query = search === "" || search.startsWith("?") ? search : `?${search}`; + window.history.replaceState(null, "", `${window.location.pathname}${query}`); +} + +/** + * Change the query the way the person would from outside the component: + * Back, Forward or an edited address. The router hears `popstate`. + */ +export function navigateExternally(search: string): void { + setSearch(search); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +/** The current query string, `?`-prefixed or empty. */ +export function currentSearch(): string { + return window.location.search; +} + +let initialHref: string | null = null; + +/** Called by `setup.ts` before each test. */ +export function rememberAddress(): void { + initialHref = window.location.href; +} + +/** Called by `setup.ts` after each test. */ +export function restoreAddress(): void { + restoreHistory?.(); + resetUrlStateForTests(); + if (initialHref !== null && window.location.href !== initialHref) { + window.history.replaceState(null, "", initialHref); + } +} diff --git a/vitest.config.mts b/vitest.config.mts index b62ed7af1..ee075169f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,3 +1,4 @@ +import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; import { globSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -10,12 +11,17 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** - * Three projects, decided per file from its source (`tests/unit-projects.ts`): - * `guards` and `unit` share a module graph inside a worker (`isolate: - * false`), because their files neither mock nor stub anything; `isolated` - * keeps one fresh graph per file for the ones that do. Run one with - * `pnpm test --project guards`. + * Four projects. Three run in Node and are decided per file from its source + * (`tests/unit-projects.ts`): `guards` and `unit` share a module graph inside + * a worker (`isolate: false`), because their files neither mock nor stub + * anything; `isolated` keeps one fresh graph per file for the ones that do. + * The fourth, `browser`, runs `*.browser.test.tsx` in headless Chromium for + * components whose behaviour is interaction (see `tests/browser/`). Run one + * with `pnpm test --project guards`. */ +/** Files the `browser` project owns; no Node project collects them. */ +const BROWSER_TEST = /\.browser\.test\.tsx$/; + const projectFiles = classifyUnitFiles( __dirname, globSync( @@ -28,28 +34,34 @@ const projectFiles = classifyUnitFiles( { cwd: __dirname, exclude: (path) => - path.startsWith("tests/integration") || path.includes("node_modules"), + path.startsWith("tests/integration") || + path.includes("node_modules") || + BROWSER_TEST.test(path), }, ).sort(), ); +/** What the three Node projects share; the browser project sets its own. */ +const nodeTest = { + globals: true, + environment: "node", + // Pin the host timezone. CI runs UTC; without this a contributor in any + // other zone sees a different suite than the gate does — verified: a + // UTC+14 host fails five tests on an otherwise green tree. Pinning makes + // local and CI agree, and makes "which zone does this test mean?" an + // explicit choice the fixture has to state rather than a property of the + // machine it runs on. Tests that need a different HOST zone set + // `process.env.TZ` themselves and restore it afterwards (see + // `next-due-day-label.test.ts`). + env: { TZ: "UTC" }, + // Primes the i18n locale cache so provider mounts resolve every + // locale synchronously (production gets the active bundle as an RSC + // prop instead — see src/lib/i18n/load-locale.ts). + setupFiles: ["./vitest.setup.ts"], +}; + export default defineConfig({ test: { - globals: true, - environment: "node", - // Pin the host timezone. CI runs UTC; without this a contributor in any - // other zone sees a different suite than the gate does — verified: a - // UTC+14 host fails five tests on an otherwise green tree. Pinning makes - // local and CI agree, and makes "which zone does this test mean?" an - // explicit choice the fixture has to state rather than a property of the - // machine it runs on. Tests that need a different HOST zone set - // `process.env.TZ` themselves and restore it afterwards (see - // `next-due-day-label.test.ts`). - env: { TZ: "UTC" }, - // Primes the i18n locale cache so provider mounts resolve every - // locale synchronously (production gets the active bundle as an RSC - // prop instead — see src/lib/i18n/load-locale.ts). - setupFiles: ["./vitest.setup.ts"], // `--shard` splits by measured duration rather than file count, and files // start longest first. See `tests/durations/sequencer.ts`. sequence: { @@ -77,15 +89,63 @@ export default defineConfig({ projects: [ { extends: true, - test: { name: "guards", include: projectFiles.guards, isolate: false }, + test: { + ...nodeTest, + name: "guards", + include: projectFiles.guards, + isolate: false, + }, }, { extends: true, - test: { name: "unit", include: projectFiles.shared, isolate: false }, + test: { + ...nodeTest, + name: "unit", + include: projectFiles.shared, + isolate: false, + }, }, { extends: true, - test: { name: "isolated", include: projectFiles.isolated }, + test: { + ...nodeTest, + name: "isolated", + include: projectFiles.isolated, + }, + }, + // Not `extends: true` and none of `nodeTest`: the Node setup file reads + // the file system and mocks the write meter. Each file gets its own + // page, so a stubbed `fetch` or a rewritten URL cannot leak into the + // next file. + { + resolve: { + alias: { + "@": resolve(__dirname, "src"), + // The App Router's hooks throw outside a mounted router. The shim + // reads and writes the page's real location instead. + "next/navigation": resolve( + __dirname, + "tests/browser/next-navigation.ts", + ), + }, + }, + test: { + name: "browser", + include: ["src/**/*.browser.test.tsx"], + globals: true, + setupFiles: ["./tests/browser/setup.ts"], + browser: { + enabled: true, + headless: true, + // Pinned like the Node projects' `TZ`, so a date the form + // derives from the clock means the same instant everywhere. + provider: playwright({ + contextOptions: { timezoneId: "UTC", locale: "en-US" }, + }), + instances: [{ browser: "chromium" }], + screenshotFailures: false, + }, + }, }, ], coverage: { From ca1cceb3a91bafb80da1ddc307915058c7db415b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:54:24 +0200 Subject: [PATCH 2/8] test(segmented): drive the control in a real browser Roving focus, Home/End, wrap, toggle mode, disabled and the chosen segment's fill are now asserted through key presses and computed styles instead of markup greps. The static render test is deleted; its two source sweeps move to segmented-single-implementation.test.ts. The browser setup sets the default theme class so colour tokens resolve, and pre-bundles the browser files' dependencies so a cold cache does not reload the page mid-run. --- docs/contributing/ui-standards.md | 2 +- .../baselines/test-source-reads.json | 5 +- .../segmented-single-implementation.test.ts | 81 +++++++ .../ui/__tests__/segmented.browser.test.tsx | 220 ++++++++++++++++- .../ui/__tests__/segmented.test.tsx | 227 ------------------ tests/browser/setup.ts | 6 +- vitest.config.mts | 7 + 7 files changed, 305 insertions(+), 243 deletions(-) create mode 100644 src/components/ui/__tests__/segmented-single-implementation.test.ts delete mode 100644 src/components/ui/__tests__/segmented.test.tsx diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md index ee32c5dd8..fe3074434 100644 --- a/docs/contributing/ui-standards.md +++ b/docs/contributing/ui-standards.md @@ -101,7 +101,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s | A sheet from the bottom edge (phones) | `` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet | | Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) | | Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined ` +`), + ).toBe(2); + expect(pressedByValue(``)).toBe(0); + }); +}); diff --git a/src/components/ui/__tests__/segmented.browser.test.tsx b/src/components/ui/__tests__/segmented.browser.test.tsx index c351c2047..d4bf150c0 100644 --- a/src/components/ui/__tests__/segmented.browser.test.tsx +++ b/src/components/ui/__tests__/segmented.browser.test.tsx @@ -1,8 +1,12 @@ +import { useState } from "react"; +import { LayoutGrid, Table } from "lucide-react"; import { describe, expect, it, vi } from "vitest"; import { userEvent } from "vitest/browser"; import { renderWithApp } from "../../../../tests/browser/render"; -import { Segmented } from "../segmented"; +import { Segmented, type SegmentedProps } from "../segmented"; + +type Zoom = "day" | "week" | "month"; const OPTIONS = [ { value: "day", label: "Day" }, @@ -10,22 +14,216 @@ const OPTIONS = [ { value: "month", label: "Month" }, ] as const; -describe(" smoke", () => { - it("moves with the arrow key", async () => { - const onChange = vi.fn(); +/** + * The control as a caller holds it: the value lives in the parent and moves + * only when `onChange` says so, which is what makes the roving tab stop + * follow the selection. + */ +function Controlled({ + initial = "day", + onChange, + ...props +}: Partial, "value">> & { initial?: Zoom }) { + const [value, setValue] = useState(initial); + return ( + <> + + { + onChange?.(next); + setValue(next); + }} + /> + + + ); +} + +async function mount(props: Parameters[0] = {}) { + const onChange = vi.fn(); + const { screen } = await renderWithApp( + , + ); + const radio = (name: string) => screen.getByRole("radio", { name }); + return { screen, onChange, radio }; +} + +describe(" in radio mode", () => { + it("is one tab stop, on the chosen segment", async () => { + const { screen, radio } = await mount({ initial: "week" }); + await expect + .element(screen.getByRole("radiogroup", { name: "Zoom" })) + .toBeInTheDocument(); + await expect.element(radio("Week")).toBeChecked(); + await expect.element(radio("Day")).not.toBeChecked(); + + await screen.getByRole("button", { name: "Before" }).click(); + await userEvent.tab(); + await expect.element(radio("Week")).toHaveFocus(); + await userEvent.tab(); + await expect + .element(screen.getByRole("button", { name: "After" })) + .toHaveFocus(); + }); + + it("moves focus and selection together with the arrow keys", async () => { + const { onChange, radio } = await mount(); + await radio("Day").click(); + onChange.mockClear(); + + await userEvent.keyboard("{ArrowRight}"); + await expect.element(radio("Week")).toHaveFocus(); + await expect.element(radio("Week")).toBeChecked(); + await userEvent.keyboard("{ArrowDown}"); + await expect.element(radio("Month")).toBeChecked(); + await userEvent.keyboard("{ArrowLeft}"); + await expect.element(radio("Week")).toHaveFocus(); + expect(onChange.mock.calls).toEqual([["week"], ["month"], ["week"]]); + }); + + it("wraps at both ends", async () => { + const { radio } = await mount({ initial: "month" }); + await radio("Month").click(); + await userEvent.keyboard("{ArrowRight}"); + await expect.element(radio("Day")).toHaveFocus(); + await expect.element(radio("Day")).toBeChecked(); + await userEvent.keyboard("{ArrowUp}"); + await expect.element(radio("Month")).toHaveFocus(); + await expect.element(radio("Month")).toBeChecked(); + }); + + it("jumps to the first and last segment with Home and End", async () => { + const { onChange, radio } = await mount({ initial: "week" }); + await radio("Week").click(); + await userEvent.keyboard("{End}"); + await expect.element(radio("Month")).toHaveFocus(); + await userEvent.keyboard("{Home}"); + await expect.element(radio("Day")).toHaveFocus(); + await expect.element(radio("Day")).toBeChecked(); + expect(onChange).toHaveBeenLastCalledWith("day"); + }); + + it("fires again on the chosen segment, which can open something", async () => { + const { onChange, radio } = await mount({ initial: "day" }); + await radio("Day").click(); + await radio("Day").click(); + expect(onChange.mock.calls).toEqual([["day"], ["day"]]); + }); + + it("takes no press and no key while disabled", async () => { + const { screen, onChange, radio } = await mount({ disabled: true }); + await expect + .element(screen.getByRole("radiogroup", { name: "Zoom" })) + .toHaveAttribute("aria-disabled", "true"); + await expect.element(radio("Week")).toBeDisabled(); + await radio("Week").click({ force: true }); + await screen.getByRole("button", { name: "Before" }).click(); + await userEvent.tab(); + await userEvent.keyboard("{ArrowRight}{End}"); + expect(onChange).not.toHaveBeenCalled(); + await expect.element(radio("Day")).toBeChecked(); + }); +}); + +describe(" in toggle mode", () => { + it("renders pressed buttons and ignores a press on the active one", async () => { + const { screen, onChange } = await mount({ mode: "toggle" }); + await expect + .element(screen.getByRole("group", { name: "Zoom" })) + .toBeInTheDocument(); + expect(screen.getByRole("radio").elements()).toHaveLength(0); + const day = screen.getByRole("button", { name: "Day" }); + const week = screen.getByRole("button", { name: "Week" }); + await expect.element(day).toHaveAttribute("aria-pressed", "true"); + + await day.click(); + expect(onChange).not.toHaveBeenCalled(); + await week.click(); + expect(onChange).toHaveBeenCalledExactlyOnceWith("week"); + await expect.element(week).toHaveAttribute("aria-pressed", "true"); + await expect.element(day).toHaveAttribute("aria-pressed", "false"); + }); + + it("leaves every segment its own tab stop and the arrows alone", async () => { + const { screen, onChange } = await mount({ mode: "toggle" }); + await screen.getByRole("button", { name: "Day" }).click(); + await userEvent.keyboard("{ArrowRight}"); + expect(onChange).not.toHaveBeenCalled(); + await userEvent.tab(); + await expect + .element(screen.getByRole("button", { name: "Week" })) + .toHaveFocus(); + }); +}); + +describe(" look", () => { + it("fills only the chosen segment in the primary colour, on no track", async () => { + const { screen, radio } = await mount({ initial: "week" }); + const group = screen.getByRole("radiogroup").element(); + const probe = document.createElement("span"); + probe.className = "bg-primary"; + document.body.append(probe); + const primary = getComputedStyle(probe).backgroundColor; + probe.remove(); + + const background = (name: string) => + getComputedStyle(radio(name).element()).backgroundColor; + expect(background("Week")).toBe(primary); + expect(background("Day")).not.toBe(primary); + expect(getComputedStyle(group).backgroundColor).toBe("rgba(0, 0, 0, 0)"); + }); + + it("sets the small size in smaller type and keeps the 36 px floor", async () => { + const { radio } = await mount(); + const md = radio("Day").element(); + expect(getComputedStyle(md).fontSize).toBe("14px"); + expect(md.getBoundingClientRect().height).toBeGreaterThanOrEqual(36); + const { screen } = await renderWithApp( {}} + label="Range" + size="sm" />, ); - await screen.getByRole("radio", { name: "Day" }).click(); - await userEvent.keyboard("{ArrowRight}"); - expect(onChange).toHaveBeenLastCalledWith("week"); + const sm = screen + .getByRole("radiogroup", { name: "Range" }) + .getByRole("radio", { name: "Day" }) + .element(); + expect(getComputedStyle(sm).fontSize).toBe("12px"); + expect(sm.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + }); + + it("names icon-only segments without showing the label", async () => { + const onChange = vi.fn(); + const { screen } = await renderWithApp( + `medications-view-${v}`} + />, + ); + const table = screen.getByRole("button", { name: "Table" }); + await expect.element(table).toHaveAttribute("title", "Table"); await expect - .element(screen.getByRole("radio", { name: "Week" })) - .toHaveFocus(); + .element(table) + .toHaveAttribute("data-slot", "medications-view-table"); + await expect.element(screen.getByText("Cards")).not.toBeInTheDocument(); + await table.click(); + expect(onChange).toHaveBeenCalledWith("table"); }); }); diff --git a/src/components/ui/__tests__/segmented.test.tsx b/src/components/ui/__tests__/segmented.test.tsx deleted file mode 100644 index 7f3770b4c..000000000 --- a/src/components/ui/__tests__/segmented.test.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { isValidElement, type ReactElement, type ReactNode } from "react"; -import { LayoutGrid, Table } from "lucide-react"; - -import { Segmented, type SegmentedProps } from "../segmented"; - -const OPTIONS = [ - { value: "recent", label: "Last 90 days" }, - { value: "all", label: "All" }, -] as const; - -type Props = SegmentedProps<"recent" | "all">; - -function html(props: Partial = {}) { - return renderToStaticMarkup( - {}} - label="History" - slot="history-view" - {...props} - />, - ); -} - -/** The segment buttons of a rendered tree, for driving their handlers. */ -function buttons(node: ReactNode): ReactElement<{ onClick: () => void }>[] { - const out: ReactElement<{ onClick: () => void }>[] = []; - const walk = (n: ReactNode) => { - if (Array.isArray(n)) return n.forEach(walk); - if (!isValidElement(n)) return; - const el = n as ReactElement<{ children?: ReactNode; onClick: () => void }>; - if (el.type === "button") out.push(el); - walk(el.props.children); - }; - walk(node); - return out; -} - -vi.mock("@/hooks/use-roving-radio-group", () => ({ - useRovingRadioGroup: () => ({ - getRadioProps: (i: number) => ({ tabIndex: i === 0 ? 0 : -1 }), - }), -})); - -describe("", () => { - it("is a radiogroup with one checked radio by default", () => { - const out = html(); - expect(out).toContain('role="radiogroup"'); - expect(out).toContain('aria-label="History"'); - expect(out.match(/role="radio"/g)?.length).toBe(2); - expect(out.match(/aria-checked="true"/g)?.length).toBe(1); - expect(out).toContain('data-slot="history-view-all"'); - }); - - it("gives every segment the 44 px floor on a coarse pointer", () => { - const out = html(); - expect(out.match(/pointer-coarse:min-h-11/g)?.length).toBe(2); - expect(out).toContain("min-h-9"); - }); - - it("fills the chosen segment in the primary colour, on no track", () => { - const out = html(); - const segments = out.match(/]*>/g) ?? []; - expect(segments).toHaveLength(2); - const chosen = segments.filter((b) => b.includes('aria-checked="true"')); - expect(chosen).toHaveLength(1); - expect(chosen[0]).toContain("bg-primary text-primary-foreground"); - const other = segments.find((b) => b.includes('aria-checked="false"')); - expect(other).not.toContain("bg-primary"); - const group = out.match(/]*role="radiogroup"[^>]*>/)?.[0]; - expect(group).not.toContain("bg-muted"); - }); - - it("sets a chart header's range row in the small type", () => { - expect(html()).toContain("text-sm"); - const small = html({ size: "sm" }); - expect(small).toContain("text-xs"); - expect(small).not.toContain("text-sm"); - expect(small.match(/min-h-11/g)?.length).toBe(2); - }); - - it("keeps a disabled chosen segment on the readable token pair", () => { - const chosen = html({ disabled: true }) - .match(/]*>/g) - ?.find((b) => b.includes('aria-checked="true"')); - expect(chosen).toContain("disabled:bg-primary-disabled"); - expect(chosen).toContain("disabled:opacity-100"); - }); - - it("renders pressed buttons in toggle mode", () => { - const out = html({ mode: "toggle" }); - expect(out).toContain('role="group"'); - expect(out).not.toContain('role="radio"'); - expect(out).toContain('aria-pressed="true"'); - expect(out).toContain('aria-pressed="false"'); - }); - - it("names icon-only segments and sizes them square", () => { - const out = renderToStaticMarkup( - {}} - label="View" - optionSlot={(v) => `medications-view-${v}`} - />, - ); - expect(out).toContain('aria-label="Cards"'); - expect(out).toContain('title="Table"'); - expect(out.match(/pointer-coarse:size-11/g)?.length).toBe(2); - expect(out).toContain('data-slot="medications-view-table"'); - expect(out).not.toContain(">Cards<"); - }); - - it("re-fires the chosen segment in radio mode, which can open something", () => { - const onChange = vi.fn(); - const tree = Segmented({ - options: OPTIONS, - value: "recent", - onChange, - label: "History", - }); - buttons(tree)[0].props.onClick(); - expect(onChange).toHaveBeenCalledWith("recent"); - }); - - it("ignores a press on the active segment in toggle mode", () => { - const onChange = vi.fn(); - const tree = Segmented({ - options: OPTIONS, - value: "recent", - onChange, - label: "History", - mode: "toggle", - }); - const [active, other] = buttons(tree); - active.props.onClick(); - expect(onChange).not.toHaveBeenCalled(); - other.props.onClick(); - expect(onChange).toHaveBeenCalledWith("all"); - }); -}); - -describe("one segmented control", () => { - it("is the only segmented-control component in the tree", async () => { - const { readFileSync } = await import("node:fs"); - const { join } = await import("node:path"); - const { walkSourceFiles } = - await import("@/__tests__/helpers/source-files"); - const src = join(__dirname, "../../.."); - const defining = walkSourceFiles(src, { floor: 2000 }) - .filter((p) => !p.includes("__tests__/")) - .filter((p) => - /export function (?:Segmented\w*|ViewToggle|\w*SegmentedToggle)\b/.test( - readFileSync(join(src, p), "utf8"), - ), - ); - expect(defining).toEqual(["components/ui/segmented.tsx"]); - }); -}); - -/** - * A row of buttons whose `aria-pressed` compares a state to each button's - * value is a segmented control spelled by hand: six of them sat beside - * `Segmented` (the narrative period, the lab result type, both admin filters, - * the chart range tabs, the mood chart's copy of them), each with its own - * size, fill and gap. New ones are refused; the ones that remain are not one - * choice on one track, and say why. - */ -describe("no hand-built segmented control", () => { - const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g; - - const NOT_A_SEGMENT: Record = { - "components/settings/security-section/passkey-list-section.tsx": - "one method is disabled while the account has no passkey; Segmented disables the whole track", - "components/medications/units-per-dose-field.tsx": - "a chip row that opens a free-number field for any other count", - "components/body-sites/body-sites-section.tsx": - "wrapping side chips under a body-site picker, not a track", - "components/illness/log-day-sheet.tsx": - "a graded severity scale per symptom, one square per level", - "components/cycle/log-day-sheet.tsx": - "a graded severity scale per symptom, one circle per level", - "components/charts/chart-overlay-controls.tsx": - "baselines stay pressable while greyed, with a tooltip saying why there is no data", - "components/timeline/timeline-view.tsx": - "a single button that opens the range sheet, beside the zoom Segmented", - }; - - function pressedByValue(source: string): number { - return (source.match(PRESSED_BY_VALUE) ?? []).length; - } - - it("finds none outside the reasoned exceptions", async () => { - const { readFileSync } = await import("node:fs"); - const { join } = await import("node:path"); - const { stripComments, walkSourceFiles } = - await import("@/__tests__/helpers/source-files"); - const src = join(__dirname, "../../.."); - const found = walkSourceFiles(src, { floor: 2000 }) - .filter((p) => /^(components|app)\//.test(p)) - .filter((p) => !p.includes("__tests__/")) - .filter((p) => p !== "components/ui/segmented.tsx") - .filter( - (p) => - pressedByValue(stripComments(readFileSync(join(src, p), "utf8"))) > 0, - ) - .sort(); - expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort()); - }); - - it("counter-check: catches a hand-built pair, not an on/off toggle", () => { - expect( - pressedByValue(` -`), - ).toBe(2); - expect(pressedByValue(``)).toBe(0); - }); -}); diff --git a/tests/browser/setup.ts b/tests/browser/setup.ts index 2be7c1b36..d1ea7821f 100644 --- a/tests/browser/setup.ts +++ b/tests/browser/setup.ts @@ -2,7 +2,9 @@ * Setup for the `browser` project (`*.browser.test.tsx`, headless Chromium). * * - The app stylesheet, compiled by Vite through the project's PostCSS - * config, so components lay out and hide as they do in the app. + * config, so components lay out and hide as they do in the app, under the + * theme class the boot script sets for a first visit (`dark`, see + * `src/lib/pwa/theme-color.ts`); without one no colour token resolves. * - The EN catalog, the fallback floor of every `t()` chain, loaded through * the production loader. `renderWithApp` loads any other locale it is * asked for. @@ -19,6 +21,8 @@ import { loadMessages } from "@/lib/i18n/load-locale"; import { restoreFetch } from "./fetch-stub"; import { rememberAddress, restoreAddress } from "./url"; +document.documentElement.classList.add("dark"); + beforeAll(async () => { await loadMessages("en"); }); diff --git a/vitest.config.mts b/vitest.config.mts index ee075169f..996edbdec 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -129,6 +129,13 @@ export default defineConfig({ ), }, }, + // Pre-bundle every dependency the browser files reach before the + // first one loads. Discovered late, a dependency makes Vite re-bundle + // and reload the page mid-run, which fails the file that was + // importing it; CI starts every run on an empty cache. + optimizeDeps: { + entries: ["src/**/*.browser.test.tsx", "tests/browser/**/*.{ts,tsx}"], + }, test: { name: "browser", include: ["src/**/*.browser.test.tsx"], From cc36f46f1096f6b95558a5cc6e6d995029263dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:00:19 +0200 Subject: [PATCH 3/8] test(filters): drive the URL filter rail in a real browser A FilterBar over useUrlFilterSync now proves the contract end to end: a facet picked from the open menu pushes a history entry, typed input replaces the current one, a pill's clear and Reset leave a bare URL, two writes in a row both land, Back and an edited address re-seed the pills, and the open menu rests inside the viewport under its pill. The markup tests for FilterBar and useUrlFilterSync are deleted; the popper-placement source pin is replaced by the measured menu position. The browser project re-bundles its dependencies on every run so a cache warmed before a new file existed cannot load a second React. --- .../baselines/test-source-reads.json | 5 +- .../baselines/version-stamp-comments.json | 5 +- .../ui/__tests__/filter-bar.test.tsx | 166 ----------- .../use-url-filter-sync.browser.test.tsx | 268 ++++++++++++++++++ .../__tests__/use-url-filter-sync.test.tsx | 117 -------- vitest.config.mts | 7 +- 6 files changed, 278 insertions(+), 290 deletions(-) delete mode 100644 src/components/ui/__tests__/filter-bar.test.tsx create mode 100644 src/hooks/__tests__/use-url-filter-sync.browser.test.tsx delete mode 100644 src/hooks/__tests__/use-url-filter-sync.test.tsx diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index bad51e959..4cafc7881 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -1,7 +1,7 @@ { "$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 359, - "total": 359, + "count": 358, + "total": 358, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -263,7 +263,6 @@ "components/timeline/__tests__/no-template-key-guard.test.ts": 1, "components/ui/__tests__/card-density.test.tsx": 1, "components/ui/__tests__/card-header-rows.test.ts": 1, - "components/ui/__tests__/filter-bar.test.tsx": 1, "hooks/__tests__/use-insight-status.test.ts": 1, "lib/__tests__/geo-offline-detection.test.ts": 1, "lib/__tests__/query-keys.test.ts": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index e546e423d..8e942a370 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2950, - "total": 10294, + "count": 2949, + "total": 10293, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1448,7 +1448,6 @@ "components/timeline/__tests__/timeline-view-keys.test.tsx": 1, "components/timeline/__tests__/value-options.test.ts": 1, "components/timeline/life-event-form.lazy.tsx": 1, - "components/ui/__tests__/filter-bar.test.tsx": 1, "components/ui/back-link.tsx": 1, "components/ui/button.tsx": 1, "components/ui/card.tsx": 1, diff --git a/src/components/ui/__tests__/filter-bar.test.tsx b/src/components/ui/__tests__/filter-bar.test.tsx deleted file mode 100644 index 8ab5fd593..000000000 --- a/src/components/ui/__tests__/filter-bar.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { FilterBar, FilterBarDateRange, FilterBarSelect } from "../filter-bar"; -import { primeLocales } from "@/__tests__/helpers/prime-locales"; - -primeLocales("de"); - -/** - * v1.16.1 — unified filter rail. SSR-markup guards for the shared - * grammar: pill = trigger + active chip in one element, reset only - * while filtered, count slot, per-pill clear button with a translated - * aria-label. - */ - -const options = [ - { value: "WEIGHT", label: "Weight" }, - { value: "PULSE", label: "Pulse" }, -]; - -function render(node: React.ReactElement, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -describe("FilterBarSelect", () => { - it("inactive: shows only the dimension name, no clear button", () => { - const html = render( - {}}> - {}} - allLabel="All types" - options={options} - /> - , - ); - expect(html).toContain('data-slot="filter-bar-pill"'); - expect(html).toContain('data-active="false"'); - expect(html).toContain("Type"); - expect(html).not.toContain("Clear filter"); - }); - - it("active: shows `label: value`, active styling and a labelled clear button", () => { - const html = render( - {}}> - {}} - allLabel="All types" - options={options} - /> - , - ); - expect(html).toContain('data-active="true"'); - expect(html).toContain("Weight"); - expect(html).toContain('aria-label="Clear filter: Type"'); - }); -}); - -describe("FilterBarDateRange", () => { - it("inactive: pill shows the dimension name only", () => { - const html = render( - {}}> - {}} - onToChange={() => {}} - idPrefix="probe" - /> - , - ); - expect(html).toContain('data-active="false"'); - expect(html).toContain("Date range"); - }); - - it("active: pill shows the formatted bounds and a clear button", () => { - const html = render( - {}}> - {}} - onToChange={() => {}} - idPrefix="probe" - /> - , - ); - expect(html).toContain('data-active="true"'); - // en dateShort: "05/12" / "06/10" — assert both day fragments are in. - expect(html).toMatch(/05\/12/); - expect(html).toMatch(/06\/10/); - expect(html).toContain('aria-label="Clear filter: Date range"'); - }); -}); - -describe("FilterBar shell", () => { - it("shows the reset action only while filtered", () => { - const inactive = render( - {}}> - - , - ); - const active = render( - {}}> - - , - ); - expect(inactive).not.toContain("Reset"); - expect(active).toContain("Reset"); - }); - - it("renders the result count slot", () => { - const html = render( - {}} count="42 readings"> - - , - ); - expect(html).toContain("42 readings"); - expect(html).toContain("tabular-nums"); - }); - - it("localises reset + clear labels in German", () => { - const html = render( - {}}> - {}} - allLabel="Alle Quellen" - options={[{ value: "MANUAL", label: "Manuell" }]} - /> - , - "de", - ); - expect(html).toContain("Zurücksetzen"); - expect(html).toContain('aria-label="Filter entfernen: Quelle"'); - }); -}); - -describe("FilterBarSelect — menu positioning (source pin)", () => { - // The pill trigger paints its own label spans and mounts no - // ``. Radix's default item-aligned placement silently - // skips ALL positioning when the value node is absent — the open - // menu then lays out below the height-locked app shell, fully - // off-viewport, which users report as "clicking does nothing". SSR - // markup cannot exercise an open portal, so this pins the source: - // the content must request popper placement, which has no - // value-node dependency. - it("requests popper placement on the select content", () => { - const src = readFileSync( - join(process.cwd(), "src/components/ui/filter-bar.tsx"), - "utf8", - ); - expect(src).toMatch(//); - }); -}); diff --git a/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx new file mode 100644 index 000000000..6897b0b2e --- /dev/null +++ b/src/hooks/__tests__/use-url-filter-sync.browser.test.tsx @@ -0,0 +1,268 @@ +/** + * The URL-owned filter contract, driven the way a list page uses it: a + * `FilterBar` of pills over `useUrlFilterSync`, in a real browser with a real + * address bar. What these pin and the markup tests could not: a facet picked + * from the open menu lands in the query as a new history entry, typed input + * rewrites the current one, the per-pill clear and Reset leave a bare URL, two + * writes in a row both land, and Back or an edited address re-seeds the pills. + */ +import { describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { + FilterBar, + FilterBarDateRange, + FilterBarSelect, +} from "@/components/ui/filter-bar"; +import { Input } from "@/components/ui/input"; + +import { renderWithApp } from "../../../tests/browser/render"; +import { + currentSearch, + navigateExternally, + recordHistory, + setSearch, +} from "../../../tests/browser/url"; +import { useUrlFilterSync } from "../use-url-filter-sync"; + +interface Filters { + type?: string; + source?: string; + q?: string; + from?: string; + to?: string; +} + +const KEYS = ["type", "source", "q", "from", "to"] as const; + +function parse(params: URLSearchParams): Filters { + const filters: Filters = {}; + for (const key of KEYS) { + const value = params.get(key); + if (value) filters[key] = value; + } + return filters; +} + +function serialise(filters: Filters): string { + const sp = new URLSearchParams(); + for (const key of KEYS) { + const value = filters[key]; + if (value) sp.set(key, value); + } + return sp.toString(); +} + +const TYPES = [ + { value: "WEIGHT", label: "Weight" }, + { value: "PULSE", label: "Pulse" }, +]; +const SOURCES = [ + { value: "MANUAL", label: "Manual" }, + { value: "WITHINGS", label: "Withings" }, +]; + +/** A list page's filter rail, as the measurements and mood lists build it. */ +function FilterRail({ syncToUrl = true }: { syncToUrl?: boolean }) { + const { filters, applyFilters, patchFilters } = useUrlFilterSync({ + parse, + serialise, + syncToUrl, + }); + const isFiltered = Object.keys(filters).length > 0; + return ( + <> + patchFilters({ q: e.target.value }, "replace")} + /> + applyFilters({})} + count={`${Object.keys(filters).length} active`} + > + patchFilters({ from })} + onToChange={(to) => patchFilters({ to })} + idPrefix="probe" + /> + + patchFilters({ type: v === "ALL" ? undefined : v }) + } + allLabel="All types" + options={TYPES} + /> + + patchFilters({ source: v === "ALL" ? undefined : v }) + } + allLabel="All sources" + options={SOURCES} + /> + + + ); +} + +async function mount(search = "", props: { syncToUrl?: boolean } = {}) { + setSearch(search); + const history = recordHistory(); + const { screen } = await renderWithApp(); + const pill = (name: string | RegExp) => + screen.getByRole("combobox", { name }); + const reset = () => screen.getByRole("button", { name: "Reset" }); + const pick = async (pillName: string | RegExp, option: string) => { + await pill(pillName).click(); + await screen.getByRole("option", { name: option }).click(); + }; + return { screen, history, pill, reset, pick }; +} + +/** The URL settles a task after the write; wait for the expected query. */ +async function expectSearch(search: string) { + await expect.poll(currentSearch).toBe(search); +} + +describe("useUrlFilterSync with FilterBar", () => { + it("seeds the pills from the address it mounts on", async () => { + const { screen, pill, reset } = await mount("?type=WEIGHT&source=WITHINGS"); + await expect + .element(pill("Type: Weight")) + .toHaveAttribute("data-active", "true"); + await expect.element(pill("Source: Withings")).toBeInTheDocument(); + await expect.element(reset()).toBeVisible(); + await expect.element(screen.getByText("2 active")).toBeVisible(); + }); + + it("shows no Reset and no clear button while unfiltered", async () => { + const { screen, pill } = await mount(); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await expect + .element(screen.getByRole("button", { name: "Reset" })) + .not.toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: /^Clear filter/ })) + .not.toBeInTheDocument(); + }); + + it("writes a facet picked from the open menu as a new history entry", async () => { + const { pill, history, pick } = await mount(); + const entries = window.history.length; + await pick("Type", "Pulse"); + await expectSearch("?type=PULSE"); + expect(history.pushes()).toEqual(["?type=PULSE"]); + expect(history.replaces()).toEqual([]); + expect(window.history.length).toBe(entries + 1); + await expect.element(pill("Type: Pulse")).toBeInTheDocument(); + }); + + it("opens the menu inside the viewport, under its pill", async () => { + const { screen, pill } = await mount(); + // Read before opening: the open menu hides the rest of the page from + // the accessibility tree, the pill included. + const trigger = pill("Source").element().getBoundingClientRect(); + await pill("Source").click(); + const listbox = screen.getByRole("listbox"); + await expect.element(listbox).toBeVisible(); + // The menu slides in from above its resting place; measure where it rests. + await Promise.all(document.getAnimations().map((a) => a.finished)); + const menu = listbox.element().getBoundingClientRect(); + expect(menu.top).toBeGreaterThanOrEqual(trigger.bottom - 1); + expect(menu.bottom).toBeLessThanOrEqual(window.innerHeight); + expect(menu.left).toBeGreaterThanOrEqual(0); + await userEvent.keyboard("{Escape}"); + await expect.element(listbox).not.toBeInTheDocument(); + }); + + it("rewrites the current entry for typed input instead of stacking one per key", async () => { + const { screen, history } = await mount("?type=WEIGHT"); + const entries = window.history.length; + await screen.getByRole("textbox", { name: "Search notes" }).fill("knee"); + await expectSearch("?type=WEIGHT&q=knee"); + expect(history.pushes()).toEqual([]); + expect(history.replaces().at(-1)).toBe("?type=WEIGHT&q=knee"); + expect(window.history.length).toBe(entries); + }); + + it("drops one facet with its clear button and keeps the others", async () => { + const { screen, pill } = await mount("?type=WEIGHT&source=MANUAL"); + await screen.getByRole("button", { name: "Clear filter: Type" }).click(); + await expectSearch("?source=MANUAL"); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await expect.element(pill("Source: Manual")).toBeInTheDocument(); + }); + + it("clears both date bounds in one press, two writes in a row", async () => { + const { screen } = await mount("?from=2026-05-12&to=2026-06-10&type=PULSE"); + const range = screen.getByRole("button", { name: /^Date range: / }); + await expect.element(range).toHaveTextContent("Date range: 05/12–06/10"); + await screen + .getByRole("button", { name: "Clear filter: Date range" }) + .click(); + await expectSearch("?type=PULSE"); + await expect + .element(screen.getByRole("button", { name: "Date range" })) + .toHaveAttribute("data-active", "false"); + }); + + it("resets every facet to a bare URL and hides Reset", async () => { + const { reset, pill, history } = await mount("?type=WEIGHT&source=MANUAL"); + await reset().click(); + await expectSearch(""); + expect(history.pushes()).toEqual([""]); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await expect + .element(pill("Source")) + .toHaveAttribute("data-active", "false"); + await expect.element(reset()).not.toBeInTheDocument(); + }); + + it("re-seeds from an address changed outside the component", async () => { + const { pill, reset } = await mount("?type=WEIGHT"); + navigateExternally("?source=WITHINGS"); + await expect.element(pill("Source: Withings")).toBeInTheDocument(); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + navigateExternally(""); + await expect.element(reset()).not.toBeInTheDocument(); + }); + + it("steps back through filter states with the browser's Back", async () => { + const { pill, pick } = await mount("?type=WEIGHT"); + await pick("Type: Weight", "Pulse"); + await expectSearch("?type=PULSE"); + window.history.back(); + await expectSearch("?type=WEIGHT"); + await expect.element(pill("Type: Weight")).toBeInTheDocument(); + }); + + it("keeps the state in memory and the address untouched with syncToUrl off", async () => { + const { pill, pick, history } = await mount("?type=WEIGHT", { + syncToUrl: false, + }); + await expect.element(pill("Type")).toHaveAttribute("data-active", "false"); + await pick("Type", "Pulse"); + await expect.element(pill("Type: Pulse")).toBeInTheDocument(); + expect(currentSearch()).toBe("?type=WEIGHT"); + expect(history.writes).toEqual([]); + }); + + it("names Reset and the clear buttons in the reader's language", async () => { + setSearch("?source=MANUAL"); + const { screen } = await renderWithApp(, { locale: "de" }); + await expect + .element(screen.getByRole("button", { name: "Zurücksetzen" })) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: "Filter entfernen: Source" })) + .toBeInTheDocument(); + }); +}); diff --git a/src/hooks/__tests__/use-url-filter-sync.test.tsx b/src/hooks/__tests__/use-url-filter-sync.test.tsx deleted file mode 100644 index 81e5a00bd..000000000 --- a/src/hooks/__tests__/use-url-filter-sync.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** - * The shared URL-owned-filter hook (`use-url-filter-sync.ts`) — the - * documents-vault pattern extracted for the measurements and mood lists. - * Pinned here: mount parses the live query string, a filter change writes - * the serialised form back through the router (push by default, replace on - * request), an empty filter set restores the bare pathname, and - * `syncToUrl: false` detaches the state from the URL entirely. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -let mockSearch = ""; -const pushMock = vi.fn(); -const replaceMock = vi.fn(); -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: pushMock, replace: replaceMock }), - usePathname: () => "/measurements", - useSearchParams: () => new URLSearchParams(mockSearch), -})); - -import { useUrlFilterSync } from "../use-url-filter-sync"; -import { - installUrlStateBrowser, - type UrlStateBrowser, -} from "@/__tests__/helpers/url-state-browser"; - -let browser: UrlStateBrowser = installUrlStateBrowser("/"); -afterEach(() => browser.uninstall()); - -interface ProbeFilters { - type?: string; - source?: string; -} - -function parse(params: URLSearchParams): ProbeFilters { - const filters: ProbeFilters = {}; - const type = params.get("type"); - if (type) filters.type = type; - const source = params.get("source"); - if (source) filters.source = source; - return filters; -} - -function serialise(filters: ProbeFilters): string { - const sp = new URLSearchParams(); - if (filters.type) sp.set("type", filters.type); - if (filters.source) sp.set("source", filters.source); - return sp.toString(); -} - -interface ProbeResult { - filters: ProbeFilters; - applyFilters: (next: ProbeFilters, mode?: "push" | "replace") => void; -} - -function mount(search: string, syncToUrl?: boolean): ProbeResult { - mockSearch = search; - // Ref-shaped capture cell: the render-scoped assignment happens inside a - // closure the control-flow analysis cannot see through. - const captured: { current: ProbeResult | null } = { current: null }; - function Probe() { - captured.current = useUrlFilterSync({ - parse, - serialise, - syncToUrl, - }); - return null; - } - renderToStaticMarkup(); - if (!captured.current) throw new Error("probe did not render"); - // The live address the writes start from (`@/lib/url-state`). - browser.uninstall(); - browser = installUrlStateBrowser( - search ? `/measurements?${search}` : "/measurements", - ); - return captured.current; -} - -beforeEach(() => { - pushMock.mockClear(); - replaceMock.mockClear(); -}); - -describe("useUrlFilterSync", () => { - it("parses the mounted query string into the filter object", () => { - const { filters } = mount("type=WEIGHT&source=WITHINGS"); - expect(filters).toEqual({ type: "WEIGHT", source: "WITHINGS" }); - }); - - it("writes a filter change to the URL via push by default", () => { - const { applyFilters } = mount(""); - applyFilters({ type: "WEIGHT" }); - expect(browser.push).toHaveBeenCalledWith("/measurements?type=WEIGHT"); - expect(browser.replace).not.toHaveBeenCalled(); - }); - - it("writes via replace when asked (high-frequency input)", () => { - const { applyFilters } = mount(""); - applyFilters({ source: "MANUAL" }, "replace"); - expect(browser.replace).toHaveBeenCalledWith("/measurements?source=MANUAL"); - expect(browser.push).not.toHaveBeenCalled(); - }); - - it("restores the bare pathname when every facet clears", () => { - const { applyFilters } = mount("type=WEIGHT"); - applyFilters({}); - expect(browser.push).toHaveBeenCalledWith("/measurements"); - }); - - it("syncToUrl: false ignores the URL and never navigates", () => { - const { filters, applyFilters } = mount("type=WEIGHT", false); - expect(filters).toEqual({}); - applyFilters({ type: "PULSE" }); - expect(browser.push).not.toHaveBeenCalled(); - expect(browser.replace).not.toHaveBeenCalled(); - }); -}); diff --git a/vitest.config.mts b/vitest.config.mts index 996edbdec..51fa3430b 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -132,8 +132,13 @@ export default defineConfig({ // Pre-bundle every dependency the browser files reach before the // first one loads. Discovered late, a dependency makes Vite re-bundle // and reload the page mid-run, which fails the file that was - // importing it; CI starts every run on an empty cache. + // importing it; CI starts every run on an empty cache. `force` + // makes a local run start there too: a cache warmed before a new + // browser file existed does not know that file's dependencies, and + // the late re-bundle loads a second React ("Invalid hook call"). + // A cold bundle costs about half a second. optimizeDeps: { + force: true, entries: ["src/**/*.browser.test.tsx", "tests/browser/**/*.{ts,tsx}"], }, test: { From 3ab85f85f9415aa24132098a996cae0cdeb45e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:07:29 +0200 Subject: [PATCH 4/8] test(measurements): drive the measurement form in a real browser Type into the form, press Save and read the request body off the fetch boundary: blood pressure as one batch, a weight typed in lb stored in kg, a decimal comma, glucose typed in mmol/L stored in mg/dL with its meal context, a sleep typed as 7:30 stored as 450 minutes. Native required validation, the pending state, a refused write's localised message, the module-off type list, the last-used type and the menu's Reset are covered too. The unit-label and glucose-unit markup tests are deleted; their source pins are replaced by the wire assertions. The module-types file keeps its pure ownership checks and drops its render. The browser helper gains a Date-only clock pin, clears localStorage between tests, carries an envelope's meta, and answers the providers' locale mirror without counting it as the component's request. --- .../baselines/test-source-reads.json | 5 +- .../baselines/version-stamp-comments.json | 5 +- .../measurement-form-glucose-unit.test.tsx | 98 ------ ... => measurement-form-module-types.test.ts} | 49 +-- .../measurement-form-unit-label.test.tsx | 66 ---- .../measurement-form.browser.test.tsx | 309 ++++++++++++++++++ tests/browser/clock.ts | 14 + tests/browser/fetch-stub.ts | 21 +- tests/browser/setup.ts | 9 +- 9 files changed, 356 insertions(+), 220 deletions(-) delete mode 100644 src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx rename src/components/measurements/__tests__/{measurement-form-module-types.test.tsx => measurement-form-module-types.test.ts} (60%) delete mode 100644 src/components/measurements/__tests__/measurement-form-unit-label.test.tsx create mode 100644 src/components/measurements/__tests__/measurement-form.browser.test.tsx create mode 100644 tests/browser/clock.ts diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index 4cafc7881..3e6c5204c 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -1,7 +1,7 @@ { "$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 358, - "total": 358, + "count": 357, + "total": 357, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -213,7 +213,6 @@ "components/layout/__tests__/offline-banner.test.tsx": 1, "components/layout/__tests__/sidebar-nav.test.tsx": 1, "components/measurements/__tests__/duration-read-as.test.tsx": 1, - "components/measurements/__tests__/measurement-form-glucose-unit.test.tsx": 1, "components/medications/__tests__/add-inventory-dialog.test.tsx": 1, "components/medications/__tests__/detail-page-cascade.test.tsx": 1, "components/medications/__tests__/inventory-section-dates.test.tsx": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 8e942a370..bf030cc41 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2949, - "total": 10293, + "count": 2948, + "total": 10292, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1122,7 +1122,6 @@ "components/measurement-reminders/vorsorge-dashboard-card.tsx": 2, "components/measurement-reminders/vorsorge-section.tsx": 26, "components/measurement-reminders/vorsorge-settings.tsx": 2, - "components/measurements/__tests__/measurement-form-unit-label.test.tsx": 1, "components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx": 4, "components/measurements/__tests__/measurement-list-bp-badge.test.tsx": 3, "components/measurements/__tests__/measurement-list-empty.test.tsx": 1, diff --git a/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx deleted file mode 100644 index af91ed46d..000000000 --- a/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * The manual entry form and the blood-glucose display unit. - * - * Glucose is stored in mg/dL and shown in whichever of mg/dL and mmol/L the - * account picked. Until the preference could be set at all, the form could - * hardcode "mg/dL" and be right by accident. It no longer can: a reader on - * mmol/L types 5.3, and 5.3 filed as mg/dL is a severe hypo that the - * plausibility band (20–800) accepts without a word and every surface then - * reads back as real. So the label and the stored number have to move - * together — the field asks in the account's unit and the submit path - * inverts to canonical mg/dL. - * - * The label half is rendered. The submit half is asserted against the source: - * this project runs SSR-only component tests (`@testing-library/react` is not - * a dependency), so nothing here can type into the field and press save. What - * the source assertion pins is the wiring — that the glucose branch inverts - * through `toCanonicalMgdl` rather than sending the typed number on — and - * `src/lib/__tests__/glucose.test.ts` proves the conversion itself. - */ - -let glucoseUnit: string | null = null; - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "testuser", - role: "USER", - unitPreference: "metric", - glucoseUnit, - }, - isAuthenticated: true, - isLoading: false, - }), -})); - -vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ - invalidateQueries: vi.fn(), - refetchQueries: vi.fn(), - }), -})); - -vi.mock("@/lib/api/api-fetch", () => ({ - apiPost: vi.fn().mockResolvedValue({}), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { MeasurementForm } from "../measurement-form"; - -function render(): string { - return renderToStaticMarkup( - - - , - ); -} - -const formSource = readFileSync( - join(process.cwd(), "src/components/measurements/measurement-form.tsx"), - "utf8", -); - -describe("MeasurementForm — blood-glucose entry unit", () => { - it("asks in mg/dL for an account that never chose", () => { - glucoseUnit = null; - const html = render(); - expect(html).toContain("mg/dL"); - expect(html).not.toContain("mmol/L"); - expect(html).toContain('placeholder="95"'); - }); - - it("asks in mmol/L once the account chose it", () => { - glucoseUnit = "mmol/L"; - const html = render(); - expect(html).toContain("mmol/L"); - // A mmol/L reader must not be shown a three-digit mg/dL example. - expect(html).toContain('placeholder="5.3"'); - expect(html).not.toContain('placeholder="95"'); - }); - - it("inverts a glucose entry to canonical mg/dL before it is sent", () => { - expect(formSource).toMatch( - /if \(isGlucoseMode\) \{\s*canonicalValue = toCanonicalMgdl\(typed, glucoseUnit\);/, - ); - }); - - it("resolves the unit from the account rather than assuming one", () => { - expect(formSource).toMatch( - /const glucoseUnit = resolveGlucoseUnit\(user\?\.glucoseUnit\)/, - ); - }); -}); diff --git a/src/components/measurements/__tests__/measurement-form-module-types.test.tsx b/src/components/measurements/__tests__/measurement-form-module-types.test.ts similarity index 60% rename from src/components/measurements/__tests__/measurement-form-module-types.test.tsx rename to src/components/measurements/__tests__/measurement-form-module-types.test.ts index 98a2ec7c9..12e4efd15 100644 --- a/src/components/measurements/__tests__/measurement-form-module-types.test.tsx +++ b/src/components/measurements/__tests__/measurement-form-module-types.test.ts @@ -6,45 +6,18 @@ * file a reading into a domain whose pages, charts and reads are all hidden, * or be refused by the route. The form now filters its types through the * surface map's type ownership (`summary:`), which the test below holds - * equal to the measurement-scope ownership the server gates on. + * equal to the measurement-scope ownership the server gates on. That the + * mounted form neither opens on nor offers such a type is driven in + * `measurement-form.browser.test.tsx`. */ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { MeasurementType } from "@/generated/prisma/client"; import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import { surfaceModule } from "@/lib/modules/surface"; -const auth = vi.hoisted(() => ({ - modules: undefined as Record | undefined, -})); -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - role: "USER", - unitPreference: "metric", - glucoseUnit: null, - modules: auth.modules, - }, - isAuthenticated: true, - isLoading: false, - }), -})); -vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ - invalidateQueries: vi.fn(), - refetchQueries: vi.fn(), - }), -})); -vi.mock("@/lib/api/api-fetch", () => ({ - apiPost: vi.fn().mockResolvedValue({}), -})); - -import { I18nProvider } from "@/lib/i18n/context"; import { MEASUREMENT_FORM_TYPE_VALUES, - MeasurementForm, measurementFormTypes, } from "../measurement-form"; @@ -77,17 +50,3 @@ describe("measurementFormTypes", () => { expect(owned).toBeGreaterThan(0); }); }); - -describe(" with a type's module off", () => { - it("does not open on that type, even when asked to", () => { - auth.modules = { glucose: false }; - const html = renderToStaticMarkup( - - - , - ); - auth.modules = undefined; - expect(html).not.toContain("mg/dL"); - expect(html).toContain("mmHg"); - }); -}); diff --git a/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx b/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx deleted file mode 100644 index b53d0d50a..000000000 --- a/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.32.26 (issue #627) — the manual entry form labels its value field in the - * user's preferred unit for a type with a metric/imperial transform. A metric - * user sees "kg"; an imperial user sees "lb". The field is the entry boundary - * that converts the typed number back to canonical SI on submit, so the label - * and the stored unit must agree with the preference. - */ - -let preference: "metric" | "imperial" = "metric"; - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "testuser", - role: "USER", - unitPreference: preference, - }, - isAuthenticated: true, - isLoading: false, - }), -})); - -vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ - invalidateQueries: vi.fn(), - refetchQueries: vi.fn(), - }), -})); - -vi.mock("@/lib/api/api-fetch", () => ({ - apiPost: vi.fn().mockResolvedValue({}), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { MeasurementForm } from "../measurement-form"; - -function render(): string { - return renderToStaticMarkup( - - - , - ); -} - -describe("MeasurementForm — preferred-unit value label", () => { - it("labels the weight field in kg for a metric user", () => { - preference = "metric"; - const html = render(); - expect(html).toContain("kg"); - expect(html).not.toContain("lb"); - // The metric placeholder (kg) is shown, not the imperial one. - expect(html).toContain('placeholder="75.5"'); - }); - - it("labels the weight field in lb for an imperial user", () => { - preference = "imperial"; - const html = render(); - expect(html).toContain("lb"); - // The imperial placeholder (lb) is shown. - expect(html).toContain('placeholder="165"'); - }); -}); diff --git a/src/components/measurements/__tests__/measurement-form.browser.test.tsx b/src/components/measurements/__tests__/measurement-form.browser.test.tsx new file mode 100644 index 000000000..10a5b9007 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-form.browser.test.tsx @@ -0,0 +1,309 @@ +/** + * `` driven by typing and pressing Save, with the request + * body read off the network boundary. What the markup tests could only pin + * against the source is asserted on the wire here: a weight typed in lb, a + * glucose typed in mmol/L and a sleep typed as "7:30" each arrive in their + * canonical unit. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; + +import type { AuthUser } from "@/hooks/use-auth"; + +import { pinDate } from "../../../../tests/browser/clock"; +import { + stubFetch, + type FetchStub, +} from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; +import { MeasurementForm } from "../measurement-form"; + +const NOW = "2026-09-15T10:00:00.000Z"; + +let api: FetchStub; + +beforeEach(() => { + pinDate(NOW); + api = stubFetch().on("POST", "/api/measurements", { status: 201, data: {} }); +}); + +async function mount( + props: Parameters[0] = {}, + user: Partial = {}, +) { + const onSuccess = vi.fn(); + const { screen } = await renderWithApp( + {}} {...props} />, + { user }, + ); + const save = screen.getByRole("button", { name: "Save" }); + return { screen, onSuccess, save }; +} + +const posted = () => api.calls("POST", "/api/measurements").map((r) => r.body); + +describe(" blood pressure", () => { + it("sends systolic, diastolic and pulse as one batch at the chosen instant", async () => { + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_PRESSURE", + }); + await screen.getByLabelText(/^Systolic/).fill("128"); + await screen.getByLabelText(/^Diastolic/).fill("84"); + await screen.getByLabelText(/^Pulse/).fill("66"); + await screen.getByLabelText(/^Notes/).fill("after the stairs"); + await save.click(); + + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + const at = NOW; + expect(posted()).toEqual([ + [ + { + type: "BLOOD_PRESSURE_SYS", + value: 128, + measuredAt: at, + notes: "after the stairs", + }, + { + type: "BLOOD_PRESSURE_DIA", + value: 84, + measuredAt: at, + notes: "after the stairs", + }, + { type: "PULSE", value: 66, measuredAt: at, notes: "after the stairs" }, + ], + ]); + // Only the write: nothing else in the cache needed refetching. + expect(api.requests.map((r) => `${r.method} ${r.path}`)).toEqual([ + "POST /api/measurements", + ]); + await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null); + }); + + it("leaves the pulse out when it was not typed", async () => { + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_PRESSURE", + }); + await screen.getByLabelText(/^Systolic/).fill("118"); + await screen.getByLabelText(/^Diastolic/).fill("76"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + const [batch] = posted() as Array>; + expect(batch.map((e) => e.type)).toEqual([ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + ]); + }); + + it("sends nothing while a required reading is missing", async () => { + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_PRESSURE", + }); + await screen.getByLabelText(/^Diastolic/).fill("80"); + await save.click(); + const sys = screen + .getByLabelText(/^Systolic/) + .element() as HTMLInputElement; + expect(sys.validity.valueMissing).toBe(true); + expect(api.requests).toEqual([]); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it("holds Save and the menu while the write is pending", async () => { + const pending = api.deferred("POST", "/api/measurements"); + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_PRESSURE", + }); + await screen.getByLabelText(/^Systolic/).fill("120"); + await screen.getByLabelText(/^Diastolic/).fill("80"); + await save.click(); + + await expect.element(save).toBeDisabled(); + await expect + .element(screen.getByRole("button", { name: "More options" })) + .toBeDisabled(); + await expect + .element(screen.getByRole("button", { name: "Cancel" })) + .toBeDisabled(); + expect(onSuccess).not.toHaveBeenCalled(); + + pending.release({ status: 201, data: {} }); + await expect.element(save).toBeEnabled(); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + + it("names a refused write in the reader's words and keeps what was typed", async () => { + api.on("POST", "/api/measurements", { + status: 409, + error: "Duplicate measurement", + meta: { errorCode: "measurement.duplicate_timestamp" }, + }); + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_PRESSURE", + }); + await screen.getByLabelText(/^Systolic/).fill("120"); + await screen.getByLabelText(/^Diastolic/).fill("80"); + await save.click(); + + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("A measurement already exists for this time."); + await expect + .element(screen.getByLabelText(/^Systolic/)) + .toHaveAttribute("aria-invalid", "true"); + await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(120); + expect(onSuccess).not.toHaveBeenCalled(); + }); +}); + +describe(" units on the way out", () => { + it("asks an imperial reader for lb and stores kg", async () => { + const { screen, save, onSuccess } = await mount( + { defaultType: "WEIGHT" }, + { unitPreference: "imperial" }, + ); + const field = screen.getByLabelText("Value (lb)"); + await expect.element(field).toHaveAttribute("placeholder", "165"); + await field.fill("210"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()).toEqual([ + { type: "WEIGHT", value: 95.25, measuredAt: NOW }, + ]); + }); + + it("reads a decimal comma for a metric reader", async () => { + const { screen, save, onSuccess } = await mount({ defaultType: "WEIGHT" }); + const field = screen.getByLabelText("Value (kg)"); + await expect.element(field).toHaveAttribute("placeholder", "75.5"); + await field.fill("75,5"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()).toEqual([ + { type: "WEIGHT", value: 75.5, measuredAt: NOW }, + ]); + }); + + it("asks in mg/dL for an account that never chose a glucose unit", async () => { + const { screen, save, onSuccess } = await mount({ + defaultType: "BLOOD_GLUCOSE", + }); + const field = screen.getByLabelText("Value (mg/dL)"); + await expect.element(field).toHaveAttribute("placeholder", "95"); + await field.fill("104"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()).toEqual([ + { + type: "BLOOD_GLUCOSE", + value: 104, + measuredAt: NOW, + glucoseContext: "FASTING", + }, + ]); + }); + + it("asks a mmol/L reader in mmol/L, stores mg/dL and sends the meal context", async () => { + const { screen, save, onSuccess } = await mount( + { defaultType: "BLOOD_GLUCOSE" }, + { glucoseUnit: "mmol/L" }, + ); + const field = screen.getByLabelText("Value (mmol/L)"); + await expect.element(field).toHaveAttribute("placeholder", "5.3"); + await field.fill("5.5"); + await screen.getByRole("combobox", { name: /^Context/ }).click(); + await screen + .getByRole("option", { name: "Post-meal (2h after eating)" }) + .click(); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()).toEqual([ + { + type: "BLOOD_GLUCOSE", + value: 99, + measuredAt: NOW, + glucoseContext: "POSTPRANDIAL", + }, + ]); + }); + + it("reads a sleep typed as hours and minutes and stores minutes", async () => { + const { screen, save, onSuccess } = await mount({ + defaultType: "SLEEP_DURATION", + }); + await screen.getByLabelText("Value (hours)").fill("7:30"); + await expect.element(screen.getByText("Read as 7 h 30 min")).toBeVisible(); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()).toEqual([ + { type: "SLEEP_DURATION", value: 450, measuredAt: NOW }, + ]); + }); + + it("refuses a duration it cannot read, under the field", async () => { + const { screen, save } = await mount({ defaultType: "SLEEP_DURATION" }); + const field = screen.getByLabelText("Value (hours)"); + await field.fill("most of the night"); + await save.click(); + await expect + .element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m.")) + .toBeVisible(); + await expect.element(field).toHaveAttribute("aria-invalid", "true"); + expect(api.requests).toEqual([]); + await field.fill("7.5"); + await expect + .element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m.")) + .not.toBeInTheDocument(); + }); +}); + +describe(" type", () => { + it("relabels the value field when the type changes", async () => { + const { screen } = await mount({ defaultType: "WEIGHT" }); + await screen.getByRole("combobox", { name: /^Type/ }).click(); + await screen.getByRole("option", { name: "Body temperature" }).click(); + await expect.element(screen.getByLabelText("Value (°C)")).toBeVisible(); + await screen.getByRole("combobox", { name: /^Type/ }).click(); + await screen.getByRole("option", { name: "Blood Pressure" }).click(); + await expect.element(screen.getByLabelText(/^Systolic/)).toBeVisible(); + }); + + it("does not open on, or offer, a type whose module is off", async () => { + const { screen } = await mount( + { defaultType: "BLOOD_GLUCOSE" }, + { modules: { glucose: false } }, + ); + const type = screen.getByRole("combobox", { name: /^Type/ }); + await expect.element(type).toHaveTextContent("Blood Pressure"); + await type.click(); + await expect + .element(screen.getByRole("option", { name: "Weight" })) + .toBeVisible(); + await expect + .element(screen.getByRole("option", { name: "Blood glucose" })) + .not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + }); + + it("opens on the type saved last when no type is asked for", async () => { + const first = await mount({ defaultType: "WEIGHT" }); + await first.screen.getByLabelText("Value (kg)").fill("80"); + await first.save.click(); + await expect.poll(() => first.onSuccess.mock.calls.length).toBe(1); + first.screen.unmount(); + + const { screen } = await mount(); + await expect + .element(screen.getByRole("combobox", { name: /^Type/ })) + .toHaveTextContent("Weight"); + }); + + it("clears every field from the menu's Reset", async () => { + const { screen } = await mount({ defaultType: "BLOOD_PRESSURE" }); + await screen.getByLabelText(/^Systolic/).fill("120"); + await screen.getByLabelText(/^Notes/).fill("left arm"); + await screen.getByRole("button", { name: "More options" }).click(); + await screen.getByRole("menuitem", { name: "Reset form" }).click(); + await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null); + await expect.element(screen.getByLabelText(/^Notes/)).toHaveValue(""); + }); +}); diff --git a/tests/browser/clock.ts b/tests/browser/clock.ts new file mode 100644 index 000000000..b1ffd8b5f --- /dev/null +++ b/tests/browser/clock.ts @@ -0,0 +1,14 @@ +/** + * Pin `Date` for a browser test, and nothing else. + * + * Forms default their timestamp to "now" and refuse a future one, so a test + * that asserts the instant a form sends has to say what now is. Only `Date` + * is faked: timers, animation frames and the microtask queue keep running, + * which is what Radix, TanStack Query and the URL-state queue wait on. + * `setup.ts` restores the real clock after every test. + */ +import { vi } from "vitest"; + +export function pinDate(iso: string): void { + vi.useFakeTimers({ toFake: ["Date"], now: new Date(iso) }); +} diff --git a/tests/browser/fetch-stub.ts b/tests/browser/fetch-stub.ts index de5a8cfbc..5bcbe4fd2 100644 --- a/tests/browser/fetch-stub.ts +++ b/tests/browser/fetch-stub.ts @@ -14,6 +14,11 @@ * state. A request no route matches fails with a 404 envelope and is recorded * too, so an unexpected call is visible rather than hanging. * + * One request is ambient: `I18nProvider` mirrors the active locale to + * `PUT /api/auth/me/locale` on every mount, as it does in the app. The stub + * answers it and leaves it out of `requests`, so "nothing was sent" means + * nothing the component under test sent. + * * `setup.ts` restores the real `fetch` after every test. */ export interface RecordedRequest { @@ -30,6 +35,8 @@ export interface StubResponse { /** The envelope's `data`. Ignored when `error` is set. */ data?: unknown; error?: string; + /** The envelope's `meta` (an error's `errorCode`, a list's totals). */ + meta?: Record; } type Responder = ( @@ -65,6 +72,9 @@ export interface FetchStub { let realFetch: typeof fetch | null = null; +/** Sent by the providers on every mount, not by the component under test. */ +const AMBIENT = [{ method: "PUT", path: "/api/auth/me/locale" }]; + function matches(route: Route, method: string, path: string): boolean { if (route.method !== method) return false; const bare = path.split("?")[0]; @@ -73,8 +83,11 @@ function matches(route: Route, method: string, path: string): boolean { : route.path.test(path); } -function envelope({ status = 200, data = null, error }: StubResponse) { - const body = error ? { data: null, error } : { data, error: null }; +function envelope({ status = 200, data = null, error, meta }: StubResponse) { + const body = { + ...(error ? { data: null, error } : { data, error: null }), + ...(meta ? { meta } : {}), + }; return new Response(JSON.stringify(body), { status: error && status === 200 ? 400 : status, headers: { "content-type": "application/json" }, @@ -110,6 +123,10 @@ export function stubFetch(): FetchStub { window.fetch = async (input, init) => { const request = await record(input, init); + const ambient = AMBIENT.some( + (a) => a.method === request.method && a.path === request.path, + ); + if (ambient) return envelope({ data: request.body ?? null }); requests.push(request); // The newest route wins, so a test can override a default it set up. const route = [...routes] diff --git a/tests/browser/setup.ts b/tests/browser/setup.ts index d1ea7821f..2a6647028 100644 --- a/tests/browser/setup.ts +++ b/tests/browser/setup.ts @@ -8,13 +8,14 @@ * - The EN catalog, the fallback floor of every `t()` chain, loaded through * the production loader. `renderWithApp` loads any other locale it is * asked for. - * - After every test: the real `fetch` and the page's starting address come - * back, so a stub or a URL write never reaches the next test. Rendered + * - After every test: the real `fetch`, the real clock, the page's starting + * address and an empty `localStorage` come back, so a stub, a pinned date, + * a URL write or a remembered preference never reaches the next test. Rendered * trees are unmounted by `vitest-browser-react` itself. */ import "@/app/globals.css"; -import { afterEach, beforeAll, beforeEach } from "vitest"; +import { afterEach, beforeAll, beforeEach, vi } from "vitest"; import { loadMessages } from "@/lib/i18n/load-locale"; @@ -33,5 +34,7 @@ beforeEach(() => { afterEach(() => { restoreFetch(); + vi.useRealTimers(); restoreAddress(); + window.localStorage.clear(); }); From b161ed3bd280967119ad35e6958f3e4716d8094b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:09:01 +0200 Subject: [PATCH 5/8] test(workouts): drive the manual workout form in a real browser Pick a sport, type a duration and distance, press Save and read the batch body: miles asked of an imperial reader arrive in metres, a blank start counts back from now, a retry after a failed save reuses the form's external id, and an edit re-posts the stored instants and the fields the form does not show. Field errors, the pending state and the phone layout are measured in the page. The markup and edit-render cases are deleted from the old suite, which keeps the save and delete helper checks as manual-workout-save.test.ts. --- .../manual-workout-form.browser.test.tsx | 205 ++++++++++++++++++ ...m.test.tsx => manual-workout-save.test.ts} | 124 +---------- 2 files changed, 213 insertions(+), 116 deletions(-) create mode 100644 src/components/workouts/__tests__/manual-workout-form.browser.test.tsx rename src/components/workouts/__tests__/{manual-workout-form.test.tsx => manual-workout-save.test.ts} (57%) diff --git a/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx new file mode 100644 index 000000000..305a63e6d --- /dev/null +++ b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx @@ -0,0 +1,205 @@ +/** + * ``, the quick-entry sheet's workout form, driven by + * picking, typing and pressing Save. The entry rules live in + * `manual-entry.ts` and its own suite; this one pins what the person sees + * and what reaches `POST /api/workouts/batch`: the field errors, the distance + * asked in the reader's unit and sent in metres, the start counted back from + * now, the pending state, and one external id per opened form. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; + +import type { AuthUser } from "@/hooks/use-auth"; +import { manualWorkoutOriginalFromRow } from "@/lib/workouts/manual-entry"; + +import { pinDate } from "../../../../tests/browser/clock"; +import { + stubFetch, + type FetchStub, +} from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; +import { ManualWorkoutForm } from "../manual-workout-form"; + +const NOW = "2026-09-15T10:00:00.000Z"; +const BATCH = "/api/workouts/batch"; + +let api: FetchStub; + +beforeEach(() => { + pinDate(NOW); + api = stubFetch().on("POST", BATCH, { + data: { entries: [{ index: 0, status: "inserted" }] }, + }); +}); + +async function mount( + props: Parameters[0] = {}, + user: Partial = {}, +) { + const onSuccess = vi.fn(); + const { screen } = await renderWithApp( + {}} {...props} />, + { user }, + ); + const save = screen.getByRole("button", { name: "Save" }); + const pickSport = async (name: string) => { + await screen.getByRole("combobox", { name: /^Activity/ }).click(); + await screen.getByRole("option", { name }).click(); + }; + return { screen, onSuccess, save, pickSport }; +} + +type Posted = { workouts: Array> }; +const posted = () => api.calls("POST", BATCH).map((r) => r.body as Posted); + +describe("", () => { + it("names what is missing under each field and sends nothing", async () => { + const { screen, save } = await mount(); + await save.click(); + await expect.element(screen.getByText("Choose an activity.")).toBeVisible(); + await expect + .element(screen.getByText("Enter how long the workout lasted.")) + .toBeVisible(); + await expect + .element(screen.getByLabelText("Hours")) + .toHaveAttribute("aria-invalid", "true"); + expect(api.requests).toEqual([]); + }); + + it("asks an imperial reader for miles and sends metres, counted back from now", async () => { + const { screen, save, onSuccess, pickSport } = await mount( + {}, + { unitPreference: "imperial" }, + ); + await pickSport("Running"); + await screen.getByLabelText("Hours").fill("1"); + await screen.getByLabelText("Minutes").fill("30"); + await screen.getByLabelText("Distance (mi)").fill("10"); + await screen.getByLabelText("Active energy (kcal)").fill("640"); + await save.click(); + + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + const [body] = posted(); + expect(body.workouts).toHaveLength(1); + expect(body.workouts[0]).toEqual({ + sportType: "running", + startedAt: "2026-09-15T08:30:00.000Z", + endedAt: NOW, + source: "MANUAL", + externalId: expect.stringMatching(/^manual:/), + totalDistanceM: 16093.4, + totalEnergyKcal: 640, + }); + }); + + it("asks a metric reader for kilometres", async () => { + const { screen, save, onSuccess, pickSport } = await mount(); + await pickSport("Cycling"); + await screen.getByLabelText("Minutes").fill("45"); + await screen.getByLabelText("Distance (km)").fill("21,5"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()[0].workouts[0]).toMatchObject({ + sportType: "cycling", + startedAt: "2026-09-15T09:15:00.000Z", + totalDistanceM: 21500, + }); + }); + + it("holds Save while the write is pending, then reports it once", async () => { + const pending = api.deferred("POST", BATCH); + const { screen, save, onSuccess, pickSport } = await mount(); + await pickSport("Yoga"); + await screen.getByLabelText("Minutes").fill("20"); + await save.click(); + + await expect.element(save).toBeDisabled(); + await expect + .element(screen.getByRole("button", { name: "Cancel" })) + .toBeDisabled(); + pending.release({ data: { entries: [{ index: 0, status: "inserted" }] } }); + await expect.element(save).toBeEnabled(); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + + it("retries a failed save under the same external id", async () => { + api.on("POST", BATCH, { status: 503, error: "Service unavailable" }); + const { screen, save, onSuccess, pickSport } = await mount(); + await pickSport("Running"); + await screen.getByLabelText("Minutes").fill("30"); + await save.click(); + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("Service unavailable"); + expect(onSuccess).not.toHaveBeenCalled(); + + api.on("POST", BATCH, { + data: { entries: [{ index: 0, status: "duplicate" }] }, + }); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + const [first, second] = posted(); + expect(second.workouts[0].externalId).toBe(first.workouts[0].externalId); + }); + + it("lays out for a phone: duration side by side, distance over energy", async () => { + await page.viewport(390, 844); + const { screen, save } = await mount(); + const top = (label: string) => + screen.getByLabelText(label).element().getBoundingClientRect().top; + expect(top("Hours")).toBe(top("Minutes")); + expect(top("Active energy (kcal)")).toBeGreaterThan(top("Distance (km)")); + expect( + save.element().getBoundingClientRect().height, + ).toBeGreaterThanOrEqual(44); + // A record, not a training log: no heart rate, sets or route to fill in. + await expect + .element(screen.getByText(/heart|bpm|sets|reps/i)) + .not.toBeInTheDocument(); + }); + + it("opens an edit on the stored values and re-posts them unchanged", async () => { + const original = manualWorkoutOriginalFromRow( + { + sportType: "cycling", + startedAt: "2026-09-14T06:00:00.000Z", + endedAt: "2026-09-14T07:30:00.000Z", + durationSec: 5400, + distanceM: 32000, + activeEnergyKcal: 640, + minHr: null, + stepCount: null, + elevationM: null, + pauseDurationSec: null, + storedAvgHr: 131, + storedMaxHr: null, + }, + { timezone: "UTC", unitPreference: "metric" }, + ); + const externalId = "manual:5b0f3c1e-1111-4222-8333-444455556666"; + api.on("POST", BATCH, { + data: { entries: [{ index: 0, status: "updated" }] }, + }); + const { screen, save, onSuccess } = await mount({ + edit: { externalId, original }, + }); + await expect + .element(screen.getByRole("combobox", { name: /^Activity/ })) + .toHaveTextContent("Cycling"); + await expect.element(screen.getByLabelText("Hours")).toHaveValue("1"); + await expect.element(screen.getByLabelText("Minutes")).toHaveValue("30"); + await expect + .element(screen.getByLabelText("Distance (km)")) + .toHaveValue("32"); + await save.click(); + await expect.poll(() => onSuccess.mock.calls.length).toBe(1); + expect(posted()[0].workouts[0]).toMatchObject({ + externalId, + startedAt: "2026-09-14T06:00:00.000Z", + endedAt: "2026-09-14T07:30:00.000Z", + totalDistanceM: 32000, + totalEnergyKcal: 640, + avgHeartRate: 131, + }); + }); +}); diff --git a/src/components/workouts/__tests__/manual-workout-form.test.tsx b/src/components/workouts/__tests__/manual-workout-save.test.ts similarity index 57% rename from src/components/workouts/__tests__/manual-workout-form.test.tsx rename to src/components/workouts/__tests__/manual-workout-save.test.ts index ff947bbf6..e04b69829 100644 --- a/src/components/workouts/__tests__/manual-workout-form.test.tsx +++ b/src/components/workouts/__tests__/manual-workout-save.test.ts @@ -1,56 +1,28 @@ /** - * `` and its save path, and the detail page's delete. + * The manual workout save path, and the detail page's delete. * - * SSR-only, like the other capture-form suites (vitest runs in `node`, no - * testing-library). The rules themselves live in `manual-entry.ts` and are - * pinned in its own suite; this one pins the wire and the markup: + * The rules themselves live in `manual-entry.ts` and are pinned in its own + * suite, and the mounted form (fields, units, layout, editing) is driven in + * `manual-workout-form.browser.test.tsx`. This one pins the save and delete + * helpers on their own: * * 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with * the form's external id, and refreshes the workout reads; * 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not; - * 3. the form renders its five fields, the distance in the reader's unit, - * and a layout that stacks distance / energy on a phone; - * 4. delete is offered for a hand-entered workout in one's own record only, + * 3. delete is offered for a hand-entered workout in one's own record only, * and calls `DELETE /api/workouts/{id}`. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; import type { QueryClient } from "@tanstack/react-query"; -import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -vi.mock("@tanstack/react-query", () => ({ - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); -vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); - -const authRef: { unitPreference: "metric" | "imperial" } = { - unitPreference: "metric", -}; -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - username: "tester", - timezone: "Europe/Berlin", - unitPreference: authRef.unitPreference, - }, - }), -})); - -import { - ManualWorkoutForm, - saveManualWorkout, - sportOptions, -} from "../manual-workout-form"; +import { saveManualWorkout, sportOptions } from "../manual-workout-form"; import { canDeleteWorkout, deleteManualWorkout, } from "../delete-workout-button"; -import { - manualWorkoutOriginalFromRow, - type ManualWorkoutEntry, -} from "@/lib/workouts/manual-entry"; +import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry"; const ENTRY: ManualWorkoutEntry = { sportType: "cycling", @@ -75,16 +47,9 @@ function fakeClient() { }; } -function render(node: React.ReactElement) { - return renderToStaticMarkup( - {node}, - ); -} - let fetchSpy: ReturnType; beforeEach(() => { - authRef.unitPreference = "metric"; fetchSpy = vi.spyOn(globalThis, "fetch"); }); @@ -176,43 +141,6 @@ describe("sportOptions", () => { }); }); -describe(" — markup", () => { - it("renders the five fields and nothing else", () => { - const html = render(); - expect(html).toContain('data-testid="manual-workout-sport"'); - expect(html).toContain('data-testid="manual-workout-start"'); - expect(html).toContain('data-testid="manual-workout-hours"'); - expect(html).toContain('data-testid="manual-workout-minutes"'); - expect(html).toContain('data-testid="manual-workout-distance"'); - expect(html).toContain('data-testid="manual-workout-energy"'); - expect(html).toContain("Activity"); - expect(html).toContain("Duration"); - expect(html).toContain("Active energy (kcal)"); - // No heart rate, no sets, no route: a record, not a training log. - expect(html).not.toMatch(/heart|bpm|sets|reps/i); - }); - - it("names the distance in kilometres for a metric reader", () => { - expect(render()).toContain("Distance (km)"); - }); - - it("names the distance in miles for an imperial reader", () => { - authRef.unitPreference = "imperial"; - expect(render()).toContain("Distance (mi)"); - }); - - it("keeps hours and minutes side by side and stacks distance and energy on a phone", () => { - const html = render(); - // Two short numeric fields fit a 390 px sheet side by side. - expect(html).toMatch(/class="grid grid-cols-2 gap-4"/); - // Distance and energy carry longer labels: one column below `sm`, two - // from 640 px up. - expect(html).toMatch(/class="grid gap-4 sm:grid-cols-2"/); - // The action buttons keep the 44 px touch floor on a phone. - expect(html).toContain("min-h-11 sm:min-h-9"); - }); -}); - describe("delete — a hand-entered workout in one's own record only", () => { it("is offered for a MANUAL workout in one's own record", () => { expect( @@ -254,39 +182,3 @@ describe("delete — a hand-entered workout in one's own record only", () => { }); }); }); - -describe(" — editing a stored workout (#1162)", () => { - const original = manualWorkoutOriginalFromRow( - { - sportType: "cycling", - startedAt: "2026-09-15T06:00:00.000Z", - endedAt: "2026-09-15T07:30:00.000Z", - durationSec: 5400, - distanceM: 32000, - activeEnergyKcal: 640, - minHr: null, - stepCount: null, - elevationM: null, - pauseDurationSec: null, - storedAvgHr: 131, - storedMaxHr: null, - }, - { timezone: "Europe/Berlin", unitPreference: "metric" }, - ); - - it("opens on the stored values", () => { - const html = render( - , - ); - expect(html).toContain('value="2026-09-15T08:00"'); - expect(html).toContain('value="1"'); - expect(html).toContain('value="30"'); - expect(html).toContain('value="32"'); - expect(html).toContain('value="640"'); - }); - - it("opens empty when it is not editing", () => { - const html = render(); - expect(html).not.toContain('value="640"'); - }); -}); From 392b2c47a97df283b709e6fc2c77460e9ba3bbdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:16:02 +0200 Subject: [PATCH 6/8] test(mental-health): drive the check-in wizard in a real browser One question at a time in the source order, Back keeping the answer, the number keys and arrows, the review with a way back to every answer, the optional PHQ-9 follow-up, a failed submit, and the note over an unvalidated translation staying inside the viewport on every step. The switch to the English original is carried in the answers, and the full path from the overview posts the instrument, the reader's language and the item scores. Questionnaire text loads through the production chunk loader. The wizard's markup cases are removed from the surface suite; the loading-frame case stays there, since a browser loads the chunk before a test can look. The browser project inlines an empty process.env for Next's client modules. --- .../check-in-wizard.browser.test.tsx | 331 ++++++++++++++++++ .../__tests__/mental-wellbeing.test.tsx | 147 +------- vitest.config.mts | 9 + 3 files changed, 355 insertions(+), 132 deletions(-) create mode 100644 src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx diff --git a/src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx b/src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx new file mode 100644 index 000000000..69469cf9f --- /dev/null +++ b/src/components/mental-health/__tests__/check-in-wizard.browser.test.tsx @@ -0,0 +1,331 @@ +/** + * The questionnaire check-in, driven by clicks and keys in a real browser. + * + * The step arithmetic is pinned on its own in `check-in-nav.test.ts`; this + * suite proves the wizard a person uses: one question at a time in the source + * order, Back keeping the answer, the number keys, the review with a way back + * to every answer, the optional PHQ-9 follow-up, the note over an unvalidated + * translation staying on screen with every question, and the body the page + * posts. The questionnaire text loads through the production chunk loader, + * not a seeded cache. + */ +import { describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; + +import { WORDING } from "@/lib/mental-health/wording"; +import type { Locale } from "@/lib/i18n/config"; + +import { stubFetch } from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; +import { CheckInWizard } from "../check-in-wizard"; +import { MentalWellbeing } from "../mental-wellbeing"; +import type { CheckInAnswers, InstrumentId } from "../types"; + +const WHO5 = WORDING.WHO5.en; +const PHQ9 = WORDING.PHQ9.en; + +async function wizard( + instrument: InstrumentId, + { + locale = "en", + isError = false, + }: { locale?: Locale; isError?: boolean } = {}, +) { + const onSubmit = vi.fn<(answers: CheckInAnswers) => void>(); + const onBack = vi.fn(); + const { screen } = await renderWithApp( + , + { locale }, + ); + const heading = screen.getByRole("heading", { level: 2 }); + /** The step on screen asks `text` (waiting out the language's chunk). */ + const expectQuestion = (text: string) => + expect.element(heading).toHaveTextContent(text); + const answer = (name: string) => + screen.getByRole("radio", { name, exact: true }); + const button = (name: string) => + screen.getByRole("button", { name, exact: true }); + return { screen, onSubmit, onBack, expectQuestion, answer, button }; +} + +/** + * Answer the current step by its position, as a keyboard user does: each new + * step focuses its question, so Tab first reaches the answers, then the + * number key chooses. + */ +async function pressAnswer(position: number) { + await userEvent.tab(); + await userEvent.keyboard(String(position)); +} + +describe(" steps", () => { + it("opens on the first question with the stem and the answers in source order", async () => { + const { screen, button } = await wizard("WHO5"); + await expect.element(screen.getByText(WHO5.items[0])).toBeVisible(); + await expect.element(screen.getByText(WHO5.stems.period)).toBeVisible(); + await expect.element(screen.getByText("Question 1 of 5")).toBeVisible(); + const labels = screen + .getByRole("radio") + .elements() + .map((el) => el.textContent); + expect(labels).toEqual([5, 4, 3, 2, 1, 0].map((v) => WHO5.anchors.all[v])); + await expect.element(button("Back")).toBeDisabled(); + await expect.element(button("Next")).toBeDisabled(); + await expect.element(button("See my result")).not.toBeInTheDocument(); + // The instrument's own explanation heads the check-in; the landing's + // self-test disclaimer does not follow the person into it. + await expect + .element( + screen.getByText( + "5-question well-being check. Answer for the last two weeks; higher numbers mean better well-being.", + ), + ) + .toBeVisible(); + await expect + .element(screen.getByText(/not a diagnosis/i)) + .not.toBeInTheDocument(); + // English is the validated wording: no note, no switch. + await expect + .element(screen.getByText(/has not been scientifically validated/)) + .not.toBeInTheDocument(); + }); + + it("gives each SCI item its own answers under its section stem", async () => { + const SCI = WORDING.SCI.en; + const { screen, expectQuestion } = await wizard("SCI"); + await expectQuestion(SCI.items[0]); + await expect.element(screen.getByText(SCI.stems.night)).toBeVisible(); + await expect.element(screen.getByText("Question 1 of 8")).toBeVisible(); + expect( + screen + .getByRole("radio") + .elements() + .map((el) => el.textContent), + ).toEqual([4, 3, 2, 1, 0].map((v) => SCI.anchors.duration[v])); + }); + + it("advances on a choice, and Back returns with the answer still chosen", async () => { + const { screen, answer, button, expectQuestion } = await wizard("WHO5"); + await answer(WHO5.anchors.all[3]).click(); + await expect.element(screen.getByText("Question 2 of 5")).toBeVisible(); + await expectQuestion(WHO5.items[1]); + await expect.element(button("Next")).toBeDisabled(); + + await button("Back").click(); + await expectQuestion(WHO5.items[0]); + await expect.element(answer(WHO5.anchors.all[3])).toBeChecked(); + await button("Next").click(); + await expectQuestion(WHO5.items[1]); + }); + + it("chooses by number key, moves with the arrows and focuses each new question", async () => { + const { screen, answer } = await wizard("WHO5"); + await answer(WHO5.anchors.all[5]).click(); + await expect + .element(screen.getByRole("heading", { name: WHO5.items[1] })) + .toHaveFocus(); + await userEvent.tab(); + await expect.element(answer(WHO5.anchors.all[5])).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{ArrowDown}"); + await expect.element(answer(WHO5.anchors.all[3])).toHaveFocus(); + await userEvent.keyboard("{End}"); + await expect.element(answer(WHO5.anchors.all[0])).toHaveFocus(); + + // "2" is the second answer as printed, which scores 4 on the WHO-5. + await userEvent.keyboard("2"); + await expect.element(screen.getByText("Question 3 of 5")).toBeVisible(); + await screen.getByRole("button", { name: "Back", exact: true }).click(); + await expect.element(answer(WHO5.anchors.all[4])).toBeChecked(); + }); + + it("reviews every answer, changes one and returns to the review", async () => { + const { screen, onSubmit, button } = await wizard("WHO5"); + for (const position of [1, 2, 3, 4, 5]) await pressAnswer(position); + await expect + .element(screen.getByRole("heading", { name: "Check your answers" })) + .toBeVisible(); + await expect + .element(screen.getByText("All 5 questions answered")) + .toBeVisible(); + + await screen + .getByRole("button", { name: "Change the answer to question 3" }) + .click(); + await expect.element(screen.getByText("Question 3 of 5")).toBeVisible(); + await screen + .getByRole("radio", { name: WHO5.anchors.all[0], exact: true }) + .click(); + await expect + .element(screen.getByRole("heading", { name: "Check your answers" })) + .toBeVisible(); + + await button("See my result").click(); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ + items: [5, 4, 0, 2, 1], + functionalDifficulty: undefined, + englishOriginal: false, + }); + }); + + it("asks the PHQ-9 follow-up as an optional step that a second press clears", async () => { + const { screen, onSubmit, answer, button, expectQuestion } = + await wizard("PHQ9"); + for (let i = 0; i < 9; i++) await pressAnswer(1); + await expect + .element(screen.getByText("Follow-up question (optional)")) + .toBeVisible(); + await expectQuestion(PHQ9.functional!.title); + await expect.element(button("Review answers")).toBeEnabled(); + + await answer(PHQ9.functional!.anchors[2]).click(); + await expect.element(answer(PHQ9.functional!.anchors[2])).toBeChecked(); + await answer(PHQ9.functional!.anchors[2]).click(); + await expect.element(answer(PHQ9.functional!.anchors[2])).not.toBeChecked(); + + await button("Review answers").click(); + await expect.element(screen.getByText("Not answered")).toBeVisible(); + await button("See my result").click(); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith({ + items: [0, 0, 0, 0, 0, 0, 0, 0, 0], + functionalDifficulty: undefined, + englishOriginal: false, + }); + }); + + it("returns to the overview and shows a failed submit", async () => { + const { screen, onBack } = await wizard("GAD7", { isError: true }); + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("Something went wrong. Please try again."); + await screen.getByRole("button", { name: "Overview" }).click(); + expect(onBack).toHaveBeenCalledOnce(); + }); +}); + +describe(" in the reader's language", () => { + const SCI_DE = WORDING.SCI.de; + const NOTE = + "Diese Übersetzung ist nicht wissenschaftlich validiert; nimm das Ergebnis als Orientierung."; + + it("keeps the note over an unvalidated translation on screen with every question", async () => { + await page.viewport(390, 640); + const { screen, expectQuestion } = await wizard("SCI", { locale: "de" }); + expect(SCI_DE.validated).toBe(false); + const note = screen.getByText(NOTE); + for (let i = 0; i < 8; i++) { + await expectQuestion(SCI_DE.items[i]); + await expect.element(note).toBeVisible(); + const rect = note.element().getBoundingClientRect(); + expect(rect.top, `step ${i + 1}`).toBeGreaterThanOrEqual(0); + expect(rect.bottom, `step ${i + 1}`).toBeLessThanOrEqual( + window.innerHeight, + ); + // The first question arrives with the language's chunk, after the + // focus move has run, so it is answered by pointer; the rest by key. + if (i === 0) await screen.getByRole("radio").first().click(); + else await pressAnswer(1); + } + await expect + .element(screen.getByRole("heading", { name: "Antworten prüfen" })) + .toBeVisible(); + await expect.element(note).toBeVisible(); + }); + + it("switches to the English original and says so in the answers", async () => { + const { screen, onSubmit, expectQuestion } = await wizard("SCI", { + locale: "de", + }); + await expectQuestion(SCI_DE.items[0]); + await screen + .getByRole("button", { name: "Englischen Originaltext zeigen" }) + .click(); + await expect + .element(screen.getByRole("heading", { name: WORDING.SCI.en.items[0] })) + .toHaveAttribute("lang", "en"); + // The note stays: the reader is still in a language with no validated form. + await expect.element(screen.getByText(NOTE)).toBeVisible(); + await screen.getByRole("radio").first().click(); + for (let i = 1; i < 8; i++) await pressAnswer(1); + await screen + .getByRole("button", { name: "Ergebnis ansehen", exact: true }) + .click(); + expect(onSubmit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ englishOriginal: true }), + ); + }); + + it("shows a validated translation with no note and no switch", async () => { + const { screen, expectQuestion } = await wizard("PHQ9", { locale: "nl" }); + await expectQuestion(WORDING.PHQ9.nl.items[0]); + await expect + .element(screen.getByText(WORDING.PHQ9.nl.stems.period)) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: /English/ })) + .not.toBeInTheDocument(); + }); +}); + +describe("the check-in from the overview", () => { + it("posts the answers with the instrument and the reader's language, then shows the result", async () => { + const api = stubFetch().on("GET", "/api/mental-health/assessments", { + data: { assessments: [] }, + }); + const post = api.deferred("POST", "/api/mental-health/assessments"); + const { screen } = await renderWithApp(); + + const start = + '[data-instrument="WHO5"] [data-slot="instrument-card-start"]'; + await expect.poll(() => document.querySelector(start)).toBeTruthy(); + await page.elementLocator(document.querySelector(start)!).click(); + await expect.element(screen.getByText(WHO5.items[0])).toBeVisible(); + await screen.getByRole("radio").nth(1).click(); + for (const position of [2, 3, 1, 2]) await pressAnswer(position); + const submit = screen.getByRole("button", { name: "See my result" }); + await submit.click(); + + await expect + .element(screen.getByRole("button", { name: "Saving…" })) + .toBeDisabled(); + expect(api.calls("POST", "/api/mental-health/assessments")).toHaveLength(1); + expect(api.calls("POST", "/api/mental-health/assessments")[0].body).toEqual( + { + instrument: "WHO5", + locale: "en", + items: [4, 4, 3, 5, 4], + englishOriginal: false, + }, + ); + + post.release({ + status: 201, + data: { + assessment: { + id: "a-1", + instrument: "WHO5", + locale: "en", + wording: "validated", + totalScore: 80, + severityBand: "good", + item9Flagged: false, + crisisShownAt: null, + takenAt: "2026-09-15T10:00:00.000Z", + }, + actionThreshold: 50, + crisis: null, + }, + }); + await expect + .element(screen.getByText("80", { exact: true }).first()) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: "See my result" })) + .not.toBeInTheDocument(); + }); +}); diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx index f31aff775..dc9925453 100644 --- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -6,15 +6,14 @@ import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import en from "../../../../messages/en.json"; import nl from "../../../../messages/nl.json"; -import { WORDING } from "@/lib/mental-health/wording"; import { seedQuestionnaireWording } from "@/__tests__/helpers/questionnaire-wording"; /** - * Mental-wellbeing surface redesign (v1.25.3). The SSR-only convention - * (`renderToStaticMarkup`, `environment: "node"`, no `@testing-library/react`) - * means clicks can't be driven here — the wizard's advance/back grammar is - * pinned in `check-in-nav.test.ts`. These tests pin the SSR-observable - * contracts: the disclaimer renders ONLY on the landing (never while testing), + * Mental-wellbeing surface redesign (v1.25.3). The check-in wizard itself is + * driven in a real browser by `check-in-wizard.browser.test.tsx` (steps, + * keys, review, follow-up, the unvalidated-translation note, the posted + * body); its step arithmetic is pinned in `check-in-nav.test.ts`. These tests + * pin the rendered contracts of the rest of the surface: the disclaimer renders ONLY on the landing (never while testing), * a positive item-9 surfaces the crisis card, and (v1.27.9) the landing is * intro + instrument cards only — the dated history + trend chart live in the * per-instrument detail a card click opens, pinned to one instrument. @@ -74,52 +73,6 @@ describe("landing chrome (v1.25.5 — disclaimer + heading removed)", () => { // …and the lead description still introduces the screeners. expect(html).toContain(mh.pageDescription); }); - - it("does NOT render any disclaimer inside the check-in wizard", () => { - const html = withProviders( - {}} - onBack={() => {}} - isPending={false} - isError={false} - />, - ); - expect(html).not.toContain('data-slot="mental-health-disclaimer"'); - // …but the standardized instrument explanation IS shown. - expect(html).toContain(mh.instrumentDescription.phq9); - }); -}); - -describe("check-in wizard scaffolding (v1.27.6 — questions only)", () => { - const html = withProviders( - {}} - onBack={() => {}} - isPending={false} - isError={false} - />, - ); - - it("no longer renders the question-overview strip", () => { - expect(html).not.toContain('data-slot="wizard-stepper"'); - }); - - it("no longer renders the review recap or the functional follow-up", () => { - expect(html).not.toContain("You have answered"); - expect(html).not.toContain('data-slot="check-in-submit"'); - }); - - it("opens on the first question with the Back/Next footer", () => { - expect(html).toContain(WORDING.PHQ9.en.items[0]); - expect(html).toContain('data-slot="check-in-back"'); - expect(html).toContain('data-slot="check-in-next"'); - // Back is disabled on the first step. - expect(html).toMatch( - /data-slot="check-in-back"[^>]*disabled|disabled[^>]*data-slot="check-in-back"/, - ); - }); }); describe("item-9 crisis surfacing on the result", () => { @@ -243,49 +196,6 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => { expect(html).not.toContain("Rhythmus"); }); - it("WHO-5 wizard opens on item 1 with the six-point scale in source order and the recall stem", () => { - const html = withProviders( - {}} - onBack={() => {}} - isPending={false} - isError={false} - />, - ); - expect(html).toContain(WORDING.WHO5.en.items[0]); - expect(html).toContain('data-slot="check-in-stem"'); - expect(html).toContain(WORDING.WHO5.en.stems.period); - // Six anchors, 5 → 0 (the WHO form leads with "All of the time"). - for (const v of [5, 4, 3, 2, 1, 0]) { - expect(html).toContain(WORDING.WHO5.en.anchors.all[v]); - } - // English items are the validated wording for en — no translation note. - expect(html).not.toContain('data-slot="check-in-unvalidated-note"'); - expect(html).not.toContain('data-slot="check-in-wording-toggle"'); - // No functional follow-up outside the PHQ-9: 5 steps total. - expect(html).toContain("Question 1 of 5"); - }); - - it("SCI wizard opens on item 1 with the item-specific duration anchors and section stem", () => { - const html = withProviders( - {}} - onBack={() => {}} - isPending={false} - isError={false} - />, - ); - expect(html).toContain(WORDING.SCI.en.items[0]); - expect(html).toContain(WORDING.SCI.en.stems.night); - // Item 1 carries the sleep-latency time ranges, 4 → 0. - for (const v of [4, 3, 2, 1, 0]) { - expect(html).toContain(WORDING.SCI.en.anchors.duration[v]); - } - expect(html).toContain("Question 1 of 8"); - }); - it("shows the gentle PHQ-9 pointer on a WHO-5 total of 50 or below", () => { const result: CreateResponse = { assessment: { @@ -530,23 +440,20 @@ describe("questionnaires in the reader's language", () => { ); } - function wizard(instrument: "PHQ9" | "SCI") { - return ( - {}} - onBack={() => {}} - isPending={false} - isError={false} - /> - ); - } - + // Kept here rather than in the browser suite: the frame before the + // language's chunk arrives is the one a server render always paints, and a + // browser loads the chunk before a test can look. it("holds the step's place while the language's text loads", () => { const html = renderToStaticMarkup( - {wizard("SCI")} + {}} + onBack={() => {}} + isPending={false} + isError={false} + /> , ); @@ -555,30 +462,6 @@ describe("questionnaires in the reader's language", () => { expect(html).not.toContain('data-slot="check-in-unvalidated-note"'); }); - it("the PHQ-9 in Dutch is the validated Dutch form, with no note and no switch", () => { - const html = renderIn("nl", wizard("PHQ9")); - const dutch = WORDING.PHQ9.nl; - expect(html).toContain(dutch.stems.period); - expect(html).toContain(dutch.items[0]); - for (const v of [0, 1, 2, 3]) expect(html).toContain(dutch.anchors.all[v]); - expect(html).not.toContain(WORDING.PHQ9.en.items[0]); - expect(html).not.toContain('data-slot="check-in-unvalidated-note"'); - expect(html).not.toContain('data-slot="check-in-wording-toggle"'); - }); - - it("the SCI in Dutch shows the translation under the note, with the switch to the English original", () => { - const html = renderIn("nl", wizard("SCI")); - const dutch = WORDING.SCI.nl; - expect(dutch.validated).toBe(false); - expect(html).toContain(dutch.stems.night); - expect(html).toContain(dutch.items[0]); - expect(html).not.toContain(WORDING.SCI.en.items[0]); - expect(html).toContain('data-slot="check-in-unvalidated-note"'); - expect(html).toContain(nl.mentalHealth.wording.unvalidatedNote); - expect(html).toContain('data-slot="check-in-wording-toggle"'); - expect(html).toContain(nl.mentalHealth.wording.showOriginal); - }); - it("the result and the history mark an unvalidated translation, and only that", () => { const row = (wording: AssessmentRow["wording"]): AssessmentRow => ({ id: `s-${wording}`, diff --git a/vitest.config.mts b/vitest.config.mts index 51fa3430b..995c34e16 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -129,6 +129,15 @@ export default defineConfig({ ), }, }, + // Next's client modules (`next/link` among them) read + // `process.env.*`, which Next's own bundler inlines at build time. + // A page has no `process`; this inlines an environment with no + // public variables set, as a build without any would. + define: { + "process.env": JSON.stringify({ + NODE_ENV: process.env.NODE_ENV ?? "test", + }), + }, // Pre-bundle every dependency the browser files reach before the // first one loads. Discovered late, a dependency makes Vite re-bundle // and reload the page mid-run, which fails the file that was From 232c3db6ef3e9431e6413b2cccb1d10994ca8984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:16:56 +0200 Subject: [PATCH 7/8] docs(testing): describe the browser project and its helper Say which claims belong in *.browser.test.tsx, how to run the project alone, and what tests/browser/ provides. The browser install guard now names the browser project beside the hero-geometry check, since both launch the headless shell the CI slices install. --- docs/contributing/testing.md | 41 ++++++++++++++++++- .../unit-suite-browser-install-guard.test.ts | 10 ++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index fd0a7d434..21f3fc211 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -11,6 +11,7 @@ is about choosing and writing tests. | Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` | | Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` | | What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` | +| How a component behaves when someone types, clicks or presses keys, and what it sends | `src/**/__tests__/*.browser.test.tsx` | `pnpm test` | | A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` | | A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` | | Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` | @@ -114,6 +115,43 @@ into a fresh account and reads it back there. A new setting column without a table entry fails the file. A column with no write route says why in its entry. +## Browser tests + +Interaction belongs in the `browser` project: `*.browser.test.tsx` files run +in headless Chromium through Vitest's Playwright provider, as part of +`pnpm test`. Run them alone with `pnpm test --project browser`, or one file +with `pnpm exec vitest run --project browser `. The Node projects never +collect these files and this project collects nothing else. CI installs the +browser it needs (`playwright install --only-shell chromium`); locally, run +that once. + +Reach for it when the claim is about behaviour a markup test cannot see: +focus, keys, an open menu, a pending state, the body a form posts, the URL a +filter writes. A `renderToStaticMarkup` grep stays the tool for what a given +state renders, and bottom-sheet gestures stay in `e2e/`. + +The helper is `tests/browser/`: + +- `renderWithApp(ui, { locale, user })` mounts the app's own providers: a real + TanStack `QueryClient` (no retries) and `I18nProvider` with the real + catalog. The signed-in account is seeded where `useAuth` reads it, with + `authUser()` defaults under `user`. The app stylesheet and the default + theme class are loaded, so layout, visibility and colour tokens resolve. +- `stubFetch()` replaces `fetch` and nothing above it. Answer a route with + `.on(method, path, { status, data, error, meta })`, hold one with + `.deferred(...)` to assert a pending state, and read the parsed bodies from + `.calls(method, path)`. An unmatched request gets a 404 envelope and is + recorded. Do not mock `@tanstack/react-query` or `@/lib/api/api-fetch`. +- `next/navigation` is aliased to a shim over the page's real `location` and + `history`; `url.ts` sets the starting query, records pushes and replaces, + and simulates Back or an edited address. +- `pinDate(iso)` fakes `Date` only, so timers and animation frames still run. + +Every test gets the real `fetch`, clock, address and an empty `localStorage` +back afterwards. Locate by role and accessible name, the way a person finds a +control; a `Label` adds a colon to its name, so match a labelled combobox with +`/^Type/` rather than `"Type"`. + ## The integration database Global setup boots one Postgres container and migrates one template database. @@ -152,7 +190,8 @@ node scripts/test-durations.mjs unit unit.json The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file regenerated from CI timings, which is the better source. -The unit suite is three projects, decided per file from its source +The unit suite is three Node projects and the `browser` project above. The +Node ones are decided per file from its source (`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a worker, `isolated` gives each file a fresh one. A file lands in `isolated` when it mocks, spies, stubs env or globals, fakes timers or resets modules. If a diff --git a/src/__tests__/unit-suite-browser-install-guard.test.ts b/src/__tests__/unit-suite-browser-install-guard.test.ts index 8e80cc492..3771c14ea 100644 --- a/src/__tests__/unit-suite-browser-install-guard.test.ts +++ b/src/__tests__/unit-suite-browser-install-guard.test.ts @@ -12,6 +12,12 @@ * same suite: every dependency bump failed on a launch error that said nothing * about the bump. One workflow was fixed, one class was not. * + * Since v1.46 the suite also carries the `browser` project + * (`*.browser.test.tsx`, see `tests/browser/`), which launches the same + * headless shell through Vitest's Playwright provider for every interaction + * test. A slice without the browser now fails on many files rather than one; + * the install it needs is the same, so this guard covers both. + * * The sweep is textual and file-scoped, which is its limit: a workflow with two * jobs, only one of which installs the browser, satisfies it. It catches the * thing that actually went wrong — a whole workflow running the suite with no @@ -58,8 +64,8 @@ describe("the browser the unit suite measures with", () => { expect( missing, "a workflow runs the unit suite without installing the headless " + - "Chromium the hero-geometry check measures with — that check refuses " + - "to skip under CI, so the job fails on a launch error instead", + "Chromium the hero-geometry check and the browser project launch — " + + "neither skips under CI, so the job fails on a launch error instead", ).toEqual([]); }); }); From 82562a8df9c85dbc7977f0ff145d2a116f136303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:31:29 +0200 Subject: [PATCH 8/8] chore(knip): reach the browser test helper The next/navigation shim is reached only through the browser project's alias, and the helper's render module is a .tsx file under tests/, which the project globs did not include. --- knip.jsonc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/knip.jsonc b/knip.jsonc index 46380cd23..78b2aac64 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -100,6 +100,9 @@ "e2e/**/*.test.ts", "tests/**/*.test.ts", "tests/**/setup.ts", + // Reached only through the browser project's `next/navigation` alias in + // vitest.config.mts, which no import statement names. + "tests/browser/next-navigation.ts", "vitest.config.ts", "vitest.config.integration.ts", "playwright.config.ts", @@ -111,6 +114,7 @@ "src/**/*.{ts,tsx}!", "scripts/**/*.{ts,mjs}!", "tests/**/*.ts", + "tests/**/*.tsx", "e2e/**/*.ts", ], "ignore": [