Slice 2 of the CLI harness Effect migration. Drops the last raw Bun.spawn call sites in withCliFixture. - `serve` and `acp` both move from `Effect.acquireRelease(Bun.spawn(...))` to `appProc.spawn(ChildProcess.make(...))`. The spawner's built-in acquireRelease finalizer handles SIGTERM on scope close — no manual wiring needed. - `handle.stdout` / `handle.stderr` are already Effect Streams, so the `fromBunStream` helper is gone (Stream.fromReadableStream + the per-pipe error-tag boilerplate it wrapped). - acp's stdin moves from imperative `proc.stdin.write` + `proc.stdin.end` to a Queue<Uint8Array> fed into the spawner's stdin Sink via Stream.fromQueue. `send` is `Queue.offer`, `close` is `Queue.shutdown` — shutdown propagates as stdin EOF, which is ACP's graceful-exit signal. - ServeHandle/AcpHandle public shape: `kill`/`close` become Effect<void> and `exited` becomes Effect<number> (was () => void and Promise<number>). The platform error that cross-spawn-spawner raises on signal-kill is collapsed to exit code -1 so `exited` stays a clean Effect<number> — matches the test contract (just needs proof of exit). Two consuming tests updated to yield the Effect instead of awaiting the Promise.
61 lines
2.7 KiB
TypeScript
61 lines
2.7 KiB
TypeScript
// Subprocess integration tests for `opencode serve`. Spawns the real CLI in
|
|
// headless mode and exercises it over HTTP — this is the only test tier that
|
|
// catches bugs spanning argv → server boot → routing → instance loading.
|
|
//
|
|
// `serve` is long-lived: the harness returns a handle (url/port/kill/exited)
|
|
// and kills the process when the test scope closes. The OS-assigned port is
|
|
// parsed off the "listening on http://..." line.
|
|
import { describe, expect } from "bun:test"
|
|
import { Effect } from "effect"
|
|
import { HttpClient } from "effect/unstable/http"
|
|
import { cliIt } from "../../lib/cli-process"
|
|
|
|
describe("opencode serve (subprocess)", () => {
|
|
// Smoke test: server starts, binds a port, and /global/health responds.
|
|
// If this fails, all other serve tests likely will too — debug here first.
|
|
cliIt.live(
|
|
"starts, binds a port, and serves /global/health",
|
|
({ opencode }) =>
|
|
Effect.gen(function* () {
|
|
const server = yield* opencode.serve()
|
|
expect(server.port).toBeGreaterThan(0)
|
|
expect(server.url).toMatch(/^http:\/\//)
|
|
|
|
const client = yield* HttpClient.HttpClient
|
|
const res = yield* client.get(`${server.url}/global/health`)
|
|
expect(res.status).toBe(200)
|
|
// GlobalHealth schema is { success: true, ... } | { success: false, error }.
|
|
// We don't lock in further shape here — any 200 with parseable JSON is
|
|
// enough proof the routing + auth-bypass + instance loading is alive.
|
|
const body = yield* res.json
|
|
expect(body).toBeDefined()
|
|
}),
|
|
60_000,
|
|
)
|
|
|
|
// The scope-close finalizer must actually terminate the child. Without this
|
|
// test a regression in the kill path (e.g. a future refactor that forgets
|
|
// to wire the finalizer) would leak processes on every test run.
|
|
cliIt.live(
|
|
"kills the subprocess on scope close",
|
|
({ opencode }) =>
|
|
Effect.gen(function* () {
|
|
// Inner scope so we can observe `.exited` resolving after it closes.
|
|
const exited = yield* Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* opencode.serve()
|
|
// Capture the Effect, not its result — scope closes after this
|
|
// gen returns, at which point the finalizer kills the child.
|
|
// handle.exitCode itself has no Scope requirement, so yielding
|
|
// it after scope close is fine.
|
|
return server.exited
|
|
}),
|
|
)
|
|
// After scope close: finalizer fired, process must have exited.
|
|
// Signal-killed processes surface as -1 (see ServeHandle.exited).
|
|
const code = yield* exited
|
|
expect(typeof code).toBe("number")
|
|
}),
|
|
60_000,
|
|
)
|
|
})
|