Skip to content

Commit 7b8a103

Browse files
authored
refactor(test/lib): generalize run-process harness into cli-process (anomalyco#28253)
1 parent ee5cf45 commit 7b8a103

2 files changed

Lines changed: 44 additions & 43 deletions

File tree

packages/opencode/test/cli/run/run-process.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
// Subprocess integration tests for `opencode run` (non-interactive mode).
22
// These exercise the real CLI binary against a TestLLMServer running in the
3-
// same process. See `test/lib/run-process.ts` for the harness — each test uses
3+
// same process. See `test/lib/cli-process.ts` for the harness — each test uses
44
// `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with
55
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
66
import { describe, expect } from "bun:test"
77
import { Effect } from "effect"
8-
import { runIt } from "../../lib/run-process"
8+
import { cliIt } from "../../lib/cli-process"
99

1010
describe("opencode run (non-interactive subprocess)", () => {
1111
// Happy path: prompt completes, output reaches stdout, process exits 0.
1212
// If this fails, all the others likely will too — debug here first.
13-
runIt.live(
13+
cliIt.live(
1414
"exits 0 and writes the response to stdout on a successful prompt",
1515
({ llm, opencode }) =>
1616
Effect.gen(function* () {
@@ -27,7 +27,7 @@ describe("opencode run (non-interactive subprocess)", () => {
2727
// makes the SDK call surface an error promptly so the process exits nonzero.
2828
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
2929
// would expire the timeout and produce a different (signal-killed) failure.
30-
runIt.live(
30+
cliIt.live(
3131
"exits nonzero promptly when the model is unknown (regression for #27371)",
3232
({ opencode }) =>
3333
Effect.gen(function* () {
@@ -47,7 +47,7 @@ describe("opencode run (non-interactive subprocess)", () => {
4747
//
4848
// This is debatable — a future cleanup might flip it to exit 1. If you're
4949
// changing this expectation, do it deliberately and say so in the PR.
50-
runIt.live(
50+
cliIt.live(
5151
"mid-stream LLM error still exits 0 today (contract lock-in)",
5252
({ llm, opencode }) =>
5353
Effect.gen(function* () {
@@ -61,7 +61,7 @@ describe("opencode run (non-interactive subprocess)", () => {
6161
// --format json puts one JSON object per line on stdout for each emitted
6262
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
6363
// shape so a future event-emit change has to update this expectation.
64-
runIt.live(
64+
cliIt.live(
6565
"--format json emits parseable line-delimited JSON to stdout",
6666
({ llm, opencode }) =>
6767
Effect.gen(function* () {
Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
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.
2120
import type { TestOptions } from "bun:test"
2221
import * as Scope from "effect/Scope"
2322
import { 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.
6665
export type RunOpts = SpawnOpts & {
6766
readonly model?: string
6867
readonly agent?: string
@@ -73,39 +72,41 @@ export type RunOpts = SpawnOpts & {
7372
}
7473

7574
export 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

Comments
 (0)