// kilocode_change - new file // // Custom test runner that executes each test file in its own isolated process. // Prevents cross-contamination between test files by ensuring separate PIDs, // temp directories, in-memory databases, and environment state. import os from "os" import path from "path" import fs from "fs/promises" const root = path.resolve(import.meta.dir, "..") const argv = process.argv.slice(2) // --------------------------------------------------------------------------- // Help // --------------------------------------------------------------------------- if (argv.includes("--help") || argv.includes("-h")) { console.log( [ "", "Usage: bun run script/test-runner.ts [options] [patterns...]", "", "Runs test files in isolated parallel processes to prevent cross-contamination.", "", "Options:", " --ci Enable JUnit XML output to .artifacts/unit/junit.xml", " --concurrency Max parallel processes (default: CPU count)", " --timeout Per-test timeout passed to bun test (default: 30000)", " --file-timeout Per-file process timeout (default: 300000)", " --bail Stop on first failure", " --verbose Show full output for every file", " -h, --help Show this help", "", "Positional:", " [patterns...] Filter test files by substring match", "", ].join("\n"), ) process.exit(0) } // --------------------------------------------------------------------------- // CLI parsing // --------------------------------------------------------------------------- function opt(name: string, fallback: number) { const i = argv.indexOf(`--${name}`) return i >= 0 && i + 1 < argv.length ? Number(argv[i + 1]) || fallback : fallback } const ci = argv.includes("--ci") const bail = argv.includes("--bail") const verbose = argv.includes("--verbose") const concurrency = opt("concurrency", os.cpus().length) const timeout = opt("timeout", 30000) const deadline = opt("file-timeout", 300000) const valued = new Set(["--concurrency", "--timeout", "--file-timeout"]) const patterns = argv.filter((arg, i) => { if (arg.startsWith("-")) return false if (i > 0 && valued.has(argv[i - 1])) return false return true }) // --------------------------------------------------------------------------- // Colors // --------------------------------------------------------------------------- const tty = !!process.stdout.isTTY const green = (s: string) => (tty ? `\x1b[32m${s}\x1b[0m` : s) const red = (s: string) => (tty ? `\x1b[31m${s}\x1b[0m` : s) const dim = (s: string) => (tty ? `\x1b[2m${s}\x1b[0m` : s) const bold = (s: string) => (tty ? `\x1b[1m${s}\x1b[0m` : s) // --------------------------------------------------------------------------- // File discovery // --------------------------------------------------------------------------- const glob = new Bun.Glob("**/*.test.{ts,tsx}") const all = (await Array.fromAsync(glob.scan({ cwd: path.join(root, "test") }))).sort() const files = patterns.length > 0 ? all.filter((f) => patterns.some((p) => f.includes(p) || path.join("test", f).includes(p))) : all if (files.length === 0) { console.log("No test files found") process.exit(0) } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type Result = { file: string passed: boolean code: number stdout: string stderr: string duration: number timedout: boolean } // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- const xmldir = ci ? path.join(os.tmpdir(), `opencode-junit-${process.pid}`) : "" if (ci) await fs.mkdir(xmldir, { recursive: true }) const counter = { done: 0 } const pad = String(files.length).length // --------------------------------------------------------------------------- // Run a single test file // --------------------------------------------------------------------------- async function run(file: string): Promise { const target = path.join("test", file) const cmd = ["bun", "test", target, "--timeout", String(timeout)] if (ci) { const name = file.replace(/[/\\]/g, "_") + ".xml" cmd.push("--reporter=junit", `--reporter-outfile=${path.join(xmldir, name)}`) } const start = performance.now() const killed = { value: false } const proc = Bun.spawn(cmd, { cwd: root, stdout: "pipe", stderr: "pipe", }) const timer = setTimeout(() => { killed.value = true proc.kill() }, deadline) const [stdout, stderr, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]) clearTimeout(timer) return { file, passed: code === 0, code, stdout, stderr, duration: performance.now() - start, timedout: killed.value, } } // --------------------------------------------------------------------------- // Report a single result // --------------------------------------------------------------------------- function report(result: Result) { counter.done++ const idx = String(counter.done).padStart(pad) const secs = (result.duration / 1000).toFixed(1) if (result.timedout) { console.log( `[${idx}/${files.length}] ${red("TIME")} ${result.file} ${dim(`(${secs}s - exceeded ${deadline / 1000}s)`)}`, ) return } if (!result.passed) { console.log(`[${idx}/${files.length}] ${red("FAIL")} ${result.file} ${dim(`(${secs}s)`)}`) if (verbose && result.stderr.trim()) console.log(result.stderr) if (verbose && result.stdout.trim()) console.log(result.stdout) return } console.log(`[${idx}/${files.length}] ${green("PASS")} ${result.file} ${dim(`(${secs}s)`)}`) if (verbose && result.stdout.trim()) console.log(dim(result.stdout)) } // --------------------------------------------------------------------------- // Parallel execution // --------------------------------------------------------------------------- console.log(`\nRunning ${bold(String(files.length))} test files with concurrency ${bold(String(concurrency))}\n`) const start = performance.now() const results: Result[] = [] const queue = [...files] const stopped = { value: false } const workers = Array.from({ length: Math.min(concurrency, files.length) }, async () => { while (queue.length > 0 && !stopped.value) { const file = queue.shift()! const result = await run(file) results.push(result) report(result) if (bail && !result.passed) stopped.value = true } }) await Promise.all(workers) const elapsed = (performance.now() - start) / 1000 // --------------------------------------------------------------------------- // Failure details // --------------------------------------------------------------------------- const failures = results.filter((r) => !r.passed).sort((a, b) => a.file.localeCompare(b.file)) if (failures.length > 0 && !verbose) { console.log(`\n${bold(red("--- FAILURES ---"))}\n`) for (const f of failures) { const tag = f.timedout ? " (TIMED OUT)" : "" console.log(`${bold(red(f.file))}${tag}:`) const output = (f.stderr || f.stdout).trim() if (output) console.log( output .split("\n") .map((l) => " " + l) .join("\n"), ) console.log() } } // --------------------------------------------------------------------------- // Summary // --------------------------------------------------------------------------- const passed = results.filter((r) => r.passed).length console.log( `\n${bold(String(results.length))} files | ` + `${green(passed + " passed")} | ` + `${failures.length > 0 ? red(failures.length + " failed") : failures.length + " failed"} | ` + `${elapsed.toFixed(1)}s\n`, ) // --------------------------------------------------------------------------- // JUnit XML merge (CI mode) // --------------------------------------------------------------------------- if (ci) { await merge() await fs.rm(xmldir, { recursive: true, force: true }).catch((err) => { console.error("cleanup failed:", err) }) } process.exit(failures.length > 0 ? 1 : 0) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function merge() { const dir = path.join(root, ".artifacts", "unit") await fs.mkdir(dir, { recursive: true }) const suites: string[] = [] const counts = { tests: 0, failures: 0, errors: 0 } for (const file of files) { const name = file.replace(/[/\\]/g, "_") + ".xml" const fpath = path.join(xmldir, name) const found = await Bun.file(fpath).exists() if (found) { const content = await Bun.file(fpath).text() const extracted = extract(content) if (extracted) { suites.push(extracted) counts.tests += attr(extracted, "tests") counts.failures += attr(extracted, "failures") counts.errors += attr(extracted, "errors") continue } } // No valid XML produced - generate synthetic entry for failed files const result = results.find((r) => r.file === file) if (!result || result.passed) continue const secs = (result.duration / 1000).toFixed(3) const msg = result.timedout ? `Test file timed out after ${deadline / 1000}s` : `Test process exited with code ${result.code}` const detail = esc((result.stderr || result.stdout || msg).slice(0, 10000)) suites.push( ` \n` + ` \n` + ` ${detail}\n` + ` \n` + ` `, ) counts.tests++ counts.failures++ } const body = [ '', ``, ...suites, "", "", ].join("\n") await Bun.write(path.join(dir, "junit.xml"), body) } function extract(content: string, from = 0): string { const open = "/g, ">") .replace(/"/g, """) .replace(/'/g, "'") }