Files
HealthLog/vitest.config.mts
T
Marc-André Bombeck a1cfb570c1 test(browser): wait for the file's own iframe before the first command
Every browser test iframe is named "vitest-iframe" and the Playwright
provider looks the frame up by that name. Once a page runs a second
file, the first file's iframe can still be found until its detach event
arrives, so the second file's first click lands in a frame that is going
away and fails with "Frame was detached". With fifteen browser files and
at most twelve pages, three pages now run two files each, and a full
local run failed this way in four of five tries. The setup now waits
until the frame found by name is its own.
2026-11-11 01:47:48 +01:00

217 lines
8.1 KiB
TypeScript

import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
import { globSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createDurationSequencer } from "./tests/durations/sequencer";
import { classifyUnitFiles } from "./tests/unit-projects";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* 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(
[
"src/**/*.test.{ts,tsx}",
"scripts/__tests__/**/*.test.ts",
"eslint-plugins/**/__tests__/**/*.test.mjs",
"tests/**/*.test.ts",
],
{
cwd: __dirname,
exclude: (path) =>
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: {
// `--shard` splits by measured duration rather than file count, and files
// start longest first. See `tests/durations/sequencer.ts`.
sequence: {
sequencer: createDurationSequencer("tests/durations/unit.json", 0.4),
},
// Default `pnpm test` keeps unit tests only — integration suite uses
// testcontainers and runs separately via `pnpm test:integration`.
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/.next/**",
"tests/integration/**",
// Playwright E2E suite — driven separately via `pnpm e2e`.
"e2e/**",
// Local tool worktrees create full copies of `src/` in the repo root;
// vitest must not collect stale duplicate suites from them. The copies
// also carry their own `node_modules`, so a collected duplicate drags in
// a SECOND React instance and fails in ways that have nothing to do with
// the working tree. `.wt-*` is the convention actually in use — the two
// names below predate it and are kept because both still occur.
".wt-*/**",
".claude/worktrees/**",
".worktrees/**",
],
projects: [
{
extends: true,
test: {
...nodeTest,
name: "guards",
include: projectFiles.guards,
isolate: false,
},
},
{
extends: true,
test: {
...nodeTest,
name: "unit",
include: projectFiles.shared,
isolate: false,
},
},
{
extends: true,
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",
),
},
},
// 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
// 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: {
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,
commands: {
// Every test iframe is named "vitest-iframe", and the provider
// finds the one to act on by that name. When a page runs a
// second file, the first file's iframe can still be found
// until its detach event arrives, so the second file's first
// click lands in a frame that is going away ("Frame was
// detached"). The setup waits here until the iframe found by
// name is this file's own.
async awaitOwnFrame({ page, testPath }) {
const isOwn = () => {
const url = page.frame("vitest-iframe")?.url();
if (!url || !testPath) return false;
return new URL(url).searchParams.get("iframeId") === testPath;
};
const deadline = Date.now() + 5_000;
while (!isOwn()) {
if (Date.now() > deadline) {
throw new Error(
`the vitest-iframe found by name never became ${testPath}`,
);
}
await new Promise((done) => setTimeout(done, 10));
}
},
},
},
},
},
],
coverage: {
provider: "v8",
// `include` enumerates the full source tree so the report shows
// per-file coverage for files never imported by a test (vitest 4
// dropped the `coverage.all` flag and uses `include` for this).
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: [
"src/generated/**",
"src/**/__tests__/**",
"src/**/*.test.ts",
"src/**/*.test.tsx",
],
},
},
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
});