mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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.
217 lines
8.1 KiB
TypeScript
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"),
|
|
},
|
|
},
|
|
});
|