From d039bdf6c3da18022464ca1d060a2a62a1d902a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 15 Apr 2026 15:11:33 -0300 Subject: [PATCH] feat: test runner --- packages/opencode/package.json | 4 +- packages/opencode/script/test-runner.ts | 346 ++++++++++++++++++ .../transforms/transform-package-json.ts | 36 ++ 3 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/script/test-runner.ts diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e46570bd4e7..7eeed2d4f31 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -8,8 +8,8 @@ "scripts": { "prepare": "effect-language-service patch || true", "typecheck": "tsgo --noEmit", - "test": "bun test --timeout 30000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun run script/test-runner.ts", + "test:ci": "bun run script/test-runner.ts --ci", "build": "bun run script/build.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts", diff --git a/packages/opencode/script/test-runner.ts b/packages/opencode/script/test-runner.ts new file mode 100644 index 00000000000..d221cb45cc7 --- /dev/null +++ b/packages/opencode/script/test-runner.ts @@ -0,0 +1,346 @@ +// 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, "'") +} diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index ce30a3f11dd..685cecf0ebd 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -383,6 +383,26 @@ export async function transformPackageJson(file: string, options: PackageJsonOpt changes.push(`scripts.changeset:version: preserved Kilo's changeset:version script`) } + // Preserve Kilo's test runner scripts for packages/opencode + if ( + relativePath === "packages/opencode/package.json" && + ourScripts?.test && + pkg.scripts?.test !== ourScripts.test + ) { + pkg.scripts = pkg.scripts || {} + pkg.scripts.test = ourScripts.test + changes.push(`scripts.test: preserved Kilo's test runner script`) + } + if ( + relativePath === "packages/opencode/package.json" && + ourScripts?.["test:ci"] && + pkg.scripts?.["test:ci"] !== ourScripts["test:ci"] + ) { + pkg.scripts = pkg.scripts || {} + pkg.scripts["test:ci"] = ourScripts["test:ci"] + changes.push(`scripts.test:ci: preserved Kilo's CI test runner script`) + } + // Merge catalog with "newest wins" strategy if (ourWorkspaces?.catalog || theirWorkspaces?.catalog) { pkg.workspaces = pkg.workspaces || {} @@ -608,6 +628,22 @@ export async function transformAllPackageJson(options: PackageJsonOptions = {}): changes.push(`scripts.extension: preserved Kilo's extension script`) } + // Preserve Kilo's test runner scripts for packages/opencode + if (path === "packages/opencode/package.json" && kiloScripts?.test && pkg.scripts?.test !== kiloScripts.test) { + pkg.scripts = pkg.scripts || {} + pkg.scripts.test = kiloScripts.test + changes.push(`scripts.test: preserved Kilo's test runner script`) + } + if ( + path === "packages/opencode/package.json" && + kiloScripts?.["test:ci"] && + pkg.scripts?.["test:ci"] !== kiloScripts["test:ci"] + ) { + pkg.scripts = pkg.scripts || {} + pkg.scripts["test:ci"] = kiloScripts["test:ci"] + changes.push(`scripts.test:ci: preserved Kilo's CI test runner script`) + } + // Merge catalog with "newest wins" strategy if (kiloWorkspaces?.catalog || upstreamWorkspaces?.catalog) { pkg.workspaces = pkg.workspaces || {}