From 5bc6a7f6d098113c9e1f1f95acd8eced1cd36d45 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 18 May 2026 21:41:34 -0400 Subject: [PATCH 1/2] test(cli): opt-in pre-built binary for ~3x faster subprocess spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`bun run --conditions=browser src/index.ts\` pays ~15s of JIT + plugin init + DB migration per subprocess spawn in isolation mode. A pre-built binary cuts that to ~5s — most of which is now the SQLite \`:memory:\` migration that runs regardless of execution mode. Adds \`script/prebuild-test-cli.ts\` which wraps the existing build.ts with \`--single --skip-embed-web-ui --skip-install\`, then symlinks the platform-specific output to \`dist/test-cli/bin/opencode\` so the harness has a stable path. The harness (test/lib/cli-process.ts) reads OPENCODE_TEST_CLI_PATH and spawns the binary directly when set; falls back to dev mode otherwise. Strictly opt-in — default behavior, CI, and local iteration are unchanged. Anyone who wants the speedup runs: bun script/prebuild-test-cli.ts export OPENCODE_TEST_CLI_PATH="\$PWD/dist/test-cli/bin/opencode" bun test test/cli/ Measured locally: Dev mode (default): 29.9s (331 tests) Binary mode: 22.1s (-26%, after one-time 2.8s build) The win compounds as more subprocess tests are added — every new test that hits DB migration saves ~10s vs dev mode. --- packages/opencode/script/prebuild-test-cli.ts | 53 +++++++++++++++++++ packages/opencode/test/lib/cli-process.ts | 19 +++++-- 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/script/prebuild-test-cli.ts diff --git a/packages/opencode/script/prebuild-test-cli.ts b/packages/opencode/script/prebuild-test-cli.ts new file mode 100644 index 000000000000..25967fa2d5a7 --- /dev/null +++ b/packages/opencode/script/prebuild-test-cli.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env bun +// Build a pre-compiled `opencode` binary for subprocess tests, then expose +// it at `dist/test-cli/bin/opencode` for the harness to consume. +// +// Why: each `bun run --conditions=browser src/index.ts ` spawn pays +// ~15s of JIT + plugin init + DB migration in isolation mode. The +// pre-compiled binary cuts that to ~5s — a 3x improvement on subprocess +// tests that touch the DB (mcp, providers list, etc.). +// +// Usage: +// bun script/prebuild-test-cli.ts +// export OPENCODE_TEST_CLI_PATH="$PWD/dist/test-cli/bin/opencode" +// bun test test/cli/ +// +// The harness (see test/lib/cli-process.ts) reads OPENCODE_TEST_CLI_PATH; if +// set, it spawns the binary directly instead of `bun run src/index.ts`. If +// unset, it falls back to dev mode — so this script is strictly opt-in. +// +// Build cost amortizes after ~1 spawn that touches the DB. Recommended for +// CI, manual `bun test test/cli/` runs, and any local iteration where the +// CLI surface itself isn't under change. Skip for normal src/* editing — the +// dev path picks up source changes without rebuild. +import { $ } from "bun" +import fs from "node:fs/promises" +import path from "node:path" + +const dir = path.resolve(import.meta.dirname, "..") +process.chdir(dir) + +const platform = process.platform === "win32" ? "win32" : process.platform === "darwin" ? "darwin" : "linux" +const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : "x64" +const targetDir = path.join(dir, "dist", `opencode-${platform}-${arch}`) +const binaryName = process.platform === "win32" ? "opencode.exe" : "opencode" +const builtBinary = path.join(targetDir, "bin", binaryName) + +// Stable path the harness reads via OPENCODE_TEST_CLI_PATH. Symlinked so +// the binary itself remains the platform-specific one (build.ts manages it). +const stableBinary = path.join(dir, "dist", "test-cli", "bin", binaryName) + +console.log(`Building test CLI binary for ${platform}-${arch}...`) +const start = Date.now() +await $`bun script/build.ts --single --skip-embed-web-ui --skip-install` +const buildMs = Date.now() - start +console.log(`Build complete in ${buildMs}ms: ${builtBinary}`) + +await fs.mkdir(path.dirname(stableBinary), { recursive: true }) +await fs.rm(stableBinary, { force: true }) +await fs.symlink(builtBinary, stableBinary) +console.log(`Symlinked stable path: ${stableBinary}`) +console.log(``) +console.log(`To use in tests:`) +console.log(` export OPENCODE_TEST_CLI_PATH="${stableBinary}"`) +console.log(` bun test test/cli/`) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 1f11bb4e794b..db984e6524db 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -31,6 +31,19 @@ import { it } from "./effect" const opencodeRoot = path.resolve(import.meta.dir, "../../") const cliEntry = path.join(opencodeRoot, "src/index.ts") +// Opt-in pre-built binary path. If set, subprocess tests spawn the binary +// directly instead of `bun run src/index.ts`, skipping JIT + plugin init +// (~3x speedup on isolation-env spawns that hit DB migration). Produced by +// `bun script/prebuild-test-cli.ts`; the harness silently falls back to dev +// mode if the var is unset, so this is strictly opt-in. +const prebuiltCli = process.env["OPENCODE_TEST_CLI_PATH"] + +// Argv prefix for spawning the CLI. Either the pre-built binary (single +// argument) or `bun run --conditions=browser src/index.ts` (four arguments). +function cliArgv(): string[] { + return prebuiltCli ? [prebuiltCli] : ["bun", "run", "--conditions=browser", cliEntry] +} + export const testModelID = "test/test-model" // Wrap a Bun subprocess pipe (or any ReadableStream) as a Stream. @@ -196,7 +209,7 @@ export function withCliFixture( Effect.promise(async () => { const start = Date.now() // Process.run pipes stdout/stderr by default and returns them as Buffers. - const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], { + const result = await Process.run([...cliArgv(), ...args], { cwd: home, timeout: opts?.timeoutMs ?? 30_000, env: { ...process.env, ...env, ...opts?.env }, @@ -235,7 +248,7 @@ export function withCliFixture( // as a finalizer error during test teardown. const proc = yield* Effect.acquireRelease( Effect.sync(() => - Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], { + Bun.spawn([...cliArgv(), ...argv], { cwd: home, env: { ...process.env, ...env, ...opts?.env }, stdout: "pipe", @@ -306,7 +319,7 @@ export function withCliFixture( // Either way we await proc.exited so the test scope doesn't leak. const proc = yield* Effect.acquireRelease( Effect.sync(() => - Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], { + Bun.spawn([...cliArgv(), ...argv], { cwd: opts?.cwd ?? home, env: { ...process.env, ...env, ...opts?.env }, stdin: "pipe", From 5acc917591fd2e15470d32381a0b67b8cffa9e3f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 18 May 2026 21:46:12 -0400 Subject: [PATCH 2/2] refactor(test/cli): simplify pass on tier-A + prebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies findings from the third simplify pass: 1. \`cliArgv\` is a module-level const, not a function — prebuiltCli is read once at module init and never mutated, so the per-spawn function allocation was pure overhead. 2. Help-snapshot failures surface via \`Effect.fail\` instead of \`throw\`, symmetric with the \`Effect.fail\` already inside the partition above. Keeps the failure typed in the Effect channel rather than as a defect. 3. \`prebuild-test-cli.ts\` skips the build when the binary is already newer than every file in src/ — saves the 2.8s rebuild cost on every subsequent invocation. Pass --force to bypass. 4. \`prebuild-test-cli.ts\` verifies the built binary is executable before symlinking — catches a silently-failed build leaving stale output instead of letting tests fail with confusing exec errors later. Verified: 331/331 CLI tests pass; typecheck clean; skip-if-fresh + --force behave as documented. --- packages/opencode/script/prebuild-test-cli.ts | 58 ++++++++++++++++--- .../test/cli/help/help-snapshots.test.ts | 4 +- packages/opencode/test/lib/cli-process.ts | 25 ++++---- 3 files changed, 64 insertions(+), 23 deletions(-) diff --git a/packages/opencode/script/prebuild-test-cli.ts b/packages/opencode/script/prebuild-test-cli.ts index 25967fa2d5a7..c9714fd6a591 100644 --- a/packages/opencode/script/prebuild-test-cli.ts +++ b/packages/opencode/script/prebuild-test-cli.ts @@ -32,16 +32,58 @@ const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" const targetDir = path.join(dir, "dist", `opencode-${platform}-${arch}`) const binaryName = process.platform === "win32" ? "opencode.exe" : "opencode" const builtBinary = path.join(targetDir, "bin", binaryName) - -// Stable path the harness reads via OPENCODE_TEST_CLI_PATH. Symlinked so -// the binary itself remains the platform-specific one (build.ts manages it). const stableBinary = path.join(dir, "dist", "test-cli", "bin", binaryName) -console.log(`Building test CLI binary for ${platform}-${arch}...`) -const start = Date.now() -await $`bun script/build.ts --single --skip-embed-web-ui --skip-install` -const buildMs = Date.now() - start -console.log(`Build complete in ${buildMs}ms: ${builtBinary}`) +const force = process.argv.includes("--force") + +// Walk src/ and return the newest mtime seen. Faster than `git status` for +// the freshness check and works for uncommitted edits. Returns 0 on error +// so a missing src/ tree forces a rebuild via the comparison below. +async function newestMtimeMs(root: string): Promise { + let max = 0 + async function walk(p: string) { + let entries: { name: string; isDirectory: () => boolean; isFile: () => boolean }[] + try { + entries = await fs.readdir(p, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = path.join(p, entry.name) + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") continue + await walk(full) + } else if (entry.isFile()) { + const stat = await fs.stat(full).catch(() => null) + if (stat && stat.mtimeMs > max) max = stat.mtimeMs + } + } + } + await walk(root) + return max +} + +async function fresh(): Promise { + const binStat = await fs.stat(builtBinary).catch(() => null) + if (!binStat) return false + const srcMs = await newestMtimeMs(path.join(dir, "src")) + return binStat.mtimeMs > srcMs +} + +if (!force && (await fresh())) { + console.log(`Test CLI binary is up to date: ${builtBinary}`) +} else { + console.log(`Building test CLI binary for ${platform}-${arch}...`) + const start = Date.now() + await $`bun script/build.ts --single --skip-embed-web-ui --skip-install` + console.log(`Build complete in ${Date.now() - start}ms: ${builtBinary}`) +} + +// Verify the binary exists and is executable before symlinking — catches +// a silently-failed build that left a stale or partial output behind. +await fs.access(builtBinary, fs.constants.X_OK).catch(() => { + throw new Error(`Built binary missing or not executable: ${builtBinary}`) +}) await fs.mkdir(path.dirname(stableBinary), { recursive: true }) await fs.rm(stableBinary, { force: true }) diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index 94ea803b2655..662e85ae6cf3 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -121,7 +121,9 @@ describe("opencode CLI help-text snapshots", () => { expect(normalize(result.stderr)).toMatchSnapshot(`opencode ${argv.join(" ")} --help`) } if (failures.length > 0) { - throw new Error(`Help text failed for:\n ${failures.join("\n ")}`) + // Keep the failure in the Effect channel — symmetric with the + // Effect.fail inside the partition above, not a defect. + yield* Effect.fail(new Error(`Help text failed for:\n ${failures.join("\n ")}`)) } }), 180_000, diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index db984e6524db..b875038f5a12 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -31,18 +31,15 @@ import { it } from "./effect" const opencodeRoot = path.resolve(import.meta.dir, "../../") const cliEntry = path.join(opencodeRoot, "src/index.ts") -// Opt-in pre-built binary path. If set, subprocess tests spawn the binary -// directly instead of `bun run src/index.ts`, skipping JIT + plugin init -// (~3x speedup on isolation-env spawns that hit DB migration). Produced by -// `bun script/prebuild-test-cli.ts`; the harness silently falls back to dev -// mode if the var is unset, so this is strictly opt-in. +// Argv prefix for spawning the CLI. If OPENCODE_TEST_CLI_PATH is set, +// subprocess tests spawn the pre-built binary directly (~3x speedup on +// isolation-env spawns that hit DB migration; produced by +// `bun script/prebuild-test-cli.ts`). Otherwise falls back to dev mode — +// strictly opt-in, default behavior unchanged. const prebuiltCli = process.env["OPENCODE_TEST_CLI_PATH"] - -// Argv prefix for spawning the CLI. Either the pre-built binary (single -// argument) or `bun run --conditions=browser src/index.ts` (four arguments). -function cliArgv(): string[] { - return prebuiltCli ? [prebuiltCli] : ["bun", "run", "--conditions=browser", cliEntry] -} +const cliArgv: readonly string[] = prebuiltCli + ? [prebuiltCli] + : ["bun", "run", "--conditions=browser", cliEntry] export const testModelID = "test/test-model" @@ -209,7 +206,7 @@ export function withCliFixture( Effect.promise(async () => { const start = Date.now() // Process.run pipes stdout/stderr by default and returns them as Buffers. - const result = await Process.run([...cliArgv(), ...args], { + const result = await Process.run([...cliArgv, ...args], { cwd: home, timeout: opts?.timeoutMs ?? 30_000, env: { ...process.env, ...env, ...opts?.env }, @@ -248,7 +245,7 @@ export function withCliFixture( // as a finalizer error during test teardown. const proc = yield* Effect.acquireRelease( Effect.sync(() => - Bun.spawn([...cliArgv(), ...argv], { + Bun.spawn([...cliArgv, ...argv], { cwd: home, env: { ...process.env, ...env, ...opts?.env }, stdout: "pipe", @@ -319,7 +316,7 @@ export function withCliFixture( // Either way we await proc.exited so the test scope doesn't leak. const proc = yield* Effect.acquireRelease( Effect.sync(() => - Bun.spawn([...cliArgv(), ...argv], { + Bun.spawn([...cliArgv, ...argv], { cwd: opts?.cwd ?? home, env: { ...process.env, ...env, ...opts?.env }, stdin: "pipe",