From ab1f2574cbea015375167f2a2e2ea716b84f0e8e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 18:50:51 +0000 Subject: [PATCH 1/2] fix: keep test-runner retries from laundering failures Shared-process batch failures retry in the same mode; isolated passes cannot stamp the suite green. The overlap lock covers all bun test processes, and retries forward caller flags including --timeout. Co-authored-by: pavelhov --- AGENTS.md | 6 +- docs-site/src/content/docs/contributing.md | 4 +- gui/scripts/test.ts | 28 +++- gui/tests/gui-test-runner.test.ts | 21 ++- scripts/test-parallel.ts | 163 +++++++++++++++------ scripts/test.ts | 66 ++++++++- tests/test-runner.test.ts | 115 ++++++++++++++- 7 files changed, 347 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ee4d0e6afc..4277487c5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 0e3f2c7abe..a07ba653ca 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -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): diff --git a/gui/scripts/test.ts b/gui/scripts/test.ts index e7d257accc..f6c746656a 100644 --- a/gui/scripts/test.ts +++ b/gui/scripts/test.ts @@ -6,7 +6,8 @@ * 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. */ @@ -14,6 +15,9 @@ 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"; @@ -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); } @@ -82,7 +90,12 @@ function spawnTest(args: string[], junitPath?: string): Promise { return child.exited.then(code => code ?? 1); } -async function runQueue(files: string[], workers: number, label: string): Promise { +async function runQueue( + files: string[], + workers: number, + label: string, + callerFlags: readonly string[], +): Promise { const failed: string[] = []; let next = 0; const workerCount = Math.min(workers, Math.max(1, files.length)); @@ -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); @@ -103,7 +116,8 @@ async function runQueue(files: string[], workers: number, label: string): Promis } async function main(): Promise { - 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-")); @@ -112,11 +126,11 @@ async function main(): Promise { 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) { @@ -144,7 +158,7 @@ async function main(): Promise { 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); diff --git a/gui/tests/gui-test-runner.test.ts b/gui/tests/gui-test-runner.test.ts index 7fb5931a06..c5f2b826a9 100644 --- a/gui/tests/gui-test-runner.test.ts +++ b/gui/tests/gui-test-runner.test.ts @@ -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", () => { @@ -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 = ` diff --git a/scripts/test-parallel.ts b/scripts/test-parallel.ts index d42ee6fe90..a70d26cebf 100644 --- a/scripts/test-parallel.ts +++ b/scripts/test-parallel.ts @@ -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. */ @@ -21,6 +27,7 @@ import { relative } from "node:path"; import { createIsolatedTestEnvironment, listRepositoryTestFiles, + partitionBunTestCliArgs, waitForExclusiveRun, } from "./test"; import { @@ -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, +): 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; @@ -67,14 +129,11 @@ function displayItem(item: WorkItem): string { return `${item.files.length} files (${displayPath(item.files[0]!)} …)`; } -function runWorkItem(item: WorkItem): Promise { +function runWorkItem(item: WorkItem, extraArgs: readonly string[]): Promise { 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", @@ -87,30 +146,32 @@ function runWorkItem(item: WorkItem): Promise { }); } -async function runQueue(items: WorkItem[], workers: number, label: string): Promise { - const workerCount = Math.min(workers, Math.max(1, items.length)); - let next = 0; - const failures: WorkItem[] = []; - - const workerLoop = async (): Promise => { - 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 { + const workerCount = Math.min(workers, Math.max(1, items.length)); + let next = 0; + const failures: WorkItem[] = []; + + const workerLoop = async (): Promise => { + 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(); @@ -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) ` @@ -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)}`); } diff --git a/scripts/test.ts b/scripts/test.ts index 8339a09ca9..57a064d857 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -115,6 +115,57 @@ function runIsolatedTestProcess(testArgs: readonly string[]): number { } } +/** + * Bun test flags that consume the next argv token when written without `=`. + * Used so `--timeout 1 tests/foo.test.ts` keeps `1` as a flag value, not a file. + */ +const BUN_TEST_VALUE_FLAGS = new Set([ + "--timeout", + "--preload", + "-r", + "--reporter", + "--reporter-outfile", + "--max-concurrency", + "--parallel", + "--test-name-pattern", + "-t", + "--rerun-each", + "--seed", + "--bail", + "--coverage-reporter", + "--env-file", + "--dotenv", + "--tsconfig", + "--config", + "-c", + "--max-timeout", + "--retry", +]); + +export function partitionBunTestCliArgs(args: readonly string[]): { + flags: string[]; + targets: string[]; +} { + const flags: string[] = []; + const targets: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg.startsWith("-")) { + flags.push(arg); + if (!arg.includes("=") && BUN_TEST_VALUE_FLAGS.has(arg)) { + const next = args[i + 1]; + if (next !== undefined && !next.startsWith("-")) { + flags.push(next); + i += 1; + } + } + continue; + } + targets.push(arg); + } + return { flags, targets }; +} + /** * Other `bun test` runners already on this machine. * @@ -123,12 +174,25 @@ function runIsolatedTestProcess(testArgs: readonly string[]): number { * left behind, and neither process said anything, so the slowdown read as a hang * in this suite. Bun's own timeouts cannot see the contention, so name it here. * + * The lock covers the whole `bun test` period, including `--no-isolate` and + * `--parallel` runs. Matching only `test --isolate` misses the parallel runner's + * shared-process batches and the GUI suite's first `--parallel` spawn. + * * `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. */ +export const COMPETING_BUN_TEST_PGREP_PATTERN = "bun(.exe)?[[:space:]]+test([[:space:]]|$)"; + +/** JS form of {@link COMPETING_BUN_TEST_PGREP_PATTERN} for unit tests and ps fallbacks. */ +const COMPETING_BUN_TEST_JS = /bun(?:\.exe)?\s+test(?:\s|$)/i; + +export function commandLineLooksLikeBunTest(commandLine: string): boolean { + return COMPETING_BUN_TEST_JS.test(commandLine); +} + export function findCompetingTestRunners(selfPid: number): number[] { try { - const found = Bun.spawnSync(["pgrep", "-f", "bun.*test --isolate"], { + const found = Bun.spawnSync(["pgrep", "-f", COMPETING_BUN_TEST_PGREP_PATTERN], { stdout: "pipe", stderr: "ignore", }); diff --git a/tests/test-runner.test.ts b/tests/test-runner.test.ts index d77b1cac10..7c2714d320 100644 --- a/tests/test-runner.test.ts +++ b/tests/test-runner.test.ts @@ -3,14 +3,25 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node: import { tmpdir } from "node:os"; import { join } from "node:path"; import { + commandLineLooksLikeBunTest, createIsolatedTestEnvironment, DEFAULT_TEST_SHARD_SIZE, + findCompetingTestRunners, listRepositoryTestFiles, + partitionBunTestCliArgs, partitionTestFiles, resolveTestShardSize, resolveTestStartShard, } from "../scripts/test"; -import { resolveRetryCount, resolveWorkerCount } from "../scripts/test-parallel"; +import { + bunTestArgvForWorkItem, + diagnosticIsolateRetryItems, + resolveRetryCount, + resolveWorkerCount, + retryFailuresInSameMode, + retryQueueForFailures, + type WorkItem, +} from "../scripts/test-parallel"; import { classifyTestFile, DEFAULT_TEST_BATCH_SIZE, @@ -130,6 +141,108 @@ describe("test runner isolation", () => { expect(() => resolveTestStartShard(0, "1")).toThrow("positive integer"); }); + test("retries forward caller flags including --timeout", () => { + expect(partitionBunTestCliArgs(["--timeout", "1", "tests/foo.test.ts", "tests/bar.test.ts"])).toEqual({ + flags: ["--timeout", "1"], + targets: ["tests/foo.test.ts", "tests/bar.test.ts"], + }); + expect(partitionBunTestCliArgs(["--timeout=1", "--bail", "tests/foo.test.ts"])).toEqual({ + flags: ["--timeout=1", "--bail"], + targets: ["tests/foo.test.ts"], + }); + expect(bunTestArgvForWorkItem( + { files: ["tests/foo.test.ts", "tests/bar.test.ts"], isolate: false }, + ["--timeout", "1"], + )).toEqual([ + "test", + "--no-isolate", + "--max-concurrency=1", + "--timeout", + "1", + "tests/foo.test.ts", + "tests/bar.test.ts", + ]); + expect(bunTestArgvForWorkItem( + { files: ["tests/foo.test.ts"], isolate: true }, + ["--timeout", "1"], + )).toEqual(["test", "--isolate", "--timeout", "1", "tests/foo.test.ts"]); + }); + + test("a shared-process batch failure stays a failure if files only pass isolated", async () => { + const batch: WorkItem = { files: ["a.test.ts", "b.test.ts"], isolate: false }; + expect(retryQueueForFailures([batch])).toEqual([ + { files: ["a.test.ts", "b.test.ts"], isolate: false }, + ]); + + const fakeRun = async (items: WorkItem[]) => items.filter(item => !item.isolate); + const result = await retryFailuresInSameMode([batch], 1, fakeRun); + expect(result.failures).toEqual([batch]); + expect(result.recovered).toEqual([]); + + const diagnostics = diagnosticIsolateRetryItems(result.failures); + expect(diagnostics).toEqual([ + { files: ["a.test.ts"], isolate: true }, + { files: ["b.test.ts"], isolate: true }, + ]); + expect(await fakeRun(diagnostics)).toEqual([]); + expect(result.failures.length).toBeGreaterThan(0); + }); + + test("a shared-process batch can recover when the same batch passes on retry", async () => { + const batch: WorkItem = { files: ["a.test.ts", "b.test.ts"], isolate: false }; + const result = await retryFailuresInSameMode([batch], 1, async () => []); + expect(result.failures).toEqual([]); + expect(result.recovered).toEqual(["a.test.ts", "b.test.ts"]); + }); + + test("overlap lock matches bun test including --no-isolate and not the wrapper scripts", () => { + expect(commandLineLooksLikeBunTest("/home/x/.bun/bin/bun test --no-isolate --max-concurrency=1 a.test.ts")).toBe(true); + expect(commandLineLooksLikeBunTest("/home/x/.bun/bin/bun test --isolate a.test.ts")).toBe(true); + expect(commandLineLooksLikeBunTest("/home/x/.bun/bin/bun test --parallel=4 tests")).toBe(true); + expect(commandLineLooksLikeBunTest("C:\\Users\\x\\.bun\\bin\\bun.exe test --no-isolate a.test.ts")).toBe(true); + expect(commandLineLooksLikeBunTest("/home/x/.bun/bin/bun scripts/test-parallel.ts")).toBe(false); + expect(commandLineLooksLikeBunTest("/home/x/.bun/bin/bun scripts/test.ts")).toBe(false); + expect(commandLineLooksLikeBunTest("/home/x/.bun/bin/bun gui/scripts/test.ts")).toBe(false); + }); + + test("overlap lock fires for a live --no-isolate bun test process", async () => { + if (process.platform === "win32") return; + const dir = mkdtempSync(join(tmpdir(), "ccx-overlap-")); + const file = join(dir, "hold.test.ts"); + writeFileSync(file, ` + test("hold the process for the overlap lock probe", async () => { + await Bun.sleep(30_000); + }); + `); + writeFileSync(join(dir, "bunfig.toml"), "[test]\n"); + const child = Bun.spawn( + [process.execPath, "test", "--no-isolate", "--timeout", "30000", file], + { + cwd: dir, + stdout: "ignore", + stderr: "ignore", + env: { ...process.env, CCX_TEST_NO_QUEUE: "1" }, + }, + ); + try { + const started = Date.now(); + let seen = false; + while (Date.now() - started < 8_000) { + const competing = findCompetingTestRunners(process.pid); + if (child.pid !== undefined && competing.includes(child.pid)) { + seen = true; + break; + } + await Bun.sleep(50); + } + expect(seen).toBe(true); + } finally { + child.kill(); + await child.exited; + rmSync(dir, { recursive: true, force: true }); + } + }); + test("discovers Bun test filename patterns in stable order", () => { const root = mkdtempSync(join(tmpdir(), "ccx-test-discovery-")); try { From 70b324d44f6b96d5494b1c15513fb433cde7b62c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 18:52:17 +0000 Subject: [PATCH 2/2] fix: do not treat boolean bun test flags as value flags --bail followed by a test path was swallowed as a flag value, which would have dropped files from the run and from retries. Co-authored-by: pavelhov --- scripts/test.ts | 9 ++++++++- tests/test-runner.test.ts | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/test.ts b/scripts/test.ts index 57a064d857..ff1e2de9c4 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -142,6 +142,13 @@ const BUN_TEST_VALUE_FLAGS = new Set([ "--retry", ]); +function looksLikeBunTestTarget(arg: string): boolean { + const base = arg.split(/[/\\]/).pop() ?? arg; + if (BUN_TEST_FILE_PATTERN.test(base)) return true; + const normalized = arg.replaceAll("\\", "/").replace(/\/+$/, ""); + return normalized === "tests" || normalized === "./tests" || normalized.endsWith("/tests"); +} + export function partitionBunTestCliArgs(args: readonly string[]): { flags: string[]; targets: string[]; @@ -154,7 +161,7 @@ export function partitionBunTestCliArgs(args: readonly string[]): { flags.push(arg); if (!arg.includes("=") && BUN_TEST_VALUE_FLAGS.has(arg)) { const next = args[i + 1]; - if (next !== undefined && !next.startsWith("-")) { + if (next !== undefined && !next.startsWith("-") && !looksLikeBunTestTarget(next)) { flags.push(next); i += 1; } diff --git a/tests/test-runner.test.ts b/tests/test-runner.test.ts index 7c2714d320..2b4bcd35ee 100644 --- a/tests/test-runner.test.ts +++ b/tests/test-runner.test.ts @@ -150,6 +150,10 @@ describe("test runner isolation", () => { flags: ["--timeout=1", "--bail"], targets: ["tests/foo.test.ts"], }); + expect(partitionBunTestCliArgs(["--bail", "tests/foo.test.ts"])).toEqual({ + flags: ["--bail"], + targets: ["tests/foo.test.ts"], + }); expect(bunTestArgvForWorkItem( { files: ["tests/foo.test.ts", "tests/bar.test.ts"], isolate: false }, ["--timeout", "1"],