Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
99 changes: 99 additions & 0 deletions scripts/test-parallel.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<void> => {
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`);
}
4 changes: 2 additions & 2 deletions scripts/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<void> {
export async function waitForExclusiveRun(selfPid: number): Promise<void> {
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
Expand Down
10 changes: 10 additions & 0 deletions tests/test-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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;
Expand Down