diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e2723c..dee8d8a614ca 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -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, ) diff --git a/packages/opencode/test/cli/serve/serve-process.test.ts b/packages/opencode/test/cli/serve/serve-process.test.ts index 6dbea372b2cd..9be9e9e28ddf 100644 --- a/packages/opencode/test/cli/serve/serve-process.test.ts +++ b/packages/opencode/test/cli/serve/serve-process.test.ts @@ -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" @@ -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() }), @@ -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, ) }) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 12e8d9c866a5..f54d35646f85 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -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 @@ -211,7 +213,7 @@ export function withCliFixture( // 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, @@ -283,7 +285,7 @@ export function withCliFixture( 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", @@ -313,8 +315,10 @@ export function withCliFixture( 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) @@ -324,7 +328,7 @@ export function withCliFixture( // 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", @@ -395,7 +399,7 @@ export function withCliFixture( // 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",