1- // Subprocess test harness for the `opencode run` CLI.
1+ // Subprocess test harness for the opencode CLI. Spawns the real binary against
2+ // a TestLLMServer running in-process at a random port, with full env isolation.
23//
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.
4+ // This is the missing test tier: in-process tests can't catch bugs that span
5+ // argv parsing → server boot → SDK call → event consumption → exit code (like
6+ // the original /event race or #27371's invalid-model hang).
87//
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
8+ // Configuration flows through opencode's built-in test affordances:
9+ // - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
10+ // - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
1311// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
14- // - OPENCODE_PURE : skip external plugin discovery + install
12+ // - OPENCODE_PURE : skip external plugin discovery + install
1513// - OPENCODE_DISABLE_AUTOUPDATE / AUTOCOMPACT / MODELS_FETCH : no background work
16- //
1714// Plus HOME / XDG_* pointing at the tmpdir for belt-and-suspenders isolation.
1815//
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.
16+ // Today only `opencode.run` is fully wired. The shape supports adding more
17+ // builders (`opencode.serve(opts)`, `opencode.acp(opts)`, `opencode.auth(...)`)
18+ // without changing the fixture. Long-lived commands like `serve` will need a
19+ // different return shape — see the TODO at the bottom of OpencodeCli.
2120import type { TestOptions } from "bun:test"
2221import * as Scope from "effect/Scope"
2322import { Effect } from "effect"
@@ -59,10 +58,10 @@ export type RunResult = {
5958 readonly durationMs : number
6059}
6160
62- type SpawnOpts = { readonly timeoutMs ?: number ; readonly env ?: Record < string , string > }
61+ export type SpawnOpts = { readonly timeoutMs ?: number ; readonly env ?: Record < string , string > }
6362
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.
63+ // Typed equivalent of constructing argv for `opencode run`. New flags should
64+ // land here so tests stay grep-able and refactor-safe.
6665export type RunOpts = SpawnOpts & {
6766 readonly model ?: string
6867 readonly agent ?: string
@@ -73,39 +72,41 @@ export type RunOpts = SpawnOpts & {
7372}
7473
7574export type OpencodeCli = {
76- // High-level: run a single prompt against the test model.
75+ // High-level: run a single prompt against the test model. Short-lived.
7776 readonly run : ( message : string , opts ?: RunOpts ) => Effect . Effect < RunResult >
78- // Escape hatch: any CLI invocation with full control over argv.
77+ // Escape hatch: any CLI invocation with full control over argv. Used to test
78+ // commands that don't yet have a typed builder.
7979 readonly spawn : ( args : string [ ] , opts ?: SpawnOpts ) => Effect . Effect < RunResult >
8080 // Convenience assertion. Dumps captured stderr/stdout on mismatch so CI
8181 // failures are debuggable without re-running locally.
8282 readonly expectExit : ( result : RunResult , expected : number , label ?: string ) => void
8383 // Parse `--format json` stdout into one event object per non-empty line.
8484 // 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.
85+ // event (see src/cli/cmd/run.ts `emit`). Throws on a malformed line so
86+ // tests fail loudly rather than silently skipping data.
8787 readonly parseJsonEvents : ( stdout : string ) => Array < Record < string , unknown > >
88+ // TODO: long-lived builders for `serve` / `acp` / etc. need a different
89+ // return shape — they yield a handle with .url / .kill and live inside the
90+ // surrounding Scope. Add when the first long-lived command is tested.
8891}
8992
90- export type RunFixture = {
93+ export type CliFixture = {
9194 readonly llm : TestLLMServer [ "Service" ]
9295 readonly home : string
9396 readonly opencode : OpencodeCli
9497}
9598
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 > ,
99+ // Provisions a TestLLMServer + tmpdir + spawn helper and invokes fn. Cleans
100+ // up the tmpdir on scope exit. TestLLMServer.layer is provided internally so
101+ // the caller doesn't need to wire it up — the fixture's lifetime is tied to
102+ // the surrounding Scope.
103+ export function withCliFixture < A , E > (
104+ fn : ( input : CliFixture ) => Effect . Effect < A , E > ,
104105) : Effect . Effect < A , E | unknown , Scope . Scope > {
105106 return Effect . gen ( function * ( ) {
106107 const llm = yield * TestLLMServer
107108
108- const home = path . join ( os . tmpdir ( ) , "oc-run -" + Math . random ( ) . toString ( 36 ) . slice ( 2 ) )
109+ const home = path . join ( os . tmpdir ( ) , "oc-cli -" + Math . random ( ) . toString ( 36 ) . slice ( 2 ) )
109110 yield * Effect . promise ( ( ) => fs . mkdir ( home , { recursive : true } ) )
110111 yield * Effect . addFinalizer ( ( ) =>
111112 Effect . promise ( ( ) => fs . rm ( home , { recursive : true , force : true } ) . catch ( ( ) => undefined ) ) ,
@@ -172,14 +173,14 @@ function expectExit(result: RunResult, expected: number, label = "opencode") {
172173 throw new Error ( `${ label } : expected exit ${ expected } , got ${ result . exitCode } ` )
173174}
174175
175- // `runIt .live(name, fixture => effect)` is the same as
176- // `it.live(name, () => withRunFixture (fixture))` — one fewer nesting level at
176+ // `cliIt .live(name, fixture => effect)` is the same as
177+ // `it.live(name, () => withCliFixture (fixture))` — one fewer nesting level at
177178// every call site. Use this for any test that needs the opencode CLI fixture.
178179//
179180// Only `.live` is exposed because subprocess tests must run against the real
180181// clock — a TestClock-paused environment can't drive a child process. If you
181- // need `.only` or `.skip`, fall back to `it.live` + `withRunFixture ` directly.
182- export const runIt = {
183- live : < A , E > ( name : string , body : ( input : RunFixture ) => Effect . Effect < A , E > , opts ?: number | TestOptions ) =>
184- it . live ( name , ( ) => withRunFixture ( body ) , opts ) ,
182+ // need `.only` or `.skip`, fall back to `it.live` + `withCliFixture ` directly.
183+ export const cliIt = {
184+ live : < A , E > ( name : string , body : ( input : CliFixture ) => Effect . Effect < A , E > , opts ?: number | TestOptions ) =>
185+ it . live ( name , ( ) => withCliFixture ( body ) , opts ) ,
185186}
0 commit comments