Skip to content

Commit ebb672a

Browse files
authored
test(cli): subprocess integration tests for opencode serve (anomalyco#28263)
1 parent 9a19e84 commit ebb672a

2 files changed

Lines changed: 196 additions & 10 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Subprocess integration tests for `opencode serve`. Spawns the real CLI in
2+
// headless mode and exercises it over HTTP — this is the only test tier that
3+
// catches bugs spanning argv → server boot → routing → instance loading.
4+
//
5+
// `serve` is long-lived: the harness returns a handle (url/port/kill/exited)
6+
// and kills the process when the test scope closes. The OS-assigned port is
7+
// parsed off the "listening on http://..." line.
8+
import { describe, expect } from "bun:test"
9+
import { Effect } from "effect"
10+
import { HttpClient } from "effect/unstable/http"
11+
import { cliIt } from "../../lib/cli-process"
12+
13+
describe("opencode serve (subprocess)", () => {
14+
// Smoke test: server starts, binds a port, and /global/health responds.
15+
// If this fails, all other serve tests likely will too — debug here first.
16+
cliIt.live(
17+
"starts, binds a port, and serves /global/health",
18+
({ opencode }) =>
19+
Effect.gen(function* () {
20+
const server = yield* opencode.serve()
21+
expect(server.port).toBeGreaterThan(0)
22+
expect(server.url).toMatch(/^http:\/\//)
23+
24+
const client = yield* HttpClient.HttpClient
25+
const res = yield* client.get(`${server.url}/global/health`)
26+
expect(res.status).toBe(200)
27+
// GlobalHealth schema is { success: true, ... } | { success: false, error }.
28+
// We don't lock in further shape here — any 200 with parseable JSON is
29+
// enough proof the routing + auth-bypass + instance loading is alive.
30+
const body = yield* res.json
31+
expect(body).toBeDefined()
32+
}),
33+
60_000,
34+
)
35+
36+
// The scope-close finalizer must actually terminate the child. Without this
37+
// test a regression in the kill path (e.g. a future refactor that forgets
38+
// to wire the finalizer) would leak processes on every test run.
39+
cliIt.live(
40+
"kills the subprocess on scope close",
41+
({ opencode }) =>
42+
Effect.gen(function* () {
43+
// Inner scope so we can observe `.exited` resolving after it closes.
44+
const exitedPromise = yield* Effect.scoped(
45+
Effect.gen(function* () {
46+
const server = yield* opencode.serve()
47+
// Capture the Promise, not the resolved value — scope closes after
48+
// this gen returns, at which point the finalizer kills the child.
49+
return server.exited
50+
}),
51+
)
52+
// After scope close: finalizer fired, process must have exited.
53+
const code = yield* Effect.promise(() => exitedPromise)
54+
// Bun reports the exit code; SIGTERM-killed processes return non-null
55+
// (typically 143 on POSIX). We just require resolution within a sane
56+
// window — anything else means the kill didn't take.
57+
expect(typeof code === "number" || code === null).toBe(true)
58+
}),
59+
60_000,
60+
)
61+
})

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

Lines changed: 135 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
// without changing the fixture. Long-lived commands like `serve` will need a
1919
// different return shape — see the TODO at the bottom of OpencodeCli.
2020
import type { TestOptions } from "bun:test"
21-
import * as Scope from "effect/Scope"
22-
import { Effect } from "effect"
21+
import { Deferred, Duration, Effect, Layer, Scope, Stream } from "effect"
22+
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
2323
import path from "node:path"
2424
import fs from "node:fs/promises"
2525
import os from "node:os"
@@ -71,9 +71,40 @@ export type RunOpts = SpawnOpts & {
7171
readonly extraArgs?: string[]
7272
}
7373

74+
// `opencode serve` is a long-lived process — it never exits on its own.
75+
// `serve(opts)` therefore returns a handle inside the caller's Scope: the
76+
// subprocess is killed when the scope closes (test end), and the URL the
77+
// server actually bound to (port 0 means OS-assigned) is parsed off stdout.
78+
export type ServeOpts = SpawnOpts & {
79+
readonly port?: number
80+
readonly hostname?: string
81+
readonly extraArgs?: string[]
82+
// How long to wait for the "listening on http://..." line before failing.
83+
// Default 15s — startup is dominated by bun's transpile + plugin init, not
84+
// the actual listen() call.
85+
readonly readyTimeoutMs?: number
86+
}
87+
88+
export type ServeHandle = {
89+
// Full URL the server is bound to, e.g. "http://127.0.0.1:54321". Use this
90+
// as the base for HTTP requests in tests — never assume the port.
91+
readonly url: string
92+
readonly hostname: string
93+
readonly port: number
94+
// Sends SIGTERM. The scope finalizer also calls this, so tests rarely need
95+
// to invoke it directly — useful for tests that assert exit behavior.
96+
readonly kill: () => void
97+
// Resolves with the exit code once the process exits. Bun returns a number.
98+
readonly exited: Promise<number>
99+
}
100+
74101
export type OpencodeCli = {
75102
// High-level: run a single prompt against the test model. Short-lived.
76103
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
104+
// Spawn `opencode serve` and wait until it's listening. Long-lived: the
105+
// returned handle is killed when the caller's Scope closes. Fails if the
106+
// listening line doesn't appear within `readyTimeoutMs`.
107+
readonly serve: (opts?: ServeOpts) => Effect.Effect<ServeHandle, Error, Scope.Scope>
77108
// Escape hatch: any CLI invocation with full control over argv. Used to test
78109
// commands that don't yet have a typed builder.
79110
readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
@@ -85,9 +116,6 @@ export type OpencodeCli = {
85116
// event (see src/cli/cmd/run.ts `emit`). Throws on a malformed line so
86117
// tests fail loudly rather than silently skipping data.
87118
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.
91119
}
92120

93121
export type CliFixture = {
@@ -101,7 +129,7 @@ export type CliFixture = {
101129
// the caller doesn't need to wire it up — the fixture's lifetime is tied to
102130
// the surrounding Scope.
103131
export function withCliFixture<A, E>(
104-
fn: (input: CliFixture) => Effect.Effect<A, E>,
132+
fn: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
105133
): Effect.Effect<A, E | unknown, Scope.Scope> {
106134
return Effect.gen(function* () {
107135
const llm = yield* TestLLMServer
@@ -145,10 +173,101 @@ export function withCliFixture<A, E>(
145173
return spawn(argv, opts)
146174
}
147175

148-
const opencode: OpencodeCli = { run, spawn, expectExit, parseJsonEvents }
176+
const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) {
177+
const argv = ["serve"]
178+
// Default port 0 — let the OS pick a free port, parse the actual one
179+
// off stdout. Hard-coded ports flake under parallel tests.
180+
argv.push("--port", String(opts?.port ?? 0))
181+
if (opts?.hostname) argv.push("--hostname", opts.hostname)
182+
if (opts?.extraArgs) argv.push(...opts.extraArgs)
183+
184+
// Acquire the subprocess; release sends SIGTERM and awaits exit on
185+
// scope close. Wrapped in Effect.ignore so a flaky kill doesn't surface
186+
// as a finalizer error during test teardown.
187+
const proc = yield* Effect.acquireRelease(
188+
Effect.sync(() =>
189+
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
190+
cwd: home,
191+
env: { ...process.env, ...env, ...opts?.env },
192+
stdout: "pipe",
193+
stderr: "pipe",
194+
}),
195+
),
196+
(p) =>
197+
Effect.promise(() => {
198+
p.kill()
199+
return p.exited
200+
}).pipe(Effect.ignore),
201+
)
202+
203+
// Drain stderr in a scope-bound fork. Without this the OS pipe buffer
204+
// eventually fills and the child blocks on its next log call. Kept as a
205+
// tail buffer so timeout failures can include context.
206+
const stderrChunks: string[] = []
207+
yield* Effect.forkScoped(
208+
Stream.fromReadableStream({
209+
evaluate: () => proc.stderr,
210+
onError: () => new Error("stderr stream error"),
211+
}).pipe(
212+
Stream.decodeText(),
213+
Stream.runForEach((chunk) => Effect.sync(() => stderrChunks.push(chunk))),
214+
Effect.ignore,
215+
),
216+
)
217+
218+
// Watch stdout line-by-line for the listening sentinel. Format
219+
// (see src/cli/cmd/serve.ts):
220+
// "opencode server listening on http://<host>:<port>"
221+
const readyRe = /listening on (http:\/\/([^\s:]+):(\d+))/
222+
const readyDeferred = yield* Deferred.make<{ url: string; hostname: string; port: number }>()
223+
yield* Effect.forkScoped(
224+
Stream.fromReadableStream({
225+
evaluate: () => proc.stdout,
226+
onError: () => new Error("stdout stream error"),
227+
}).pipe(
228+
Stream.decodeText(),
229+
Stream.splitLines,
230+
Stream.runForEach((line) => {
231+
const m = line.match(readyRe)
232+
return m
233+
? Deferred.succeed(readyDeferred, { url: m[1], hostname: m[2], port: Number(m[3]) })
234+
: Effect.void
235+
}),
236+
Effect.ignore,
237+
),
238+
)
239+
240+
const readyTimeoutMs = opts?.readyTimeoutMs ?? 15_000
241+
const match = yield* Deferred.await(readyDeferred).pipe(
242+
Effect.timeoutOrElse({
243+
duration: Duration.millis(readyTimeoutMs),
244+
orElse: () =>
245+
Effect.fail(
246+
new Error(
247+
`opencode serve did not become ready within ${readyTimeoutMs}ms\n` +
248+
`stderr (last 2000):\n${stderrChunks.join("").slice(-2000)}`,
249+
),
250+
),
251+
}),
252+
)
253+
254+
return {
255+
url: match.url,
256+
hostname: match.hostname,
257+
port: match.port,
258+
kill: () => {
259+
proc.kill()
260+
},
261+
exited: proc.exited as Promise<number>,
262+
} satisfies ServeHandle
263+
})
264+
265+
const opencode: OpencodeCli = { run, serve, spawn, expectExit, parseJsonEvents }
149266

150267
return yield* fn({ llm, home, opencode })
151-
}).pipe(Effect.provide(TestLLMServer.layer))
268+
// FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`
269+
// and hit endpoints on `opencode.serve()` without rolling their own fetch.
270+
}).pipe(Effect.provide(Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.layer)))
152271
}
153272

154273
function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
@@ -180,7 +299,13 @@ function expectExit(result: RunResult, expected: number, label = "opencode") {
180299
// Only `.live` is exposed because subprocess tests must run against the real
181300
// clock — a TestClock-paused environment can't drive a child process. If you
182301
// need `.only` or `.skip`, fall back to `it.live` + `withCliFixture` directly.
302+
// Body's R is `Scope.Scope | never` so tests can yield* scope-requiring
303+
// resources (e.g. `opencode.serve`) without an extra `Effect.scoped` wrapper —
304+
// `withCliFixture`'s outer scope is the natural lifetime.
183305
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),
306+
live: <A, E>(
307+
name: string,
308+
body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
309+
opts?: number | TestOptions,
310+
) => it.live(name, () => withCliFixture(body), opts),
186311
}

0 commit comments

Comments
 (0)