Testing Runners
This template uses three test runners. Each one has a narrow, well-defined responsibility, and there is exactly one right answer for where any given new test should live. The matrix below is the canonical place for that decision — when adding tests, consult this doc first.
Why three? Different layers of the stack break in different ways. Pure TypeScript needs only a fast Node-based runner; Preact components benefit from a real browser when the failure mode is jsdom-specific (Q22); full pages need an end-to-end harness against the built site. Trying to consolidate everything into one runner would either slow down the fast tests or weaken the realism of the slow tests.
At a glance
| Runner | What it tests | Where it lives | Command |
|---|---|---|---|
| Vitest | Pure TS / Node logic, plugin lifecycle, data loaders, schemas, render-only Preact assertions, hooks-only utilities | packages/*/src/__tests__/**/*.test.{ts,tsx} (excluding __tests__/ct/) | pnpm test |
| Playwright CT | Single Preact component mounted in real Chromium — for any test that uses fireEvent / userEvent, conditional remounts, focus management, event delegation, media queries, scroll/resize, or anything that has historically tripped jsdom | packages/ui/src/__tests__/ct/**/*.test.tsx | pnpm test:ct |
| Playwright E2E | Full built sample apps — multi-page flows, real navigation, full-stack data loading, plugin output, sitemap/RSS/robots.txt artifacts | apps/web-e2e/tests/**/*.spec.ts | pnpm test:e2e |
Decision tree
new test
│
├─ pure TS / Node logic, no DOM? ────────────────────────────────► Vitest
│
├─ Preact component, render-only, no events / no remounts? ─────► Vitest (jsdom)
│
├─ Preact component with `fireEvent` / `userEvent`,
│ conditional remount, focus, scroll, viewport, animation,
│ or anything that has ever crashed jsdom on Windows? ───────► Playwright CT
│
└─ multi-page flow, full sample app, sitemap/RSS/robots? ───────► Playwright E2E
Rules
Vitest is the default
Vitest is the fast path. Every test that can run in Vitest should. It
is the only runner with V8 coverage wired up, the only one in the Turborepo
cache, and the only one whose pnpm test walltime is bounded in seconds-not-
minutes per package.
Use it for:
- Pure TypeScript: every test in
packages/core,packages/plugins,packages/adapters,packages/sync,packages/astro-integration, and everyplugin-*package. packages/uinon-Preact tests:lib/utils,lib/keyboard, thecnhelper, plugin pipeline, sort algorithms, etc.packages/uiPreact components that are render-only in their assertion pattern: e.g.back-to-top.test.tsx(visibility toggle on scroll, but the scroll is dispatched as a single event with no chained re-renders),sort-select.test.tsx(single onChange + assertion), etc.
Playwright Component Testing (CT) for Preact components that crash jsdom
Playwright CT is the interactive path. It boots a real Chromium browser,
mounts a single Preact component into a Playwright page via the official
@playwright/experimental-ct-react package and a react → preact/compat
Vite alias, and exercises it with real DOM events. Use it whenever the test
needs:
fireEvent/userEventchains that mutate state across multiple re-renders. (This is the failure mode that drove Q22 — see below.)- Conditional mount/unmount of children based on state. The classic case is
FilterBar'sClear filtersbutton, which only renders after a user interaction. jsdom's event-target teardown crashes the worker on Windows + Node 24 when this pattern is exercised in a test. - Focus management:
focus(),blur(), focus rings, focus traps,aria-activedescendant. Real browser focus behavior diverges from jsdom in small but assertion-breaking ways. - Viewport / media queries:
matchMedia,ResizeObserver,IntersectionObserver. jsdom's polyfills are partial. - Scroll / resize listeners:
window.scrollTo,scrollevents. jsdom does not paint, soscrollTop/getBoundingClientRectlie. - Event delegation through
<span role="button">or other non-native button patterns. Real browsers honortabIndex+keydown+clickevent order; jsdom implements them but without the same Microsoft-quirk timing.
CT tests live in packages/ui/src/__tests__/ct/ and are matched by
playwright.ct.config.ts (testMatch: '**/*.test.{ts,tsx}'). Vitest's
include glob excludes __tests__/ct/** so the two runners never collide.
The CT toolchain is configured to:
- Use
@playwright/experimental-ct-react(Preact has no first-party CT package — verified 2026-04-26 viapnpm view). - Alias
react/react-dom/react-dom/test-utilstopreact/compat/preact/test-utilsinsideuse.ctViteConfig.resolve.alias. This mirrors the long-standing pattern inpackages/ui/vitest.config.tsand is what makes the React mount layer end up callingpreact/compat.h. - Type-check via a separate
packages/ui/tsconfig.ct.jsonbecause the build tsconfig hasrootDir: ./srcand would refuseplaywright/files outside that root.
Playwright E2E for full sample apps
Playwright E2E (in apps/web-e2e) tests the shipped product: a built
sample app served from disk, navigated as a real user would. Use it when:
- The behavior involves more than one page (link follows, breadcrumb navigation, list → detail).
- The behavior involves the build output (sitemap.xml, robots.txt, RSS feed, Pagefind index, JSON-LD blob in the head).
- The behavior involves a plugin's side effect on the rendered DOM across pages, not just its in-memory output (for which use Vitest).
- A regression would be invisible from a single mounted component, e.g.
"every detail page must include a
BreadcrumbListJSON-LD".
E2E does not replace CT. CT is faster (no full build, single component) and produces clearer failure messages when an interaction breaks. Always reach for CT first for component behavior; promote to E2E only when the assertion spans pages or build artifacts.
Q23 — second component migrated (LayoutSwitcher, iteration 107)
Q23 (documented in docs/questions.md#q23, opened iteration 106, resolved
iteration 107) was a sibling of Q22 with a slightly different fingerprint: the
packages/ui/src/__tests__/preact/layout-switcher.test.tsx Vitest run hung at
the RUN v4.1.5 banner with zero test output (vs. Q22's "3-4 tests
report then crash"). The component shape is similar — LayoutSwitcher has
1 useState + 2 useEffect + 1 useCallback and a localStorage-backed
sync — but the failure surfaced earlier in the worker lifecycle.
The resolution path was identical to Q22: port to Playwright CT under
packages/ui/src/__tests__/ct/layout-switcher.ct.test.tsx, delete the
original Vitest file, exclude src/preact/LayoutSwitcher.tsx from the V8
coverage report. The exclusion was lifted in iteration 115 (Q22 follow-up
#3 Phase 2) and the CT V8 coverage is now folded into the merged report
at coverage/merged/ via pnpm coverage (Phase 3, iteration 116).
12/12 cases pass in the CT runner; combined with the FilterBar 16/16, the
total CT signal is 28/28 passing in ~1 min on Windows + Node 24.14.0.
Two infrastructure items moved during the Q23 migration:
playwright.ct.config.ts:workers: 1andfullyParallel: falseare now hard-pinned (wasworkers: process.env.CI ? 1 : undefined,fullyParallel: true). Reason: Playwright CT shares a single Vite dev server onctPort: 3100across the whole run; with multiple parallel workers locally, the second-and-later workers hitnet::ERR_CONNECTION_REFUSED at http://localhost:3100/. Observed once real (with 28 tests across 2 files) and would have been latent until any 3rd CT file landed. Pinning to 1 worker matches CI behavior and adds <10 s of wall time at the current test volume.packages/ui/scripts/test-per-file.ts: now skips the__tests__/ct/subdirectory during discovery. Without this, the per-file runner spawned Vitest against*.ct.test.tsxfiles, which import@playwright/experimental-ct-reactand immediately fail.
Q22 background — why we have CT in the first place
Q22 (documented in docs/questions.md#q22, iterations 97–105) is the
long-standing @testing-library/preact fireEvent × FilterBar × jsdom ×
Node 24 IPC crash on Windows. After 5 diagnostic iterations, the failure was
characterized as:
- Pool-independent:
forks,threads,vmThreadsall crash at the same boundary. - Reporter-independent: JSON reporter (no per-test stdout writes) crashes identically.
- File-specific:
back-to-top.test.tsxruns cleanly under the same config that crashesfilter-bar.test.tsx. - Component-specific: among Preact tests, only
FilterBarcrashes — the uniquely re-render-heavy combination of 3useState+ 2useEffect+ 3useCallback+ a conditionally-mountedClear filtersbutton is what pushes jsdom + tinypool + Node 24 IPC over the edge. - Vitest-version-independent: Vitest 3.2.4 is worse than 4.1.5 (2/16 before crash vs. 5/16 before crash), so the bug pre-dates the 4.x pool rewrite.
The pragmatic fix was to migrate the affected test surface off jsdom into
Playwright CT. Iteration 105 completed that migration: 16/16 tests pass on
local Windows + Node 24.14.0 in ~6 s walltime. The original
packages/ui/src/__tests__/preact/filter-bar.test.tsx was deleted; the new
file is packages/ui/src/__tests__/ct/filter-bar.ct.test.tsx.
The CT migration also surfaced a real bug in FilterBar that the
Vitest+jsdom suite would have caught had it run: the default value
selectedTags: initialTags = [] allocated a fresh [] on every render, so
the useEffect([initialTags]) reset loop continually wiped user clicks. The
fix is a stable EMPTY_TAGS sentinel module-level constant. Without the CT
migration, this bug would have remained dormant under the worker crash.
Adding a new test — checklist
- Where does it belong? Walk the decision tree above. If unclear, default to Vitest and only escalate to CT if the assertion needs real-browser DOM semantics or if a Vitest run reveals jsdom flakiness.
- Vitest tests: file name
<thing>.test.{ts,tsx}underpackages/<pkg>/src/__tests__/. They run automatically withpnpm test. - CT tests: file name
<thing>.test.tsxunderpackages/ui/src/__tests__/ct/. Make sure the file is not picked up by Vitest (theincludeglob issrc/**/__tests__/**/*.test.{ts,tsx}andexcludecarves out**/__tests__/ct/**). Run withpnpm test:ct. - E2E tests: file name
<flow>.spec.tsunderapps/web-e2e/tests/. They require a built app — seeapps/web-e2e/README.mdfor the per-project setup. - Update the relevant spec (
.specify/features/testing.mdAC #10) if the total test count changes.
Coverage handling
V8 coverage is reported by both Vitest and Playwright CT. As of iteration 119 (Q22 follow-up #3 Phase 6b), both runners emit the same raw-V8 shape so a single pnpm coverage merge command produces the canonical per-package coverage number.
There are three distinct artifact streams in packages/ui/coverage/:
coverage/(Vitest) — written bypnpm test:coverage. Vitest's coverage provider isvitest-monocart-coverage(custom; wraps@vitest/coverage-v8); options live inpackages/ui/mcr.config.ts(sibling tovitest.config.ts).raw/<id>.json— per-test raw V8 entries (40 files per Vitest run). Consumed by the merge script via MCRinputDir. Same shape as the CT side.
coverage/ct/(Playwright CT) — written bypnpm test:ctviamonocart-reporter(configured inpackages/ui/playwright.ct.config.ts).coverage-report.json— V8-JSON shape with full per-byte data.raw-v8.json— Phase 1 traceability sentinel (post-merge file summary).raw/<id>.json— per-test raw V8 entries (49 files per CT run). Consumed by the merge script via MCRinputDir.index.html,index.json,coverage-data.js— the visual report.
coverage/merged/(canonical merged report) — written bypnpm coverage(packages/ui/scripts/coverage-merge.ts). MCR loadscoverage/raw/*.jsonANDcoverage/ct/raw/*.jsonviainputDir: [...], applies a sharedsourceFilter, and writescoverage-report.json(V8-JSON),lcov.info,codecov.json,index.html, andlcov-report/.
Current scope (iteration 124): the merged report covers the full packages/ui/src/ surface — every file Vitest exercises plus the three CT-migrated components. Both inputs flow through MCR's V8 path; there is no Istanbul mixing.
Per-file gate (Phase 6c — coverage-merge.ts exits non-zero if any allow-listed file drops below 80% branches):
FilterBar.tsx— 100% branches (27/27) ✅LayoutSwitcher.tsx— 100% branches (22/22) ✅MobileMenu.tsx— 100% branches (35/35) ✅ (was 67.57% pre-iteration-120; rose to 91.89% (34/37) in iteration 120 via focus-trap CT additions; reached 100% in iteration 124 via Q27 — synthetic Tab dispatch throughpage.evaluatefor B1/B2/B3 + one/* v8 ignore next */pragma on the defensivemenuRefrace-guard)
Aggregate (iteration 124, 19 files): branches 100% (233/233), functions 100% (104/104), lines 99.76% (1240/1243), statements 99.72% (352/353), bytes 99.79% (45,558/45,650). The CI coverage-gate job from iteration 121 now has zero margin to absorb regressions — any future code change that drops a branch will fail the merge.
The Phase 0-6c implementation history lives in docs/plans/q22-playwright-coverage.md; Q27's 3-branch outlier closure lives in docs/plans/q27-mobilemenu-empty-items-coverage.md. Phase 6d (iteration 122) was the original status-flip pass; Q27 (iteration 124) lifted the per-file MobileMenu number to 100% and the aggregate to 100%.
Local commands
# Vitest — every Vitest suite across the monorepo (Turborepo task)
pnpm test
# Vitest — single package
pnpm --filter @ever-works/ui test
# Vitest — per-file defensive fallback for the @ever-works/ui package. Used
# historically as a workaround for the Q22 Worker-IPC hang. As of iteration
# 110 (2026-04-27), plain `pnpm test` runs all 11 UI Vitest files (174 tests)
# in ~98s with no hangs; this script is preserved as an escape hatch in case
# a future Vitest/jsdom/Node bump re-introduces the symptom. Also useful for
# isolating a single failing Vitest file during debugging.
pnpm test:ui:safe
# Playwright CT — entire @ever-works/ui CT suite
pnpm test:ct
# Playwright CT — install browsers (one-time, per machine)
pnpm test:ct:install
# Playwright CT — type-check only (no execution)
pnpm --filter @ever-works/ui typecheck:ct
# Playwright E2E — full multi-app suite
pnpm test:e2e
# Phase 3 merge — runs Vitest coverage, then CT, then merges via
# `packages/ui/scripts/coverage-merge.ts` into `coverage/merged/`.
# Walltime ~3m on Windows + Node 24.
pnpm coverage
CI integration
- Vitest runs in the main CI job (
.github/workflows/ci.yml,teststep) viapnpm test. Coverage is uploaded bytest:coverageon a separate schedule. - Playwright CT runs in a dedicated
test-ctmatrix job (os: [ubuntu-latest, windows-latest]). Thewindows-latestcell is the definitive Q22 fix signal — if it ever goes red on aFilterBartest, the migration has regressed. - Playwright E2E runs in the
e2ejob(s) per sample app, gated on the app's build succeeding.
Authoring conventions for CT tests
Translation table from Vitest+@testing-library/preact idioms to Playwright
CT idioms (the same table is repeated in docs/plans/q22-playwright-ct.md
Step 4 for traceability):
| Vitest pattern | Playwright CT equivalent |
|---|---|
render(<C />) | await mount(<C />) (returns a Locator) |
screen.getByText('X') | component.getByText('X') |
screen.getByRole('button', { name }) | component.getByRole('button', { name }) |
expect(el).toBeTruthy() | await expect(locator).toBeVisible() |
expect(screen.queryByText('X')).toBeNull() | await expect(component.getByText('X')).toHaveCount(0) |
fireEvent.click(el) | await locator.click() |
fireEvent.keyDown(el, { key: 'Enter' }) | await locator.press('Enter') (focus is automatic) |
vi.fn() callback | inline const calls: T[] = []; <C onX={(v) => calls.push(v)}/> then expect(calls).toEqual([...]) — the closure executes in the test process via Playwright CT's RPC bridge |
Future work
Preemptive CT migration of— ✅ COMPLETE in iteration 108.MobileMenu(Q22 follow-up #1)MobileMenu(15 cases) is now exercised bypackages/ui/src/__tests__/ct/mobile-menu.ct.test.tsxand verifies in isolation (15/15 passing, 45.7s walltime). The migration covers the Escape-key listener, click-outside via wrapper-mount, body-scroll lock read viapage.evaluate(() => document.body.style.overflow), and the conditional panel remount — all real-browser idioms documented in.specify/features/q22-mobilemenu-ct.md.Removal of(Q22 follow-up #2 / Q23 follow-up #1) — SUPERSEDED in iteration 110: the goal flipped from "remove the script" to "keep as a defensive fallback". Plainpnpm test:ui:safepnpm --filter @ever-works/ui testruns all 11 Vitest files (174 tests) in ~98s on Windows + Node 24.14.0 — verified 2 of 2 consecutive runs in iteration 110. The Q22 Worker-IPC hang fingerprint does not reproduce. The script is left in place (with updated JSDoc and CLAUDE.md note) as insurance against a future Vitest/jsdom/Node regression. The cron-task instruction "Do NOT remove anything (move or improve is OK)" + AGENTS.md R15 ("Replace, don't remove") both prefer this resolution shape over outright deletion. Seedocs/log.mditeration 110 anddocs/questions.mdQ22 for the decision trail.— ✅ COMPLETE iteration 121 (Phase 6d, this entry). All six phases landed across iterations 113–121:playwright-coverageintegration (Q22 follow-up #3)- Phase 0 (iter 113) — library smoke test PASS-API.
- Phase 1 (iter 114) —
monocart-reporterwired intoplaywright.ct.config.ts; CT now emits source-mapped raw V8. - Phase 2 (iter 115) — three Vitest
coverage.excludelines for the migrated components dropped. - Phase 3 (iter 116) —
pnpm coveragemerge command landed for the CT subgraph; opened Q26 for the Istanbul-vs-V8 hard limitation. - Phase 6a (iter 117) —
vitest-monocart-coveragesmoke test PASSED; Q25 + Q26 confirmed. - Phase 6b (iter 119) —
vitest-monocart-coverageadopted as the Vitest provider; both runners now emit raw V8; Q26 ✅ RESOLVED. - Phase 6c (iter 121) — coverage gate flipped from informational
⚠️toprocess.exit(1); CIcoverage-gatejob added with 14-day artifact upload of the merged HTML report. Aggregate merged coverage on the fullpackages/ui/src/surface after Q27 closed in iteration 124: branches 100% (233/233), functions 100% (104/104), lines 99.76% (1240/1243), statements 99.72% (352/353). Per-file gate green for all three migrated components at 100%. The previous 3-branch shortfall (focusable.length === 0early-return + 2 fall-through branches) closed in iteration 124 via Q27 — three new CT tests using syntheticKeyboardEventdispatch throughpage.evaluate(Option A.1, bypassed the iter-120 CT-host-page focus-attribution edge case by usingtoBeAttached()instead oftoBeVisible()) plus one/* v8 ignore next */pragma on a defensivemenuRefrace-guard that surfaced during execution (Option A.3). Seedocs/plans/q27-mobilemenu-empty-items-coverage.mdanddocs/log.mditeration 124 for the full closure trail.