# Task 2014 design — cut the jsdom the `platform/ui` suite does not use

Date: 2026-07-26
Task: `.tasks/pending/2014-platform-ui-suite-is-nondeterministic-under-parallel-load.md`
Supersedes brief: 1765, folded into 2014 on 2026-07-26

## The measurement this design rests on

All figures from the dev Mac, Node 22.22.0, vitest 4.1.2, worktree
`task-2014-ui-suite-nondeterminism`, 2026-07-26.

### Most apparent worktree flakiness is missing build output, not flakiness

`platform/node_modules` and `platform/lib/*/dist` are gitignored build output. A
fresh worktree has neither. Four full-suite runs, each after adding one more
piece of provisioning:

| Run | Worktree state | Failed | Duration |
|---|---|---|---|
| 1 | `platform/ui` deps only | 63 files / 32 tests | 435s |
| 2 | + 3 libs built | 5 files / 8 tests | 180s |
| 3 | + `platform` deps | 5 files / 3 tests | 111s |
| 4 | + all 24 libs built | 0 | 143s |

Runs 1 and 2 failed on `Cannot find module .../dist/index.js` and
`Failed to resolve import "neo4j-driver"`. Those are absent artifacts, not
timing. Only run 3's three failures are the defect this task describes, and all
three are in the brief's population: both `/login/start` sub-account cases in
`whatsapp-self-or-house-scope.test.ts` at 5013ms and 515ms, and
`whatsapp-conversation-graph-state-projection.test.ts` "sub-account all-senders
mode returns only its bound thread" at 5017ms. 5000ms is the vitest default
`testTimeout`.

### Environment setup costs six times what the tests cost

Run 4, fully green:

```
tests        267s
environment 1571s
import       257s
setup        196s
transform     42s
```

`vitest.config.ts` sets `environment: 'jsdom'` globally. 530 test files pay it.

### Roughly half the suite boots a DOM it never touches

266 `.test.ts` files do not declare `@vitest-environment node`. Both runs
back to back, same machine load:

| | Duration | environment CPU |
|---|---|---|
| as-is (jsdom) | 103.0s | 1317.6s |
| `--environment node` | 30.9s | 0.074s |

Three consecutive node-environment passes over those 266 files agreed on 17
`.ts` files that genuinely need a DOM. A file that appeared in only one pass
(`route-cache-watchers.test.ts`) is the known flake, not a DOM dependency. A
`.tsx` file in the failure list arrived because vitest CLI arguments are
substring filters rather than exact paths.

So the suite's problem is not 4757 tests. The tests are 267s of CPU. It is that
about half the files run a browser DOM environment for nothing, and that is the
CPU starving the workers the named tests race against.

### Machine context

The dev Mac ran these measurements at load average 100 to 350 with 60 `claude`
processes across six peer worktree sessions. Self-contention from 20 vitest
forks is not the only contender for CPU, and any verification has to record the
load it ran under or a red run cannot be attributed.

## Design

### 1. Route the test environment by file extension

`platform/ui/vitest.config.ts` replaces its single global `environment: 'jsdom'`
with two projects:

- `dom`: `**/*.{test,spec}.?(c|m)[jt]sx`, environment `jsdom`.
- `node`: `**/*.{test,spec}.?(c|m)[jt]s` plus `../scripts/__tests__/**`,
  environment `node`.

The existing `resolve.dedupe`, `setupFiles`, `globals`, and `exclude` settings
carry into both projects unchanged.

The rule is structural, not a maintained list: a test that renders JSX gets a
DOM, and anything else opts in explicitly. No `.tsx` file is edited.

### 2. Seventeen `.ts` files opt back into jsdom

Each gets `// @vitest-environment jsdom` with a short reason. Verified on vitest
4.1.2 that a per-file docblock overrides a project's `environment` setting.

```
app/__tests__/spinning-class-global.test.ts
app/__tests__/wa-viewer-width.test.ts
app/graph/__tests__/side-panel-helpers.test.ts
app/hooks/__tests__/useCopyFeedback.test.ts
app/hooks/__tests__/useMediaQuery.test.ts
app/hooks/__tests__/useVoiceRecorder.deviceId.test.ts
app/hooks/__tests__/useVoiceRecorder.mic-result.test.ts
app/lib/__tests__/admin-auth-login-remote-auth.test.ts
app/lib/__tests__/switch-account-scope.test.ts
app/lib/__tests__/use-admin-auth-claude-connected.test.ts
app/lib/__tests__/use-admin-auth-heartbeat-remote-auth.test.ts
app/lib/__tests__/useAdminFetch.test.ts
app/lib/__tests__/useSubAccountSwitcher.test.ts
app/public/__tests__/extract-seed-question.test.ts
app/whatsapp/__tests__/useTranscriptStream-activity.test.ts
app/whatsapp/__tests__/useTranscriptStream-endpoint.test.ts
app/whatsapp/__tests__/useTranscriptStream.test.ts
```

A `.ts` test that needs a DOM and lacks the docblock fails loudly on
`document is not defined`. It does not pass silently.

### 3. A prebuild gate so the waste cannot creep back

New `platform/ui/scripts/check-jsdom-budget.mjs`, joining the 24 checks already
in the `prebuild` script. It counts the files that will boot jsdom, which is
every `.test.tsx`/`.spec.tsx` plus every `.ts` test declaring the jsdom
docblock, and fails the build when that count exceeds a ceiling written in the
script. Adding a jsdom file becomes a deliberate one-line bump with a reason,
not an accident. The failure message names the files above the ceiling and the
CPU cost per jsdom file measured here, so a reader knows what the number buys.

### 4. The 141 existing `@vitest-environment node` docblocks stay

They remain accurate and are now redundant. Removing them is 141 files of churn
for no behaviour change.

## What this does not claim

Removing the environment waste reduces the contention the named tests race
against. It is not proven, before measurement, that the ten named tests stop
failing. If group A's long-press tests still race React Testing Library's
1000ms `findBy` default once contention drops, that is a second and separate
lever, and it gets measured and brought back rather than stacked on top as a
speculative second fix.

## Verification

**Group A and the suite.** Ten consecutive `npm test` runs in `platform/ui` on
the merged branch, `uptime` captured before and after each run, exit code and
any `×` lines collected to one file. Success is ten `exit=0` and zero `×`
matches across the collection, reported alongside the load each ran under. A red
run with its load recorded is attributable; a red run without is not.

**Group B.** Ten runs do not test a one-in-twenty failure rate. Instead measure
the wall-clock cost of the four group B seed calls under full-suite parallel
load, before and after the change, and state the headroom inside the 5000ms
default. The measurement is the evidence, not the run count.

**The jsdom cut itself.** The suite must still report 530 files and 4757 tests.
A file silently dropping out of the run because a glob missed it would look like
a pass.

## Out of scope

- The `platform/lib` build artifacts that made runs 1 to 3 look flaky. That is
  worktree provisioning friction and gets its own task file.
- A blanket `testTimeout` bump, excluded by the brief.
- Deleting, skipping, or loosening the four group B tests, excluded by the
  brief: they are the only place the cross-account 403 ordering is asserted.
- Removing the 141 redundant node docblocks.
- Any product change to the whatsapp routes, the data browser, or graph-write.
