diff --git a/package.json b/package.json index 52762f1d1d..b4ab2aa7c7 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dev:gui": "cd gui && bun run dev", "start": "bun run src/cli/index.ts start", "test": "bun scripts/test.ts", + "test:parallel": "bun scripts/test-parallel.ts", "typecheck": "bun x tsc --noEmit", "audit:high": "bun audit --audit-level=high && cd gui && bun audit --audit-level=high", "privacy:scan": "bun scripts/privacy-scan.ts", diff --git a/scripts/test-parallel.ts b/scripts/test-parallel.ts new file mode 100644 index 0000000000..63cbc541f4 --- /dev/null +++ b/scripts/test-parallel.ts @@ -0,0 +1,99 @@ +/** + * Parallel full-suite runner. + * + * `scripts/test.ts` runs one fresh process per test file (shard size 1) because + * Bun 1.3.x on macOS can leave the event loop spinning after a server-heavy file + * completes, stalling the next file in the same process. That isolation is + * preserved here: each file still gets its own `bun test --isolate` process, + * but up to `CCX_TEST_PARALLEL_WORKERS` of them run at the same time. + * + * On a multi-core machine this turns the serial ~40-minute full suite into a + * few minutes. Default worker count is min(4, CPU count); override with + * `CCX_TEST_PARALLEL_WORKERS`. Optional positional args restrict the run to the + * given test files (useful for smoke probes). + * + * The same exclusivity queue as the serial runner applies: this script waits + * for any other `bun test --isolate` runner to finish before starting, so two + * suites never fight over the CPU. + */ +import { availableParallelism } from "node:os"; +import { + createIsolatedTestEnvironment, + findCompetingTestRunners, + listRepositoryTestFiles, + waitForExclusiveRun, +} from "./test"; + +export function resolveWorkerCount( + raw = process.env.CCX_TEST_PARALLEL_WORKERS, + cpuCount = availableParallelism(), +): number { + if (raw === undefined || raw.trim() === "") return Math.max(1, Math.min(4, cpuCount)); + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error( + `CCX_TEST_PARALLEL_WORKERS must be a positive integer, received ${JSON.stringify(raw)}`, + ); + } + return parsed; +} + +function runIsolatedFile(file: string): Promise { + const isolated = createIsolatedTestEnvironment(); + const memoryArgs = process.env.CCX_TEST_SMOL === "1" ? ["--smol"] : []; + return Bun.spawn( + [process.execPath, "test", "--isolate", ...memoryArgs, file], + { + env: isolated.env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ).exited.then((code) => { + isolated.cleanup(); + return code ?? 1; + }); +} + +if (import.meta.main) { + const requested = process.argv.slice(2); + await waitForExclusiveRun(process.pid); + + const files = requested.length > 0 ? requested : listRepositoryTestFiles(); + if (files.length === 0) throw new Error("no test files found"); + const workers = Math.min(resolveWorkerCount(), files.length); + + console.warn( + `[test:parallel] ${files.length} file(s) across ${workers} worker(s)` + + (requested.length > 0 ? " (explicit file list)" : " (CCX_TEST_PARALLEL_WORKERS to override)"), + ); + + const startedAt = Date.now(); + let next = 0; + let failed = 0; + const failures: string[] = []; + + const workerLoop = async (): Promise => { + for (;;) { + const index = next++; + if (index >= files.length) return; + const file = files[index]!; + const code = await runIsolatedFile(file); + if (code !== 0) { + failed += 1; + failures.push(file); + } + console.warn(`[test:parallel] ${index + 1}/${files.length} ${code === 0 ? "ok" : "FAIL"} ${file}`); + } + }; + + await Promise.all(Array.from({ length: workers }, () => workerLoop())); + const minutes = ((Date.now() - startedAt) / 60_000).toFixed(1); + + if (failures.length > 0) { + console.error(`[test:parallel] ${failures.length} file(s) failed after ${minutes} min:`); + for (const file of failures) console.error(` ${file}`); + process.exit(1); + } + console.warn(`[test:parallel] all ${files.length} file(s) passed in ${minutes} min`); +} diff --git a/scripts/test.ts b/scripts/test.ts index c1a5bd5b9d..8339a09ca9 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -126,7 +126,7 @@ function runIsolatedTestProcess(testArgs: readonly string[]): number { * `pgrep` is absent on Windows and may exit non-zero for "no matches"; both cases * mean "nothing to warn about" rather than an error worth failing a test run over. */ -function findCompetingTestRunners(selfPid: number): number[] { +export function findCompetingTestRunners(selfPid: number): number[] { try { const found = Bun.spawnSync(["pgrep", "-f", "bun.*test --isolate"], { stdout: "pipe", @@ -155,7 +155,7 @@ function findCompetingTestRunners(selfPid: number): number[] { * which bypasses this file entirely. Waiting is the behavior that survives being * worked around. `CCX_TEST_NO_QUEUE=1` opts out for anyone who really wants overlap. */ -async function waitForExclusiveRun(selfPid: number): Promise { +export async function waitForExclusiveRun(selfPid: number): Promise { if (process.env.CCX_TEST_NO_QUEUE === "1") return; const pollMs = 5_000; // Long enough for a full suite plus slack; past this, assume the holder is wedged diff --git a/tests/test-runner.test.ts b/tests/test-runner.test.ts index deb05cbb1d..9769c00fee 100644 --- a/tests/test-runner.test.ts +++ b/tests/test-runner.test.ts @@ -10,6 +10,7 @@ import { resolveTestShardSize, resolveTestStartShard, } from "../scripts/test"; +import { resolveWorkerCount } from "../scripts/test-parallel"; describe("test runner isolation", () => { test("redirects user homes to a disposable root", () => { @@ -40,6 +41,15 @@ describe("test runner isolation", () => { expect(() => partitionTestFiles(["a"], 0)).toThrow("positive integer"); }); + test("parallel runner defaults to a bounded worker count and validates overrides", () => { + expect(resolveWorkerCount(undefined, 10)).toBe(4); + expect(resolveWorkerCount(undefined, 2)).toBe(2); + expect(resolveWorkerCount("8", 10)).toBe(8); + expect(() => resolveWorkerCount("0", 10)).toThrow("positive integer"); + expect(() => resolveWorkerCount("1.5", 10)).toThrow("positive integer"); + expect(() => resolveWorkerCount("x", 10)).toThrow("positive integer"); + }); + test("uses a bounded default shard size and validates overrides", () => { const originalShardSize = process.env.CCX_TEST_SHARD_SIZE; delete process.env.CCX_TEST_SHARD_SIZE;