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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 12:50:42 +02:00
1 parent 3eb5aa9364
commit 3d1692204b
9 files changed
+736 -32

No files matched your search

+3 -1
View File
@@ -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"
}
}
+148 -7
View File
@@ -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
@@ -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("<Segmented> smoke", () => {
it("moves with the arrow key", async () => {
const onChange = vi.fn();
const { screen } = await renderWithApp(
<Segmented
options={OPTIONS}
value="day"
onChange={onChange}
label="Zoom"
/>,
);
await screen.getByRole("radio", { name: "Day" }).click();
await userEvent.keyboard("{ArrowRight}");
expect(onChange).toHaveBeenLastCalledWith("week");
await expect
.element(screen.getByRole("radio", { name: "Week" }))
.toHaveFocus();
});
});
+158
View File
@@ -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<StubResponse>;
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<RecordedRequest> {
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<StubResponse>((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;
}
+89
View File
@@ -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<string, string> {
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");
}
+101
View File
@@ -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(<MeasurementForm />, {
* 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> = {}): 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<AuthUser> | 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<AppRender> {
const client = queryClient ?? testQueryClient();
if (user !== null) client.setQueryData(queryKeys.authMe(), authUser(user));
if (locale !== "en") await loadMessages(locale);
const screen = await render(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{ui}</I18nProvider>
</QueryClientProvider>,
);
return { screen, queryClient: client };
}
+33
View File
@@ -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();
});
+89
View File
@@ -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);
}
}
+84 -24
View File
@@ -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: {