test(web): migrate component tests to Vitest browser mode #1014
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!1014
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "spike/vitest-browser-mode"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Drop happy-dom +
@testing-library/*fromapps/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
vitest3 → 4. Added@vitest/browser-playwright,vitest-browser-react.happy-dom,@testing-library/{jest-dom,user-event,dom,react}.apps/web/vitest.config.ts— Playwright provider, headless, optionalCHROMIUM_PATHoverride for the bundled chromium-headless-shell.apps/web/vitest.setup.tsxkeeps the paraglide + tanstack-router stubs only —cleanup(), jest-dom matchers and the localStorage shim retired (browser mode bundlesexpect.elementmatchers; Chromium has the realStorage).just ci-setuprunsbunx playwright install --with-deps chromiumso CI pre-pulls the headless-shell + system libs.noRestrictedImportsbans the dropped packages from sneaking back in.Test rewrites
import { render } from "vitest-browser-react"(async — everyrenderisawaited).import { userEvent } from "vitest/browser"(CDP-driven, nosetup()).expect.element(loc).toBeVisible() / .toBeInTheDocument() / …replaces jest-dom matchers +waitFor.findByXcollapsed togetByXsince locators retry insideexpect.element.act()calls dropped —vitest-browser-reactflushes effects.toBeInTheDocument(); visible-rendered usestoBeVisible()(real Chromium computes visibility).loc.element().click()(Playwright refuses to drive disabled elements).useMediaQuerymocked 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.tsxextractshardNavigate(url)intoapps/web/src/lib/hard-navigate.tsso the wizard-probe redirect test canvi.mockthe wrapper.window.locationis non-configurable in real Chromium, which previously forced the test to be skipped.Escape hatch retired
board.test.tsxpreviously keptfireEventfrom@testing-library/reactfor window-level keyDown and drag-drop synthesis. Replaced by three local helpers (click,keyDown,mouseEnter) that wrap nativedispatchEventin React'sact()to preserve the flush semantics RTL provided. With this,@testing-library/reactis 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 (toBeVisiblevstoBeInTheDocument, disabled-control click semantics,useMediaQueryiframe quirk, fake timers,renderHookis async).CLAUDE.md— pointer to the apps/web testing section.Excluded from browser mode
apps/web/src/lib/settings-manifest.test.ts— walksnode:fsto enumerate the source tree. Pure-Node test, can't load in Chromium. Run underbun testif needed.Test plan
cd apps/web && CHROMIUM_PATH=/usr/bin/chromium bun x vitest run→ 1160/1160bun run typecheck→ clean (after fixing 4 pre-existing-but-revealed errors)bun x @biomejs/biome@^2 check apps/web/→ cleanjust qa(typecheck + lint + fmt-check + test + sql-layer-check + paraglide-check + i18n-string-check) across all workspaces — apps/server's 3473 tests still greenjust ci-setup'splaywright install --with-deps chromiumstep works on the Forgejo runner (chromium download + apt-get for libnss/libgbm/libxkbcommon)Follow-ups
forge-base/docker/web.Dockerfile(or extendbun.Dockerfile) so downstream web projects don't repeat theplaywright installstep in theirci-setup. Out of scope here.🤖 Generated with Claude Code
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>bun upgradeinstead of npm install -g when bun existsCI 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>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>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>