2026-05-19 00:55:58 +00:00
|
|
|
// 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.
|
2026-05-19 20:18:38 +00:00
|
|
|
const exited = yield* Effect.scoped(
|
2026-05-19 00:55:58 +00:00
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const server = yield* opencode.serve()
|
2026-05-19 20:18:38 +00:00
|
|
|
// 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.
|
2026-05-19 00:55:58 +00:00
|
|
|
return server.exited
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
// After scope close: finalizer fired, process must have exited.
|
2026-05-19 20:18:38 +00:00
|
|
|
// Signal-killed processes surface as -1 (see ServeHandle.exited).
|
|
|
|
|
const code = yield* exited
|
|
|
|
|
expect(typeof code).toBe("number")
|
2026-05-19 00:55:58 +00:00
|
|
|
}),
|
|
|
|
|
60_000,
|
|
|
|
|
)
|
|
|
|
|
})
|