diff --git a/bunfig.toml b/bunfig.toml index 00cbc1231e..318845b44a 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,6 +5,7 @@ # so a bare `bun test` — or `bun test tests/` (a substring filter that also matches # devlog/opencode-cursor/tests/) — drags them in and reports hundreds of spurious failures. # `root` pins discovery to ./tests so every invocation stays on the real suite. +# File-level `--parallel` has no bunfig key; `scripts/test.ts` passes it for `bun run test`. # The npm script already uses `bun test ./tests/`; this makes a bare `bun test` behave the same. [test] root = "tests" diff --git a/scripts/test.ts b/scripts/test.ts index 5297a17722..9a48648261 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -59,6 +59,33 @@ export function createIsolatedTestEnvironment( }; } +function hasCliFlag(requested: string[], name: string): boolean { + return requested.some(arg => arg === name || arg.startsWith(`${name}=`)); +} + +/** True for a filter-less `bun run test`. `--timeout` / `--dots` / `--parallel=N` still count. */ +function isFullSuiteRun(requested: string[]): boolean { + return !requested.some(arg => arg !== "-" && !arg.startsWith("-")); +} + +/** + * Default `bun test` argv for this repo. + * + * `--isolate` keeps a fresh global per file, and is the substring the exclusive-run pgrep + * matches. `--parallel` is what makes the suite finishable: with isolate alone Bun re-evaluates + * the module graph once per file on a single core, so past ~900 files the run stops looking slow + * and starts looking hung — measured here at 1 h 29 m with zero output, ~57 % CPU and 8.5 MB RSS, + * against ~110-190 s for the identical suite with `--parallel`. A caller-supplied `--parallel=N` + * is left alone. + */ +export function resolveBunTestArgs(requested: string[]): string[] { + const args = ["--isolate"]; + if (!hasCliFlag(requested, "--parallel")) args.push("--parallel"); + args.push(...requested); + if (isFullSuiteRun(requested)) args.push("./tests/"); + return args; +} + /** * Other `bun test` runners already on this machine. * @@ -138,10 +165,18 @@ if (import.meta.main) { const isolated = createIsolatedTestEnvironment(); try { const requestedTests = process.argv.slice(2); - await waitForExclusiveRun(process.pid); + // Only full-suite runs queue. The lock guards CPU contention, not state — each run gets its + // own mkdtemp sandbox — and the case it was written for is two 900-file suites crawling into + // what reads as a hang. A focused file finishes in seconds, so making it wait behind someone + // else's multi-minute suite costs more than the contention it avoids. The trade-off is real + // though: with --parallel a full run already saturates the machine, so a focused run started + // alongside one does slow it. + if (isFullSuiteRun(requestedTests)) { + await waitForExclusiveRun(process.pid); + } const startedAt = Date.now(); const child = Bun.spawnSync( - [process.execPath, "test", "--isolate", ...(requestedTests.length > 0 ? requestedTests : ["./tests/"])], + [process.execPath, "test", ...resolveBunTestArgs(requestedTests)], { env: isolated.env, stdin: "inherit", @@ -152,7 +187,7 @@ if (import.meta.main) { const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); if (requestedTests.length === 0 && elapsedSeconds > 600) { console.warn( - `[test] the suite took ${elapsedSeconds}s; it normally runs in about 210s on an idle machine. ` + `[test] the suite took ${elapsedSeconds}s; with --parallel it should finish in a few minutes on an idle machine. ` + "Check for another test runner, a busy CPU, or a test that started polling something real.", ); } diff --git a/tests/test-runner.test.ts b/tests/test-runner.test.ts index ef48654f15..253a9afb41 100644 --- a/tests/test-runner.test.ts +++ b/tests/test-runner.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { isAbsolute, join } from "node:path"; -import { createIsolatedTestEnvironment } from "../scripts/test"; +import { createIsolatedTestEnvironment, resolveBunTestArgs } from "../scripts/test"; import { decodeWindowsIdentityPowerShellOutputForTests, windowsIdentityPowerShellCommandForTests, @@ -68,3 +68,32 @@ describe("test runner isolation", () => { }, ); }); + +/** + * Without `--parallel`, `--isolate` re-evaluates the module graph once per file on a single + * core. Past ~900 files that stops reading as slow and starts reading as hung: measured at + * 1 h 29 m with zero output, ~57 % CPU and 8.5 MB RSS, against ~110-190 s for the identical + * suite with the flag. These pin the argv so the flag cannot be dropped again silently. + */ +describe("bun test argv", () => { + test("a filter-less run gets isolate, parallel and the suite path", () => { + expect(resolveBunTestArgs([])).toEqual(["--isolate", "--parallel", "./tests/"]); + }); + + test("a file filter keeps isolate and parallel but no suite path", () => { + expect(resolveBunTestArgs(["tests/foo.test.ts"])) + .toEqual(["--isolate", "--parallel", "tests/foo.test.ts"]); + }); + + test("a caller-supplied concurrency is left alone", () => { + expect(resolveBunTestArgs(["--parallel=2"])) + .toEqual(["--isolate", "--parallel=2", "./tests/"]); + expect(resolveBunTestArgs(["--parallel"])) + .toEqual(["--isolate", "--parallel", "./tests/"]); + }); + + test("option-only arguments still count as a full suite run", () => { + expect(resolveBunTestArgs(["--timeout=30000"])) + .toEqual(["--isolate", "--parallel", "--timeout=30000", "./tests/"]); + }); +});