Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 5acc917591 refactor(test/cli): simplify pass on tier-A + prebuild
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.
2026-05-19 11:36:15 -04:00
Kit Langton 5bc6a7f6d0 test(cli): opt-in pre-built binary for ~3x faster subprocess spawns
\`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.
2026-05-19 11:36:15 -04:00
3 changed files with 111 additions and 4 deletions
@@ -0,0 +1,95 @@
#!/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 <cmd>` 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)
const stableBinary = path.join(dir, "dist", "test-cli", "bin", binaryName)
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<number> {
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<boolean> {
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 })
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/`)
@@ -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,
+13 -3
View File
@@ -31,6 +31,16 @@ import { it } from "./effect"
const opencodeRoot = path.resolve(import.meta.dir, "../../")
const cliEntry = path.join(opencodeRoot, "src/index.ts")
// 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"]
const cliArgv: readonly string[] = prebuiltCli
? [prebuiltCli]
: ["bun", "run", "--conditions=browser", cliEntry]
export const testModelID = "test/test-model"
// Wrap a Bun subprocess pipe (or any ReadableStream<Uint8Array>) as a Stream.
@@ -196,7 +206,7 @@ export function withCliFixture<A, E>(
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 +245,7 @@ export function withCliFixture<A, E>(
// 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 +316,7 @@ export function withCliFixture<A, E>(
// 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",