fix(web): swallow vitest browser-mode birpc-teardown race that flakes CI #1091

Merged
reviewer merged 4 commits from code-lead/1080 into main 2026-05-11 09:47:51 +00:00
Collaborator

Summary

Closes #1080. Patches the [birpc] rpc is closed, cannot call "resolveManualMock" rejection that flakes apps/web CI even when every test passes, and re-enables the three test files that were excluded as a coverage tax.

Diagnosis

@vitest/browser@4.1.5 registers manual-mock factories without a try/catch around rpc.resolveManualMock() (see index.js:3231). When Playwright's RouteHandler — whose lifecycle is the page, not the runner — invokes the factory after the runner has closed its birpc channel, the rejection escapes _handleInternal as an unhandled error and the runner exits 1.

The upstream pre-mocker branch (index.js:3211) already catches the same RPC call and returns {} on failure; the post-mocker branch just forgot. The issue body's sync-factory suggestion is a red herring — @vitest/mocker/node.js:239 classifies every factory mock as mockType: "manual", so sync and async go through the identical RPC.

Fix

apps/web/vitest.mock-rpc-patch.ts monkey-patches ManualMockedModule.fromJSON at vitest-config load so every factory it wraps treats [birpc] rpc is closed rejections as "return empty exports" — the same graceful degradation upstream already applies on its other branch. @vitest/mocker is deduped (single physical copy in node_modules/.bun), so one Node-side patch covers every factory the runner registers later.

Other changes:

  • apps/web/vitest.config.ts — side-effect imports the patch and drops the three exclude entries for app-shell.test.tsx, nav-sections.test.tsx, sidebar-nav.test.tsx.
  • apps/web/package.json — declares @vitest/mocker@4.1.5 as a direct devDep so the patch can import { ManualMockedModule } cleanly. The package is already in the install tree as a transitive dep of vitest/@vitest/browser; pinning it here just makes TS resolve it.
  • apps/web/vitest.mock-rpc-patch.test.ts — Node-only unit test (run via bun test apps/web/vitest.mock-rpc-patch.test.ts) covering the three branches: rpc-closed → {}, other errors → still throw (with original cause preserved), happy-path → exports untouched.

Why not the other options

  • Pin/bisect — 4.1.5 is currently the latest stable (next stable is 5.0.0-beta.2); no earlier patch fixes this race.
  • Sync factories everywhere — doesn't help; both sync and async vi.mock factories become mockType: "manual".
  • Process-level unhandledRejection handler — already attempted in #1080's description and did not catch (rejection propagates through Playwright internals).
  • Switch to happy-dom — contradicts docs/specs/vitest-browser-mode.md; would regress test fidelity.

Test plan

  • bun test apps/web/vitest.mock-rpc-patch.test.ts — patch unit tests (4 pass, 0 fail).
  • bun x tsc --noEmit (apps/web) — clean.
  • bun x @biomejs/biome@^2 check apps/web — clean (pre-existing warnings only).
  • CI run on this PR — confirm the three re-enabled test files are green and no rpc-closed rejection appears in teardown.
  • Watch the next 5 PRs that touch apps/web to confirm the flake is gone.

🤖 Generated with Claude Code

## Summary Closes #1080. Patches the `[birpc] rpc is closed, cannot call "resolveManualMock"` rejection that flakes `apps/web` CI even when every test passes, and re-enables the three test files that were excluded as a coverage tax. ### Diagnosis `@vitest/browser@4.1.5` registers manual-mock factories without a try/catch around `rpc.resolveManualMock()` (see `index.js:3231`). When Playwright's `RouteHandler` — whose lifecycle is the **page**, not the runner — invokes the factory after the runner has closed its birpc channel, the rejection escapes `_handleInternal` as an unhandled error and the runner exits 1. The upstream pre-mocker branch (`index.js:3211`) **already** catches the same RPC call and returns `{}` on failure; the post-mocker branch just forgot. The issue body's sync-factory suggestion is a red herring — `@vitest/mocker/node.js:239` classifies every factory mock as `mockType: "manual"`, so sync and async go through the identical RPC. ### Fix `apps/web/vitest.mock-rpc-patch.ts` monkey-patches `ManualMockedModule.fromJSON` at vitest-config load so every factory it wraps treats `[birpc] rpc is closed` rejections as "return empty exports" — the same graceful degradation upstream already applies on its other branch. `@vitest/mocker` is deduped (single physical copy in `node_modules/.bun`), so one Node-side patch covers every factory the runner registers later. Other changes: - `apps/web/vitest.config.ts` — side-effect imports the patch and drops the three `exclude` entries for `app-shell.test.tsx`, `nav-sections.test.tsx`, `sidebar-nav.test.tsx`. - `apps/web/package.json` — declares `@vitest/mocker@4.1.5` as a direct devDep so the patch can `import { ManualMockedModule }` cleanly. The package is already in the install tree as a transitive dep of `vitest`/`@vitest/browser`; pinning it here just makes TS resolve it. - `apps/web/vitest.mock-rpc-patch.test.ts` — Node-only unit test (run via `bun test apps/web/vitest.mock-rpc-patch.test.ts`) covering the three branches: rpc-closed → `{}`, other errors → still throw (with original `cause` preserved), happy-path → exports untouched. ### Why not the other options - **Pin/bisect** — 4.1.5 is currently the latest stable (next stable is `5.0.0-beta.2`); no earlier patch fixes this race. - **Sync factories everywhere** — doesn't help; both sync and async vi.mock factories become `mockType: "manual"`. - **Process-level `unhandledRejection` handler** — already attempted in #1080's description and did not catch (rejection propagates through Playwright internals). - **Switch to happy-dom** — contradicts `docs/specs/vitest-browser-mode.md`; would regress test fidelity. ## Test plan - [x] `bun test apps/web/vitest.mock-rpc-patch.test.ts` — patch unit tests (4 pass, 0 fail). - [x] `bun x tsc --noEmit` (apps/web) — clean. - [x] `bun x @biomejs/biome@^2 check apps/web` — clean (pre-existing warnings only). - [ ] CI run on this PR — confirm the three re-enabled test files are green and no rpc-closed rejection appears in teardown. - [ ] Watch the next 5 PRs that touch `apps/web` to confirm the flake is gone. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(web): swallow vitest browser-mode birpc-teardown race that flakes CI
Some checks failed
qa / dockerfile (pull_request) Successful in 15s
qa / db-schema (pull_request) Successful in 19s
qa / i18n-string-check (pull_request) Successful in 12s
qa / sql-layer-check (pull_request) Successful in 10s
qa / qa-1 (pull_request) Failing after 2m42s
qa / qa (pull_request) Failing after 0s
47b78ca3a7
`@vitest/browser@4.1.5` registers manual-mock factories without a try/catch
around `rpc.resolveManualMock()` (index.js:3231). When Playwright's
RouteHandler — whose lifecycle is the page, not the runner — invokes the
factory after the runner has closed its birpc channel, the rejection
escapes `_handleInternal` as an unhandled error and the runner exits 1
even when every test passed.

Wrap `ManualMockedModule.fromJSON` at vitest-config load so every factory
treats `[birpc] rpc is closed` as "return empty exports" — the same
graceful degradation upstream already applies on the pre-mocker branch
(index.js:3211). `@vitest/mocker` is deduped, so a single Node-side patch
covers every factory the runner registers later. Re-enables the three
test files (`app-shell`, `nav-sections`, `sidebar-nav`) that had been
excluded as a coverage tax. Tracks #1080.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(web): make test-router-stub Link preventDefault to keep vitest iframe alive
Some checks failed
qa / dockerfile (pull_request) Successful in 13s
qa / sql-layer-check (pull_request) Successful in 12s
qa / i18n-string-check (pull_request) Successful in 13s
qa / db-schema (pull_request) Successful in 15s
qa / qa-1 (pull_request) Failing after 2m19s
qa / qa (pull_request) Failing after 0s
fcf7d399b9
After re-enabling app-shell.test.tsx in PR #1091, its "tapping a nav link
closes the drawer" test exposed a second iframe-channel race: clicking a
`<Link>` rendered by `routerStubs.StubLink` as a plain `<a href="/board">`
caused the Playwright iframe to perform a real browser navigation, severing
the vitest runner's connection and failing the suite with
`Cannot connect to the iframe. ... Received URL: http://localhost:63315/board`.

The fix matches production behaviour: the real `<Link>` from tanstack-router
calls `preventDefault` (routing through `navigate()` instead of the browser),
so a test-router stub should too. Callers that still want to observe
`defaultPrevented === false` (e.g. avatar-menu.test.tsx's element-level
`addEventListener` listener) keep working because React's delegated `onClick`
fires last; only navigation is suppressed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Collaborator

Follow-up commit fcf7d39: the birpc-closed patch landed and worked (CI run #1902 reached vitest summary 20 passed / 603 passed (605) — none of the failures were the original race). The remaining hard failure on app-shell.test.tsx was a different teardown error:

Caused by: Error: Cannot connect to the iframe. ...
Received URL: http://localhost:63315/board
Expected:    http://localhost:63315/?sessionId=…

The drawer-close test was clicking nav-board, which routerStubs.StubLink rendered as a plain <a href="/board">. The click navigated the Playwright iframe out from under vitest. Fix: StubLink now calls preventDefault() (matching real <Link> from tanstack-router, which routes through navigate() rather than letting the browser load). Element-level addEventListener listeners still observe defaultPrevented === false because React's delegated onClick fires last, so avatar-menu.test.tsx's existing assertion continues to hold.

Follow-up commit `fcf7d39`: the birpc-closed patch landed and worked (CI run #1902 reached vitest summary `20 passed / 603 passed (605)` — none of the failures were the original race). The remaining hard failure on `app-shell.test.tsx` was a different teardown error: ``` Caused by: Error: Cannot connect to the iframe. ... Received URL: http://localhost:63315/board Expected: http://localhost:63315/?sessionId=… ``` The drawer-close test was clicking `nav-board`, which `routerStubs.StubLink` rendered as a plain `<a href="/board">`. The click navigated the Playwright iframe out from under vitest. Fix: `StubLink` now calls `preventDefault()` (matching real `<Link>` from tanstack-router, which routes through `navigate()` rather than letting the browser load). Element-level `addEventListener` listeners still observe `defaultPrevented === false` because React's delegated `onClick` fires last, so `avatar-menu.test.tsx`'s existing assertion continues to hold.
Author
Collaborator

Blocked on log access. Run #1905 (head fcf7d39) — qa-1 still red after the StubLink preventDefault fix. Step grid from the run page:

  • Setup / Typecheck / Lint / Format check: success
  • Test: failure (46s)bun x turbo run test

Every Forgejo log endpoint I tried returns dbfs open "actions_log/": file does not exist (/actions/runs/1905/jobs/{0,5,8420}/attempt/1/logs, /api/v1/repos/.../actions/{jobs,tasks}/{7349,8420}/logs, /actions/runs/1905/jobs/.../logs/full, …). The job's embedded stepsLog is []. Web UI is cookie-gated and doesn't accept the token, and apt-get is locked so I can't bring up Chromium locally to reproduce (missing libglib-2.0.so.0, no root). No logs ⇒ no way to identify which test case is now bailing the runner at 46s.

What I can rule out from the static diff against the previous-known-bad run (#1902, "20 passed / 603 passed (605)"):

  • avatar-menu.test.tsx "click does not prevent default navigation" still holds: element-level addEventListener fires at the anchor's native target phase, before React's root-delegated onClick in the bubble — e.defaultPrevented === false there.
  • app-shell.test.tsx "tapping a nav link closes the drawer" still has closeMenu() fire in onClickCapture before our preventDefault, so the drawer should still close.
  • No re-enabled test (sidebar-nav, nav-sections) appears to click an <a href> rendered by StubLink — only buttons.

So either the second (605 − 603 = 2) failure from #1902 was a separate test the comment didn't name, or fcf7d39 regressed something unrelated. Can't tell which without the Test-step output.

Suggested unblock: re-run the job once log retention is healthy, or have an operator surface the failing test name (e.g. paste the last 50 lines of the Test step) so a targeted fix is possible. Stopping rather than guess-pushing.

Blocked on log access. Run [#1905](https://forge.jacquin.app/charles/claude-hooks/actions/runs/1905) (head `fcf7d39`) — `qa-1` still red after the StubLink `preventDefault` fix. Step grid from the run page: - Setup / Typecheck / Lint / Format check: success - **Test: failure (46s)** — `bun x turbo run test` Every Forgejo log endpoint I tried returns `dbfs open "actions_log/": file does not exist` (`/actions/runs/1905/jobs/{0,5,8420}/attempt/1/logs`, `/api/v1/repos/.../actions/{jobs,tasks}/{7349,8420}/logs`, `/actions/runs/1905/jobs/.../logs/full`, …). The job's embedded `stepsLog` is `[]`. Web UI is cookie-gated and doesn't accept the token, and `apt-get` is locked so I can't bring up Chromium locally to reproduce (missing `libglib-2.0.so.0`, no root). No logs ⇒ no way to identify which test case is now bailing the runner at 46s. What I can rule out from the static diff against the previous-known-bad run (#1902, "20 passed / 603 passed (605)"): - `avatar-menu.test.tsx` "click does not prevent default navigation" still holds: element-level `addEventListener` fires at the anchor's native target phase, before React's root-delegated `onClick` in the bubble — `e.defaultPrevented === false` there. - `app-shell.test.tsx` "tapping a nav link closes the drawer" still has `closeMenu()` fire in `onClickCapture` before our `preventDefault`, so the drawer should still close. - No re-enabled test (`sidebar-nav`, `nav-sections`) appears to click an `<a href>` rendered by `StubLink` — only buttons. So either the second `(605 − 603 = 2)` failure from #1902 was a separate test the comment didn't name, or `fcf7d39` regressed something unrelated. Can't tell which without the Test-step output. Suggested unblock: re-run the job once log retention is healthy, or have an operator surface the failing test name (e.g. paste the last 50 lines of the `Test` step) so a targeted fix is possible. Stopping rather than guess-pushing.
code-lead force-pushed code-lead/1080 from fcf7d399b9
Some checks failed
qa / dockerfile (pull_request) Successful in 13s
qa / sql-layer-check (pull_request) Successful in 12s
qa / i18n-string-check (pull_request) Successful in 13s
qa / db-schema (pull_request) Successful in 15s
qa / qa-1 (pull_request) Failing after 2m19s
qa / qa (pull_request) Failing after 0s
to cd926c0d5a
Some checks failed
qa / i18n-string-check (pull_request) Successful in 8s
qa / db-schema (pull_request) Successful in 13s
qa / dockerfile (pull_request) Successful in 15s
qa / sql-layer-check (pull_request) Successful in 8s
qa / qa-1 (pull_request) Failing after 1m54s
qa / qa (pull_request) Failing after 0s
2026-05-11 00:24:27 +00:00
Compare
code-lead force-pushed code-lead/1080 from cd926c0d5a
Some checks failed
qa / i18n-string-check (pull_request) Successful in 8s
qa / db-schema (pull_request) Successful in 13s
qa / dockerfile (pull_request) Successful in 15s
qa / sql-layer-check (pull_request) Successful in 8s
qa / qa-1 (pull_request) Failing after 1m54s
qa / qa (pull_request) Failing after 0s
to e824972d6b
Some checks failed
qa / i18n-string-check (pull_request) Successful in 12s
qa / db-schema (pull_request) Successful in 13s
qa / dockerfile (pull_request) Successful in 20s
qa / sql-layer-check (pull_request) Successful in 8s
qa / qa-1 (pull_request) Failing after 1m58s
qa / qa (pull_request) Failing after 0s
2026-05-11 01:47:15 +00:00
Compare
code-lead force-pushed code-lead/1080 from e824972d6b
Some checks failed
qa / i18n-string-check (pull_request) Successful in 12s
qa / db-schema (pull_request) Successful in 13s
qa / dockerfile (pull_request) Successful in 20s
qa / sql-layer-check (pull_request) Successful in 8s
qa / qa-1 (pull_request) Failing after 1m58s
qa / qa (pull_request) Failing after 0s
to 83bf44cb98
Some checks failed
qa / i18n-string-check (pull_request) Successful in 14s
qa / db-schema (pull_request) Successful in 16s
qa / dockerfile (pull_request) Successful in 18s
qa / sql-layer-check (pull_request) Successful in 8s
qa / qa-1 (pull_request) Failing after 2m5s
qa / qa (pull_request) Failing after 0s
2026-05-11 03:04:14 +00:00
Compare
code-lead force-pushed code-lead/1080 from 83bf44cb98
Some checks failed
qa / i18n-string-check (pull_request) Successful in 14s
qa / db-schema (pull_request) Successful in 16s
qa / dockerfile (pull_request) Successful in 18s
qa / sql-layer-check (pull_request) Successful in 8s
qa / qa-1 (pull_request) Failing after 2m5s
qa / qa (pull_request) Failing after 0s
to 0159343e84
Some checks failed
qa / i18n-string-check (pull_request) Successful in 15s
qa / db-schema (pull_request) Successful in 22s
qa / dockerfile (pull_request) Successful in 25s
qa / sql-layer-check (pull_request) Successful in 13s
qa / qa-1 (pull_request) Failing after 3m24s
qa / qa (pull_request) Failing after 0s
2026-05-11 08:31:54 +00:00
Compare
reviewer requested changes 2026-05-11 08:40:06 +00:00
Dismissed
reviewer left a comment

Review — REQUEST_CHANGES

One blocker: CI is red on HEAD 0159343 (run #1905, Test step, 46 s). The fix itself is well-crafted — this is purely a "get CI green first" gate.


What's good

Diagnosis is correct. The PR description nails the upstream split: the pre-mocker branch at line ~3211 already has a try/catch that returns {}, the post-mocker branch at line ~3231 is the one missing it. Confirming this via a quick check of the @vitest/browser/dist/index.js source is the right call, and the conclusion that sync vs. async factories makes no difference (both route through mockType: "manual" in @vitest/mocker/dist/node.js:239) is accurate.

Monkey-patch is well-crafted.

  • Idempotency guard (__chBirpcSafe) is clean — safe to re-import the file.
  • bind(ManualMockedModule) preserves the receiver for the original call.
  • message.includes("rpc is closed") is specific enough; it matches the full upstream message and won't swallow unrelated rejections.
  • Non-rpc errors are re-thrown unchanged, so upstream's createHelpfulError still wraps them and the original .cause is preserved — confirmed by the unit test.
  • Removal path is documented clearly.

StubLink fix is correct. Calling callerOnClick?.(e) first then if (!e.defaultPrevented) e.preventDefault() matches tanstack-router's behaviour, doesn't break avatar-menu.test.tsx's defaultPrevented === false assertion (element-level addEventListener fires at the anchor's native target phase before React's root-delegated bubble), and the comment explains all of this precisely.

Unit tests cover all branches (idempotency marker, rpc-closed → {}, other error → still throws with cause, happy path).

@vitest/mocker pin — declaring the transitive dep directly so TS resolves it cleanly is the right approach; no resolution ambiguity.


Blocker

CI run #1905 is still failing. From the code-lead's comment the log content is inaccessible (dbfs open "actions_log/": file does not exist), so the specific failing test isn't known. Before this can merge the failure needs to be identified and fixed (or ruled out as infra noise and re-run clean).

Suggested diagnostic paths:

  1. Re-run the job — if the actions_log error is transient/infra noise the next run may produce readable logs.
  2. Run locally with Playwright visiblebun x vitest --reporter=verbose in apps/web against the 3 re-enabled files pinpoints which test assertion is bailing the runner in 46 s.
  3. Narrow by file — run each of app-shell.test.tsx, nav-sections.test.tsx, sidebar-nav.test.tsx in isolation (vitest run --reporter=verbose <file>) to isolate the failing file before digging into the specific case.

Once CI is green this is ready to merge — the approach is sound and the coverage tax is worth removing.

## Review — REQUEST_CHANGES One blocker: **CI is red on HEAD `0159343`** (run #1905, Test step, 46 s). The fix itself is well-crafted — this is purely a "get CI green first" gate. --- ### What's good **Diagnosis is correct.** The PR description nails the upstream split: the pre-mocker branch at line ~3211 already has a `try/catch` that returns `{}`, the post-mocker branch at line ~3231 is the one missing it. Confirming this via a quick check of the `@vitest/browser/dist/index.js` source is the right call, and the conclusion that sync vs. async factories makes no difference (both route through `mockType: "manual"` in `@vitest/mocker/dist/node.js:239`) is accurate. **Monkey-patch is well-crafted.** - Idempotency guard (`__chBirpcSafe`) is clean — safe to re-import the file. - `bind(ManualMockedModule)` preserves the receiver for the original call. - `message.includes("rpc is closed")` is specific enough; it matches the full upstream message and won't swallow unrelated rejections. - Non-rpc errors are re-thrown unchanged, so upstream's `createHelpfulError` still wraps them and the original `.cause` is preserved — confirmed by the unit test. - Removal path is documented clearly. **`StubLink` fix is correct.** Calling `callerOnClick?.(e)` first then `if (!e.defaultPrevented) e.preventDefault()` matches tanstack-router's behaviour, doesn't break `avatar-menu.test.tsx`'s `defaultPrevented === false` assertion (element-level `addEventListener` fires at the anchor's native target phase before React's root-delegated bubble), and the comment explains all of this precisely. **Unit tests cover all branches** (idempotency marker, rpc-closed → `{}`, other error → still throws with cause, happy path). **`@vitest/mocker` pin** — declaring the transitive dep directly so TS resolves it cleanly is the right approach; no resolution ambiguity. --- ### Blocker **CI run #1905 is still failing.** From the code-lead's comment the log content is inaccessible (`dbfs open "actions_log/": file does not exist`), so the specific failing test isn't known. Before this can merge the failure needs to be identified and fixed (or ruled out as infra noise and re-run clean). Suggested diagnostic paths: 1. **Re-run the job** — if the `actions_log` error is transient/infra noise the next run may produce readable logs. 2. **Run locally with Playwright visible** — `bun x vitest --reporter=verbose` in `apps/web` against the 3 re-enabled files pinpoints which test assertion is bailing the runner in 46 s. 3. **Narrow by file** — run each of `app-shell.test.tsx`, `nav-sections.test.tsx`, `sidebar-nav.test.tsx` in isolation (`vitest run --reporter=verbose <file>`) to isolate the failing file before digging into the specific case. Once CI is green this is ready to merge — the approach is sound and the coverage tax is worth removing.
Collaborator

CI log surfaced (run #1929, head 0159343e)

Operator pasted the Test step output. The single failing scope:

src/components/app-shell.test.tsx > AppShell — sidebar layout > renders the sidebar rail with the grouped <NavSections variant='rail'>

⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯
Error: Failed to run the test /workspace/.../apps/web/src/components/app-shell.test.tsx.
Caused by: Error: Cannot connect to the iframe. Did you change the location or submitted a form?
If so, don't forget to call `event.preventDefault()` to avoid reloading the page.
Received URL: http://localhost:63316/board
Expected:    http://localhost:63316/?sessionId=…&iframeId=…/app-shell.test.tsx

Test count: 599 passed (600). Same iframe-navigation pattern as the drawer-close test you already fixed in fcf7d39 — different test, same root cause. The StubLink preventDefault did its job for the link the drawer test clicked; this one is a separate <a href="/board"> path that bypasses routerStubs.Link.

Diagnostic hints

The sidebar-rail render mounts the full <AppShell>. Candidates for an un-stubbed <a href="/board">:

  1. Brand strip / logo — does it render <Link to="/board"> directly, or via a sub-component that imports Link from somewhere vi.mock("@tanstack/react-router", …) doesn't intercept (e.g. @tanstack/react-router-core or @tanstack/router-core)?
  2. NavSections rail variant — the nav items themselves. Rail variant might render <a> differently than the drawer variant.
  3. Bottom cluster Collapse navigation button — does it render as a link?
  4. Stray React Router <Link> import — search for from "@tanstack/router-core" or any other path the global vi.mock doesn't cover.

Suggested next pass

In apps/web/src/lib/test-router-stub.tsx, the vi.mock mocks @tanstack/react-router. Grep for other modules that re-export or wrap Link:

grep -rn 'from ["'\'']@tanstack/' apps/web/src/components/sidebar-nav.tsx apps/web/src/components/nav-sections.tsx apps/web/src/components/app-shell.tsx
grep -rn '<a href' apps/web/src/components/sidebar-nav.tsx apps/web/src/components/nav-sections.tsx apps/web/src/components/app-shell.tsx

Two viable fixes:

  • A — Add the discovered module to the vi.mock list in vitest.setup.tsx with routerStubs (preserves test fidelity).
  • B — Add a global capture listener at the test root that calls preventDefault on every anchor click whose href starts with /. Acts as a backstop for any future un-stubbed <Link>.

Going with A is correct because it stays test-scoped and surfaces future mismatches at mock time, but B as a safety net (in vitest.setup.tsx) would prevent class of regression entirely.

Re-dispatching with the failing test name + log should unblock the loop; the fix is bounded to one file in apps/web/src/components/.

## CI log surfaced (run #1929, head `0159343e`) Operator pasted the Test step output. The single failing scope: > `src/components/app-shell.test.tsx > AppShell — sidebar layout > renders the sidebar rail with the grouped <NavSections variant='rail'>` ``` ⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯ Error: Failed to run the test /workspace/.../apps/web/src/components/app-shell.test.tsx. Caused by: Error: Cannot connect to the iframe. Did you change the location or submitted a form? If so, don't forget to call `event.preventDefault()` to avoid reloading the page. Received URL: http://localhost:63316/board Expected: http://localhost:63316/?sessionId=…&iframeId=…/app-shell.test.tsx ``` Test count: `599 passed (600)`. Same iframe-navigation pattern as the drawer-close test you already fixed in `fcf7d39` — different test, same root cause. The `StubLink` `preventDefault` did its job for the link the drawer test clicked; this one is a separate `<a href="/board">` path that bypasses `routerStubs.Link`. ### Diagnostic hints The sidebar-rail render mounts the full `<AppShell>`. Candidates for an un-stubbed `<a href="/board">`: 1. **Brand strip / logo** — does it render `<Link to="/board">` directly, or via a sub-component that imports `Link` from somewhere `vi.mock("@tanstack/react-router", …)` doesn't intercept (e.g. `@tanstack/react-router-core` or `@tanstack/router-core`)? 2. **`NavSections` rail variant** — the nav items themselves. Rail variant might render `<a>` differently than the drawer variant. 3. **Bottom cluster `Collapse navigation` button** — does it render as a link? 4. **Stray React Router `<Link>` import** — search for `from "@tanstack/router-core"` or any other path the global `vi.mock` doesn't cover. ### Suggested next pass In `apps/web/src/lib/test-router-stub.tsx`, the `vi.mock` mocks `@tanstack/react-router`. Grep for other modules that re-export or wrap `Link`: ``` grep -rn 'from ["'\'']@tanstack/' apps/web/src/components/sidebar-nav.tsx apps/web/src/components/nav-sections.tsx apps/web/src/components/app-shell.tsx grep -rn '<a href' apps/web/src/components/sidebar-nav.tsx apps/web/src/components/nav-sections.tsx apps/web/src/components/app-shell.tsx ``` Two viable fixes: - **A** — Add the discovered module to the `vi.mock` list in `vitest.setup.tsx` with `routerStubs` (preserves test fidelity). - **B** — Add a global capture listener at the test root that calls `preventDefault` on every anchor click whose `href` starts with `/`. Acts as a backstop for any future un-stubbed `<Link>`. Going with **A** is correct because it stays test-scoped and surfaces future mismatches at mock time, but **B** as a safety net (in `vitest.setup.tsx`) would prevent class of regression entirely. Re-dispatching with the failing test name + log should unblock the loop; the fix is bounded to one file in `apps/web/src/components/`.
chore: re-trigger CI with diagnostic log in #1091 comment
Some checks failed
qa / dockerfile (pull_request) Successful in 12s
qa / db-schema (pull_request) Successful in 13s
qa / i18n-string-check (pull_request) Successful in 16s
qa / sql-layer-check (pull_request) Successful in 15s
qa / qa-1 (pull_request) Failing after 2m8s
qa / qa (pull_request) Failing after 0s
f745f7f5f3
reviewer requested changes 2026-05-11 09:25:59 +00:00
Dismissed
reviewer left a comment

Review — fix(web): swallow vitest browser-mode birpc-teardown race

Overview

The PR tackles two separate root causes of CI failures in apps/web:

  1. birpc teardown racevitest.mock-rpc-patch.ts monkey-patches ManualMockedModule.fromJSON so rpc is closed rejections during teardown degrade gracefully to {} instead of escaping as unhandled rejections.
  2. StubLink iframe navigationtest-router-stub.tsx now calls preventDefault() on clicks, stopping the Playwright iframe from navigating away from the vitest session URL.

Both diagnoses are correct and the implementations are solid. The problem is that CI is still red and one of the three re-enabled test files is still failing — see below.


What's well done

  • birpc patch is well-engineered. Idempotent guard (__chBirpcSafe marker), narrowly-scoped catch (only the rpc is closed substring), non-rpc errors re-thrown untouched, rich inline documentation explaining the upstream bug. This is exactly the right shape for a monkey-patch workaround.
  • Patch unit tests are thorough. All four branches covered: marker idempotency, rpc-closed → {}, other errors → still throw (with .cause preserved), happy-path → exports unchanged.
  • Pinning @vitest/mocker as a direct devDep is the correct way to give TS a clean resolution target for an otherwise-transitive package.
  • StubLink fix matches production behaviour. Real tanstack-router <Link> routes through navigate() and prevents browser navigation; the comment correctly explains why avatar-menu.test.tsx's defaultPrevented === false assertion continues to hold (React delegated onClick fires after native addEventListener).

Blocker — CI is still failing

CI run #1929 (head 0159343e) reports 599 passed (600) with one failure:

app-shell.test.tsx > AppShell — sidebar layout > renders the sidebar rail with the grouped <NavSections variant='rail'>
Caused by: Error: Cannot connect to the iframe. Did you change the location or submitted a form?
Received URL: http://localhost:63316/board
Expected:    http://localhost:63316/?sessionId=…&iframeId=…/app-shell.test.tsx

This is the same root cause as the drawer-close failure fixed by the StubLink preventDefault — a different code path (sidebar-rail render) is navigating the Playwright iframe to /board via an un-stubbed <a href="/board">. The StubLink fix only covers links that flow through routerStubs.StubLink; this path bypasses it (likely the brand strip / logo or a NavSections item rendered via a <Link> import that isn't covered by the current vi.mock("@tanstack/react-router", …)).

claude-desktop's comment #16858 already identifies the likely candidates and suggests two viable fixes (A: add the missing module to the vi.mock list; B: global anchor-click backstop in vitest.setup.tsx). That comment has not been acted on yet.

The removal of the three exclude entries is therefore premature. app-shell.test.tsx is still broken. Until all three re-enabled files are confirmed green in CI, those exclusions should stay in place (or the test should be fixed in this PR).


⚠️ Minor observations (non-blocking)

1. vitest.mock-rpc-patch.test.ts placement risk

The file lives at apps/web/vitest.mock-rpc-patch.test.ts (root, not under src/). The exclude list doesn't cover it. Depending on vitest's include glob, the browser runner might pick it up — but it imports @vitest/mocker (Node-side API) and is explicitly intended to run under bun test. If include is src/**/*.test.{ts,tsx} it's fine; if it's **/*.test.{ts,tsx} it would be wrong. Worth adding a comment or confirming the include pattern guards it.

2. safeFactory type cast

return await (factory as () => unknown | Promise<unknown>)();

FactoryArg is Parameters<FromJSON>[1] which likely accepts undefined | (() => …). The cast side-steps the nullability. Harmless in practice (we're only called when factory is defined) but a factory?.() null-guard would be cleaner.


Required before merge

  1. Fix the remaining iframe-navigation failure in app-shell.test.tsx > sidebar rail. Follow the diagnostic in comment #16858 — find the un-stubbed <Link> import and either add it to the mock list or add a global anchor-click backstop. The StubLink patch alone is not sufficient.
  2. Confirm all three re-enabled test files are green in CI before removing the exclusions.
  3. (Optional) Verify or document the include scope so vitest.mock-rpc-patch.test.ts is clearly not mistakenly picked up by the browser runner.

The birpc patch and the StubLink fix are both correct and can land — the PR just needs the remaining iframe-navigation path closed out.

## Review — fix(web): swallow vitest browser-mode birpc-teardown race ### Overview The PR tackles two separate root causes of CI failures in `apps/web`: 1. **birpc teardown race** — `vitest.mock-rpc-patch.ts` monkey-patches `ManualMockedModule.fromJSON` so `rpc is closed` rejections during teardown degrade gracefully to `{}` instead of escaping as unhandled rejections. 2. **StubLink iframe navigation** — `test-router-stub.tsx` now calls `preventDefault()` on clicks, stopping the Playwright iframe from navigating away from the vitest session URL. Both diagnoses are correct and the implementations are solid. The problem is that **CI is still red** and one of the three re-enabled test files is still failing — see below. --- ### ✅ What's well done - **birpc patch is well-engineered.** Idempotent guard (`__chBirpcSafe` marker), narrowly-scoped catch (only the `rpc is closed` substring), non-rpc errors re-thrown untouched, rich inline documentation explaining the upstream bug. This is exactly the right shape for a monkey-patch workaround. - **Patch unit tests are thorough.** All four branches covered: marker idempotency, rpc-closed → `{}`, other errors → still throw (with `.cause` preserved), happy-path → exports unchanged. - **Pinning `@vitest/mocker` as a direct devDep** is the correct way to give TS a clean resolution target for an otherwise-transitive package. - **`StubLink` fix matches production behaviour.** Real tanstack-router `<Link>` routes through `navigate()` and prevents browser navigation; the comment correctly explains why `avatar-menu.test.tsx`'s `defaultPrevented === false` assertion continues to hold (React delegated `onClick` fires after native `addEventListener`). --- ### ❌ Blocker — CI is still failing CI run #1929 (head `0159343e`) reports **599 passed (600)** with one failure: ``` app-shell.test.tsx > AppShell — sidebar layout > renders the sidebar rail with the grouped <NavSections variant='rail'> Caused by: Error: Cannot connect to the iframe. Did you change the location or submitted a form? Received URL: http://localhost:63316/board Expected: http://localhost:63316/?sessionId=…&iframeId=…/app-shell.test.tsx ``` This is the **same root cause** as the drawer-close failure fixed by the `StubLink` `preventDefault` — a different code path (sidebar-rail render) is navigating the Playwright iframe to `/board` via an un-stubbed `<a href="/board">`. The `StubLink` fix only covers links that flow through `routerStubs.StubLink`; this path bypasses it (likely the brand strip / logo or a `NavSections` item rendered via a `<Link>` import that isn't covered by the current `vi.mock("@tanstack/react-router", …)`). `claude-desktop`'s comment #16858 already identifies the likely candidates and suggests two viable fixes (A: add the missing module to the `vi.mock` list; B: global anchor-click backstop in `vitest.setup.tsx`). That comment has not been acted on yet. **The removal of the three `exclude` entries is therefore premature.** `app-shell.test.tsx` is still broken. Until all three re-enabled files are confirmed green in CI, those exclusions should stay in place (or the test should be fixed in this PR). --- ### ⚠️ Minor observations (non-blocking) **1. `vitest.mock-rpc-patch.test.ts` placement risk** The file lives at `apps/web/vitest.mock-rpc-patch.test.ts` (root, not under `src/`). The `exclude` list doesn't cover it. Depending on vitest's `include` glob, the browser runner might pick it up — but it imports `@vitest/mocker` (Node-side API) and is explicitly intended to run under `bun test`. If `include` is `src/**/*.test.{ts,tsx}` it's fine; if it's `**/*.test.{ts,tsx}` it would be wrong. Worth adding a comment or confirming the include pattern guards it. **2. `safeFactory` type cast** ```ts return await (factory as () => unknown | Promise<unknown>)(); ``` `FactoryArg` is `Parameters<FromJSON>[1]` which likely accepts `undefined | (() => …)`. The cast side-steps the nullability. Harmless in practice (we're only called when `factory` is defined) but a `factory?.()` null-guard would be cleaner. --- ### Required before merge 1. **Fix the remaining iframe-navigation failure** in `app-shell.test.tsx > sidebar rail`. Follow the diagnostic in comment #16858 — find the un-stubbed `<Link>` import and either add it to the mock list or add a global anchor-click backstop. The `StubLink` patch alone is not sufficient. 2. **Confirm all three re-enabled test files are green in CI** before removing the exclusions. 3. *(Optional)* Verify or document the `include` scope so `vitest.mock-rpc-patch.test.ts` is clearly not mistakenly picked up by the browser runner. The birpc patch and the `StubLink` fix are both correct and can land — the PR just needs the remaining iframe-navigation path closed out.
fix(web): document-level bubble preventDefault for un-stubbed internal links
All checks were successful
qa / sql-layer-check (pull_request) Successful in 13s
qa / dockerfile (pull_request) Successful in 16s
qa / db-schema (pull_request) Successful in 17s
qa / i18n-string-check (pull_request) Successful in 19s
qa / qa-1 (pull_request) Successful in 4m5s
qa / qa (pull_request) Successful in 0s
a2cb147de7
The `StubLink` preventDefault path from `fcf7d39` does cancel React's
synthetic event, but in vitest browser-mode + Playwright + React 19 that
does not propagate to the native click reliably — the iframe still
navigates to `/board` after `.click()` on `nav-board`, severing the
orchestrator's session URL and surfacing as:

  Error: Cannot connect to the iframe. Received URL: …/board

Reproduced locally with CHROMIUM_PATH=/usr/bin/chromium on the PR head:
`tapping a nav link closes the drawer` test passes its assertion but the
post-test iframe state breaks the next file's run. Run on
`src/components/app-shell.test.tsx` alone: `Tests 8 passed (8)` + 1
unhandled error in teardown.

Document-level **bubble** listener in `vitest.setup.tsx` catches every
internal-link click (`href` starts with `/`) and preventDefaults before
the browser commits to navigation. Bubble (not capture) so element-level
`addEventListener` listeners on the anchor still observe
`defaultPrevented === false` — `avatar-menu.test.tsx` asserts that
contract (`click does not prevent default navigation`).

Verified locally: full apps/web suite green — 97 files / 1066 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
reviewer approved these changes 2026-05-11 09:47:36 +00:00
reviewer left a comment

Review — fix(web): swallow vitest browser-mode birpc-teardown race

CI green on a2cb147. Three previously-excluded test files restored. Approved.


Overview

Two independent root causes were identified and fixed:

  1. birpc teardown race@vitest/browser@4.1.5's post-mocker factory branch lacks the try/catch that the pre-mocker branch already has. A Playwright RouteHandler tied to the page outlives the runner's birpc socket, causing a post-teardown rejection that fails the run even when every assertion passed.
  2. Iframe navigation severs the test session — Clicking a stubbed <Link> (or any un-stubbed <a href="/">) was navigating the Playwright iframe to the link's href, killing the orchestrator's iframe channel mid-suite.

Both are now addressed with a targeted shim + a document-level backstop. The diagnosis in the PR body and code comments is thorough and accurate.


vitest.mock-rpc-patch.ts — core patch

  • Idempotent guard via __chBirpcSafe marker prevents double-patching across repeated config loads. ✓
  • original.bind(ManualMockedModule) correctly preserves the static method's receiver before wrapping. ✓
  • Non-RPC errors are re-thrown unchanged — real factory failures are never swallowed. ✓
  • @vitest/mocker is deduped to a single physical copy, so one Node-side wrap covers every factory registered later. ✓
  • The removal comment with an upstream tracker reference is exactly right for a temporary shim. ✓

Minor observation: message.includes("rpc is closed") is a string-match on an internal birpc message. If birpc changes the text in a future version the shim silently becomes a no-op (the error would resurface). Not blocking — it's the right pragmatic choice — but worth adding the exact birpc version in the comment (it's already implied by vitest 4.1.5 + @vitest/browser-playwright 4.1.5 in the file header, which is sufficient).


vitest.mock-rpc-patch.test.ts — test placement

Placing the test at apps/web/vitest.mock-rpc-patch.test.ts (not under src/) is intentional and correct: the include: ["src/**/*.{test,spec}.{ts,tsx}"] pattern in vitest.config.ts keeps it out of the browser runner. It imports Node-only @vitest/mocker and would fail in Chromium. Running it via bun test is the right call and the PR description is clear about this. The four test branches (marker, rpc-closed → {}, non-rpc error → rethrown with .cause, happy-path passthrough) give complete coverage of the shim's logic.


test-router-stub.tsx

  • callerOnClick?.(e) fires first so caller-provided handlers see defaultPrevented === false before the stub's own preventDefault. ✓
  • if (!e.defaultPrevented) e.preventDefault() — won't double-prevent if the caller already did. ✓
  • React 17+ delegates events to the root container, so native element.addEventListener listeners fire before React's synthetic handler. Element-level listeners (avatar-menu contract) continue to observe defaultPrevented === false. ✓

vitest.setup.tsx — document-level backstop

  • Guards with typeof document !== "undefined" for Node contexts. ✓
  • target?.closest("a[href]") handles clicks on child elements inside an anchor. ✓
  • href?.startsWith("/") — correct scope; hash-only #foo links are left alone (they don't navigate the origin). ✓
  • Bubble phase: element-level listeners fire at target phase before this document listener, preserving the defaultPrevented === false contract. ✓
  • if (ev.defaultPrevented) return — cooperates with StubLink.handleClick; if React already prevented it, the backstop is a no-op. ✓

This is a good defence-in-depth layer for any un-stubbed anchor that slips through vi.mock.


package.json / bun.lock

Pinning @vitest/mocker to exact 4.1.5 (no ^) is correct — the patch is written against this specific version's class shape and should not silently upgrade. Already a transitive dep; this just makes TS resolve it cleanly.


Acceptance criteria (closes #1080)

  • [birpc] rpc is closed rejection no longer fails the run
  • app-shell.test.tsx, nav-sections.test.tsx, sidebar-nav.test.tsx re-enabled
  • Patch unit tests cover all branches (4 pass)
  • CI green
  • Watch next 5 apps/web PRs to confirm flake is gone (ongoing, not a merge blocker)
## Review — `fix(web): swallow vitest browser-mode birpc-teardown race` CI ✅ green on `a2cb147`. Three previously-excluded test files restored. **Approved.** --- ### Overview Two independent root causes were identified and fixed: 1. **birpc teardown race** — `@vitest/browser@4.1.5`'s post-mocker factory branch lacks the try/catch that the pre-mocker branch already has. A Playwright `RouteHandler` tied to the page outlives the runner's birpc socket, causing a post-teardown rejection that fails the run even when every assertion passed. 2. **Iframe navigation severs the test session** — Clicking a stubbed `<Link>` (or any un-stubbed `<a href="/">`) was navigating the Playwright iframe to the link's `href`, killing the orchestrator's iframe channel mid-suite. Both are now addressed with a targeted shim + a document-level backstop. The diagnosis in the PR body and code comments is thorough and accurate. --- ### `vitest.mock-rpc-patch.ts` — core patch - Idempotent guard via `__chBirpcSafe` marker prevents double-patching across repeated config loads. ✓ - `original.bind(ManualMockedModule)` correctly preserves the static method's receiver before wrapping. ✓ - Non-RPC errors are re-thrown unchanged — real factory failures are never swallowed. ✓ - `@vitest/mocker` is deduped to a single physical copy, so one Node-side wrap covers every factory registered later. ✓ - The removal comment with an upstream tracker reference is exactly right for a temporary shim. ✓ **Minor observation:** `message.includes("rpc is closed")` is a string-match on an internal birpc message. If birpc changes the text in a future version the shim silently becomes a no-op (the error would resurface). Not blocking — it's the right pragmatic choice — but worth adding the exact birpc version in the comment (it's already implied by `vitest 4.1.5 + @vitest/browser-playwright 4.1.5` in the file header, which is sufficient). --- ### `vitest.mock-rpc-patch.test.ts` — test placement Placing the test at `apps/web/vitest.mock-rpc-patch.test.ts` (not under `src/`) is intentional and correct: the `include: ["src/**/*.{test,spec}.{ts,tsx}"]` pattern in `vitest.config.ts` keeps it out of the browser runner. It imports Node-only `@vitest/mocker` and would fail in Chromium. Running it via `bun test` is the right call and the PR description is clear about this. The four test branches (marker, rpc-closed → `{}`, non-rpc error → rethrown with `.cause`, happy-path passthrough) give complete coverage of the shim's logic. --- ### `test-router-stub.tsx` - `callerOnClick?.(e)` fires first so caller-provided handlers see `defaultPrevented === false` before the stub's own `preventDefault`. ✓ - `if (!e.defaultPrevented) e.preventDefault()` — won't double-prevent if the caller already did. ✓ - React 17+ delegates events to the root container, so native `element.addEventListener` listeners fire before React's synthetic handler. Element-level listeners (avatar-menu contract) continue to observe `defaultPrevented === false`. ✓ --- ### `vitest.setup.tsx` — document-level backstop - Guards with `typeof document !== "undefined"` for Node contexts. ✓ - `target?.closest("a[href]")` handles clicks on child elements inside an anchor. ✓ - `href?.startsWith("/")` — correct scope; hash-only `#foo` links are left alone (they don't navigate the origin). ✓ - Bubble phase: element-level listeners fire at target phase before this document listener, preserving the `defaultPrevented === false` contract. ✓ - `if (ev.defaultPrevented) return` — cooperates with `StubLink.handleClick`; if React already prevented it, the backstop is a no-op. ✓ This is a good defence-in-depth layer for any un-stubbed anchor that slips through `vi.mock`. --- ### `package.json` / `bun.lock` Pinning `@vitest/mocker` to exact `4.1.5` (no `^`) is correct — the patch is written against this specific version's class shape and should not silently upgrade. Already a transitive dep; this just makes TS resolve it cleanly. --- ### Acceptance criteria (closes #1080) - [x] `[birpc] rpc is closed` rejection no longer fails the run - [x] `app-shell.test.tsx`, `nav-sections.test.tsx`, `sidebar-nav.test.tsx` re-enabled - [x] Patch unit tests cover all branches (4 pass) - [x] CI green - [ ] Watch next 5 `apps/web` PRs to confirm flake is gone (ongoing, not a merge blocker)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
4 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!1091
No description provided.