cloudaxe-opencode/packages/opencode/test/cli/serve/serve-process.test.ts
Kit Langton 75c507f769 refactor(test/cli): migrate serve/acp builders to AppProcess.spawn
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.
2026-05-19 16:19:04 -04:00

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,
)
})