Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions packages/opencode/test/cli/run/run-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,15 @@ describe("opencode run (non-interactive subprocess)", () => {
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.hang
const run = yield* opencode.startRun("wait forever")
yield* llm.wait(1)
run.interrupt()
const result = yield* run.result
const result = yield* Effect.gen(function* () {
const run = yield* opencode.startRun("wait forever")
yield* llm.wait(1)
run.interrupt()
return yield* run.result
}).pipe(Effect.timeout("24 seconds"))

expect(result.exitCode).not.toBe(0)
expect(result.durationMs).toBeLessThan(30_000)
expect(result.durationMs).toBeLessThan(24_000)
}),
30_000,
)
Expand Down
41 changes: 25 additions & 16 deletions packages/opencode/test/cli/serve/serve-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
// 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.
// and kills the process when the test scope closes. The bound port is parsed
// off the "listening on http://..." line — `--port 0` asks for 4096 first and
// falls back to an OS-assigned port only if that bind fails.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
Expand All @@ -24,9 +25,11 @@ describe("opencode serve (subprocess)", () => {
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 }.
// GlobalHealth returns { healthy: true, version } (handlers/global.ts).
// We don't lock in further shape here — any 200 with parseable JSON is
// enough proof the routing + auth-bypass + instance loading is alive.
// enough proof that argv → server boot → routing works. It does NOT
// prove instance loading: `serve` runs with instance: false and the
// global health route loads no instance (src/cli/effect-cmd.ts).
const body = yield* res.json
expect(body).toBeDefined()
}),
Expand All @@ -40,22 +43,28 @@ describe("opencode serve (subprocess)", () => {
"kills the subprocess on scope close",
({ opencode }) =>
Effect.gen(function* () {
// Inner scope so we can observe `.exited` resolving after it closes.
const exitedPromise = yield* Effect.scoped(
const client = yield* HttpClient.HttpClient
const handle = yield* Effect.scoped(
Effect.gen(function* () {
const server = yield* opencode.serve()
// Capture the Promise, not the resolved value — scope closes after
// this gen returns, at which point the finalizer kills the child.
return server.exited
const response = yield* client.get(`${server.url}/global/health`)
expect(response.status).toBe(200)
return { url: server.url, exited: server.exited }
}),
)
// After scope close: finalizer fired, process must have exited.
const code = yield* Effect.promise(() => exitedPromise)
// Bun reports the exit code; SIGTERM-killed processes return non-null
// (typically 143 on POSIX). We just require resolution within a sane
// window — anything else means the kill didn't take.
expect(typeof code === "number" || code === null).toBe(true)
}),

yield* Effect.promise(() => handle.exited)

const request = () =>
client.get(`${handle.url}/global/health`).pipe(Effect.exit, Effect.timeout("1 second"))
const first = yield* request()
yield* Effect.sleep("75 millis")
const second = yield* request()
yield* Effect.sleep("75 millis")
const third = yield* request()

expect([first, second, third].map((result) => result._tag)).toEqual(["Failure", "Failure", "Failure"])
}).pipe(Effect.timeout("20 seconds")),
60_000,
)
})
18 changes: 11 additions & 7 deletions packages/opencode/test/lib/cli-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ export type RunOpts = SpawnOpts & {
// `opencode serve` is a long-lived process — it never exits on its own.
// `serve(opts)` therefore returns a handle inside the caller's Scope: the
// subprocess is killed when the scope closes (test end), and the URL the
// server actually bound to (port 0 means OS-assigned) is parsed off stdout.
// server actually bound to is parsed off stdout. Note `--port 0` does not mean
// "OS-assigned": the server asks for 4096 first and only falls back to an
// OS-assigned port if that bind fails (src/server/server.ts).
export type ServeOpts = SpawnOpts & {
readonly port?: number
readonly hostname?: string
Expand Down Expand Up @@ -211,7 +213,7 @@ export function withCliFixture<A, E>(
// on `Bun.stdin.text()` (see src/cli/cmd/run.ts — non-TTY stdin is
// consumed as the prompt). The old Process.run wrapper defaulted to
// ignore; ChildProcess.make defaults to pipe, so we set it explicitly.
const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], {
const command = ChildProcess.make(process.execPath, ["run", "--conditions=browser", cliEntry, ...args], {
cwd: home,
env: { ...env, ...opts?.env },
extendEnv: true,
Expand Down Expand Up @@ -283,7 +285,7 @@ export function withCliFixture<A, E>(
const options = runOpts(opts)
const proc = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], {
Bun.spawn([process.execPath, "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], {
cwd: home,
env: { ...process.env, ...env, ...options?.env },
stdin: "ignore",
Expand Down Expand Up @@ -313,8 +315,10 @@ export function withCliFixture<A, E>(

const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) {
const argv = ["serve"]
// Default port 0 — let the OS pick a free port, parse the actual one
// off stdout. Hard-coded ports flake under parallel tests.
// Default port 0 — the server tries 4096 first and falls back to an
// OS-assigned port only if that bind fails (src/server/server.ts); the
// actual port is parsed off stdout. Hard-coded ports flake under
// parallel tests.
argv.push("--port", String(opts?.port ?? 0))
if (opts?.hostname) argv.push("--hostname", opts.hostname)
if (opts?.extraArgs) argv.push(...opts.extraArgs)
Expand All @@ -324,7 +328,7 @@ export function withCliFixture<A, E>(
// as a finalizer error during test teardown.
const proc = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
Bun.spawn([process.execPath, "run", "--conditions=browser", cliEntry, ...argv], {
cwd: home,
env: { ...process.env, ...env, ...opts?.env },
stdout: "pipe",
Expand Down Expand Up @@ -395,7 +399,7 @@ export function withCliFixture<A, E>(
// Either way we await proc.exited so the test scope doesn't leak.
const proc = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
Bun.spawn([process.execPath, "run", "--conditions=browser", cliEntry, ...argv], {
cwd: opts?.cwd ?? home,
env: { ...process.env, ...env, ...opts?.env },
stdin: "pipe",
Expand Down
Loading