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.
2020import 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 "
2323import path from "node:path"
2424import fs from "node:fs/promises"
2525import 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+
74101export 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
93121export 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.
103131export 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 = / l i s t e n i n g o n ( h t t p : \/ \/ ( [ ^ \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
154273function 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.
183305export 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