From 170f5c089a1dac6449097f53064346689ee78e1b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 18:58:32 -0700 Subject: [PATCH 01/12] docs: parallel test runs design (issue #261) --- .../specs/2026-09-29-parallel-tests-design.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-29-parallel-tests-design.md diff --git a/docs/superpowers/specs/2026-09-29-parallel-tests-design.md b/docs/superpowers/specs/2026-09-29-parallel-tests-design.md new file mode 100644 index 000000000..4fbf12bb9 --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-parallel-tests-design.md @@ -0,0 +1,92 @@ +# Parallel test runs (issue #261) + +Date: 2026-09-29. Branch: `feat/parallel-tests`. + +## Problem and evidence + +`bun run test` is `turbo run test`: parallel *across* the 52 packages, but +inside each package `bun test` runs its files serially. 763 test files; +`apps/server/api` (270 files, real SQLite, real tmux) is the long pole. +Measured on a 48-core dev machine, serial vs. `bun test --parallel`: + +| Package | serial | parallel | parallel failures | +|---|---|---|---| +| apps/server/api | 115.6 s | 47.9 s (N=8) | 56 | +| apps/server/web | 58.8 s | 11.3 s (N=12) | 0 | +| packages/pane-runtime | 18.7 s | 13.9 s (N=12) | 0 | +| apps/node/agent | 13.6 s | 6.0 s (N=12) | 0 | + +CI (`.github/workflows/test.yml`) runs on GitHub-hosted 4-core +`ubuntu-24.04` runners, sharded by package group. + +## Mechanism + +Per-package `bunfig.toml` carries `parallel = N`. Bun spawns N workers, +distributes files, and isolates each file's module registry +(`--isolate`, implied). Worker count is capped by file count, so small +packages self-limit. Proposed N: **12** for `server/api`, `server/web`, +`node/agent`; **4** elsewhere. Tunable after first green. + +`bunfig.toml` (not a script flag) because the flag must reach every +invocation — `bun run test`, `turbo test`, and a bare hand-typed +`bun test` — the same rationale each package's `preload` already lives +there for. + +## Measured runner semantics (Bun 1.4.0, probed) + +- A preload-registered `afterAll` fires **once per run in serial** but + **after every file under `--parallel`**. +- Serial mode shares one module cache across files; parallel mode gives + each file a fresh registry. Both are load-bearing for how today's + suites pass and must stay pinned by what we learn here. +- Post-startup `process.env` writes do NOT reach children spawned via + `bun:spawn` without an explicit `env` (a preload cannot re-home + `TMUX_TMPDIR` for children). + +## Hazards fixed before the flag lands (PR 1) + +1. **The tmux sweep** (`server/api` preload): kills every tmux server + under the run's shared `TMUX_TMPDIR`. Under parallel it would fire + per file and kill sibling workers' live panes. Fix: move kill-all + + temp-dir removal into the package's test script (a small shell + script that owns the directory, runs after every worker exits, and + preserves the exit code). The preload keeps its per-PID DB sweep, + which is parallel-safe as measured. + +2. **Implicit migration ordering** (`server/api`): 14 files query tables + without running migrations, surviving serially because files that run + earlier migrated the process-wide shared DB. Fresh per-file DBs under + parallel expose it as `no such table` (56 failures). Fix: an explicit + `ensureTestDatabase()` helper (Kysely + better-auth migrations) called + in each DB-touching suite's `beforeAll`. Idempotent, so serial stays + green. + +3. **A subprocess/port test** (`bootstrap argv`): triage individually in + the same PR (reproducer-first). + +4. **Ordering shuffle**: parallel changes which files share a worker. + Latent env-mutation leaks surface as flakes; the bake below is the + control, not a static audit of 763 files. + +## CI (PR 2) + +`test-server-node` becomes a 2-way `--shard` matrix (turbo arg passthrough +appends `--shard=1/2` to each filtered package's run; each job runs one +half). One committed per-file durations JSON (bun's `--timings`) lets bun +balance the shards and start the slowest files first; a root +`test:timings` script refreshes it locally. Other shards get re-measured +after PR 1; split another job only if a real long pole remains. + +## Verification + +- Per-cluster reproducers before fixes (two-file parallel runs). +- PR 1 gate: full `bun run test` three consecutive green runs locally at + the chosen N, plus one serial run (`--parallel=1`) to prove the sweep + move didn't break serial semantics. +- Flake policy: any package that fails the bake gets its `N` set back to + `1` and rejoins later. + +## Non-goals + +`--no-isolate`, Playwright e2e, `test:cli`, cargo tests, turbo config +changes, CI runner upgrades. From 88eaed2909e0250fa7ac18de1aff935018c05e5a Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 19:00:31 -0700 Subject: [PATCH 02/12] docs: parallel test runs implementation plan --- .../plans/2026-09-29-parallel-tests.md | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-parallel-tests.md diff --git a/docs/superpowers/plans/2026-09-29-parallel-tests.md b/docs/superpowers/plans/2026-09-29-parallel-tests.md new file mode 100644 index 000000000..3fce19696 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-parallel-tests.md @@ -0,0 +1,402 @@ +# Parallel Test Runs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or extra:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `bun test` run files in parallel (issue #261): fix the hazards that break under `--parallel`, then set `parallel = N` in every package's `bunfig.toml`, then shard CI's longest test job. + +**Architecture:** Bun 1.4.0's `bun test --parallel=N` runs each test file in a worker process with a **fresh module registry per file** (measured; serial mode shares one cache across files). A preload-registered `afterAll` fires once per run serially but **once per file** under parallel (measured). Two existing test-infrastructure behaviors rely on the serial facts and must be fixed first: the `server/api` tmux sweep (kills sibling workers' panes) and 14 suites that reach SQLite tables without ever running migrations (they ride another file's boot). + +**Tech Stack:** Bun 1.4.0 (`bun test`, `bunfig.toml [test]`), Kysely + bun:sqlite, better-auth, turbo 2.x, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-09-29-parallel-tests-design.md` + +## Global Constraints + +- Never use `await import()` (`.claude/rules/code-style.md` "No Dynamic Imports"). +- Package manager is Bun only; scripts run via `bun run` / `bunx`. +- No em dashes (U+2014) in authored prose or shipped strings; `bun run lint:prose` gates it. +- Comments in this repo carry the "why" and cite measurements; match the density of the files being edited. +- This machine's shell exports `SHELLOPTS=...onecmd:posix`, which makes bash-spawning tests run zero commands (known trap). ALWAYS run verification as `env -u SHELLOPTS bun ...` and suspect this artifact on any `bootstrap argv` / tmux-install style failure before believing it. +- `bun test` silently skips nonexistent paths: check the "Ran N tests across M files" line. +- No changeset: this changes no shipped code (test infra + CI only). +- Focused verification while iterating (`.claude/rules/verification.md`); the full trio at boundaries. + +--- + +## PR 1: make the suites parallel-safe, then flip every package + +### Task 1: `ensureMigratedTestDb()` helper, wired into the six implicit-boot files + +Under `--parallel`, every file gets a fresh `@/db/index.js` singleton → a fresh per-file DB file (`subshell-test--.db` from `constants.ts`), and files that query without migrating fail with `no such table`. Fix each file's own dependency: run both migration runners (Kysely app tables + better-auth tables) in `beforeAll`. Idempotent by design (both runners track applied state in the DB itself), so serial runs are unaffected. + +**Files:** +- Create: `apps/server/api/src/__tests__/helpers/test-database.ts` +- Modify: `apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts` +- Modify: `apps/server/api/src/lib/__tests__/context.test.ts` +- Modify: `apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts` (beforeAll at ~:107) +- Modify: `apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts` (beforeAll at ~:81) +- Modify: `apps/server/api/src/services/__tests__/subshell-manager.service.test.ts` (beforeAll at ~:100) +- Modify: `apps/server/api/src/auth/__tests__/provider-policy-service.test.ts` (beforeAll at ~:69, replace bare `runMigrations()`) + +**Interfaces:** +- Produces: `export async function ensureMigratedTestDb(): Promise` importable as `import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"` (precedent: `@/__tests__/helpers/true-binary.js`). + +- [ ] **Step 1: Write the failing repro** + +Run from `apps/server/api/`: + +```bash +env -u SHELLOPTS bun test src/db/repositories/__tests__/channels.repository.test.ts src/db/repositories/__tests__/identities.repository.test.ts --parallel=2 --timeout 30000 +``` + +Expected: FAIL with `SQLiteError: no such table: channel_cursors` (already reproduced on 2026-09-29). + +- [ ] **Step 2: Create the helper** + +`apps/server/api/src/__tests__/helpers/test-database.ts`: + +```ts +import { runAuthMigrations } from "@/db/auth-migrations.js"; +import { runMigrations } from "@/db/migrate.js"; + +/** + * Ensures the test database carries both the app's tables and better-auth's, + * via the same runners the server calls at boot. Both are idempotent (each + * tracks applied state inside the database itself), so this is a full build + * on a pristine file and a fast no-op afterwards. + * + * Every suite that touches `db` - directly or through a service - must call + * this in `beforeAll`. Until issue #261 the rule was implicit: serial `bun + * test` shares one module cache across files, so one early file's migrations + * served every later file in the process. `--parallel` isolates each file + * (fresh `db` singleton, fresh database file), and the implicit-boot suites + * surfaced it as `no such table` - 14 suites, measured 2026-09-29. + */ +export async function ensureMigratedTestDb(): Promise { + await runMigrations(); + await runAuthMigrations(); +} +``` + +- [ ] **Step 3: Wire the six files** + +For `channels.repository.test.ts`: add `beforeAll` to the `bun:test` import, import the helper (step 2's import line), and add at top level after the imports: + +```ts +beforeAll(ensureMigratedTestDb); +``` + +Same for `context.test.ts`. + +For `subshell-manager-mcp.test.ts`, `subshell-manager-titling.test.ts`, `subshell-manager.service.test.ts`: import the helper and make the top line of each file's top-level `beforeAll(async () => { ... })` body: + +```ts + await ensureMigratedTestDb(); +``` + +(Each of these suites also builds its own scratch database or stubs; the call fixes only the app-singleton `db` their service code reaches for the allowlist/token tables. Do not touch their private migrators.) + +For `provider-policy-service.test.ts`: replace `await runMigrations();` with `await ensureMigratedTestDb();` inside its `beforeAll` (~:70), and drop the now-unused `runMigrations` import if nothing else in the file uses it. + +- [ ] **Step 4: Run the repro and the six files** + +```bash +env -u SHELLOPTS bun test src/db/repositories/__tests__/channels.repository.test.ts src/db/repositories/__tests__/identities.repository.test.ts --parallel=2 --timeout 30000 +env -u SHELLOPTS bun test src/auth/__tests__/provider-policy-service.test.ts src/lib/__tests__/context.test.ts src/services/__tests__/subshell-manager-mcp.test.ts src/services/__tests__/subshell-manager-titling.test.ts src/services/__tests__/subshell-manager.service.test.ts --parallel=5 --timeout 30000 +``` + +Expected: 0 fail. (The earlier 5-file load repro: `env -u SHELLOPTS bun test src/commands/__tests__/tmux-install.test.ts src/services/__tests__/subshell-manager-mcp.test.ts src/api/__tests__/files-route.test.ts src/api/__tests__/uploads-route.test.ts src/db/repositories/__tests__/prompts.repository.test.ts --parallel=5 --timeout 30000` must also be 0 fail.) + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/api/src/__tests__/helpers/test-database.ts apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts apps/server/api/src/lib/__tests__/context.test.ts apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts apps/server/api/src/services/__tests__/subshell-manager.service.test.ts apps/server/api/src/auth/__tests__/provider-policy-service.test.ts +git commit -m "test(server): migrate the DB per file so parallel runs don't rely on file order" +``` + +### Task 2: move the tmux sweep from the preload to the test script + +The preload's `afterAll` tmux net kills every server under the shared `TMUX_TMPDIR`. Serial it fires once at the end (safe); under `--parallel` it fires after EVERY file in every worker, killing sibling workers' live panes. The script that creates the temp dir already outlives all workers; it owns the kill. + +**Files:** +- Create: `apps/server/api/scripts/test-run.sh` +- Modify: `apps/server/api/package.json` (`test` script) +- Modify: `apps/server/api/src/test-preload.ts` (remove the tmux `afterAll` block and its comment; rewrite surrounding comments) + +**Interfaces:** +- Produces: `bun run test` / `turbo run test` for `@internal/server` keeps the same guarantees (throwaway tmux namespace per run, everything killed at exit) with no per-file side effects. +- Consumes: nothing from Task 1. + +- [ ] **Step 1: Create the script** + +`apps/server/api/scripts/test-run.sh`: + +```bash +#!/usr/bin/env bash +# The test runner @internal/server's `test` script wraps. +# +# Owns the run's throwaway tmux namespace: TMUX_TMPDIR is set for the whole +# `bun test` (children inherit the STARTUP env - a preload cannot re-home it +# later, measured on bun 1.4.2), so tmux resolves `-L ` under it and +# "which servers did this run start" is a directory listing. This file existed +# inside `src/test-preload.ts`'s afterAll until issue #261: under +# `bun test --parallel` a preload afterAll fires after EVERY file (measured +# 1.4.0), and a sweep scoped by directory would kill the panes of sibling +# workers still mid-suite. The script runs after every worker has exited, +# serial or parallel. +# +# The DB-file sweep stays in the preload: it is scoped by this process's PID +# prefix, so a per-file firing is exactly its right moment there. +set -u +dir="$(mktemp -d /tmp/subshell-test-tmux-XXXXXX)" +status=0 +TMUX_TMPDIR="$dir" bun test --timeout 30000 src || status=$? +if command -v tmux >/dev/null 2>&1; then + for sock in "$dir"/tmux-*/*; do + [ -e "$sock" ] || continue + tmux -L "${sock##*/}" kill-server 2>/dev/null + done +fi +rm -rf "$dir" +exit "$status" +``` + +`chmod +x apps/server/api/scripts/test-run.sh`. + +- [ ] **Step 2: Point the package script at it** + +In `apps/server/api/package.json`, replace the `test` value + +``` +"test": "TMUX_TMPDIR=$(mktemp -d /tmp/subshell-test-tmux-XXXXXX) bun test --timeout 30000 src", +``` + +with + +``` +"test": "bash scripts/test-run.sh", +``` + +- [ ] **Step 3: Remove the tmux sweep from the preload** + +In `apps/server/api/src/test-preload.ts`, delete the second doc comment (the "Kills every tmux server this run started" block) and its `afterAll`. Keep `process.env.SUBSHELL_TEST_MODE = "1"`, the tmux-related `spawnSync` import (now unused - drop it), and the DB-cleanup `afterAll` verbatim. Adjust the file header comment's one sentence that references the tmux net so it says the sweep now lives in `scripts/test-run.sh` and WHY (per-file firing under `--parallel`). + +- [ ] **Step 4: Verify serial and parallel** + +```bash +env -u SHELLOPTS bun run test 2>&1 | tail -3 # serial today (bunfig has no parallel yet): green +env -u SHELLOPTS TMUX_TMPDIR=$(mktemp -d /tmp/subshell-test-tmux-XXXXXX) bun test --parallel=8 --timeout 30000 src 2>&1 | tail -3 # launch suites green under parallel +ls -d /tmp/subshell-test-tmux-* 2>/dev/null # after `bun run test`: nothing left behind +``` + +Expected: `0 fail` both times; temp dirs cleaned. (The middle command keeps the OLD sweep for now but no file kills another file's server here because the failing cluster is fixed - if a leftover dir appears from the manual run, kill it by hand.) + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/api/scripts/test-run.sh apps/server/api/package.json apps/server/api/src/test-preload.ts +git commit -m "test(server): move the tmux kill-all sweep to the run script (parallel-safe)" +``` + +### Task 3: flip `parallel` in every package's `bunfig.toml` + +`bunfig.toml` over a script flag because it reaches `bun run test`, `turbo test`, AND a bare hand-typed `bun test` - the same reason the preloads live there. `--parallel` implies `--isolate` (fresh module registry per file), which Task 1's suites now assume. + +**Files (modify existing bunfig; create where absent):** +- `parallel = 12`: `apps/server/api/bunfig.toml`, `apps/server/web/bunfig.toml`, `apps/node/agent/bunfig.toml` +- `parallel = 4` (create a bare file if none exists): `packages/pane-runtime/`, `apps/node/web/`, `apps/website/`, `packages/assistant/`, `packages/node-admin/`, `apps/client/desktop/` AND `apps/client/desktop/ui/`, `apps/server/desktop/` AND `apps/server/desktop/ui/`, `apps/client/mobile/`, `apps/docs/`, `packages/backend-errors/`, `packages/mcp-core/`, `packages/plugin-api/`, `packages/subshell-protocol/`, every `packages/plugins//` (10 of them) + +**Interfaces:** +- Consumes: Tasks 1-2 (server/api is only safe after both). +- Produces: `bun run test` wall time roughly: api ~12 s, web ~12 s, agent ~6 s, the rest sub-second each; CI jobs inherit it unchanged. + +- [ ] **Step 1: Edit the three big packages** + +Append to each existing file (keep its current comments; add one line above `parallel` per file): + +```toml +# Files run in N worker processes (bun >= 1.2). N is measured, not core count: +# turbo runs packages concurrently, so core-count-per-package oversubscribes. +parallel = 12 +``` + +- [ ] **Step 2: Create/extend the rest at `parallel = 4`** + +For packages that already have a `[test]` section, add `parallel = 4` under it with a one-line comment. For a package with no bunfig, create: + +```toml +# Issue #261: run this package's test files in parallel worker processes. +# 4: worker count is capped by file count anyway, and turbo runs packages +# alongside this one - CI runners have 4 cores. +[test] +parallel = 4 +``` + +Skip any package whose `test` script does not invoke `bun test` (verify: `apps/docs` runs `bun test`, so it is included; `test:cli`/e2e/playwright are separate scripts and untouched). + +- [ ] **Step 3: Verify per-package counts and result** + +```bash +env -u SHELLOPTS bun run --cwd apps/server/api test 2>&1 | tail -3 +env -u SHELLOPTS bun run --cwd apps/server/web test 2>&1 | tail -3 +env -u SHELLOPTS bun run --cwd apps/node/agent test 2>&1 | tail -3 +env -u SHELLOPTS bun run --cwd packages/pane-runtime test 2>&1 | tail -3 +``` + +Expected: each `0 fail`, and the file counts match the serial baselines (270 / 260 / 48 / 28). Any package that flakes goes to `parallel = 1` with a comment naming the flake and rejoins after a fix (spec's flake policy). + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "test: run every package's bun test in parallel workers (issue #261)" +``` + +### Task 4: bake - three full suites and one serial gate + +**Files:** none (verification task); touch only if the bake forces `parallel = 1` reversions. + +- [ ] **Step 1: Full parallel suite, three consecutive runs** + +```bash +for i in 1 2 3; do env -u SHELLOPTS bun run test 2>&1 | tail -1 || echo "RUN $i FAILED"; done +``` + +Expected: three `... Failed: N`-free lines (turbo prints `Failed:` per failing task; expect zero across all packages). A failure here is a finding: reproduce the package alone under `--parallel`, fix at the root (Task 1 pattern or file-local leak of `process.env` across files sharing a worker), re-bake. Do not paper over with retries. + +- [ ] **Step 2: Serial gate - the sweep move must not have broken serial semantics** + +```bash +env -u SHELLOPTS bunx turbo run test --continue --filter=@internal/server -- --parallel=1 +env -u SHELLOPTS bun run test:scripts +``` + +Expected: green. (`--parallel=1` on the task command proves the script/preload pair still works one-file-at-a-time.) + +- [ ] **Step 3: Full static verification boundary** + +```bash +env -u SHELLOPTS bun run verify-types && bun run lint:check && bun run lint:prose +``` + +Expected: all three clean (the preload edit touched typed files). + +- [ ] **Step 4: Update the docs that describe the old behavior** + +- `.claude/rules/testing.md`: add 2-3 lines under "Commands": every package's `bunfig.toml` sets `parallel`; files get a fresh module registry per file (no cross-file boot); suites must migrate their DB via `@/__tests__/helpers/test-database.js`. +- Check `AGENTS.md` files and `docs/` for claims of serial test behavior (`grep -rn "serial" AGENTS.md apps/server/api/AGENTS.md docs -i | grep -i test`) and correct any that the parallel flip invalidates. + +- [ ] **Step 5: Commit and open the PR** + +```bash +git add -A && git commit -m "docs(test): record parallel run semantics in the testing rules" +git push -u origin feat/parallel-tests +gh pr create --title "Run bun test files in parallel (fixes #261)" --body "Moves the tmux sweep to the run script, makes every DB-touching server suite migrate its own DB in beforeAll (parallel isolates each file - the old per-process boot sharing is gone), then sets parallel in every package's bunfig.toml. Baselines, measurements and the design: docs/superpowers/specs/2026-09-29-parallel-tests-design.md. No changeset: test infra only, no shipped code changes." +``` + +Watch CI to green (`gh run watch`; per memory, `gh pr merge --auto` merges immediately here - merge by hand after green). + +--- + +## PR 2: CI shard + committed timings + +### Task 5: split `test-server-node` into two `--shard` jobs + +Bun's `--shard=a/b` runs one subset of files; `--timings` (committed JSON) lets it balance subsets by measured per-file duration and start the slowest first. Turbo's `--` passthrough appends args to each filtered task, so both shards' halves run per package, and the two halves of all three packages land across the two jobs. + +**Files:** +- Modify: `.github/workflows/test.yml` (the `test-server-node` job, ~:387-467) +- Create: `.bun-test-timings.json` (repo root, committed) +- Create: `scripts/test-timings.sh` + root `package.json` `test:timings` script + +**Interfaces:** +- Consumes: PR 1 (parallel flag lands first; sharding without it would still serialize each half). +- Produces: `test-server-node` job runs as two matrix legs, each ~half the wall time. + +- [ ] **Step 1: Generate the timings file** + +```bash +bash scripts/test-timings.sh +``` + +where `scripts/test-timings.sh` (create, chmod +x) runs the three sharded packages sequentially so no two processes write the JSON at once: + +```bash +#!/usr/bin/env bash +# Regenerates the committed .bun-test-timings.json that CI's --shard balancing +# reads (bun merges durations from every --timings file it is given; only the +# first receives writes, so this runs package by package). +# +# After big test waves, or when a CI shard leg starts finishing much later +# than its sibling. Takes minutes; the full suite, three packages, run +# serially on purpose so the shared file is never written concurrently. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +tfile="$root/.bun-test-timings.json" +cd "$root/apps/server/api" && ./scripts/test-run.sh && \ + bun test --timeout 30000 src --timings="$tfile" --update-timings +cd "$root/apps/node/agent" && bun test --timings="$tfile" --update-timings +cd "$root/apps/node/web" && bun test --pass-with-no-tests --timings="$tfile" --update-timings +echo "wrote $tfile" +``` + +(Running api's tests once for warm-up then measuring is unnecessary; the `--update-timings` pass alone is fine - drop the first `./scripts/test-run.sh &&` if it adds no signal. Expected: a JSON file mapping test-file paths to durations, committed.) + +- [ ] **Step 2: Add the root script** + +In root `package.json` scripts, beside `"test"`: + +```json +"test:timings": "bash scripts/test-timings.sh", +``` + +- [ ] **Step 3: Matrix the CI job** + +In `.github/workflows/test.yml`, change `test-server-node` to: + +```yaml + test-server-node: + name: "Test: server + node (shard ${{ matrix.shard }})" + needs: plan + if: needs.plan.outputs.server-node == 'true' + strategy: + fail-fast: false + matrix: + shard: ["1/2", "2/2"] + runs-on: ubuntu-24.04 +``` + +(everything else in the job unchanged) and the test step to: + +```yaml + - name: Run server and node tests + run: bunx turbo run test --continue --filter=@internal/server --filter=@internal/node -- --shard=${{ matrix.shard }} --timings="$GITHUB_WORKSPACE/.bun-test-timings.json" +``` + +Verify `plan.outputs.server-node` gating and the `Un-root the workspace` step are untouched (they run per leg; chown is idempotent). + +- [ ] **Step 4: Prove the wiring locally** + +```bash +env -u SHELLOPTS bunx turbo run test --continue --filter=@internal/node -- --shard=1/2 --timings="$PWD/.bun-test-timings.json" +env -u SHELLOPTS bunx turbo run test --continue --filter=@internal/node -- --shard=2/2 --timings="$PWD/.bun-test-timings.json" +``` + +Expected: both green, together covering all of `@internal/node`'s files (sum the "across N files" lines = the package's full count). + +- [ ] **Step 5: PR, merge, measure** + +```bash +git add .github/workflows/test.yml .bun-test-timings.json scripts/test-timings.sh package.json +git commit -m "ci: shard the server+node test job across two runners (issue #261)" +git push && gh pr create --title "Shard test-server-node across two runners" --body "Two --shard legs with committed --timings durations so bun balances files by measured time. Follow-up to PR 1 per the parallel-tests spec." +``` + +After merge, record the before/after job duration in the PR (comment). If a leg is still a long pole relative to other jobs, that is a new decision, not this PR's problem. + +--- + +## Self-review notes (author, 2026-09-29) + +- Spec coverage: hazard 1 (sweep) = Task 2; hazard 2 (implicit migrations) = Task 1; ordering-shuffle control = Task 4 bake + flake policy; mechanism = Task 3; CI = Task 5. The `bootstrap argv` failure the spec's section-3 predicted turned out to be the local `SHELLOPTS` artifact (reproduced serially, passes under `env -u SHELLOPTS`) - no task needed; the Global Constraints now name the trap. +- The 14-failing-suite list came from one `--parallel=8` run of `apps/server/api` on 2026-09-29; the bake is the net for any suite this list missed. From 8eaa90e00d9b856e0f9aba8b07445af5afe7847e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 19:02:33 -0700 Subject: [PATCH 03/12] test(server): migrate and seed per file so parallel runs don't ride file order --- .../src/__tests__/helpers/test-database.ts | 20 +++++++++++++++++++ .../__tests__/provider-policy-service.test.ts | 4 ++-- .../__tests__/channels.repository.test.ts | 8 +++++++- .../api/src/lib/__tests__/context.test.ts | 7 ++++++- .../__tests__/subshell-manager-mcp.test.ts | 13 ++++++++++++ .../subshell-manager-titling.test.ts | 6 ++++++ 6 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 apps/server/api/src/__tests__/helpers/test-database.ts diff --git a/apps/server/api/src/__tests__/helpers/test-database.ts b/apps/server/api/src/__tests__/helpers/test-database.ts new file mode 100644 index 000000000..c14a182c4 --- /dev/null +++ b/apps/server/api/src/__tests__/helpers/test-database.ts @@ -0,0 +1,20 @@ +import { runAuthMigrations } from "@/db/auth-migrations.js"; +import { runMigrations } from "@/db/migrate.js"; + +/** + * Ensures the test database carries both the app's tables and better-auth's, + * via the same runners the server calls at boot. Both are idempotent (each + * tracks applied state inside the database itself), so this is a full build + * on a pristine file and a fast no-op afterwards. + * + * Every suite that touches `db` - directly or through a service - must call + * this in `beforeAll`. Until issue #261 the rule was implicit: serial `bun + * test` shares one module cache across files, so one early file's migrations + * served every later file in the process. `--parallel` isolates each file + * (fresh `db` singleton, fresh database file), and the implicit-boot suites + * surfaced it as `no such table` - 14 suites, measured 2026-09-29. + */ +export async function ensureMigratedTestDb(): Promise { + await runMigrations(); + await runAuthMigrations(); +} diff --git a/apps/server/api/src/auth/__tests__/provider-policy-service.test.ts b/apps/server/api/src/auth/__tests__/provider-policy-service.test.ts index a8c8851a4..b85d33980 100644 --- a/apps/server/api/src/auth/__tests__/provider-policy-service.test.ts +++ b/apps/server/api/src/auth/__tests__/provider-policy-service.test.ts @@ -1,9 +1,9 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { sql } from "kysely"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { evaluateProviderPolicy } from "@/auth/provider-policy.js"; import { markApprovalProviderArrival, setAuthPolicyDb } from "@/auth.js"; import { db } from "@/db/index.js"; -import { runMigrations } from "@/db/migrate.js"; import { AuthProvidersRepository } from "@/db/repositories/auth-providers.repository.js"; import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; @@ -67,7 +67,7 @@ async function makeAccount(userId: string, providerId: string): Promise } beforeAll(async () => { - await runMigrations(); + await ensureMigratedTestDb(); // The arrival hook reads the same injected handle production injects. setAuthPolicyDb(db); }); diff --git a/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts b/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts index 6fd5e596f..658c70a0e 100644 --- a/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts +++ b/apps/server/api/src/db/repositories/__tests__/channels.repository.test.ts @@ -1,9 +1,15 @@ -import { beforeEach, describe, expect, it } from "bun:test"; +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { db } from "@/db/index.js"; import { ChannelPostsRepository } from "@/db/repositories/channel-posts.repository.js"; import { ChannelNameTakenError, ChannelsRepository } from "@/db/repositories/channels.repository.js"; import { IdentitiesRepository } from "@/db/repositories/identities.repository.js"; +// Parallel `bun test` isolates each file's modules, so this suite gets its +// own unmigrated database - the tables used to arrive on the strength of +// whichever file happened to run first in the shared serial process. +beforeAll(ensureMigratedTestDb); + /** Clears every channels-feature table (FK-safe order). */ async function wipe(): Promise { await db.deleteFrom("channelCursors").execute(); diff --git a/apps/server/api/src/lib/__tests__/context.test.ts b/apps/server/api/src/lib/__tests__/context.test.ts index b6fc39fee..c11d29f17 100644 --- a/apps/server/api/src/lib/__tests__/context.test.ts +++ b/apps/server/api/src/lib/__tests__/context.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "bun:test"; +import { beforeAll, describe, expect, it } from "bun:test"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { db } from "@/db/index.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; @@ -12,6 +13,10 @@ import { getLogger } from "@/utils/logger.js"; * rebuilding) and arrive fully wired — repos and services included. * The per-process temp-file test DB from test-preload applies automatically. */ +// Parallel `bun test` gives this file its own fresh (unmigrated) database, +// so the tables arrive here by right, not by whoever imported first. +beforeAll(ensureMigratedTestDb); + describe("getRequestlessContext", () => { it("returns the same instance on every call", () => { expect(getRequestlessContext()).toBe(getRequestlessContext()); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts index 6e6109d4e..488ef16c2 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { TmuxRunner } from "@internal/pane-runtime"; import { CamelCasePlugin, Kysely } from "kysely"; import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import * as initMigration from "@/db/migrations/0001-init.js"; import * as operatorUxMigration from "@/db/migrations/0002-operator-ux.js"; import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; @@ -26,6 +27,7 @@ import type { Database } from "@/db/types/index.js"; import type { SubshellTable, SubshellUpdate } from "@/db/types/subshells.db-types.js"; import { seedPreset } from "@/services/__tests__/helpers/seed-preset.js"; import { subshellMcpConfigPath } from "@/services/mcp-launch.js"; +import { prepareLocalPlugins } from "@/services/nodes/local-plugins.js"; import { SubshellManagerService, type SubshellTokenProvider } from "@/services/subshell-manager.service.js"; /** @@ -105,6 +107,17 @@ let presetId: string; let previousClaudePath: string | undefined; beforeAll(async () => { + // The manager reaches past this file's injected db for the launch allowlist + // gate and the auto-restart gate, which read the SHARED `@/db` singleton. + // Parallel `bun test` gives every file its own fresh singleton, so the old + // ride-on-whatever-file-migrated-first arrangement is gone: migrate and + // seed here, explicitly. An unmigrated singleton throws `no such table`; + // a migrated-but-pluginless one makes every harness unusable, the auto- + // restart silently defers, and the respawn-count assertions below read as + // off-by-one. `subshell-manager.service.test.ts` documents the same pairing. + await ensureMigratedTestDb(); + await prepareLocalPlugins(); + const harnessStub = join(testDir, "claude-stub"); writeFileSync(harnessStub, "#!/bin/sh\nexec sleep 300\n", { mode: 0o755 }); previousClaudePath = process.env.CLAUDE_PATH; diff --git a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts index 7b160f7e4..5a5804025 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-titling.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { getHarness, TmuxRunner } from "@internal/pane-runtime"; import { CamelCasePlugin, Kysely } from "kysely"; import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import * as initMigration from "@/db/migrations/0001-init.js"; import * as operatorUxMigration from "@/db/migrations/0002-operator-ux.js"; import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; @@ -79,6 +80,11 @@ let presetId: string; let previousClaudePath: string | undefined; beforeAll(async () => { + // The launch gate reads `node_allowed_dirs` through the SHARED `@/db` + // singleton, which parallel `bun test` hands every file fresh and + // unmigrated. The old serial run only covered this by file order. + await ensureMigratedTestDb(); + // Same hermetic harness trick as the MCP suite: CLAUDE_PATH is the first // thing findBinary() consults, so binary resolution never touches the host. const harnessStub = join(testDir, "claude-stub"); From 06ee00fc0f4faab88dac3548d15dc61ddcc6e392 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 19:05:18 -0700 Subject: [PATCH 04/12] test(server): move the tmux kill-all sweep to the run script (parallel-safe) --- apps/server/api/package.json | 2 +- apps/server/api/scripts/test-run.sh | 27 ++++++++++ apps/server/api/src/test-preload.ts | 83 ++++++----------------------- 3 files changed, 45 insertions(+), 67 deletions(-) create mode 100755 apps/server/api/scripts/test-run.sh diff --git a/apps/server/api/package.json b/apps/server/api/package.json index eabbbf9ef..828b4ebe2 100644 --- a/apps/server/api/package.json +++ b/apps/server/api/package.json @@ -30,7 +30,7 @@ "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", "prod": "bun run ./dist/index.js", - "test": "TMUX_TMPDIR=$(mktemp -d /tmp/subshell-test-tmux-XXXXXX) bun test --timeout 30000 src", + "test": "bash scripts/test-run.sh", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/server/api/scripts/test-run.sh b/apps/server/api/scripts/test-run.sh new file mode 100755 index 000000000..461408a87 --- /dev/null +++ b/apps/server/api/scripts/test-run.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# The test runner @internal/server's `test` script wraps. +# +# Owns the run's throwaway tmux namespace: TMUX_TMPDIR is set for the whole +# `bun test` (children inherit the STARTUP env - a preload cannot re-home it +# later, measured on bun 1.4.2), so tmux resolves `-L ` under it and +# "which servers did this run start" is a directory listing. This file lived +# inside `src/test-preload.ts`'s afterAll until issue #261: under +# `bun test --parallel` a preload afterAll fires after EVERY file (measured +# on 1.4.0), and a sweep scoped by directory would kill the panes of sibling +# workers still mid-suite. The script runs after every worker has exited, +# serial or parallel. +# +# The DB-file sweep stays in the preload: it is scoped by this process's PID +# prefix, so a per-file firing is exactly its right moment there. +set -u +dir="$(mktemp -d /tmp/subshell-test-tmux-XXXXXX)" +status=0 +TMUX_TMPDIR="$dir" bun test --timeout 30000 src "$@" || status=$? +if command -v tmux >/dev/null 2>&1; then + for sock in "$dir"/tmux-*/*; do + [ -e "$sock" ] || continue + tmux -L "${sock##*/}" kill-server 2>/dev/null + done +fi +rm -rf "$dir" +exit "$status" diff --git a/apps/server/api/src/test-preload.ts b/apps/server/api/src/test-preload.ts index 8b61d11f5..638c24652 100644 --- a/apps/server/api/src/test-preload.ts +++ b/apps/server/api/src/test-preload.ts @@ -1,8 +1,7 @@ import { afterAll } from "bun:test"; -import { readdirSync, rmSync, unlinkSync } from "node:fs"; +import { readdirSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; -import { spawnSync } from "bun"; +import { join } from "node:path"; /** * Test preload: marks the process as a test run before any module that reads @@ -23,68 +22,16 @@ import { spawnSync } from "bun"; * Bun loads `.env` before a preload runs — so `DATABASE_PATH` from a * developer's `.env` is already present here, and "set it only if absent" * silently hands the suites the live database. - */ -process.env.SUBSHELL_TEST_MODE = "1"; - -/** - * Kills every tmux server this run started, as the net under each suite's own - * reaper. - * - * A per-file `afterAll` is still the right thing to write — it releases the - * pane when the file ends rather than when the run does, and it is visible to - * whoever reads that file. This catches what no per-file reaper can: a launch - * that throws before its socket is registered, a file with no reaper at all, - * and a per-test timeout that ends a file before its hooks run. What leaks is - * not a socket file but a live harness process — measured 2026-09-14, 17 - * `claude` panes at ~220 MB each were alive on one developer's machine, the - * oldest a day old, one per full `bun test` since. * - * SCOPED BY `TMUX_TMPDIR`, which the `test` script creates fresh per - * invocation (`mktemp -d /tmp/subshell-test-tmux-XXXXXX`): tmux resolves - * `-L ` under it, so "which servers did this run start" becomes a - * directory listing rather than a guess, and a concurrent run's panes are - * outside it. Two things make the variable's home the script and not this - * file, both measured on bun 1.4.2: - * - * - **A child does not see a `process.env` written after startup.** Bun hands - * a spawned process the environment this one was STARTED with, so setting - * it here reaches `tmuxSocketPath()` in this process and NOT the tmux client - * — which would be worse than doing nothing: the socket would land in the - * default `/tmp`, while `cleanSocket` unlinked a path in the temp dir. - * - **`/tmp`, not `tmpdir()`.** On macOS `tmpdir()` is a ~49-byte - * `/var/folders/...` path, and a unix socket path is capped at 104 bytes - * there (`assertSocketPathFits`) — `//tmux-/subshell-<12hex>` - * lands within a few bytes of the limit, and tripping it fails every launch - * in the suite with tmux's bare "File name too long". - * - * A bare hand-typed `bun test` sets no such variable and gets no net; it also - * gets the default socket dir, where killing anything would reach panes this - * run never started. `kill-server`, not `kill-session`: the pane is the point. - * Errors are ignored throughout — cleanup may never mask the run's results. + * The tmux kill-all sweep that lived in this file until issue #261 is now in + * `scripts/test-run.sh`, which wraps the `test` script. Here it was a + * preload-registered `afterAll`, which fires once per run serially but after + * EVERY file under `--parallel` (measured on 1.4.0) — and a sweep scoped by + * the run's shared `TMUX_TMPDIR` would then kill the panes of sibling workers + * still mid-suite. The script outlives every worker, so it can sweep the same + * namespace safely. */ -afterAll(() => { - const base = process.env.TMUX_TMPDIR; - if (!base || !basename(base).startsWith("subshell-test-tmux-")) return; - const socketDir = join(base, `tmux-${process.getuid?.() ?? 0}`); - let sockets: string[] = []; - try { - sockets = readdirSync(socketDir); - } catch { - // no sockets were ever created — the common case - } - for (const socket of sockets) { - try { - // `spawnSync` THROWS ENOENT when tmux is not installed — it does not - // report it as a non-zero exit — so without this the comment above - // about ignoring errors would be false on the one machine where the - // sweep has nothing to do anyway. - spawnSync(["tmux", "-L", socket, "kill-server"], { stdout: "ignore", stderr: "ignore" }); - } catch { - // no tmux on this machine; there is nothing it could have started - } - } - rmSync(base, { recursive: true, force: true }); -}); +process.env.SUBSHELL_TEST_MODE = "1"; /** * Best-effort removal of this process's temp test databases once the run is @@ -94,9 +41,13 @@ afterAll(() => { * This must be the runner's own `afterAll` rather than a `process.on("exit")` * hook: `bun test` never fires exit/beforeExit listeners (verified * empirically on Bun 1.4.0), so an exit hook would leak a database file on - * every single run. A preload-registered `afterAll` fires exactly once, - * after every test file's own `afterAll` hooks — no suite loses its DB - * mid-run — and it also fires on a failing run. + * every single run. A preload-registered `afterAll` fires after every test + * file's own `afterAll` hooks and on failing runs — once per run serially, + * once per file under `--parallel`. The PID prefix makes that safe in both + * modes: a firing removes only this process's files, files run one at a time + * inside a worker, and a later file's DB is created when it imports + * `constants.ts` — after the earlier sweep. (The tmux sweep could not say the + * same; see the header.) * * The file is matched by this process's `subshell-test--` prefix instead of * importing `TEST_DATABASE_PATH`: static imports hoist above the flag From d9454ff60e4ff7ce7b547b0473c01d1bb4c3bbd5 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 19:09:33 -0700 Subject: [PATCH 05/12] test: run every package's bun test in parallel worker processes (issue #261) --- apps/client/desktop/package.json | 2 +- apps/client/mobile/package.json | 2 +- apps/docs/package.json | 2 +- apps/node/agent/package.json | 2 +- apps/node/web/package.json | 2 +- apps/server/api/scripts/test-run.sh | 9 ++++++++- apps/server/desktop/package.json | 2 +- apps/server/web/package.json | 2 +- apps/website/package.json | 2 +- packages/assistant/package.json | 2 +- packages/backend-errors/package.json | 2 +- packages/mcp-core/package.json | 2 +- packages/node-admin/package.json | 2 +- packages/pane-runtime/package.json | 2 +- packages/plugin-api/package.json | 2 +- packages/plugins/claude-code/package.json | 2 +- packages/plugins/cloudflare-tunnel/package.json | 2 +- packages/plugins/codex/package.json | 2 +- packages/plugins/headscale/package.json | 2 +- packages/plugins/hermes/package.json | 2 +- packages/plugins/netbird/package.json | 2 +- packages/plugins/opencode/package.json | 2 +- packages/plugins/pi/package.json | 2 +- packages/plugins/tailscale/package.json | 2 +- packages/plugins/terminal/package.json | 2 +- packages/subshell-protocol/package.json | 2 +- 26 files changed, 33 insertions(+), 26 deletions(-) diff --git a/apps/client/desktop/package.json b/apps/client/desktop/package.json index b0bf8052d..00da30e4d 100644 --- a/apps/client/desktop/package.json +++ b/apps/client/desktop/package.json @@ -15,7 +15,7 @@ "lint": "biome check --write --unsafe src ui", "lint:check": "biome check src ui", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src ui", - "test": "bun test --path-ignore-patterns \"**/ui/**\" src && cd ui && bun test", + "test": "bun test --parallel=4 --path-ignore-patterns \"**/ui/**\" src && cd ui && bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit && tsc --project ui/tsconfig.json --noEmit", "compile:release": "bun run src/scripts/release.ts" }, diff --git a/apps/client/mobile/package.json b/apps/client/mobile/package.json index 203592c7a..1c497fa61 100644 --- a/apps/client/mobile/package.json +++ b/apps/client/mobile/package.json @@ -15,7 +15,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src app scripts", "lint:check": "biome check --no-errors-on-unmatched src app scripts", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src app scripts", - "test": "bun test --pass-with-no-tests", + "test": "bun test --parallel=4 --pass-with-no-tests", "verify-types": "tsc --noEmit", "harness:m1": "bun scripts/m1-transport-harness.ts" }, diff --git a/apps/docs/package.json b/apps/docs/package.json index 76160310b..370f9edf4 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -13,7 +13,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe app lib components src content", "lint:check": "biome check --no-errors-on-unmatched app lib components src content", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged app lib components src content", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/node/agent/package.json b/apps/node/agent/package.json index 70e0e3d0a..682977804 100644 --- a/apps/node/agent/package.json +++ b/apps/node/agent/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=12", "verify-types": "tsc --project tsconfig.json --noEmit", "compile": "bun build --compile --bytecode --minify --sourcemap ./src/main.ts --outfile ./dist/subshell", "compile:release": "bun run src/scripts/release.ts" diff --git a/apps/node/web/package.json b/apps/node/web/package.json index 93b0f59f7..fce10d658 100644 --- a/apps/node/web/package.json +++ b/apps/node/web/package.json @@ -12,7 +12,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --pass-with-no-tests", + "test": "bun test --parallel=4 --pass-with-no-tests", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/server/api/scripts/test-run.sh b/apps/server/api/scripts/test-run.sh index 461408a87..107bfbcc8 100755 --- a/apps/server/api/scripts/test-run.sh +++ b/apps/server/api/scripts/test-run.sh @@ -13,10 +13,17 @@ # # The DB-file sweep stays in the preload: it is scoped by this process's PID # prefix, so a per-file firing is exactly its right moment there. +# +# --parallel=12 (issue #261): worker processes for the run's files. 12 is +# measured on this repo's suite, not core count - bunfig's `parallel` key is +# silently ignored by bun 1.4.0, so the flag lives here and every package's +# `test` script, and a bare hand-typed `bun test` stays serial (the preload +# above still applies to it). Trailing args pass through to `bun test` +# (CI appends --shard for its matrix legs). set -u dir="$(mktemp -d /tmp/subshell-test-tmux-XXXXXX)" status=0 -TMUX_TMPDIR="$dir" bun test --timeout 30000 src "$@" || status=$? +TMUX_TMPDIR="$dir" bun test --parallel=12 --timeout 30000 src "$@" || status=$? if command -v tmux >/dev/null 2>&1; then for sock in "$dir"/tmux-*/*; do [ -e "$sock" ] || continue diff --git a/apps/server/desktop/package.json b/apps/server/desktop/package.json index d725d2022..05fdf7a3e 100644 --- a/apps/server/desktop/package.json +++ b/apps/server/desktop/package.json @@ -15,7 +15,7 @@ "lint": "biome check --write --unsafe src ui", "lint:check": "biome check src ui", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src ui", - "test": "bun test --path-ignore-patterns \"**/ui/**\" src && cd ui && bun test", + "test": "bun test --parallel=4 --path-ignore-patterns \"**/ui/**\" src && cd ui && bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit && tsc --project ui/tsconfig.json --noEmit", "compile:release": "bun run src/scripts/release.ts" }, diff --git a/apps/server/web/package.json b/apps/server/web/package.json index 3475878cc..5429c2d26 100644 --- a/apps/server/web/package.json +++ b/apps/server/web/package.json @@ -12,7 +12,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --pass-with-no-tests", + "test": "bun test --parallel=12 --pass-with-no-tests", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/website/package.json b/apps/website/package.json index 21e87af90..d2eaf400f 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -15,7 +15,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe app lib components scripts", "lint:check": "biome check --no-errors-on-unmatched app lib components scripts", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged app lib components scripts", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/packages/assistant/package.json b/packages/assistant/package.json index 97fb7d6af..46321385d 100644 --- a/packages/assistant/package.json +++ b/packages/assistant/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/backend-errors/package.json b/packages/backend-errors/package.json index 2954d1ca8..41c4f4c38 100644 --- a/packages/backend-errors/package.json +++ b/packages/backend-errors/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index ab3e29346..be62f5b10 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/node-admin/package.json b/packages/node-admin/package.json index 54bcafa00..1335f92e3 100644 --- a/packages/node-admin/package.json +++ b/packages/node-admin/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/pane-runtime/package.json b/packages/pane-runtime/package.json index 8df58ab27..1d0a64181 100644 --- a/packages/pane-runtime/package.json +++ b/packages/pane-runtime/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index 3abb67ddb..de0b8864b 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -32,7 +32,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/claude-code/package.json b/packages/plugins/claude-code/package.json index 84afc5693..aa6045943 100644 --- a/packages/plugins/claude-code/package.json +++ b/packages/plugins/claude-code/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/cloudflare-tunnel/package.json b/packages/plugins/cloudflare-tunnel/package.json index 399162590..c3f8496de 100644 --- a/packages/plugins/cloudflare-tunnel/package.json +++ b/packages/plugins/cloudflare-tunnel/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/codex/package.json b/packages/plugins/codex/package.json index 304268db4..07e59eb98 100644 --- a/packages/plugins/codex/package.json +++ b/packages/plugins/codex/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/headscale/package.json b/packages/plugins/headscale/package.json index ea47d4f35..d97e30788 100644 --- a/packages/plugins/headscale/package.json +++ b/packages/plugins/headscale/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/hermes/package.json b/packages/plugins/hermes/package.json index 68772550f..53e3a6b08 100644 --- a/packages/plugins/hermes/package.json +++ b/packages/plugins/hermes/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/netbird/package.json b/packages/plugins/netbird/package.json index 4c428a2bd..d19178ffc 100644 --- a/packages/plugins/netbird/package.json +++ b/packages/plugins/netbird/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/opencode/package.json b/packages/plugins/opencode/package.json index 57013807c..8f92ac1c6 100644 --- a/packages/plugins/opencode/package.json +++ b/packages/plugins/opencode/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/pi/package.json b/packages/plugins/pi/package.json index fd6454f5c..e0b0800ba 100644 --- a/packages/plugins/pi/package.json +++ b/packages/plugins/pi/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/tailscale/package.json b/packages/plugins/tailscale/package.json index a95752048..6af0e216b 100644 --- a/packages/plugins/tailscale/package.json +++ b/packages/plugins/tailscale/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/terminal/package.json b/packages/plugins/terminal/package.json index de38a8bb6..0495d2959 100644 --- a/packages/plugins/terminal/package.json +++ b/packages/plugins/terminal/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/subshell-protocol/package.json b/packages/subshell-protocol/package.json index 3899dedb1..1e88e0201 100644 --- a/packages/subshell-protocol/package.json +++ b/packages/subshell-protocol/package.json @@ -40,7 +40,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test", + "test": "bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { From 1d2b91922fdc0f86031d9c6f358e9e48c1dcd2cf Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 19:14:36 -0700 Subject: [PATCH 06/12] test(desktop): keep --parallel after the script halves the tauri-config test pins --- apps/client/desktop/package.json | 2 +- apps/server/desktop/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/desktop/package.json b/apps/client/desktop/package.json index 00da30e4d..2a25a6144 100644 --- a/apps/client/desktop/package.json +++ b/apps/client/desktop/package.json @@ -15,7 +15,7 @@ "lint": "biome check --write --unsafe src ui", "lint:check": "biome check src ui", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src ui", - "test": "bun test --parallel=4 --path-ignore-patterns \"**/ui/**\" src && cd ui && bun test --parallel=4", + "test": "bun test --path-ignore-patterns \"**/ui/**\" src --parallel=4 && cd ui && bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit && tsc --project ui/tsconfig.json --noEmit", "compile:release": "bun run src/scripts/release.ts" }, diff --git a/apps/server/desktop/package.json b/apps/server/desktop/package.json index 05fdf7a3e..706c91836 100644 --- a/apps/server/desktop/package.json +++ b/apps/server/desktop/package.json @@ -15,7 +15,7 @@ "lint": "biome check --write --unsafe src ui", "lint:check": "biome check src ui", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src ui", - "test": "bun test --parallel=4 --path-ignore-patterns \"**/ui/**\" src && cd ui && bun test --parallel=4", + "test": "bun test --path-ignore-patterns \"**/ui/**\" src --parallel=4 && cd ui && bun test --parallel=4", "verify-types": "tsc --project tsconfig.json --noEmit && tsc --project ui/tsconfig.json --noEmit", "compile:release": "bun run src/scripts/release.ts" }, From 30c28a5f1031247eba58447526e920d0ea0acc3b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 19:29:26 -0700 Subject: [PATCH 07/12] docs(test): record parallel run semantics where the serial facts were written down --- .claude/rules/testing.md | 11 ++++ apps/server/api/AGENTS.md | 35 +++++----- apps/server/api/docs/testing-notes.md | 65 ++++++++++++------- .../plans/2026-09-29-parallel-tests.md | 27 +++++--- .../specs/2026-09-29-parallel-tests-design.md | 44 ++++++++----- 5 files changed, 116 insertions(+), 66 deletions(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 046c28340..5ffe4634e 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -6,6 +6,17 @@ turbo test # Run tests across all packages ``` +Each package's `test` script passes `--parallel=N` (issue #261): test files +run in worker processes and **every file gets a fresh module registry** - a +fresh `db` singleton with its own database file. Two consequences for suite +authors: a suite that touches the DB must migrate it itself (server/api: +`@/__tests__/helpers/test-database.js`) and may assume neither an empty DB +nor a sibling file's boot; and runner-wide cleanup cannot live in a preload +`afterAll` (those fire per file under `--parallel`), it belongs in the +package's `test` script. A bare `bun test` still runs serially - bun ignores +a `parallel` key in `bunfig.toml [test]` (measured on 1.4.0 and 1.4.2), so +the flag lives in the scripts only. + ## Guidelines **Always write tests for new features.** Every new service, repository, route, or significant function should have corresponding tests. Tests should cover: diff --git a/apps/server/api/AGENTS.md b/apps/server/api/AGENTS.md index 50e4534f4..af68100f1 100644 --- a/apps/server/api/AGENTS.md +++ b/apps/server/api/AGENTS.md @@ -558,23 +558,28 @@ one path string per process, so the app's Kysely connection and better-auth's handle share one DB, and concurrent `bun test` invocations cannot contend) and a throwaway log directory. `NODE_ARTIFACTS_DIR` derives from that temp data dir under the test env, which is how the download-route tests write -fixtures straight in. All test files within one invocation share -that DB, so suites must not assume it starts empty. Tests never touch -`data/`. - -**A run reaps its own tmux servers**: the `test` script prefixes -`TMUX_TMPDIR=$(mktemp -d /tmp/subshell-test-tmux-XXXXXX)` and the -preload's `afterAll` `kill-server`s exactly what that directory lists, -so a concurrent run's panes are outside it. A bare hand-typed `bun test` -sets no such variable and gets no net, which is correct, since it also -gets the shared default socket dir, where killing anything would reach panes -the run never started. The net covers THIS package only; +fixtures straight in. A bare `bun test` runs files serially in one process, +so they all share that DB; the `test` script adds `--parallel=12` (issue +#261), where every file gets a fresh module registry and so its own DB +file. Suites must therefore migrate their own DB (`@/__tests__/helpers/ +test-database.js`) and may assume neither an empty one nor a sibling file's +boot. Tests never touch `data/`. + +**A run reaps its own tmux servers**: `test` runs `scripts/test-run.sh`, +which owns the run's `TMUX_TMPDIR=$(mktemp -d /tmp/subshell-test-tmux-XXXXXX)` +namespace and, after `bun test` exits, `kill-server`s every socket it lists +and removes the directory. It lives in the script, not the preload, because +issue #261 measured that a preload `afterAll` fires after EVERY file under +`--parallel` - a directory-scoped sweep there would kill sibling workers' +live panes. A bare hand-typed `bun test` gets no net, which is correct, +since it also gets the shared default socket dir, where killing anything +would reach panes the run never started. The net covers THIS package only; `packages/pane-runtime` and `apps/node/agent` stand on each suite's own register-before-spawn `afterAll`. The `--timeout 30000` in the script is -measured: this package's DB + better-auth setup against the shared temp -DB is what blows bun's 5 s default under CI load, not the tests. Why -each is shaped this way (the leaked-harness incident, where the variable -may and may not be set, the CI run numbers): +measured: this package's DB + better-auth setup against the temp DB is +what blows bun's 5 s default under CI load, not the tests. Why each is +shaped this way (the leaked-harness incident, the parallel per-file firing +that moved the sweep out of the preload, the CI run numbers): `apps/server/api/docs/testing-notes.md`. **A new column on a shared table must also join the HAND-CURATED migration diff --git a/apps/server/api/docs/testing-notes.md b/apps/server/api/docs/testing-notes.md index cf8f61098..fa9bef5b1 100644 --- a/apps/server/api/docs/testing-notes.md +++ b/apps/server/api/docs/testing-notes.md @@ -1,31 +1,40 @@ -# Testing internals: why the tmux reaper, the 30 s timeout and turbo dependsOn are shaped this way. Moved verbatim from AGENTS.md ("Testing"); AGENTS.md keeps the rules and routes here. +# Testing internals: why the tmux reaper, the per-file database, the 30 s timeout and turbo dependsOn are shaped this way. Moved verbatim from AGENTS.md ("Testing"); AGENTS.md keeps the rules and routes here. -**A run reaps its own tmux servers, and that net lives in the `test` script, -not the preload.** The script prefixes `TMUX_TMPDIR=$(mktemp -d -/tmp/subshell-test-tmux-XXXXXX)`; tmux resolves `-L ` under it, so the -preload's `afterAll` can `kill-server` exactly what this run started by -listing a directory, and a concurrent run's panes are outside it. What leaks -without it is not a socket file but a live harness: measured 2026-09-14, 17 -real `claude` panes at ~220 MB each were alive on a developer's machine, the -oldest a day old, one per full `bun test` since, from -`subshells-local-launch-off.test.ts`, which asserted `not 403 / not 404` on a -create and got a 409 on CI (no claude) and a genuine 200 on a dev box. That -suite now pins `CLAUDE_PATH` at a path that does not exist, which is the real -fix; this is the net under it, for the leak classes a per-file `afterAll` -cannot catch (a launch that throws before its socket is registered, a file -with no reaper, a timeout that ends a file before its hooks). +**A run reaps its own tmux servers, and that net lives in the script that +wraps the `test` command (`scripts/test-run.sh`), not the preload.** The +script mktemps the run's `TMUX_TMPDIR=/tmp/subshell-test-tmux-XXXXXX` +namespace, runs `bun test` with it, and after `bun test` exits +`kill-server`s every socket under it and removes the directory; tmux +resolves `-L ` under the variable, so "what this run started" is a +directory listing, and a concurrent run's panes are outside it. Until +issue #261 the sweep itself sat in the preload's `afterAll`. That moved +because of a second measured fact (bun 1.4.0): a preload `afterAll` fires +once per run serially, but after EVERY file under `--parallel`. A +directory-scoped sweep there would kill the panes of sibling workers still +mid-suite. The script outlives every worker, so it owns the kill. What +leaks without the net is not a socket file but a live harness: measured +2026-09-14, 17 real `claude` panes at ~220 MB each were alive on a +developer's machine, the oldest a day old, one per full `bun test` since, +from `subshells-local-launch-off.test.ts`, which asserted `not 403 / not +404` on a create and got a 409 on CI (no claude) and a genuine 200 on a dev +box. That suite now pins `CLAUDE_PATH` at a path that does not exist, which +is the real fix; the net is under it, for the leak classes a per-file +`afterAll` cannot catch (a launch that throws before its socket is +registered, a file with no reaper, a timeout that ends a file before its +hooks). Two measured facts fix WHERE the variable is set. A child does not see a `process.env` written after startup (bun 1.4.2): Bun hands a spawned process the environment this one was STARTED with, so setting it in the preload reaches `tmuxSocketPath()` in-process and not the tmux client; the socket would land in `/tmp` while `cleanSocket` unlinked a path in the temp dir, -which is worse than no net. And `/tmp` rather than `$TMPDIR`: on macOS the -latter is a ~49-byte `/var/folders/...` path against a 104-byte socket-path -cap, so the derived socket lands within a few bytes of tmux's bare "File name -too long". A bare hand-typed `bun test` sets no such variable and gets no -net, correctly, since it also gets the shared default socket dir, where -killing anything would reach panes this run never started. +which is worse than no net. The script sets it before `bun test` starts, so +every worker and every child inherits it. And `/tmp` rather than `$TMPDIR`: +on macOS the latter is a ~49-byte `/var/folders/...` path against a 104-byte +socket-path cap, so the derived socket lands within a few bytes of tmux's +bare "File name too long". A bare hand-typed `bun test` sets no such +variable and gets no net, correctly, since it also gets the shared default +socket dir, where killing anything would reach panes this run never started. **The net covers this package only.** `packages/pane-runtime` and `apps/node/agent` also spawn real tmux and run a bare `bun test`, so nothing @@ -38,8 +47,9 @@ extending the variable to those packages is a change to how their sockets are named, not a line of cleanup. **The `--timeout 30000` in the `test` script is measured, not caution.** -This package's suites set up against that shared DB through migrations and -better-auth table creation, and bun's 5000 ms per-test/hook default blew +This package's suites set up against that temp DB through migrations and +better-auth table creation (each file's own DB under `--parallel`), and +bun's 5000 ms per-test/hook default blew three times in three CI runs, each time in a different file, which is the signature of load, not of a bug: the auth-registration `beforeAll` at 8830 ms (run 34581907693), the heaviest `default-profiles` case (suite since @@ -67,7 +77,12 @@ correct and this package now inherits it. ## The testing history this replaced (moved from AGENTS.md "Testing") -All test files within one invocation share that DB, so suites must not -assume it starts empty. Tests never touch `data/`. (It used to be the URI string `file::memory:?cache=shared`, but +Serial `bun test` (one process, shared module cache) gives every file the +same DB, so a suite may never assume it starts empty; under the `test` +script's `--parallel=12` the opposite holds, every file gets a fresh module +registry and its own DB file, so a suite may never assume another file's +migrations happened either - DB-touching suites call +`@/__tests__/helpers/test-database.js` explicitly. Tests never touch +`data/`. (The path used to be the URI string `file::memory:?cache=shared`, but Bun treats URI strings as file names; every suite was sharing one literal CWD file.) diff --git a/docs/superpowers/plans/2026-09-29-parallel-tests.md b/docs/superpowers/plans/2026-09-29-parallel-tests.md index 3fce19696..c59344c78 100644 --- a/docs/superpowers/plans/2026-09-29-parallel-tests.md +++ b/docs/superpowers/plans/2026-09-29-parallel-tests.md @@ -4,9 +4,9 @@ **Goal:** Make `bun test` run files in parallel (issue #261): fix the hazards that break under `--parallel`, then set `parallel = N` in every package's `bunfig.toml`, then shard CI's longest test job. -**Architecture:** Bun 1.4.0's `bun test --parallel=N` runs each test file in a worker process with a **fresh module registry per file** (measured; serial mode shares one cache across files). A preload-registered `afterAll` fires once per run serially but **once per file** under parallel (measured). Two existing test-infrastructure behaviors rely on the serial facts and must be fixed first: the `server/api` tmux sweep (kills sibling workers' panes) and 14 suites that reach SQLite tables without ever running migrations (they ride another file's boot). +**Architecture:** Bun's `bun test --parallel=N` runs each test file in a worker process with a **fresh module registry per file** (measured on 1.4.0 and 1.4.2; serial mode shares one cache across files). A preload-registered `afterAll` fires once per run serially but **once per file** under parallel (measured). Two existing test-infrastructure behaviors rely on the serial facts and must be fixed first: the `server/api` tmux sweep (kills sibling workers' panes) and 14 suites that reach SQLite tables without ever running migrations (they ride another file's boot). -**Tech Stack:** Bun 1.4.0 (`bun test`, `bunfig.toml [test]`), Kysely + bun:sqlite, better-auth, turbo 2.x, GitHub Actions. +**Tech Stack:** Bun 1.4.2 (upgraded from 1.4.0 during implementation; CI's builder image already had 1.4.2), Kysely + bun:sqlite, better-auth, turbo 2.x, GitHub Actions. **Spec:** `docs/superpowers/specs/2026-09-29-parallel-tests-design.md` @@ -198,13 +198,22 @@ git add apps/server/api/scripts/test-run.sh apps/server/api/package.json apps/se git commit -m "test(server): move the tmux kill-all sweep to the run script (parallel-safe)" ``` -### Task 3: flip `parallel` in every package's `bunfig.toml` - -`bunfig.toml` over a script flag because it reaches `bun run test`, `turbo test`, AND a bare hand-typed `bun test` - the same reason the preloads live there. `--parallel` implies `--isolate` (fresh module registry per file), which Task 1's suites now assume. - -**Files (modify existing bunfig; create where absent):** -- `parallel = 12`: `apps/server/api/bunfig.toml`, `apps/server/web/bunfig.toml`, `apps/node/agent/bunfig.toml` -- `parallel = 4` (create a bare file if none exists): `packages/pane-runtime/`, `apps/node/web/`, `apps/website/`, `packages/assistant/`, `packages/node-admin/`, `apps/client/desktop/` AND `apps/client/desktop/ui/`, `apps/server/desktop/` AND `apps/server/desktop/ui/`, `apps/client/mobile/`, `apps/docs/`, `packages/backend-errors/`, `packages/mcp-core/`, `packages/plugin-api/`, `packages/subshell-protocol/`, every `packages/plugins//` (10 of them) +### Task 3: flip `parallel` in every package's `test` script + +> **Correction during execution (2026-09-29):** the plan originally put +> `parallel = N` in each package's `bunfig.toml` (so a bare `bun test` would +> get it too). Measured on bun 1.4.0 AND re-measured on 1.4.2: bun SILENTLY +> IGNORES a `parallel` key in bunfig's `[test]` - no PARALLEL banner, serial +> timings - and https://bun.com/docs/test documents `parallel` only as a CLI +> flag. The flag therefore lives in the `test` scripts (inside +> `apps/server/api/scripts/test-run.sh` for the server); a bare hand-typed +> `bun test` stays serial, which is the safe default. `--parallel` implies +> `--isolate` (fresh module registry per file), which Task 1's suites now +> assume. + +**Files (the `test` entry of every package.json that runs `bun test`, plus `apps/server/api/scripts/test-run.sh`):** +- `--parallel=12`: `apps/server/api` (in `test-run.sh`), `apps/server/web`, `apps/node/agent` +- `--parallel=4`: the remaining 22 packages. For the two desktop apps the flag goes at the END of each half (`... src --parallel=4 && cd ui && bun test --parallel=4`) because `apps/server/desktop/ui/src/__tests__/tauri-config.test.ts` pins the exact `bun test --path-ignore-patterns "**/ui/**" src` string. **Interfaces:** - Consumes: Tasks 1-2 (server/api is only safe after both). diff --git a/docs/superpowers/specs/2026-09-29-parallel-tests-design.md b/docs/superpowers/specs/2026-09-29-parallel-tests-design.md index 4fbf12bb9..5c982fdcc 100644 --- a/docs/superpowers/specs/2026-09-29-parallel-tests-design.md +++ b/docs/superpowers/specs/2026-09-29-parallel-tests-design.md @@ -21,16 +21,22 @@ CI (`.github/workflows/test.yml`) runs on GitHub-hosted 4-core ## Mechanism -Per-package `bunfig.toml` carries `parallel = N`. Bun spawns N workers, -distributes files, and isolates each file's module registry +`--parallel=N` lives in every package's `test` **script** (inside +`apps/server/api/scripts/test-run.sh` for the server). Bun spawns N +workers, distributes files, and isolates each file's module registry (`--isolate`, implied). Worker count is capped by file count, so small -packages self-limit. Proposed N: **12** for `server/api`, `server/web`, +packages self-limit. N: **12** for `server/api`, `server/web`, `node/agent`; **4** elsewhere. Tunable after first green. -`bunfig.toml` (not a script flag) because the flag must reach every -invocation — `bun run test`, `turbo test`, and a bare hand-typed -`bun test` — the same rationale each package's `preload` already lives -there for. +The design started from `bunfig.toml` — the flag there would reach every +invocation, the same rationale each package's `preload` lives there for. +Measured during implementation: bun silently IGNORES a `parallel` +key in bunfig's `[test]` (no PARALLEL banner, serial timings; measured on +1.4.0 and again on 1.4.2, to which the dev machine was upgraded mid-work - +CI's builder image already ran 1.4.2). [bun.com/docs/test](https://bun.com/docs/test) +lists `parallel` as a CLI flag only. A bare hand-typed `bun test` therefore +stays serial, which is the safe default: the preload still reaches it, and +nothing correctness-relevant depends on the flag. ## Measured runner semantics (Bun 1.4.0, probed) @@ -53,16 +59,20 @@ there for. preserves the exit code). The preload keeps its per-PID DB sweep, which is parallel-safe as measured. -2. **Implicit migration ordering** (`server/api`): 14 files query tables - without running migrations, surviving serially because files that run - earlier migrated the process-wide shared DB. Fresh per-file DBs under - parallel expose it as `no such table` (56 failures). Fix: an explicit - `ensureTestDatabase()` helper (Kysely + better-auth migrations) called - in each DB-touching suite's `beforeAll`. Idempotent, so serial stays - green. - -3. **A subprocess/port test** (`bootstrap argv`): triage individually in - the same PR (reproducer-first). +2. **Implicit migration ordering** (`server/api`): five files query the + shared `@/db` singleton (directly or through service imports) without + running migrations, surviving serially because files that run earlier + migrated the process-wide shared DB. Fresh per-file DBs under parallel + expose it as `no such table`. Fix: an explicit `ensureMigratedTestDb()` + helper (Kysely + better-auth migrations) called in each affected + suite's `beforeAll`, plus `prepareLocalPlugins()` alongside it in the + MCP suite (the auto-restart gate reads the plugin store and silently + defers on an unseeded one). Idempotent, so serial stays green. + Two of the original 14 failing suites + (`subshell-manager.service.test.ts`'s live-pane cases) turned out to be + hazard 1's collateral, not a DB problem; the one `bootstrap argv` + failure reproduces SERIALLY under the dev shell's `SHELLOPTS` export + and is an environment artifact, not parallel-related. 4. **Ordering shuffle**: parallel changes which files share a worker. Latent env-mutation leaks surface as flakes; the bake below is the From 099a23d3bbfe21ac8ade10cb33c2ff8b9aa8337e Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 19:38:59 -0700 Subject: [PATCH 08/12] test(node): wait for both update-settle unlinks, not one then a peek --- apps/node/agent/src/__tests__/daemon.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/node/agent/src/__tests__/daemon.test.ts b/apps/node/agent/src/__tests__/daemon.test.ts index 0bbac2cdd..d91acfb02 100644 --- a/apps/node/agent/src/__tests__/daemon.test.ts +++ b/apps/node/agent/src/__tests__/daemon.test.ts @@ -2366,9 +2366,15 @@ describe("the update transaction, settled by the daemon (spec 2026-09-15 §5.1/ // is what proves this plane is talking TO this binary. await signAndSend(h, { type: "set_allowed_dirs", dirs: [] }); const deadline = Date.now() + 2000; - while (existsSync(previous) && Date.now() < deadline) await sleep(10); + // Wait for BOTH drops, not one then an immediate peek at the other: the + // settle unlinks are not atomic against each other, and on a loaded + // runner (CI's 4-core box under `--parallel=12`) the gap between them + // outlived the single-file wait - the shape of the 2026-09-30 flake, + // `.previous` gone while the marker still read present. + const marker = join(h.config.dataDir, "update-pending.json"); + while ((existsSync(previous) || existsSync(marker)) && Date.now() < deadline) await sleep(10); expect(existsSync(previous)).toBe(false); - expect(existsSync(join(h.config.dataDir, "update-pending.json"))).toBe(false); + expect(existsSync(marker)).toBe(false); expect(readFileSync(binary, "utf8")).toBe("NEW"); }); From f4f9a09e4cbead7728ebed7fda1c5042d52b6100 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 19:38:59 -0700 Subject: [PATCH 09/12] test: size bun --parallel per runner via SUBSHELL_TEST_PARALLEL (CI pins 4) --- .claude/rules/testing.md | 4 +++- .github/workflows/test.yml | 7 +++++++ apps/client/desktop/package.json | 2 +- apps/client/mobile/package.json | 2 +- apps/docs/package.json | 2 +- apps/node/agent/package.json | 2 +- apps/node/web/package.json | 2 +- apps/server/api/scripts/test-run.sh | 16 +++++++++------- apps/server/desktop/package.json | 2 +- apps/server/web/package.json | 2 +- apps/website/package.json | 2 +- .../plans/2026-09-29-parallel-tests.md | 6 ++++++ .../specs/2026-09-29-parallel-tests-design.md | 12 ++++++++++-- packages/assistant/package.json | 2 +- packages/backend-errors/package.json | 2 +- packages/mcp-core/package.json | 2 +- packages/node-admin/package.json | 2 +- packages/pane-runtime/package.json | 2 +- packages/plugin-api/package.json | 2 +- packages/plugins/claude-code/package.json | 2 +- packages/plugins/cloudflare-tunnel/package.json | 2 +- packages/plugins/codex/package.json | 2 +- packages/plugins/headscale/package.json | 2 +- packages/plugins/hermes/package.json | 2 +- packages/plugins/netbird/package.json | 2 +- packages/plugins/opencode/package.json | 2 +- packages/plugins/pi/package.json | 2 +- packages/plugins/tailscale/package.json | 2 +- packages/plugins/terminal/package.json | 2 +- packages/subshell-protocol/package.json | 2 +- 30 files changed, 60 insertions(+), 35 deletions(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 5ffe4634e..16f6670e1 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -15,7 +15,9 @@ nor a sibling file's boot; and runner-wide cleanup cannot live in a preload `afterAll` (those fire per file under `--parallel`), it belongs in the package's `test` script. A bare `bun test` still runs serially - bun ignores a `parallel` key in `bunfig.toml [test]` (measured on 1.4.0 and 1.4.2), so -the flag lives in the scripts only. +the flag lives in the scripts only. `SUBSHELL_TEST_PARALLEL` overrides a +package's default (CI pins 4 for its 4-core runners; 12 starved the web +suite's timeouts there). ## Guidelines diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c09ff9b0f..aab1d77dd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,6 +63,13 @@ on: branches: - main +# Every package's `test` script defaults its own bun --parallel width; these +# runners are 4-core hosted machines, and 12 CPU-heavy workers here starve +# wall-clock timeouts (measured: the web suite's 5 s default blew at 12/4 on +# run 36660089740, green at 48 cores locally). One knob, no per-job repeats. +env: + SUBSHELL_TEST_PARALLEL: 4 + permissions: contents: read # The container jobs pull ghcr.io/subshell-ai/desktop-builder, which inherits diff --git a/apps/client/desktop/package.json b/apps/client/desktop/package.json index 2a25a6144..c20ac0661 100644 --- a/apps/client/desktop/package.json +++ b/apps/client/desktop/package.json @@ -15,7 +15,7 @@ "lint": "biome check --write --unsafe src ui", "lint:check": "biome check src ui", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src ui", - "test": "bun test --path-ignore-patterns \"**/ui/**\" src --parallel=4 && cd ui && bun test --parallel=4", + "test": "bun test --path-ignore-patterns \"**/ui/**\" src --parallel=${SUBSHELL_TEST_PARALLEL:-4} && cd ui && bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit && tsc --project ui/tsconfig.json --noEmit", "compile:release": "bun run src/scripts/release.ts" }, diff --git a/apps/client/mobile/package.json b/apps/client/mobile/package.json index 1c497fa61..83420e42c 100644 --- a/apps/client/mobile/package.json +++ b/apps/client/mobile/package.json @@ -15,7 +15,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src app scripts", "lint:check": "biome check --no-errors-on-unmatched src app scripts", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src app scripts", - "test": "bun test --parallel=4 --pass-with-no-tests", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4} --pass-with-no-tests", "verify-types": "tsc --noEmit", "harness:m1": "bun scripts/m1-transport-harness.ts" }, diff --git a/apps/docs/package.json b/apps/docs/package.json index 370f9edf4..979d5dfd1 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -13,7 +13,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe app lib components src content", "lint:check": "biome check --no-errors-on-unmatched app lib components src content", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged app lib components src content", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/node/agent/package.json b/apps/node/agent/package.json index 682977804..01b37e1d3 100644 --- a/apps/node/agent/package.json +++ b/apps/node/agent/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=12", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-12}", "verify-types": "tsc --project tsconfig.json --noEmit", "compile": "bun build --compile --bytecode --minify --sourcemap ./src/main.ts --outfile ./dist/subshell", "compile:release": "bun run src/scripts/release.ts" diff --git a/apps/node/web/package.json b/apps/node/web/package.json index fce10d658..1a0bbfa44 100644 --- a/apps/node/web/package.json +++ b/apps/node/web/package.json @@ -12,7 +12,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4 --pass-with-no-tests", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4} --pass-with-no-tests", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/server/api/scripts/test-run.sh b/apps/server/api/scripts/test-run.sh index 107bfbcc8..9c1053780 100755 --- a/apps/server/api/scripts/test-run.sh +++ b/apps/server/api/scripts/test-run.sh @@ -14,16 +14,18 @@ # The DB-file sweep stays in the preload: it is scoped by this process's PID # prefix, so a per-file firing is exactly its right moment there. # -# --parallel=12 (issue #261): worker processes for the run's files. 12 is -# measured on this repo's suite, not core count - bunfig's `parallel` key is -# silently ignored by bun 1.4.0, so the flag lives here and every package's -# `test` script, and a bare hand-typed `bun test` stays serial (the preload -# above still applies to it). Trailing args pass through to `bun test` -# (CI appends --shard for its matrix legs). +# --parallel (issue #261): worker processes for the run's files. Default 12, +# measured on this repo's suite at 48 cores; SUBSHELL_TEST_PARALLEL overrides +# it (CI pins 4 for its 4-core runners - 12 starved the web suite's wall-clock +# timeouts there). bunfig's `parallel` key is silently ignored by bun (1.4.0 +# and 1.4.2), so the flag lives here and in every package's `test` script, and +# a bare hand-typed `bun test` stays serial (the preload above still applies +# to it). Trailing args pass through to `bun test` (CI appends --shard for +# its matrix legs). set -u dir="$(mktemp -d /tmp/subshell-test-tmux-XXXXXX)" status=0 -TMUX_TMPDIR="$dir" bun test --parallel=12 --timeout 30000 src "$@" || status=$? +TMUX_TMPDIR="$dir" bun test --parallel="${SUBSHELL_TEST_PARALLEL:-12}" --timeout 30000 src "$@" || status=$? if command -v tmux >/dev/null 2>&1; then for sock in "$dir"/tmux-*/*; do [ -e "$sock" ] || continue diff --git a/apps/server/desktop/package.json b/apps/server/desktop/package.json index 706c91836..464a2a70c 100644 --- a/apps/server/desktop/package.json +++ b/apps/server/desktop/package.json @@ -15,7 +15,7 @@ "lint": "biome check --write --unsafe src ui", "lint:check": "biome check src ui", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src ui", - "test": "bun test --path-ignore-patterns \"**/ui/**\" src --parallel=4 && cd ui && bun test --parallel=4", + "test": "bun test --path-ignore-patterns \"**/ui/**\" src --parallel=${SUBSHELL_TEST_PARALLEL:-4} && cd ui && bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit && tsc --project ui/tsconfig.json --noEmit", "compile:release": "bun run src/scripts/release.ts" }, diff --git a/apps/server/web/package.json b/apps/server/web/package.json index 5429c2d26..68927f833 100644 --- a/apps/server/web/package.json +++ b/apps/server/web/package.json @@ -12,7 +12,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=12 --pass-with-no-tests", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-12} --pass-with-no-tests", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/website/package.json b/apps/website/package.json index d2eaf400f..cf2564901 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -15,7 +15,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe app lib components scripts", "lint:check": "biome check --no-errors-on-unmatched app lib components scripts", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged app lib components scripts", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/docs/superpowers/plans/2026-09-29-parallel-tests.md b/docs/superpowers/plans/2026-09-29-parallel-tests.md index c59344c78..493671493 100644 --- a/docs/superpowers/plans/2026-09-29-parallel-tests.md +++ b/docs/superpowers/plans/2026-09-29-parallel-tests.md @@ -409,3 +409,9 @@ After merge, record the before/after job duration in the PR (comment). If a leg - Spec coverage: hazard 1 (sweep) = Task 2; hazard 2 (implicit migrations) = Task 1; ordering-shuffle control = Task 4 bake + flake policy; mechanism = Task 3; CI = Task 5. The `bootstrap argv` failure the spec's section-3 predicted turned out to be the local `SHELLOPTS` artifact (reproduced serially, passes under `env -u SHELLOPTS`) - no task needed; the Global Constraints now name the trap. - The 14-failing-suite list came from one `--parallel=8` run of `apps/server/api` on 2026-09-29; the bake is the net for any suite this list missed. + +## Execution record (2026-09-29/30) + +- Task 1 shipped 5 files (not 6): `subshell-manager.service.test.ts` already migrated its DB; its two parallel failures were hazard-1 collateral (sweep-killed live panes), fixed by Task 2. The MCP suite additionally needs `prepareLocalPlugins()` (the auto-restart gate silently defers on a pluginless instance). +- Task 3 shipped as script flags, not bunfig (see the correction note there); `apps/server/desktop`'s tauri-config meta-test pins the script string, so the flag goes at each half's END (commit 1d2b9192). +- CI's first PR1 run (4-core runner, N=12) caught what 48 cores could not: three `Test: web` wall-clock timeouts and a one-unlink-then-peek race in `daemon.test.ts`. Fixed by `SUBSHELL_TEST_PARALLEL` (CI pins 4, commit abcc2089) and waiting for both settle unlinks (099a23d3). No package needed the `parallel = 1` fallback. diff --git a/docs/superpowers/specs/2026-09-29-parallel-tests-design.md b/docs/superpowers/specs/2026-09-29-parallel-tests-design.md index 5c982fdcc..29cb0db42 100644 --- a/docs/superpowers/specs/2026-09-29-parallel-tests-design.md +++ b/docs/superpowers/specs/2026-09-29-parallel-tests-design.md @@ -91,10 +91,18 @@ after PR 1; split another job only if a real long pole remains. - Per-cluster reproducers before fixes (two-file parallel runs). - PR 1 gate: full `bun run test` three consecutive green runs locally at - the chosen N, plus one serial run (`--parallel=1`) to prove the sweep - move didn't break serial semantics. + the chosen N (passed on 1.4.2, 51/51 tasks each), one serial + (`--parallel=1`) run proving the sweep move kept serial semantics, and + `verify-types`/`lint:check`/`lint:prose`/`test:scripts`. - Flake policy: any package that fails the bake gets its `N` set back to `1` and rejoins later. +- What CI's first run of PR 1 found (4-core hosted runner, N=12): three + `Test: web` wall-clock timeouts (a pure-logic test measured 6 s against + bun's 5 s default) and one real race in `daemon.test.ts` that waited on + one settle-unlink and peeked the other. Fixes: per-runner width via + `SUBSHELL_TEST_PARALLEL` (scripts default to their N, CI pins 4; local + 48 cores keep 12/4), and the daemon test now waits for both unlinks. + No package fell back to `parallel = 1`. ## Non-goals diff --git a/packages/assistant/package.json b/packages/assistant/package.json index 46321385d..320644e91 100644 --- a/packages/assistant/package.json +++ b/packages/assistant/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/backend-errors/package.json b/packages/backend-errors/package.json index 41c4f4c38..cbe2544d6 100644 --- a/packages/backend-errors/package.json +++ b/packages/backend-errors/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index be62f5b10..b20ef0011 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/node-admin/package.json b/packages/node-admin/package.json index 1335f92e3..54d019940 100644 --- a/packages/node-admin/package.json +++ b/packages/node-admin/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/pane-runtime/package.json b/packages/pane-runtime/package.json index 1d0a64181..7094799c5 100644 --- a/packages/pane-runtime/package.json +++ b/packages/pane-runtime/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index de0b8864b..e4db17b4e 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -32,7 +32,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/claude-code/package.json b/packages/plugins/claude-code/package.json index aa6045943..a550f71fb 100644 --- a/packages/plugins/claude-code/package.json +++ b/packages/plugins/claude-code/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/cloudflare-tunnel/package.json b/packages/plugins/cloudflare-tunnel/package.json index c3f8496de..c81b43d0c 100644 --- a/packages/plugins/cloudflare-tunnel/package.json +++ b/packages/plugins/cloudflare-tunnel/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/codex/package.json b/packages/plugins/codex/package.json index 07e59eb98..d5fb684d7 100644 --- a/packages/plugins/codex/package.json +++ b/packages/plugins/codex/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/headscale/package.json b/packages/plugins/headscale/package.json index d97e30788..ca28ff4ad 100644 --- a/packages/plugins/headscale/package.json +++ b/packages/plugins/headscale/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/hermes/package.json b/packages/plugins/hermes/package.json index 53e3a6b08..269cc81f0 100644 --- a/packages/plugins/hermes/package.json +++ b/packages/plugins/hermes/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/netbird/package.json b/packages/plugins/netbird/package.json index d19178ffc..f1e2c89c0 100644 --- a/packages/plugins/netbird/package.json +++ b/packages/plugins/netbird/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/opencode/package.json b/packages/plugins/opencode/package.json index 8f92ac1c6..cce861dd8 100644 --- a/packages/plugins/opencode/package.json +++ b/packages/plugins/opencode/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/pi/package.json b/packages/plugins/pi/package.json index e0b0800ba..e7b91d8b9 100644 --- a/packages/plugins/pi/package.json +++ b/packages/plugins/pi/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/tailscale/package.json b/packages/plugins/tailscale/package.json index 6af0e216b..7f0c77eb7 100644 --- a/packages/plugins/tailscale/package.json +++ b/packages/plugins/tailscale/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/terminal/package.json b/packages/plugins/terminal/package.json index 0495d2959..282c03dc8 100644 --- a/packages/plugins/terminal/package.json +++ b/packages/plugins/terminal/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/subshell-protocol/package.json b/packages/subshell-protocol/package.json index 1e88e0201..4036d723a 100644 --- a/packages/subshell-protocol/package.json +++ b/packages/subshell-protocol/package.json @@ -40,7 +40,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=4", + "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { From 90489bbc28f2e7bca240dc6d57803b45555964dc Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 19:49:48 -0700 Subject: [PATCH 10/12] test(server): poll for signed frames instead of a fixed 5 ms nap (shared until helper) --- .../server/api/src/__tests__/helpers/until.ts | 19 +++++++++++++++++++ .../nodes/__tests__/held-connections.test.ts | 8 +++++--- .../remote-attach-ws.integration.test.ts | 10 +--------- .../ws/__tests__/remote-subshell-ws.test.ts | 10 +--------- 4 files changed, 26 insertions(+), 21 deletions(-) create mode 100644 apps/server/api/src/__tests__/helpers/until.ts diff --git a/apps/server/api/src/__tests__/helpers/until.ts b/apps/server/api/src/__tests__/helpers/until.ts new file mode 100644 index 000000000..eca692d4a --- /dev/null +++ b/apps/server/api/src/__tests__/helpers/until.ts @@ -0,0 +1,19 @@ +/** + * Poll `cond` until it is true, throwing after `budgetMs`. + * + * Command dispatch through the node link is JWS-signed before it is sent, so + * "assert the frame landed" cannot be a fixed `setTimeout` peek: under CI load + * (4-core runner, `--parallel`) the chain is slow-but-fine and a 5 ms nap + * reads it as nothing-sent - the shape of the 2026-09-30 flake in + * `held-connections.test.ts`. The budget keeps a real regression red rather + * than merely late. (Two ws integration suites carried private copies of + * this; this is the third, extracted at the point the duplication stopped + * paying rent.) + */ +export async function until(cond: () => boolean, what = "condition", budgetMs = 4000): Promise { + for (let waited = 0; ; waited += 5) { + if (cond()) return; + if (waited > budgetMs) throw new Error(`timed out waiting for ${what}`); + await new Promise((r) => setTimeout(r, 5)); + } +} diff --git a/apps/server/api/src/services/nodes/__tests__/held-connections.test.ts b/apps/server/api/src/services/nodes/__tests__/held-connections.test.ts index e41247a71..58afb6752 100644 --- a/apps/server/api/src/services/nodes/__tests__/held-connections.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/held-connections.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { NODE_CLOSE_SUPERSEDED } from "@internal/subshell-protocol"; +import { until } from "@/__tests__/helpers/until.js"; import { attachConnection, disconnectNode, @@ -181,13 +182,14 @@ describe("held connections", () => { expect(ws.sent).toHaveLength(0); // `update` still reaches it — that is the whole reason the socket is held. - void sendCommand("n1", { + const update = sendCommand("n1", { type: "update", version: "9.9.9", url: "http://127.0.0.1:1/subshell-node-cli-linux-x64", sha256: "0".repeat(64), - }).catch(() => undefined); - await new Promise((r) => setTimeout(r, 5)); + }); + update.catch(() => undefined); // the URL is a dead port; delivery is the assertion + await until(() => ws.sent.length > 0, "the update frame"); expect(ws.sent).toHaveLength(1); }); diff --git a/apps/server/api/src/ws/__tests__/remote-attach-ws.integration.test.ts b/apps/server/api/src/ws/__tests__/remote-attach-ws.integration.test.ts index 76c772c55..3ea95ef8d 100644 --- a/apps/server/api/src/ws/__tests__/remote-attach-ws.integration.test.ts +++ b/apps/server/api/src/ws/__tests__/remote-attach-ws.integration.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeAll, describe, expect, it } from "bun:test"; import { appendFileSync } from "node:fs"; import { tmuxSocketFor } from "@internal/pane-runtime"; import type { NodeCommandBody } from "@internal/subshell-protocol"; +import { until } from "@/__tests__/helpers/until.js"; import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; import { NodesRepository } from "@/db/repositories/nodes.repository.js"; @@ -34,15 +35,6 @@ const LOG = "ab\ncd\n"; // 7 bytes — small enough that the whole window replay const b64 = (s: string): string => Buffer.from(s, "utf8").toString("base64"); -/** Poll `cond` until true (signing/RPC settle on real async paths). */ -async function until(cond: () => boolean, what = "condition", budgetMs = 4000): Promise { - for (let waited = 0; ; waited += 5) { - if (cond()) return; - if (waited > budgetMs) throw new Error(`timed out waiting for ${what}`); - await new Promise((r) => setTimeout(r, 5)); - } -} - /** Give a late second send a bounded chance to land (then it never should). */ async function sawWithin(cond: () => boolean, budgetMs: number): Promise { return await until(cond, "late second send", budgetMs).then( diff --git a/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts b/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts index c679e90f9..ed163bd5d 100644 --- a/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts +++ b/apps/server/api/src/ws/__tests__/remote-subshell-ws.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import type { JsonValue, NodeCommandBody } from "@internal/subshell-protocol"; +import { until } from "@/__tests__/helpers/until.js"; import { db } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; @@ -42,15 +43,6 @@ const ESU = "\x1b[?2026l"; const b64 = (s: string): string => Buffer.from(s, "utf8").toString("base64"); -/** Poll `cond` until true (signing/RPC settle on real async paths). */ -async function until(cond: () => boolean, what = "condition", budgetMs = 4000): Promise { - for (let waited = 0; ; waited += 5) { - if (cond()) return; - if (waited > budgetMs) throw new Error(`timed out waiting for ${what}`); - await new Promise((r) => setTimeout(r, 5)); - } -} - /** One unwrapped command frame: the claims' jti plus the plain command body. */ interface WireCmd { jti: string; From f61062f43c00bb6e87e98c3c68bb33124c3dcc51 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 20:00:22 -0700 Subject: [PATCH 11/12] ci(web): run the react-act suite serially in CI; the container starves parallel workers --- .github/workflows/test.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aab1d77dd..36705d5b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -183,6 +183,15 @@ jobs: runs-on: ubuntu-24.04 container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 timeout-minutes: 15 + # The react-act suite is CI's one starved slice: this container gets a + # fraction of its 4 labels (measured 0.4 CPU in the 2026-09-17 budgets; + # reconfirmed 2026-09-30, where width 4 stretched a 2.5 s act chain to + # 25 s and bun's per-test timeouts fired three times - a width-4 budget + # raise would not help, the tests' own inner waitFor budgets starve too). + # Serial is the regime this suite has passed in here for months; dev + # machines keep the script's 12 (measured 11 s for the whole suite). + env: + SUBSHELL_TEST_PARALLEL: 1 defaults: run: # A container job defaults to `sh`, not bash, and these steps use From caa7cccc0030c7b01ef0320ecb87d6c84de91f90 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Tue, 29 Sep 2026 20:14:22 -0700 Subject: [PATCH 12/12] test: CI runs serial via SUBSHELL_TEST_SERIAL; parallel is the local default (par-test.sh) --- .github/workflows/test.yml | 22 ++++++---------- apps/client/desktop/package.json | 2 +- .../ui/src/__tests__/tauri-config.test.ts | 7 ++++- apps/client/mobile/package.json | 2 +- apps/docs/package.json | 2 +- apps/node/agent/package.json | 2 +- apps/node/web/package.json | 2 +- apps/server/api/scripts/test-run.sh | 17 +++++++----- apps/server/desktop/package.json | 2 +- .../ui/src/__tests__/tauri-config.test.ts | 15 +++++++---- apps/server/web/package.json | 2 +- apps/website/package.json | 2 +- packages/assistant/package.json | 2 +- packages/backend-errors/package.json | 2 +- packages/mcp-core/package.json | 2 +- packages/node-admin/package.json | 2 +- packages/pane-runtime/package.json | 2 +- packages/plugin-api/package.json | 2 +- packages/plugins/claude-code/package.json | 2 +- .../plugins/cloudflare-tunnel/package.json | 2 +- packages/plugins/codex/package.json | 2 +- packages/plugins/headscale/package.json | 2 +- packages/plugins/hermes/package.json | 2 +- packages/plugins/netbird/package.json | 2 +- packages/plugins/opencode/package.json | 2 +- packages/plugins/pi/package.json | 2 +- packages/plugins/tailscale/package.json | 2 +- packages/plugins/terminal/package.json | 2 +- packages/subshell-protocol/package.json | 2 +- scripts/par-test.sh | 26 +++++++++++++++++++ 30 files changed, 85 insertions(+), 52 deletions(-) create mode 100755 scripts/par-test.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36705d5b4..07bf8ff68 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,12 +63,15 @@ on: branches: - main -# Every package's `test` script defaults its own bun --parallel width; these -# runners are 4-core hosted machines, and 12 CPU-heavy workers here starve -# wall-clock timeouts (measured: the web suite's 5 s default blew at 12/4 on -# run 36660089740, green at 48 cores locally). One knob, no per-job repeats. +# CI runs every suite SERIALLY inside its package (issue #261's scripts +# still parallelize local runs). The hosted container gets a fraction of its +# 4 labels (0.4 CPU measured in apps/server/web's budgets, twice), and bun's +# --parallel at ANY width - even 1, which is not serial - starved +# time-budgeted suites into per-test timeouts across three CI attempts on +# PR #277. scripts/par-test.sh omits the flag on this variable; the shard +# matrix is CI's speed, not worker threads. env: - SUBSHELL_TEST_PARALLEL: 4 + SUBSHELL_TEST_SERIAL: "1" permissions: contents: read @@ -183,15 +186,6 @@ jobs: runs-on: ubuntu-24.04 container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 timeout-minutes: 15 - # The react-act suite is CI's one starved slice: this container gets a - # fraction of its 4 labels (measured 0.4 CPU in the 2026-09-17 budgets; - # reconfirmed 2026-09-30, where width 4 stretched a 2.5 s act chain to - # 25 s and bun's per-test timeouts fired three times - a width-4 budget - # raise would not help, the tests' own inner waitFor budgets starve too). - # Serial is the regime this suite has passed in here for months; dev - # machines keep the script's 12 (measured 11 s for the whole suite). - env: - SUBSHELL_TEST_PARALLEL: 1 defaults: run: # A container job defaults to `sh`, not bash, and these steps use diff --git a/apps/client/desktop/package.json b/apps/client/desktop/package.json index c20ac0661..f5c567742 100644 --- a/apps/client/desktop/package.json +++ b/apps/client/desktop/package.json @@ -15,7 +15,7 @@ "lint": "biome check --write --unsafe src ui", "lint:check": "biome check src ui", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src ui", - "test": "bun test --path-ignore-patterns \"**/ui/**\" src --parallel=${SUBSHELL_TEST_PARALLEL:-4} && cd ui && bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test --path-ignore-patterns \"**/ui/**\" src && cd ui && bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit && tsc --project ui/tsconfig.json --noEmit", "compile:release": "bun run src/scripts/release.ts" }, diff --git a/apps/client/desktop/ui/src/__tests__/tauri-config.test.ts b/apps/client/desktop/ui/src/__tests__/tauri-config.test.ts index 6a399deca..4027810cc 100644 --- a/apps/client/desktop/ui/src/__tests__/tauri-config.test.ts +++ b/apps/client/desktop/ui/src/__tests__/tauri-config.test.ts @@ -146,7 +146,12 @@ describe("the two test runs", () => { }); it("runs the web half with ui/ as the cwd, which is how it finds its preload", () => { - expect(pkg.scripts.test).toContain("cd ui && bun test"); + // Issue #261: each half runs through scripts/par-test.sh (the --parallel + // flag has to be omittable for CI, which package.json cannot express); + // the second half still executes from ui/ as its cwd. + expect(pkg.scripts.test).toContain( + 'cd ui && bash "$(git rev-parse --show-toplevel)/scripts/par-test.sh" 4 bun test', + ); const bunfig = readFileSync(join(import.meta.dir, "../../bunfig.toml"), "utf8"); expect(bunfig).toContain('preload = ["./src/test-setup.ts"]'); }); diff --git a/apps/client/mobile/package.json b/apps/client/mobile/package.json index 83420e42c..f4073ff29 100644 --- a/apps/client/mobile/package.json +++ b/apps/client/mobile/package.json @@ -15,7 +15,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src app scripts", "lint:check": "biome check --no-errors-on-unmatched src app scripts", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src app scripts", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4} --pass-with-no-tests", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test --pass-with-no-tests", "verify-types": "tsc --noEmit", "harness:m1": "bun scripts/m1-transport-harness.ts" }, diff --git a/apps/docs/package.json b/apps/docs/package.json index 979d5dfd1..11eff806b 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -13,7 +13,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe app lib components src content", "lint:check": "biome check --no-errors-on-unmatched app lib components src content", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged app lib components src content", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/node/agent/package.json b/apps/node/agent/package.json index 01b37e1d3..984be0e5a 100644 --- a/apps/node/agent/package.json +++ b/apps/node/agent/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-12}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 12 bun test", "verify-types": "tsc --project tsconfig.json --noEmit", "compile": "bun build --compile --bytecode --minify --sourcemap ./src/main.ts --outfile ./dist/subshell", "compile:release": "bun run src/scripts/release.ts" diff --git a/apps/node/web/package.json b/apps/node/web/package.json index 1a0bbfa44..cd518a372 100644 --- a/apps/node/web/package.json +++ b/apps/node/web/package.json @@ -12,7 +12,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4} --pass-with-no-tests", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test --pass-with-no-tests", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/server/api/scripts/test-run.sh b/apps/server/api/scripts/test-run.sh index 9c1053780..3c8ccdfef 100755 --- a/apps/server/api/scripts/test-run.sh +++ b/apps/server/api/scripts/test-run.sh @@ -16,16 +16,19 @@ # # --parallel (issue #261): worker processes for the run's files. Default 12, # measured on this repo's suite at 48 cores; SUBSHELL_TEST_PARALLEL overrides -# it (CI pins 4 for its 4-core runners - 12 starved the web suite's wall-clock -# timeouts there). bunfig's `parallel` key is silently ignored by bun (1.4.0 -# and 1.4.2), so the flag lives here and in every package's `test` script, and -# a bare hand-typed `bun test` stays serial (the preload above still applies -# to it). Trailing args pass through to `bun test` (CI appends --shard for -# its matrix legs). +# the width and SUBSHELL_TEST_SERIAL=1 omits the flag (CI's regime - its +# container starves parallel workers; see scripts/par-test.sh). bunfig's +# `parallel` key is silently ignored by bun (1.4.0 and 1.4.2), so the flag +# lives here and in every package's `test` script, and a bare hand-typed +# `bun test` stays serial (the preload above still applies to it). Trailing +# args pass through to `bun test` (CI appends --shard for its matrix legs). set -u +par="--parallel=12" +[ -n "${SUBSHELL_TEST_PARALLEL:-}" ] && par="--parallel=$SUBSHELL_TEST_PARALLEL" +[ -n "${SUBSHELL_TEST_SERIAL:-}" ] && par="" dir="$(mktemp -d /tmp/subshell-test-tmux-XXXXXX)" status=0 -TMUX_TMPDIR="$dir" bun test --parallel="${SUBSHELL_TEST_PARALLEL:-12}" --timeout 30000 src "$@" || status=$? +TMUX_TMPDIR="$dir" bun test $par --timeout 30000 src "$@" || status=$? if command -v tmux >/dev/null 2>&1; then for sock in "$dir"/tmux-*/*; do [ -e "$sock" ] || continue diff --git a/apps/server/desktop/package.json b/apps/server/desktop/package.json index 464a2a70c..160fc5dd0 100644 --- a/apps/server/desktop/package.json +++ b/apps/server/desktop/package.json @@ -15,7 +15,7 @@ "lint": "biome check --write --unsafe src ui", "lint:check": "biome check src ui", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src ui", - "test": "bun test --path-ignore-patterns \"**/ui/**\" src --parallel=${SUBSHELL_TEST_PARALLEL:-4} && cd ui && bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test --path-ignore-patterns \"**/ui/**\" src && cd ui && bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit && tsc --project ui/tsconfig.json --noEmit", "compile:release": "bun run src/scripts/release.ts" }, diff --git a/apps/server/desktop/ui/src/__tests__/tauri-config.test.ts b/apps/server/desktop/ui/src/__tests__/tauri-config.test.ts index e671cb366..46557bdda 100644 --- a/apps/server/desktop/ui/src/__tests__/tauri-config.test.ts +++ b/apps/server/desktop/ui/src/__tests__/tauri-config.test.ts @@ -158,11 +158,16 @@ describe("the build wiring", () => { describe("the test runs", () => { it("runs the release-script tests, then the page's under its own harness", () => { // The client app's two-half wiring, mirrored at the moment this page - // gained component tests: `bun test src` (the release script, plain bun, - // no DOM) first, then `cd ui && bun test`, whose cwd is what resolves - // `ui/bunfig.toml`. - expect(pkg.scripts.test).toContain(`bun test --path-ignore-patterns "**/ui/**" src`); - expect(pkg.scripts.test).toContain("cd ui && bun test"); + // gained component tests: the release-script `bun test src` (plain bun, + // no DOM) first, then `cd ui && bun test`, whose cwd is what + // resolves `ui/bunfig.toml`. Issue #261 wrapped each half in + // scripts/par-test.sh (the bun --parallel flag must be omittable for CI, + // which neither bun's script shell nor a package.json string can express) + // - both halves still start at `bun test`, which is what this pins. + expect(pkg.scripts.test).toContain(`par-test.sh" 4 bun test --path-ignore-patterns "**/ui/**" src`); + expect(pkg.scripts.test).toContain( + 'cd ui && bash "$(git rev-parse --show-toplevel)/scripts/par-test.sh" 4 bun test', + ); }); it("carries the happy-dom preload in ui/bunfig.toml and nowhere the release script can see", () => { diff --git a/apps/server/web/package.json b/apps/server/web/package.json index 68927f833..613e0ccd1 100644 --- a/apps/server/web/package.json +++ b/apps/server/web/package.json @@ -12,7 +12,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe src", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-12} --pass-with-no-tests", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 12 bun test --pass-with-no-tests", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/apps/website/package.json b/apps/website/package.json index cf2564901..678107185 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -15,7 +15,7 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe app lib components scripts", "lint:check": "biome check --no-errors-on-unmatched app lib components scripts", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged app lib components scripts", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --noEmit" }, "dependencies": { diff --git a/packages/assistant/package.json b/packages/assistant/package.json index 320644e91..35ee43471 100644 --- a/packages/assistant/package.json +++ b/packages/assistant/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/backend-errors/package.json b/packages/backend-errors/package.json index cbe2544d6..1e111335b 100644 --- a/packages/backend-errors/package.json +++ b/packages/backend-errors/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index b20ef0011..583b1c5b7 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/node-admin/package.json b/packages/node-admin/package.json index 54d019940..54dfee153 100644 --- a/packages/node-admin/package.json +++ b/packages/node-admin/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/pane-runtime/package.json b/packages/pane-runtime/package.json index 7094799c5..dce553ba0 100644 --- a/packages/pane-runtime/package.json +++ b/packages/pane-runtime/package.json @@ -20,7 +20,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index e4db17b4e..acab08d98 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -32,7 +32,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/claude-code/package.json b/packages/plugins/claude-code/package.json index a550f71fb..7831fbe38 100644 --- a/packages/plugins/claude-code/package.json +++ b/packages/plugins/claude-code/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/cloudflare-tunnel/package.json b/packages/plugins/cloudflare-tunnel/package.json index c81b43d0c..c8d269266 100644 --- a/packages/plugins/cloudflare-tunnel/package.json +++ b/packages/plugins/cloudflare-tunnel/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/codex/package.json b/packages/plugins/codex/package.json index d5fb684d7..ffa5f04a9 100644 --- a/packages/plugins/codex/package.json +++ b/packages/plugins/codex/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/headscale/package.json b/packages/plugins/headscale/package.json index ca28ff4ad..f1918f382 100644 --- a/packages/plugins/headscale/package.json +++ b/packages/plugins/headscale/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/hermes/package.json b/packages/plugins/hermes/package.json index 269cc81f0..723c082d0 100644 --- a/packages/plugins/hermes/package.json +++ b/packages/plugins/hermes/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/netbird/package.json b/packages/plugins/netbird/package.json index f1e2c89c0..bf200188f 100644 --- a/packages/plugins/netbird/package.json +++ b/packages/plugins/netbird/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/opencode/package.json b/packages/plugins/opencode/package.json index cce861dd8..e12a3c194 100644 --- a/packages/plugins/opencode/package.json +++ b/packages/plugins/opencode/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/pi/package.json b/packages/plugins/pi/package.json index e7b91d8b9..4817c2729 100644 --- a/packages/plugins/pi/package.json +++ b/packages/plugins/pi/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/tailscale/package.json b/packages/plugins/tailscale/package.json index 7f0c77eb7..137a3e3ad 100644 --- a/packages/plugins/tailscale/package.json +++ b/packages/plugins/tailscale/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/plugins/terminal/package.json b/packages/plugins/terminal/package.json index 282c03dc8..9255324e5 100644 --- a/packages/plugins/terminal/package.json +++ b/packages/plugins/terminal/package.json @@ -28,7 +28,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "devDependencies": { diff --git a/packages/subshell-protocol/package.json b/packages/subshell-protocol/package.json index 4036d723a..88dc0afa1 100644 --- a/packages/subshell-protocol/package.json +++ b/packages/subshell-protocol/package.json @@ -40,7 +40,7 @@ "lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix", "lint:check": "biome check --no-errors-on-unmatched src", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src", - "test": "bun test --parallel=${SUBSHELL_TEST_PARALLEL:-4}", + "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", "verify-types": "tsc --project tsconfig.json --noEmit" }, "dependencies": { diff --git a/scripts/par-test.sh b/scripts/par-test.sh new file mode 100755 index 000000000..cc59c1d6a --- /dev/null +++ b/scripts/par-test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Injects (or omits) bun test's --parallel flag for every package's `test` script. +# +# Default is parallel N (first argument; per-package, measured). Two overrides: +# SUBSHELL_TEST_PARALLEL changes the width, and SUBSHELL_TEST_SERIAL=1 omits +# the flag entirely - which CI does. CI's omission is not caution: bun's +# --parallel=1 is NOT serial (it still implies --isolate, re-importing each +# file's modules), and the hosted container runs on a fraction of its 4 labels +# (0.4 CPU, measured in apps/server/web's 2026-09-17 budgets and again +# 2026-09-30, where width 1 stretched a 2.5 s react-act chain past 25 s). The +# suite-level budgets starve at any worker width there; serial is the regime +# that passes. Developers get the width; CI gets determinism. +# +# The flag must be omittable, and neither bun's script shell (no ${VAR:+}) nor +# a package.json string can express omission - hence a script, called as +# bash "$(git rev-parse --show-toplevel)/scripts/par-test.sh" bun test ... +set -u +n="$1" +shift +if [ -n "${SUBSHELL_TEST_PARALLEL:-}" ]; then n="$SUBSHELL_TEST_PARALLEL"; fi +# Insert the flag where bun test parses it: after `bun test`, before the rest. +prog="$1" +sub="$2" +shift 2 +if [ -z "${SUBSHELL_TEST_SERIAL:-}" ]; then set -- --parallel="$n" "$@"; fi +exec "$prog" "$sub" "$@"