|
| 1 | +// Subprocess test harness for the `opencode run` CLI. |
| 2 | +// |
| 3 | +// This is the missing test tier: every other `cli/run/*.test.ts` is a unit |
| 4 | +// test of an extracted helper. Nothing actually exercises the `RunCommand` |
| 5 | +// handler end-to-end. Bugs that span argv parsing → server boot → SDK call → |
| 6 | +// event consumption → exit code (like the original /event race or the |
| 7 | +// non-interactive hang #27371) are invisible to in-process tests. |
| 8 | +// |
| 9 | +// The harness uses opencode's built-in test affordances to spawn the real CLI |
| 10 | +// hermetically: |
| 11 | +// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find |
| 12 | +// - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir |
| 13 | +// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json |
| 14 | +// - OPENCODE_PURE : skip external plugin discovery + install |
| 15 | +// - OPENCODE_DISABLE_AUTOUPDATE / AUTOCOMPACT / MODELS_FETCH : no background work |
| 16 | +// |
| 17 | +// Plus HOME / XDG_* pointing at the tmpdir for belt-and-suspenders isolation. |
| 18 | +// |
| 19 | +// The custom `test` provider points at a TestLLMServer running in the same |
| 20 | +// process at a random port. The CLI subprocess talks to it over real HTTP. |
| 21 | +import type { TestOptions } from "bun:test" |
| 22 | +import * as Scope from "effect/Scope" |
| 23 | +import { Effect } from "effect" |
| 24 | +import path from "node:path" |
| 25 | +import fs from "node:fs/promises" |
| 26 | +import os from "node:os" |
| 27 | +import { Process } from "@/util/process" |
| 28 | +import { TestLLMServer } from "./llm-server" |
| 29 | +import { testProviderConfig } from "./test-provider" |
| 30 | +import { it } from "./effect" |
| 31 | + |
| 32 | +const opencodeRoot = path.resolve(import.meta.dir, "../../") |
| 33 | +const cliEntry = path.join(opencodeRoot, "src/index.ts") |
| 34 | + |
| 35 | +export const testModelID = "test/test-model" |
| 36 | + |
| 37 | +function isolatedEnv(home: string, configJson: string): Record<string, string> { |
| 38 | + return { |
| 39 | + OPENCODE_TEST_HOME: home, |
| 40 | + HOME: home, |
| 41 | + XDG_CONFIG_HOME: path.join(home, ".config"), |
| 42 | + XDG_DATA_HOME: path.join(home, ".local/share"), |
| 43 | + XDG_STATE_HOME: path.join(home, ".local/state"), |
| 44 | + XDG_CACHE_HOME: path.join(home, ".cache"), |
| 45 | + OPENCODE_CONFIG_CONTENT: configJson, |
| 46 | + OPENCODE_DISABLE_PROJECT_CONFIG: "1", |
| 47 | + OPENCODE_PURE: "1", |
| 48 | + OPENCODE_DISABLE_AUTOUPDATE: "1", |
| 49 | + OPENCODE_DISABLE_AUTOCOMPACT: "1", |
| 50 | + OPENCODE_DISABLE_MODELS_FETCH: "1", |
| 51 | + OPENCODE_AUTH_CONTENT: "{}", |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +export type RunResult = { |
| 56 | + readonly exitCode: number |
| 57 | + readonly stdout: string |
| 58 | + readonly stderr: string |
| 59 | + readonly durationMs: number |
| 60 | +} |
| 61 | + |
| 62 | +type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> } |
| 63 | + |
| 64 | +// A `RunOpts` is the typed equivalent of constructing argv for `opencode run`. |
| 65 | +// New flags should land here so tests stay grep-able and refactor-safe. |
| 66 | +export type RunOpts = SpawnOpts & { |
| 67 | + readonly model?: string |
| 68 | + readonly agent?: string |
| 69 | + readonly format?: "default" | "json" |
| 70 | + readonly command?: string |
| 71 | + readonly printLogs?: boolean |
| 72 | + readonly extraArgs?: string[] |
| 73 | +} |
| 74 | + |
| 75 | +export type OpencodeCli = { |
| 76 | + // High-level: run a single prompt against the test model. |
| 77 | + readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult> |
| 78 | + // Escape hatch: any CLI invocation with full control over argv. |
| 79 | + readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult> |
| 80 | + // Convenience assertion. Dumps captured stderr/stdout on mismatch so CI |
| 81 | + // failures are debuggable without re-running locally. |
| 82 | + readonly expectExit: (result: RunResult, expected: number, label?: string) => void |
| 83 | + // Parse `--format json` stdout into one event object per non-empty line. |
| 84 | + // The CLI writes `JSON.stringify({ type, sessionID, ... }) + EOL` for each |
| 85 | + // event (see src/cli/cmd/run.ts `emit`). Throws if any line is malformed |
| 86 | + // so tests fail loudly rather than silently skipping data. |
| 87 | + readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>> |
| 88 | +} |
| 89 | + |
| 90 | +export type RunFixture = { |
| 91 | + readonly llm: TestLLMServer["Service"] |
| 92 | + readonly home: string |
| 93 | + readonly opencode: OpencodeCli |
| 94 | +} |
| 95 | + |
| 96 | +// `withRunFixture(fn)` provisions a TestLLMServer + tmpdir + spawn helper and |
| 97 | +// invokes fn. Cleans up the tmpdir on scope exit. |
| 98 | +// |
| 99 | +// Note on the R channel: TestLLMServer.layer is provided internally so the |
| 100 | +// caller doesn't need to wire it up. The fixture's lifetime is tied to the |
| 101 | +// surrounding Scope. |
| 102 | +export function withRunFixture<A, E>( |
| 103 | + fn: (input: RunFixture) => Effect.Effect<A, E>, |
| 104 | +): Effect.Effect<A, E | unknown, Scope.Scope> { |
| 105 | + return Effect.gen(function* () { |
| 106 | + const llm = yield* TestLLMServer |
| 107 | + |
| 108 | + const home = path.join(os.tmpdir(), "oc-run-" + Math.random().toString(36).slice(2)) |
| 109 | + yield* Effect.promise(() => fs.mkdir(home, { recursive: true })) |
| 110 | + yield* Effect.addFinalizer(() => |
| 111 | + Effect.promise(() => fs.rm(home, { recursive: true, force: true }).catch(() => undefined)), |
| 112 | + ) |
| 113 | + |
| 114 | + const configJson = JSON.stringify(testProviderConfig(llm.url)) |
| 115 | + const env = isolatedEnv(home, configJson) |
| 116 | + |
| 117 | + const spawn = ( |
| 118 | + args: string[], |
| 119 | + opts?: SpawnOpts, |
| 120 | + ): Effect.Effect<RunResult> => |
| 121 | + Effect.promise(async () => { |
| 122 | + const start = Date.now() |
| 123 | + // Process.run pipes stdout/stderr by default and returns them as Buffers. |
| 124 | + const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], { |
| 125 | + cwd: home, |
| 126 | + timeout: opts?.timeoutMs ?? 30_000, |
| 127 | + env: { ...process.env, ...env, ...opts?.env }, |
| 128 | + nothrow: true, |
| 129 | + }) |
| 130 | + return { |
| 131 | + exitCode: result.code, |
| 132 | + stdout: result.stdout.toString(), |
| 133 | + stderr: result.stderr.toString(), |
| 134 | + durationMs: Date.now() - start, |
| 135 | + } |
| 136 | + }) |
| 137 | + |
| 138 | + const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => { |
| 139 | + const argv: string[] = ["run"] |
| 140 | + if (opts?.printLogs) argv.push("--print-logs") |
| 141 | + argv.push("--model", opts?.model ?? testModelID) |
| 142 | + if (opts?.agent) argv.push("--agent", opts.agent) |
| 143 | + if (opts?.format) argv.push("--format", opts.format) |
| 144 | + if (opts?.command) argv.push("--command", opts.command) |
| 145 | + if (opts?.extraArgs) argv.push(...opts.extraArgs) |
| 146 | + argv.push(message) |
| 147 | + return spawn(argv, opts) |
| 148 | + } |
| 149 | + |
| 150 | + const opencode: OpencodeCli = { run, spawn, expectExit, parseJsonEvents } |
| 151 | + |
| 152 | + return yield* fn({ llm, home, opencode }) |
| 153 | + }).pipe(Effect.provide(TestLLMServer.layer)) |
| 154 | +} |
| 155 | + |
| 156 | +function parseJsonEvents(stdout: string): Array<Record<string, unknown>> { |
| 157 | + return stdout |
| 158 | + .split("\n") |
| 159 | + .map((line) => line.trim()) |
| 160 | + .filter((line) => line.length > 0) |
| 161 | + .map((line) => JSON.parse(line) as Record<string, unknown>) |
| 162 | +} |
| 163 | + |
| 164 | +// Convenience for the common assertion pattern. Dumps stderr/stdout when |
| 165 | +// the exit code doesn't match — saves debugging time on CI failures. |
| 166 | +function expectExit(result: RunResult, expected: number, label = "opencode") { |
| 167 | + if (result.exitCode === expected) return |
| 168 | + const tail = (s: string, n: number) => (s.length > n ? "..." + s.slice(-n) : s) |
| 169 | + // eslint-disable-next-line no-console |
| 170 | + console.error( |
| 171 | + `[${label}] expected exit ${expected}, got ${result.exitCode} after ${result.durationMs}ms`, |
| 172 | + ) |
| 173 | + // eslint-disable-next-line no-console |
| 174 | + console.error(`[${label}] stderr (last 2000):\n${tail(result.stderr, 2000)}`) |
| 175 | + // eslint-disable-next-line no-console |
| 176 | + console.error(`[${label}] stdout (last 500):\n${tail(result.stdout, 500)}`) |
| 177 | + throw new Error(`${label}: expected exit ${expected}, got ${result.exitCode}`) |
| 178 | +} |
| 179 | + |
| 180 | +// `runIt.live(name, fixture => effect)` is the same as |
| 181 | +// `it.live(name, () => withRunFixture(fixture))` — one fewer nesting level at |
| 182 | +// every call site. Use this for any test that needs the opencode CLI fixture. |
| 183 | +// |
| 184 | +// Only `.live` is exposed because subprocess tests must run against the real |
| 185 | +// clock — a TestClock-paused environment can't drive a child process. If you |
| 186 | +// need `.only` or `.skip`, fall back to `it.live` + `withRunFixture` directly. |
| 187 | +export const runIt = { |
| 188 | + live: <A, E>( |
| 189 | + name: string, |
| 190 | + body: (input: RunFixture) => Effect.Effect<A, E>, |
| 191 | + opts?: number | TestOptions, |
| 192 | + ) => it.live(name, () => withRunFixture(body), opts), |
| 193 | +} |
0 commit comments