test(web): migrate component tests to Vitest browser mode #1014

Merged
charles merged 14 commits from spike/vitest-browser-mode into main 2026-05-09 17:04:27 +00:00
Collaborator

Summary

Drop happy-dom + @testing-library/* from apps/web. Component tests now run inside real headless Chromium via @vitest/browser-playwright. 88 test files migrated; full suite is 1160 passing / 0 failing / 0 skipped in ~12 s.

Synthetic-event drift between happy-dom and real browsers had been producing both false positives (passes that broke in prod) and false negatives (failures that only happened in CI). Real Chromium ends that.

Spec: docs/specs/vitest-browser-mode.md.

What changes

Toolchain

  • Bumped vitest 3 → 4. Added @vitest/browser-playwright, vitest-browser-react.
  • Removed happy-dom, @testing-library/{jest-dom,user-event,dom,react}.
  • Single apps/web/vitest.config.ts — Playwright provider, headless, optional CHROMIUM_PATH override for the bundled chromium-headless-shell.
  • apps/web/vitest.setup.tsx keeps the paraglide + tanstack-router stubs only — cleanup(), jest-dom matchers and the localStorage shim retired (browser mode bundles expect.element matchers; Chromium has the real Storage).
  • just ci-setup runs bunx playwright install --with-deps chromium so CI pre-pulls the headless-shell + system libs.
  • Biome noRestrictedImports bans the dropped packages from sneaking back in.

Test rewrites

  • import { render } from "vitest-browser-react" (async — every render is awaited).
  • import { userEvent } from "vitest/browser" (CDP-driven, no setup()).
  • expect.element(loc).toBeVisible() / .toBeInTheDocument() / … replaces jest-dom matchers + waitFor.
  • findByX collapsed to getByX since locators retry inside expect.element.
  • act() calls dropped — vitest-browser-react flushes effects.
  • Decorative-only assertions use toBeInTheDocument(); visible-rendered uses toBeVisible() (real Chromium computes visibility).
  • Disabled-control click tests use raw loc.element().click() (Playwright refuses to drive disabled elements).
  • useMediaQuery mocked module-wide where the Playwright iframe's narrow viewport otherwise flips desktop tests into mobile branches.

Production code touched (1 file)

apps/web/src/routes/index.tsx extracts hardNavigate(url) into apps/web/src/lib/hard-navigate.ts so the wizard-probe redirect test can vi.mock the wrapper. window.location is non-configurable in real Chromium, which previously forced the test to be skipped.

Escape hatch retired

board.test.tsx previously kept fireEvent from @testing-library/react for window-level keyDown and drag-drop synthesis. Replaced by three local helpers (click, keyDown, mouseEnter) that wrap native dispatchEvent in React's act() to preserve the flush semantics RTL provided. With this, @testing-library/react is fully removed.

Docs

  • docs/specs/vitest-browser-mode.md — full user story + status block.
  • apps/web/CLAUDE.md — testing section: canonical imports, old/new table, gotchas (toBeVisible vs toBeInTheDocument, disabled-control click semantics, useMediaQuery iframe quirk, fake timers, renderHook is async).
  • Root CLAUDE.md — pointer to the apps/web testing section.

Excluded from browser mode

apps/web/src/lib/settings-manifest.test.ts — walks node:fs to enumerate the source tree. Pure-Node test, can't load in Chromium. Run under bun test if needed.

Test plan

  • cd apps/web && CHROMIUM_PATH=/usr/bin/chromium bun x vitest run → 1160/1160
  • bun run typecheck → clean (after fixing 4 pre-existing-but-revealed errors)
  • bun x @biomejs/biome@^2 check apps/web/ → clean
  • Pre-push hook ran full just qa (typecheck + lint + fmt-check + test + sql-layer-check + paraglide-check + i18n-string-check) across all workspaces — apps/server's 3473 tests still green
  • CI run on this PR confirms just ci-setup's playwright install --with-deps chromium step works on the Forgejo runner (chromium download + apt-get for libnss/libgbm/libxkbcommon)
  • Spot-check on a slow / loaded dev box that the 12 s wallclock holds without flake

Follow-ups

  • Long-term: bake Chromium into forge-base/docker/web.Dockerfile (or extend bun.Dockerfile) so downstream web projects don't repeat the playwright install step in their ci-setup. Out of scope here.

🤖 Generated with Claude Code

## Summary Drop happy-dom + `@testing-library/*` from `apps/web`. Component tests now run inside real headless Chromium via `@vitest/browser-playwright`. 88 test files migrated; full suite is **1160 passing / 0 failing / 0 skipped** in ~12 s. Synthetic-event drift between happy-dom and real browsers had been producing both false positives (passes that broke in prod) and false negatives (failures that only happened in CI). Real Chromium ends that. Spec: [`docs/specs/vitest-browser-mode.md`](docs/specs/vitest-browser-mode.md). ## What changes ### Toolchain - Bumped `vitest` 3 → 4. Added `@vitest/browser-playwright`, `vitest-browser-react`. - Removed `happy-dom`, `@testing-library/{jest-dom,user-event,dom,react}`. - Single `apps/web/vitest.config.ts` — Playwright provider, headless, optional `CHROMIUM_PATH` override for the bundled chromium-headless-shell. - `apps/web/vitest.setup.tsx` keeps the paraglide + tanstack-router stubs only — `cleanup()`, jest-dom matchers and the localStorage shim retired (browser mode bundles `expect.element` matchers; Chromium has the real `Storage`). - `just ci-setup` runs `bunx playwright install --with-deps chromium` so CI pre-pulls the headless-shell + system libs. - Biome `noRestrictedImports` bans the dropped packages from sneaking back in. ### Test rewrites - `import { render } from "vitest-browser-react"` (async — every `render` is `await`ed). - `import { userEvent } from "vitest/browser"` (CDP-driven, no `setup()`). - `expect.element(loc).toBeVisible() / .toBeInTheDocument() / …` replaces jest-dom matchers + `waitFor`. - `findByX` collapsed to `getByX` since locators retry inside `expect.element`. - `act()` calls dropped — `vitest-browser-react` flushes effects. - Decorative-only assertions use `toBeInTheDocument()`; visible-rendered uses `toBeVisible()` (real Chromium computes visibility). - Disabled-control click tests use raw `loc.element().click()` (Playwright refuses to drive disabled elements). - `useMediaQuery` mocked module-wide where the Playwright iframe's narrow viewport otherwise flips desktop tests into mobile branches. ### Production code touched (1 file) `apps/web/src/routes/index.tsx` extracts `hardNavigate(url)` into `apps/web/src/lib/hard-navigate.ts` so the wizard-probe redirect test can `vi.mock` the wrapper. `window.location` is non-configurable in real Chromium, which previously forced the test to be skipped. ### Escape hatch retired `board.test.tsx` previously kept `fireEvent` from `@testing-library/react` for window-level keyDown and drag-drop synthesis. Replaced by three local helpers (`click`, `keyDown`, `mouseEnter`) that wrap native `dispatchEvent` in React's `act()` to preserve the flush semantics RTL provided. With this, `@testing-library/react` is fully removed. ### Docs - [`docs/specs/vitest-browser-mode.md`](docs/specs/vitest-browser-mode.md) — full user story + status block. - [`apps/web/CLAUDE.md`](apps/web/CLAUDE.md) — testing section: canonical imports, old/new table, gotchas (`toBeVisible` vs `toBeInTheDocument`, disabled-control click semantics, `useMediaQuery` iframe quirk, fake timers, `renderHook` is async). - Root `CLAUDE.md` — pointer to the apps/web testing section. ### Excluded from browser mode `apps/web/src/lib/settings-manifest.test.ts` — walks `node:fs` to enumerate the source tree. Pure-Node test, can't load in Chromium. Run under `bun test` if needed. ## Test plan - [x] `cd apps/web && CHROMIUM_PATH=/usr/bin/chromium bun x vitest run` → 1160/1160 - [x] `bun run typecheck` → clean (after fixing 4 pre-existing-but-revealed errors) - [x] `bun x @biomejs/biome@^2 check apps/web/` → clean - [x] Pre-push hook ran full `just qa` (typecheck + lint + fmt-check + test + sql-layer-check + paraglide-check + i18n-string-check) across all workspaces — apps/server's 3473 tests still green - [ ] CI run on this PR confirms `just ci-setup`'s `playwright install --with-deps chromium` step works on the Forgejo runner (chromium download + apt-get for libnss/libgbm/libxkbcommon) - [ ] Spot-check on a slow / loaded dev box that the 12 s wallclock holds without flake ## Follow-ups - Long-term: bake Chromium into `forge-base/docker/web.Dockerfile` (or extend `bun.Dockerfile`) so downstream web projects don't repeat the `playwright install` step in their `ci-setup`. Out of scope here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Drop happy-dom + @testing-library/* in apps/web; component tests now
run inside real headless Chromium via @vitest/browser-playwright. 88
test files migrated (1160 tests, ~12s wall-clock). Branch covers:

- vitest.config.ts: single browser-mode config (Playwright provider,
  CHROMIUM_PATH override for the bundled chromium-headless-shell).
- vitest.setup.tsx: paraglide + tanstack-router stubs only; jest-dom
  matchers / cleanup / localStorage shim retired (browser-mode bundles
  expect.element matchers and Chromium has the real Storage API).
- vitest-browser-react render returns locators; expect.element retries.
- userEvent imported from "vitest/browser" (CDP-driven, no .setup()).
- board.test.tsx: native dispatchEvent + React act() helpers replace
  fireEvent — last @testing-library/react consumer; dep dropped.
- routes/index.tsx: hard-navigate extracted to lib/hard-navigate.ts so
  tests can vi.mock the module (window.location is non-configurable in
  real Chromium; the previous wizard-probe test was skipped).
- biome.json: noRestrictedImports bans re-introducing the dropped
  @testing-library/* + happy-dom packages.
- justfile ci-setup: bunx playwright install --with-deps chromium so
  CI bakes the headless-shell into the runner cache.
- docs/specs/vitest-browser-mode.md: full user story + status block.
- apps/web/CLAUDE.md: testing section (canonical imports, old/new
  table, gotchas: toBeVisible vs toBeInTheDocument, disabled-control
  click semantics, useMediaQuery iframe quirk, fake timers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(web): resolve vitest browser-mode typecheck errors
Some checks failed
qa / sql-layer-check (pull_request) Successful in 8s
qa / i18n-string-check (pull_request) Successful in 12s
qa / db-schema (pull_request) Successful in 13s
qa / dockerfile (pull_request) Successful in 18s
qa / qa-1 (pull_request) Failing after 21s
qa / qa (pull_request) Failing after 0s
2b3688b561
Pre-push hook surfaced four leftover TS errors from the migration:

- vitest.config.ts: launchOptions belongs on the playwright(...) factory
  arg, not on the instance — typed surface lives in
  PlaywrightProviderOptions, not BrowserInstanceOption.
- session-scrubber.test.tsx: renderScrubber's return type widened to
  intersect ReturnType<typeof vi.fn> with the prop signatures so vi.fn
  can pass the strict callback prop check.
- TestFireModal.test.tsx: TS narrowed `resolveFn` to never inside the
  closure-captured branch; cast at the call site to recover the union.
- architect.test.ts: vitest-browser-react renderHook expects an
  optional initialProps; widen the callback signature with a default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(ci): playwright install must cd into apps/web, not pass --cwd
Some checks failed
qa / dockerfile (pull_request) Successful in 11s
qa / sql-layer-check (pull_request) Successful in 11s
qa / i18n-string-check (pull_request) Successful in 12s
qa / db-schema (pull_request) Successful in 24s
qa / qa-1 (pull_request) Failing after 1m13s
qa / qa (pull_request) Failing after 0s
d03dab4ca4
`bun x --cwd apps/web playwright install` makes bun treat `apps/web`
as a registry package spec and tries to GET
`<registry>/api/v1/repos/apps/web/tarball/` (404). Use a subshell
+ `bun x` so the binary is resolved from `apps/web/node_modules`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(web): drop @testing-library/jest-dom from tsconfig types
Some checks failed
qa / sql-layer-check (pull_request) Successful in 11s
qa / i18n-string-check (pull_request) Successful in 12s
qa / db-schema (pull_request) Successful in 19s
qa / dockerfile (pull_request) Successful in 18s
qa / qa-1 (pull_request) Failing after 1m14s
qa / qa (pull_request) Failing after 0s
d9f03fec5d
Stale tsconfig reference left over from the migration — the package was
removed but `compilerOptions.types` still listed it, breaking typecheck
under CI's clean install (no fallback to a hoisted local copy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(ci): install Node 22 when crypto.hash is missing
Some checks failed
qa / dockerfile (pull_request) Successful in 13s
qa / sql-layer-check (pull_request) Successful in 13s
qa / db-schema (pull_request) Successful in 16s
qa / i18n-string-check (pull_request) Successful in 20s
qa / qa-1 (pull_request) Failing after 2m1s
qa / qa (pull_request) Failing after 0s
3ba78497d0
Vitest 4 + Vite 7 call `crypto.hash()` which requires Node ≥ 21.7. The
forge-base/bun image inherits debian-bookworm-slim's Node 18 (installed
via apt for actions/checkout), and vitest spawns vite's dep optimiser
under that runtime. Detect the missing API and pull Node 22 from
NodeSource instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(ci): require bun ≥ 1.3.13 — 1.3.11 ESM bug breaks vitest browser
Some checks failed
qa / i18n-string-check (pull_request) Successful in 11s
qa / dockerfile (pull_request) Successful in 12s
qa / sql-layer-check (pull_request) Successful in 20s
qa / db-schema (pull_request) Successful in 23s
qa / qa-1 (pull_request) Failing after 51s
qa / qa (pull_request) Failing after 0s
2ba050aa1a
forge-base/bun v0.2.2 bakes bun 1.3.11. Some files trigger an ESM
resolver path where vitest assigns the test to the forks pool instead
of browser mode, then the `import { userEvent } from "vitest/browser"`
guard fails. The same files pass locally on bun 1.3.13. Force-upgrade
when the baked version is older than the floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(ci): use bun upgrade instead of npm install -g when bun exists
Some checks failed
qa / sql-layer-check (pull_request) Successful in 10s
qa / dockerfile (pull_request) Successful in 15s
qa / db-schema (pull_request) Successful in 19s
qa / i18n-string-check (pull_request) Successful in 23s
qa / qa-1 (pull_request) Failing after 23s
qa / qa (pull_request) Failing after 0s
2465da9d2e
forge-base/bun bakes /usr/local/bin/bun. npm install -g bun then aborts
with EEXIST instead of overwriting. Switch the upgrade path to
`bun upgrade` (idempotent, in-place) and only fall back to the curl
installer when bun is missing entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(ci): npm install -g --force bun (overwrites pre-existing binary)
Some checks failed
qa / dockerfile (pull_request) Successful in 10s
qa / i18n-string-check (pull_request) Successful in 13s
qa / sql-layer-check (pull_request) Successful in 13s
qa / db-schema (pull_request) Successful in 24s
qa / qa-1 (pull_request) Failing after 2m33s
qa / qa (pull_request) Failing after 0s
2779fb3dc0
`bun upgrade` fails with HTTPError on the runner (no egress to
bun.com), so fall back to the npm install path. `--force` is required
because forge-base/bun bakes /usr/local/bin/bun and npm 9 refuses to
overwrite without it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(web): serialise vitest browser test files to dodge dep-optimizer race
All checks were successful
qa / sql-layer-check (pull_request) Successful in 9s
qa / dockerfile (pull_request) Successful in 16s
qa / db-schema (pull_request) Successful in 19s
qa / i18n-string-check (pull_request) Successful in 24s
qa / qa-1 (pull_request) Successful in 4m0s
qa / qa (pull_request) Successful in 0s
60760ccaca
CI flake under parallel Playwright contexts: Vite's dep-optimizer
caches `node_modules/.vite/vitest/.../deps/react_*.js`. When two
contexts race the warm-up, one reads a half-written chunk and either
"Failed to fetch dynamically imported module" or React resolves to
null ("Cannot read properties of null (reading 'useEffect')").

`fileParallelism: false` serialises test files. Local wallclock 12s
→ 80s; CI was burning that on the failed retries anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
test(web): silence act() warning + stub useNavigate/useRouter
All checks were successful
qa / sql-layer-check (pull_request) Successful in 13s
qa / i18n-string-check (pull_request) Successful in 15s
qa / db-schema (pull_request) Successful in 19s
qa / dockerfile (pull_request) Successful in 19s
qa / qa-1 (pull_request) Successful in 3m25s
qa / qa (pull_request) Successful in 0s
aabe6f5a43
Two log noise sources from the browser-mode migration:

1. "The current testing environment is not configured to support
   act(...)" — React 19 logs this for every act() call when
   IS_REACT_ACT_ENVIRONMENT is unset. board.test.tsx legitimately
   wraps native dispatchEvent in act() to recover RTL's flush
   semantics. Set the flag globally in vitest.setup.tsx.

2. "useRouter must be used inside a <RouterProvider>" — the existing
   tanstack-router stub provided Link/useParams/createFileRoute but
   not useNavigate/useRouter/useSearch/etc., so production components
   that call them rendered against the real router and warned every
   time. Add stubs for the full surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
test(web): clean up act()+useRouter warnings via shared router stubs
All checks were successful
qa / dockerfile (pull_request) Successful in 12s
qa / sql-layer-check (pull_request) Successful in 15s
qa / i18n-string-check (pull_request) Successful in 15s
qa / db-schema (pull_request) Successful in 18s
qa / qa-1 (pull_request) Successful in 3m17s
qa / qa (pull_request) Successful in 0s
0fb024ec26
Two CI noise sources from the migration, fixed at the root rather than
filtered:

1. "useRouter must be used inside a <RouterProvider>" — per-file
   `vi.mock("@tanstack/react-router", …)` calls were spreading
   `...actual` and only overriding `Link`/`createFileRoute`, leaking
   the real `useRouter`/`useNavigate`/`useRouterState`/etc. back to
   the components. Extracted the full stub surface into
   `src/lib/test-router-stub.tsx`; setup file + every per-file mock
   now spread `...actual, ...routerStubs, …file-specific overrides`.

2. "The current testing environment is not configured to support
   act(...)" — board.test.tsx's `click` / `keyDown` / `mouseEnter`
   helpers wrapped native dispatchEvent in React's `act()`. Replaced
   with `userEvent.click(loc, { modifiers: ["Meta"|"Shift"] })` /
   `userEvent.keyboard("…")` / `userEvent.hover(loc)` from
   `vitest/browser`, which auto-flushes. `act()` import dropped
   entirely. Drag-drop dispatchEvent retained (Playwright doesn't
   synthesise the DataTransfer payload tests inspect).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
test(web): silence remaining router + Select hydration warnings
All checks were successful
qa / dockerfile (pull_request) Successful in 11s
qa / sql-layer-check (pull_request) Successful in 13s
qa / db-schema (pull_request) Successful in 16s
qa / i18n-string-check (pull_request) Successful in 26s
qa / qa-1 (pull_request) Successful in 3m45s
qa / qa (pull_request) Successful in 0s
3328d7294a
Two leftover noise sources from the previous routerStubs cleanup:

1. flows/FlowCanvas.{create,replay,test-fire}.test.tsx + RunsDrawer
   each had their own `vi.mock("@tanstack/react-router")` that spread
   `...actual` and only overrode `useNavigate` (and a redundant
   `useParams: () => ({ locale: "en" })` already covered by the
   shared stub). Spread `routerStubs` in between so leaked hooks stay
   stubbed.

2. settings.appearance.test.tsx stubbed Base UI Select as native
   `<select>`/`<option>` while the production `<Trigger>` and
   `<PackSwatchRow>` render `<span>` children — browser logged
   "<span> cannot be a child of <select>/<option>" hydration errors
   for every render. Reshaped the stub to plain `<button>` / `<div>`
   wired through a React context (Item.onClick → Root.onValueChange)
   and switched the six `userEvent.selectOptions` call sites to
   `screen.getByTestId("appearance-dark-select-option-<pack>").click()`.

Full suite: 1160/1160 passing, zero hydration / useRouter / act
warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
test(web): pin Vue feature flags to silence esm-bundler warning
All checks were successful
qa / sql-layer-check (pull_request) Successful in 11s
qa / dockerfile (pull_request) Successful in 12s
qa / i18n-string-check (pull_request) Successful in 12s
qa / db-schema (pull_request) Successful in 14s
qa / qa-1 (pull_request) Successful in 2m53s
qa / qa (pull_request) Successful in 0s
4fbcfff3b7
Milkdown's CodeMirror block transitively imports Vue's esm-bundler
build, which expects the host bundler to inject __VUE_OPTIONS_API__,
__VUE_PROD_DEVTOOLS__ and __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ as
compile-time `define` constants. Without them Vue logs a "feature
flags are not explicitly defined" warning on every render. Pin all
three to false in vitest.config.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into spike/vitest-browser-mode
All checks were successful
qa / sql-layer-check (pull_request) Successful in 14s
qa / dockerfile (pull_request) Successful in 15s
qa / db-schema (pull_request) Successful in 19s
qa / i18n-string-check (pull_request) Successful in 26s
qa / qa-1 (pull_request) Successful in 3m31s
qa / qa (pull_request) Successful in 0s
33107ceb84
# Conflicts:
#	apps/web/src/components/agent/run-diff-review.test.tsx
#	apps/web/src/components/avatar-menu.test.tsx
#	apps/web/src/features/flows/FlowCanvas.create.test.tsx
#	apps/web/src/features/flows/FlowCanvas.replay.test.tsx
#	apps/web/src/features/flows/RunsDrawer.test.tsx
#	apps/web/src/routes/$locale.test.tsx
#	apps/web/src/routes/agents.index.test.tsx
#	apps/web/src/routes/index.test.tsx
#	apps/web/src/routes/index.tsx
#	apps/web/src/routes/onboarding.test.tsx
#	apps/web/src/routes/settings.agent-config.test.tsx
#	apps/web/src/routes/settings.agents.admin.test.tsx
#	apps/web/src/routes/settings.appearance.test.tsx
#	apps/web/src/routes/settings.language.test.tsx
#	apps/web/src/routes/settings.service.test.tsx
charles deleted branch spike/vitest-browser-mode 2026-05-09 17:04:28 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!1014
No description provided.