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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ approving any non-trivial change. CI runs these on Linux, Windows, and macOS.

If `test:parallel` reports failed files, rerun **only those files**
(`bun run test:parallel tests/foo.test.ts …`). The runner already retries
failed files once on a single worker. Do not recover from a load flake by
rerunning the entire suite at `CCX_TEST_PARALLEL_WORKERS=2`.
failed items once on a single worker, in the same isolation mode they failed
in (shared-process batches stay batches). Isolated reruns of a failed batch
are diagnostic only and cannot mark the suite green. Do not recover from a
load flake by rerunning the entire suite at `CCX_TEST_PARALLEL_WORKERS=2`.

## Issues and pull requests (agents)

Expand Down
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ Most tests are flat `tests/*.test.ts` Bun tests. `tests/helpers/` contains share
`tests/e2e-style/` contains broader native-parity scenarios. Keep a focused regression near the
existing tests for the subsystem you change; run `bun run test:parallel` for shared routing,
adapters, config, or server behavior. If that reports failed files, rerun only those files — not
the entire suite. The parallel runner retries failed files once automatically.
the entire suite. The parallel runner retries failed items once in the same
isolation mode; an isolated rerun of a failed shared-process batch cannot mark
the suite green.

The docs site you're reading lives in `docs-site/` (Astro + Starlight):

Expand Down
28 changes: 21 additions & 7 deletions gui/scripts/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@
* afterEach then throws on `window.event` and poisons later files. `--parallel`
* implies `--isolate` (fresh global per file) and is the default here, capped
* like the proxy suite at min(4, CPU). Failed files retry once on a single
* worker — never a full-suite rerun.
* worker — never a full-suite rerun — and retries forward the caller's `bun test`
* flags unchanged (including `--timeout`).
*
* Override with CCX_TEST_PARALLEL_WORKERS and CCX_TEST_RETRY.
*/
import { availableParallelism } from "node:os";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative } from "node:path";
import { partitionBunTestCliArgs, waitForExclusiveRun } from "../../scripts/test";

export { partitionBunTestCliArgs };

const GUI_ROOT = join(import.meta.dir, "..");
const TEST_ROOT = "tests";
Expand Down Expand Up @@ -60,6 +64,10 @@ function displayPath(file: string): string {
return rel && !rel.startsWith("..") ? rel : file;
}

export function retryTestArgs(callerFlags: readonly string[], file: string): string[] {
return [...callerFlags, "--isolate", file];
}

function resolveSuiteFile(file: string): string {
return isAbsolute(file) ? file : join(GUI_ROOT, file);
}
Expand All @@ -82,7 +90,12 @@ function spawnTest(args: string[], junitPath?: string): Promise<number> {
return child.exited.then(code => code ?? 1);
}

async function runQueue(files: string[], workers: number, label: string): Promise<string[]> {
async function runQueue(
files: string[],
workers: number,
label: string,
callerFlags: readonly string[],
): Promise<string[]> {
const failed: string[] = [];
let next = 0;
const workerCount = Math.min(workers, Math.max(1, files.length));
Expand All @@ -92,7 +105,7 @@ async function runQueue(files: string[], workers: number, label: string): Promis
next += 1;
if (index >= files.length) return;
const file = files[index]!;
const code = await spawnTest(["--isolate", file]);
const code = await spawnTest(retryTestArgs(callerFlags, file));
const status = code === 0 ? "ok" : "FAIL";
console.warn(`[gui:test] ${label} ${index + 1}/${files.length} ${status} ${displayPath(file)}`);
if (code !== 0) failed.push(file);
Expand All @@ -103,7 +116,8 @@ async function runQueue(files: string[], workers: number, label: string): Promis
}

async function main(): Promise<void> {
const requested = process.argv.slice(2);
const { flags: callerFlags, targets: requested } = partitionBunTestCliArgs(process.argv.slice(2));
await waitForExclusiveRun(process.pid);
const workers = resolveWorkerCount();
const retryCount = resolveRetryCount();
const scratch = mkdtempSync(join(tmpdir(), "ccx-gui-test-"));
Expand All @@ -112,11 +126,11 @@ async function main(): Promise<void> {
try {
const patterns = requested.length > 0 ? requested : [TEST_ROOT];
console.warn(
`[gui:test] ${patterns.join(" ")} across ${workers} worker(s) `
`[gui:test] ${[...callerFlags, ...patterns].join(" ")} across ${workers} worker(s) `
+ `(CCX_TEST_PARALLEL_WORKERS / CCX_TEST_RETRY to override)`,
);
const startedAt = Date.now();
const code = await spawnTest([`--parallel=${workers}`, ...patterns], junitPath);
const code = await spawnTest([`--parallel=${workers}`, ...callerFlags, ...patterns], junitPath);

let failures: string[] = [];
if (code !== 0) {
Expand Down Expand Up @@ -144,7 +158,7 @@ async function main(): Promise<void> {
for (const file of failures) console.warn(` ${displayPath(file)}`);
const stillFailing = new Set(failures);
for (let attempt = 1; attempt <= retryCount; attempt += 1) {
const retryFailures = await runQueue([...stillFailing], 1, `retry ${attempt}/${retryCount}`);
const retryFailures = await runQueue([...stillFailing], 1, `retry ${attempt}/${retryCount}`, callerFlags);
const failedThisPass = new Set(retryFailures);
for (const file of stillFailing) {
if (!failedThisPass.has(file)) recovered.push(file);
Expand Down
21 changes: 20 additions & 1 deletion gui/tests/gui-test-runner.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { failedFilesFromJunit, resolveRetryCount, resolveWorkerCount } from "../scripts/test";
import { failedFilesFromJunit, partitionBunTestCliArgs, resolveRetryCount, resolveWorkerCount, retryTestArgs } from "../scripts/test";

describe("GUI test runner", () => {
test("defaults to a bounded worker count and validates overrides", () => {
Expand Down Expand Up @@ -27,6 +27,25 @@ describe("GUI test runner", () => {
win.close();
});

test("retries forward caller flags including --timeout", () => {
expect(partitionBunTestCliArgs(["--timeout", "1", "tests/foo.test.tsx"])).toEqual({
flags: ["--timeout", "1"],
targets: ["tests/foo.test.tsx"],
});
expect(retryTestArgs(["--timeout", "1"], "tests/foo.test.tsx")).toEqual([
"--timeout",
"1",
"--isolate",
"tests/foo.test.tsx",
]);
expect(retryTestArgs(["--timeout=1", "--bail"], "tests/bar.test.ts")).toEqual([
"--timeout=1",
"--bail",
"--isolate",
"tests/bar.test.ts",
]);
});

test("junit parser names only files that actually failed", () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="bun test" tests="3" failures="2" errors="0">
Expand Down
163 changes: 120 additions & 43 deletions scripts/test-parallel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@
* `CCX_TEST_PARALLEL_WORKERS`. Batch size defaults to 8 (`CCX_TEST_BATCH_SIZE`).
* `CCX_TEST_FORCE_ISOLATE=1` restores one process per file.
*
* Failed items are retried once, file-by-file, on a single worker. That is the
* load-flake recovery path: do not rerun the entire suite. Optional positional
* args restrict the run to the given test files (including retry-only reruns).
* Failed items are retried once on a single worker, in the same isolation mode
* they failed in. Shared-process batches retry as batches; isolated files retry
* isolated. That is the load-flake recovery path: do not rerun the entire suite,
* and do not stamp a batch failure green because the files later passed
* `--isolate`. Isolated reruns of a still-failing batch are diagnostic only.
*
* Optional positional args restrict the run to the given test files. Caller
* `bun test` flags (`--timeout`, and so on) are forwarded on every spawn,
* including retries.
*
* The same exclusivity queue as the serial runner applies.
*/
Expand All @@ -21,6 +27,7 @@ import { relative } from "node:path";
import {
createIsolatedTestEnvironment,
listRepositoryTestFiles,
partitionBunTestCliArgs,
waitForExclusiveRun,
} from "./test";
import {
Expand Down Expand Up @@ -52,11 +59,66 @@ export function resolveRetryCount(raw = process.env.CCX_TEST_RETRY): number {
return parsed;
}

interface WorkItem {
export interface WorkItem {
files: string[];
isolate: boolean;
}

/** Retry the failed item in the mode it failed in — never split a batch into isolate. */
export function retryQueueForFailures(failures: readonly WorkItem[]): WorkItem[] {
return failures.map(item => ({ files: [...item.files], isolate: item.isolate }));
}

/** Per-file `--isolate` reruns for a failed shared-process batch. Diagnostic only. */
export function diagnosticIsolateRetryItems(failures: readonly WorkItem[]): WorkItem[] {
const items: WorkItem[] = [];
for (const item of failures) {
if (item.isolate) continue;
for (const file of item.files) {
items.push({ files: [file], isolate: true });
}
}
return items;
}

export function workItemKey(item: WorkItem): string {
return `${item.isolate ? "I" : "B"}\0${item.files.join("\0")}`;
}

export async function retryFailuresInSameMode(
failures: WorkItem[],
retryCount: number,
run: (items: WorkItem[], workers: number, label: string) => Promise<WorkItem[]>,
): Promise<{ failures: WorkItem[]; recovered: string[] }> {
if (failures.length === 0 || retryCount <= 0) {
return { failures, recovered: [] };
}
const recovered: string[] = [];
let remaining = failures;
for (let attempt = 1; attempt <= retryCount; attempt += 1) {
const retryItems = retryQueueForFailures(remaining);
const retryFailures = await run(retryItems, 1, `retry ${attempt}/${retryCount}`);
const failedKeys = new Set(retryFailures.map(workItemKey));
for (const item of remaining) {
if (!failedKeys.has(workItemKey(item))) recovered.push(...item.files);
}
remaining = retryFailures;
if (remaining.length === 0) break;
}
return { failures: remaining, recovered };
}

export function bunTestArgvForWorkItem(
item: WorkItem,
extraArgs: readonly string[] = [],
memoryArgs: readonly string[] = [],
): string[] {
const isolateArgs = item.isolate
? ["--isolate"]
: ["--no-isolate", "--max-concurrency=1"];
return ["test", ...isolateArgs, ...memoryArgs, ...extraArgs, ...item.files];
}

function displayPath(file: string): string {
const rel = relative(process.cwd(), file);
return rel && !rel.startsWith("..") ? rel : file;
Expand All @@ -67,14 +129,11 @@ function displayItem(item: WorkItem): string {
return `${item.files.length} files (${displayPath(item.files[0]!)} …)`;
}

function runWorkItem(item: WorkItem): Promise<number> {
function runWorkItem(item: WorkItem, extraArgs: readonly string[]): Promise<number> {
const isolated = createIsolatedTestEnvironment();
const memoryArgs = process.env.CCX_TEST_SMOL === "1" ? ["--smol"] : [];
const isolateArgs = item.isolate
? ["--isolate"]
: ["--no-isolate", "--max-concurrency=1"];
return Bun.spawn(
[process.execPath, "test", ...isolateArgs, ...memoryArgs, ...item.files],
[process.execPath, ...bunTestArgvForWorkItem(item, extraArgs, memoryArgs)],
{
env: isolated.env,
stdin: "inherit",
Expand All @@ -87,30 +146,32 @@ function runWorkItem(item: WorkItem): Promise<number> {
});
}

async function runQueue(items: WorkItem[], workers: number, label: string): Promise<WorkItem[]> {
const workerCount = Math.min(workers, Math.max(1, items.length));
let next = 0;
const failures: WorkItem[] = [];

const workerLoop = async (): Promise<void> => {
for (;;) {
const index = next++;
if (index >= items.length) return;
const item = items[index]!;
const code = await runWorkItem(item);
if (code !== 0) failures.push(item);
console.warn(
`[test:parallel] ${label} ${index + 1}/${items.length} ${code === 0 ? "ok" : "FAIL"} ${displayItem(item)}`,
);
}
};
function createRunQueue(extraArgs: readonly string[]) {
return async function runQueue(items: WorkItem[], workers: number, label: string): Promise<WorkItem[]> {
const workerCount = Math.min(workers, Math.max(1, items.length));
let next = 0;
const failures: WorkItem[] = [];

const workerLoop = async (): Promise<void> => {
for (;;) {
const index = next++;
if (index >= items.length) return;
const item = items[index]!;
const code = await runWorkItem(item, extraArgs);
if (code !== 0) failures.push(item);
console.warn(
`[test:parallel] ${label} ${index + 1}/${items.length} ${code === 0 ? "ok" : "FAIL"} ${displayItem(item)}`,
);
}
};

await Promise.all(Array.from({ length: workerCount }, () => workerLoop()));
return failures;
await Promise.all(Array.from({ length: workerCount }, () => workerLoop()));
return failures;
};
}

if (import.meta.main) {
const requested = process.argv.slice(2);
const { flags: extraArgs, targets: requested } = partitionBunTestCliArgs(process.argv.slice(2));
await waitForExclusiveRun(process.pid);

const files = requested.length > 0 ? requested : listRepositoryTestFiles();
Expand All @@ -123,6 +184,7 @@ if (import.meta.main) {
];
const workers = Math.min(resolveWorkerCount(), items.length);
const retryCount = resolveRetryCount();
const runQueue = createRunQueue(extraArgs);

console.warn(
`[test:parallel] ${files.length} file(s): ${plan.isolate.length} isolated, ${plan.batches.length} batch(es) `
Expand All @@ -132,34 +194,49 @@ if (import.meta.main) {

const startedAt = Date.now();
let failures = await runQueue(items, workers, "run");
const recovered: string[] = [];
let recovered: string[] = [];

if (failures.length > 0 && retryCount > 0) {
const retryFiles = filesFromFailedPlanItems(failures);
console.warn(
`[test:parallel] ${retryFiles.length} file(s) failed; retrying only those files (${retryCount} pass(es), 1 worker):`,
`[test:parallel] ${retryFiles.length} file(s) failed; retrying in the same isolation mode `
+ `(${retryCount} pass(es), 1 worker):`,
);
for (const file of retryFiles) console.warn(` ${displayPath(file)}`);
const retried = await retryFailuresInSameMode(failures, retryCount, runQueue);
failures = retried.failures;
recovered = retried.recovered;
}

const stillFailing = new Set(retryFiles);
for (let attempt = 1; attempt <= retryCount; attempt += 1) {
const retryItems: WorkItem[] = [...stillFailing].map(file => ({ files: [file], isolate: true }));
const retryFailures = await runQueue(retryItems, 1, `retry ${attempt}/${retryCount}`);
const failedThisPass = new Set(filesFromFailedPlanItems(retryFailures));
for (const file of stillFailing) {
if (!failedThisPass.has(file)) recovered.push(file);
if (failures.length > 0) {
const diagnosticItems = diagnosticIsolateRetryItems(failures);
if (diagnosticItems.length > 0) {
console.warn(
"[test:parallel] shared-process retry still failed; running isolated diagnostics "
+ "(these cannot mark the suite green):",
);
const diagnosticFailures = await runQueue(diagnosticItems, 1, "isolate-diagnostic");
const stillFailed = new Set(filesFromFailedPlanItems(diagnosticFailures));
let passedIsolated = 0;
for (const item of diagnosticItems) {
const file = item.files[0]!;
if (stillFailed.has(file)) continue;
passedIsolated += 1;
console.warn(` ${displayPath(file)} passed isolated (cross-file contamination or shared-process-only failure)`);
}
if (passedIsolated > 0 && stillFailed.size === 0) {
console.error(
"[test:parallel] every file in the failed batch passed `--isolate`. "
+ "That is not a pass: the shared-process batch is still red.",
);
}
stillFailing.clear();
for (const file of failedThisPass) stillFailing.add(file);
if (stillFailing.size === 0) break;
}
failures = [...stillFailing].map(file => ({ files: [file], isolate: true }));
}

const minutes = ((Date.now() - startedAt) / 60_000).toFixed(1);

if (recovered.length > 0) {
console.warn(`[test:parallel] recovered after retry (${recovered.length} file(s)):`);
console.warn(`[test:parallel] recovered after same-mode retry (${recovered.length} file(s)):`);
for (const file of recovered) console.warn(` ${displayPath(file)}`);
}

Expand Down
Loading