Plan: Q22 — Playwright Component Testing migration for FilterBar
Implementation plan paired with
.specify/features/q22-playwright-ct.md.Authored: iteration 102 (2026-04-26). Corrected: iteration 103 (2026-04-26). Steps 1-3 executed and validated: iteration 104 (2026-04-27). Steps 4, 5, 7, 8, 9 executed: iteration 105 (2026-04-27) — Q22 RESOLVED locally + CI matrix wired.
Status: ✅ FULLY COMPLETE (Q22 → Q28 saga closed, iteration 124). All phases executed: Q22 RESOLVED iter 105, CI matrix landed iter 105, Q22 follow-up #1 ✅ iter 108 (preemptive
MobileMenuCT migration), Q22 follow-up #2SUPERSEDEDiter 110 (per-file Vitest fallback kept as defensive escape hatch), Q22 follow-up #3 ✅ iter 121 (CI hard-gate enforced; Q26 + Q27 closed in the same arc). Step 6 (first CI run observation) was satisfied implicitly across the iter-105 → iter-141 session window — thetest-ctjob runs on every push todevelop, the windows-latest cell has been green every run, and iter-145 grep confirms zero outstandingPLANNED/SPECIFIED/DRAFTstatus lines across the Q-track plan/spec surface. Phase 2 → fully-complete status flip belatedly landed iter 146 (caught alongside theq22-upstream-repro.mdDRAFT flip in the same iter-146 audit pass).
✅ ITERATION 105 EXECUTION RECORD (2026-04-27)
Steps 4, 5, 7, 8, and 9 below were executed in iteration 105. Outcomes:
- Step 4 —
packages/ui/src/__tests__/ct/filter-bar.ct.test.tsxrewritten to cover all 16 cases. Initial run: 13/16 pass. Three failures (multi-selects tags, deselects tag on second click, sets aria-pressed on selected tags) all traced to a real bug inpackages/ui/src/preact/FilterBar.tsx: the default valueselectedTags: initialTags = []allocates a new[]on every function call, souseEffect([initialTags])fires on every render and silently resetsactiveTagsto[]. Fixed by hoisting aconst EMPTY_TAGS = Object.freeze([])module-level sentinel and changing the destructure default toselectedTags: initialTags = EMPTY_TAGS as string[]. Re-ranpnpm test:ct→ 16/16 pass in ~6.1s on Windows + Node 24.14.0. - Step 5 — Original
packages/ui/src/__tests__/preact/filter-bar.test.tsxdeleted.packages/ui/vitest.config.tsupdated:test.exclude: ['**/__tests__/ct/**', 'node_modules/**', 'dist/**']so Vitest's collector ignores.ct.test.tsxfiles;coverage.excludeadds'src/preact/FilterBar.tsx'(with comment pointing at follow-up #3)..specify/features/testing.mdAC #10 reworded to "1149 Vitest unit tests + 16 Playwright Component Tests = 1165 total"; new AC #12 added documenting thepnpm test:cttoolchain. - Step 7 —
.github/workflows/ci.ymlgains atest-ctmatrix job (os: [ubuntu-latest, windows-latest],needs: ci). Steps: checkout, pnpm setup, Node 24 setup,pnpm install --frozen-lockfile, anactions/cache@v4block keyed onpnpm-lock.yamlhash for~/.cache/ms-playwright+~/AppData/Local/ms-playwright(avoids re-downloading Chromium on every PR),pnpm exec playwright install --with-deps chromium(no-op on Windows for--with-deps, installs apt deps on Ubuntu),pnpm test:ct, and anif: failure()artifact upload forplaywright-report/+test-results/named per-OS. Thewindows-latestcell is the canonical Q22 fix signal — flagged with a header comment in the workflow file. - Step 8 —
docs/architecture/testing-runners.mdpublished. Covers: at-a-glance table mapping each runner to its responsibility, decision tree for picking a runner, per-runner rules with examples from this codebase, Q22 background, authoring conventions table, coverage handling, local commands, CI integration, and future work pointing at the three Q22 follow-ups. docs/index.mdupdated with the iteration-105 headline and a new sidebar entry under Architecture pointing attesting-runners.md..specify/project.mdCurrent State header bumped 104 → 105 with the Q22 RESOLVED status, the new test-count split, and the FilterBar bug-fix note.docs/questions.mdQ22: status flipped from OPEN to ✅ RESOLVED at the top of the section; full iteration-105 update appended at the bottom.
Step 6 (first CI run on ubuntu-latest + windows-latest cells confirms the matrix passes) is the only outstanding work, and is satisfied by observation on the next CI run — no additional code change required. Step 9 (log iteration) is fulfilled by this block plus the docs/log.md entry for iteration 105.
✅ ITERATION 104 EXECUTION RECORD (2026-04-27)
Steps 1-3 below were executed in iteration 104. Outcomes:
- Step 1 —
@playwright/[email protected]and@playwright/[email protected]installed inpackages/ui/devDependencies. Lockfile updated, version pin matchesapps/web-e2e. - Step 2 — Scaffold complete:
playwright.ct.config.ts(with thereact→preact/compatVite alias from the iteration-103 correction inlined underuse.ctViteConfig),playwright/index.html,playwright/index.ts,src/__tests__/ct/.gitkeep. Createdpackages/ui/tsconfig.ct.json(separate file because the build tsconfig hasrootDir: ./src, which would error onplaywright/files outside the root). Addedpnpm test:ct,pnpm test:ct:install, andpnpm typecheck:ctscripts plus root-level passthrough scripts. - Step 3 — Smoke test
src/__tests__/ct/filter-bar.ct.test.tsx(singlemount(<FilterBar />)→toHaveAttribute('data-component', 'filter-bar')) ran on local Windows + Node 24.14.0:1 passed (3.5s)with Vite 6.4.2 building a 115 KB FilterBar chunk via the compat alias. Path A is validated; Path B (custom mount adapter) is not required.
Subsequent iterations can proceed directly to Step 4 (port remaining 15 cases) without re-running the decision gate.
⚠️ CORRECTION (iteration 103)
The iteration-102 plan referenced @playwright/experimental-ct-preact. That package does not exist on npm (verified 2026-04-26 via pnpm view). Playwright officially supports React and Vue only.
Use @playwright/experimental-ct-react everywhere this plan says @playwright/experimental-ct-preact, paired with a Vite alias in playwright.ct.config.ts:
resolve: {
alias: {
'react': 'preact/compat',
'react-dom': 'preact/compat',
'react-dom/test-utils': 'preact/test-utils',
},
},
This mirrors the existing alias pattern in packages/ui/vitest.config.ts. The mount layer in experimental-ct-react ultimately calls React.createElement, which the alias maps to preact/compat.h.
If the Step-3 smoke test fails, switch to @playwright/experimental-ct-core (the framework-agnostic mount engine that the react/vue/svelte packages wrap) and write a thin Preact mount adapter. See .specify/features/q22-playwright-ct.md#correction-iteration-103-2026-04-26 for the full rationale and Path A / Path B decision tree.
The numbered steps below are otherwise unchanged. Read every literal @playwright/experimental-ct-preact as @playwright/experimental-ct-react + the alias block above.
Context
Q22 (see docs/questions.md) is the long-standing UI test
hang on Windows + Node 24. After 5 iterations of diagnostic work
(iterations 97 → 101) the failure was traced to a single combination:
@testing-library/preactfireEvent×FilterBar× jsdom × Node 24 IPC
The bug is not Vitest-version-specific (4.1.5 = 5/16 tests; 3.2.4 = 2/16 tests; both crash). The bug is not test-count-specific (5-test render-only file passes; 2-test fireEvent file crashes). It is environment-specific and component-specific.
The pragmatic fix is to migrate the affected tests off jsdom — to a real
Chromium browser — via Playwright Component Testing. The spec lives at
.specify/features/q22-playwright-ct.md. This document is the
execution plan.
Step-by-step
Step 1 — Install dependencies (~10 min)
cd packages/ui
pnpm add -D @playwright/experimental-ct-preact@^1.59.1 @playwright/test@^1.59.1
cd ../..
pnpm install
Verify:
pnpm-lock.yamlupdated.packages/ui/package.jsonlists both new devDependencies.- Versions match
apps/web-e2e/package.json@playwright/testconstraint.
Step 2 — Scaffold Playwright CT (~20 min)
Create:
packages/ui/playwright.ct.config.ts— config from spec §"Playwright CT config".packages/ui/playwright/index.html— mount fixture HTML from spec.packages/ui/playwright/index.ts— mount fixture TS from spec (initially empty / commented imports).packages/ui/src/__tests__/ct/.gitkeep— placeholder so Playwright can resolvetestDir.
Update:
packages/ui/tsconfig.json— addplaywright/**/*.tsandsrc/__tests__/ct/**/*.tsxtoinclude.packages/ui/.gitignore— addtest-results/,playwright-report/, andplaywright/.cache/.
Step 3 — First smoke test (~30 min)
Create packages/ui/src/__tests__/ct/filter-bar.ct.test.tsx with a single
test:
import { test, expect } from '@playwright/experimental-ct-preact';
import FilterBar from '../../preact/FilterBar';
test('renders with data-component attribute', async ({ mount }) => {
const component = await mount(<FilterBar />);
await expect(component).toHaveAttribute('data-component', 'filter-bar');
});
Add scripts:
// packages/ui/package.json (scripts)
{
"test:ct": "playwright test --config=playwright.ct.config.ts",
"test:ct:install": "playwright install --with-deps chromium"
}
// root package.json (scripts)
{
"test:ct": "pnpm --filter @ever-works/ui test:ct"
}
Verify locally:
pnpm --filter @ever-works/ui test:ct:install # one-time
pnpm test:ct
Expected: 1/1 passed in <10 s on Windows.
Decision gate: if this smoke test fails, STOP the migration and follow the spec's Rollback Plan (revert files, document failure mode, fall back to Q22 Option E or upstream repro).
Step 4 — Port remaining 15 test cases (~2 hours)
Read each test from packages/ui/src/__tests__/preact/filter-bar.test.tsx
and translate to Playwright CT idioms:
| Vitest pattern | Playwright CT equivalent |
|---|---|
render(<C />) | await mount(<C />) (returns Locator) |
screen.getByText('X') | component.getByText('X') |
screen.getByRole('button', { name: 'X' }) | component.getByRole('button', { name: 'X' }) |
expect(el).toBeTruthy() | await expect(locator).toBeVisible() |
fireEvent.click(el) | await locator.click() |
fireEvent.keyDown(el, { key: 'Enter' }) | await locator.press('Enter') |
vi.fn() callback | inline const calls: T[] = []; <C onX={(v) => calls.push(v)} /> then expect(calls).toEqual([...]) |
expect(callback).toHaveBeenCalledWith(x) | expect(calls).toEqual([x]) |
Run after every 2-3 cases:
pnpm test:ct
Each pass should add cases to the green count. Do not delete the original Vitest file yet — keep it as a behavioral oracle until all 16 cases are green in CT.
Step 5 — Delete the broken Vitest file (~10 min)
Once pnpm test:ct reports 16 passed:
- Delete
packages/ui/src/__tests__/preact/filter-bar.test.tsx. - Update
packages/ui/vitest.config.tscoverageexcludeto add'src/preact/FilterBar.tsx'(with a comment pointing at this plan). - Update
.specify/features/testing.mdAcceptance Criteria #10 to reflect the new total test count (was 1165 unit tests; now 1149 Vitest- 16 Playwright CT). Update the
packages/uiline in the file listing to removefilter-bar.test.tsx.
- 16 Playwright CT). Update the
Step 6 — Verify on Linux (~10 min)
If a Linux box / WSL is available locally:
pnpm test:ct
Should still pass 16/16. If only Windows is available, defer this to CI in Step 7.
Step 7 — CI integration (~30 min)
Add to .github/workflows/ci.yml:
test-ct:
name: Playwright Component Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 10 }
- uses: actions/setup-node@v4
with: { node-version: 24, cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
working-directory: packages/ui
- run: pnpm test:ct
Push, wait for CI. Both ubuntu-latest and windows-latest cells must
go green. If windows-latest fails, Q22 is not fixed by the
migration — re-open the question and document the new failure mode.
Step 8 — Documentation (~30 min)
Create docs/architecture/testing-runners.md from the spec's "Decision
matrix documented" section. Add a sidebar link from docs/index.md.
Update docs/questions.md Q22:
**Status**: **RESOLVED** in iteration <N>. `FilterBar` tests migrated to
Playwright Component Testing per `.specify/features/q22-playwright-ct.md`
and `docs/plans/q22-playwright-ct.md`. Windows + Node 24 CI green.
The per-file Vitest runner (`pnpm test:ui:safe`) remains in place as a
fallback for other Preact tests but is no longer required for the
filter-bar surface.
Update .specify/project.md Current State to mention the migration and the
new Playwright CT job.
Step 9 — Log iteration (~10 min)
Add a ## 2026-MM-DD — Iteration <N>: Q22 RESOLVED via Playwright CT
section to docs/log.md summarizing the migration.
Risks per step
| Step | Risk | Mitigation |
|---|---|---|
| 1 | Version skew with apps/web-e2e @playwright/test | Pin to identical ^1.59.1 constraint; verify lockfile resolves to same exact version |
| 3 | experimental-ct-preact does not mount Preact 10.29.1 | Smoke test is the decision gate; rollback per spec if it fails |
| 4 | Behavioral drift — CT expect.toBeVisible() is stricter than expect.toBeTruthy() | Run after every 2-3 cases; if a previously-passing assertion fails, debug whether the production component has a real visibility bug or whether the new assertion is too strict, and choose the correct fix |
| 5 | Coverage regression alarm | Update vitest.config.ts exclude AND .specify/features/testing.md AC #10 in the same commit as the file deletion to keep specs in sync |
| 7 | CI installs Playwright browsers from scratch on every run (~2-3 min) | Cache ~/.cache/ms-playwright keyed on pnpm-lock.yaml hash; defer optimization if first run already <5 min |
Out of scope (see spec §"Non-Goals")
- Migrating
BackToTop,SortSelect,SearchInput,ThemeToggle,MobileMenu,LayoutSwitcher,ItemBrowserto Playwright CT. - Adding screenshot snapshots.
- Removing
pnpm test:ui:safe. - React 19 / Docusaurus upgrades.
Success criteria
pnpm test:ctreports16 passedon Windows + Node 24 locally.windows-latesttest-ctCI cell green.ubuntu-latesttest-ctCI cell green.pnpm typecheck,pnpm lint,pnpm test(Vitest, all other packages) all still green.- Q22 status flipped to RESOLVED in
docs/questions.md. .specify/features/testing.mdAC #10 reflects the new total test count.docs/architecture/testing-runners.mdpublished with the Vitest vs. Playwright CT decision matrix.
Estimated total time
~7 hours of focused work, spread across 3-4 scheduled iterations (Steps 1-3 in run A, Steps 4-5 in run B, Steps 6-9 in run C).
After this plan succeeds
Open follow-up questions for the next scheduled run:
- Should we migrate
MobileMenu(also has conditional remount + focus trap, both jsdom-fragile) to CT preemptively, before it crashes too? - Should the per-file Vitest runner (
pnpm test:ui:safe) be removed once no Preact test files require it? (Yes if remaining Preact tests stay render-only and pass via the defaultpnpm test.) - Should we adopt
playwright-coverageto merge CT coverage into the V8 report, restoring the 100% branch coverage signal forFilterBar.tsx?