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] 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: {