|
18 | 18 | // without changing the fixture. Long-lived commands like `serve` will need a |
19 | 19 | // different return shape — see the TODO at the bottom of OpencodeCli. |
20 | 20 | import type { TestOptions } from "bun:test" |
| 21 | +import { AppFileSystem } from "@opencode-ai/core/filesystem" |
| 22 | +import { AppProcess } from "@opencode-ai/core/process" |
21 | 23 | import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect" |
22 | 24 | import { FetchHttpClient, HttpClient } from "effect/unstable/http" |
| 25 | +import { ChildProcess } from "effect/unstable/process" |
23 | 26 | import path from "node:path" |
24 | | -import fs from "node:fs/promises" |
25 | | -import os from "node:os" |
26 | | -import { Process } from "@/util/process" |
27 | 27 | import { TestLLMServer } from "./llm-server" |
28 | 28 | import { testProviderConfig } from "./test-provider" |
29 | 29 | import { it } from "./effect" |
@@ -182,33 +182,59 @@ export function withCliFixture<A, E>( |
182 | 182 | ): Effect.Effect<A, E | unknown, Scope.Scope> { |
183 | 183 | return Effect.gen(function* () { |
184 | 184 | const llm = yield* TestLLMServer |
| 185 | + const fs = yield* AppFileSystem.Service |
| 186 | + const appProc = yield* AppProcess.Service |
185 | 187 |
|
186 | | - const home = path.join(os.tmpdir(), "oc-cli-" + Math.random().toString(36).slice(2)) |
187 | | - yield* Effect.promise(() => fs.mkdir(home, { recursive: true })) |
188 | | - yield* Effect.addFinalizer(() => |
189 | | - Effect.promise(() => fs.rm(home, { recursive: true, force: true }).catch(() => undefined)), |
190 | | - ) |
| 188 | + // FileSystem.makeTempDirectoryScoped handles both creation and scope-tied |
| 189 | + // cleanup — replaces the old mkdir + addFinalizer pair. |
| 190 | + const home = yield* fs.makeTempDirectoryScoped({ prefix: "oc-cli-" }) |
191 | 191 |
|
192 | 192 | const configJson = JSON.stringify(testProviderConfig(llm.url)) |
193 | 193 | const env = isolatedEnv(home, configJson) |
194 | 194 |
|
195 | | - const spawn = (args: string[], opts?: SpawnOpts): Effect.Effect<RunResult> => |
196 | | - Effect.promise(async () => { |
197 | | - const start = Date.now() |
198 | | - // Process.run pipes stdout/stderr by default and returns them as Buffers. |
199 | | - const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], { |
200 | | - cwd: home, |
201 | | - timeout: opts?.timeoutMs ?? 30_000, |
202 | | - env: { ...process.env, ...env, ...opts?.env }, |
203 | | - nothrow: true, |
204 | | - }) |
205 | | - return { |
206 | | - exitCode: result.code, |
207 | | - stdout: result.stdout.toString(), |
208 | | - stderr: result.stderr.toString(), |
209 | | - durationMs: Date.now() - start, |
210 | | - } |
| 195 | + const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) { |
| 196 | + const start = Date.now() |
| 197 | + const timeoutMs = opts?.timeoutMs ?? 30_000 |
| 198 | + // stdin: "ignore" so the child doesn't see a piped stdin and block |
| 199 | + // on `Bun.stdin.text()` (see src/cli/cmd/run.ts — non-TTY stdin is |
| 200 | + // consumed as the prompt). The old Process.run wrapper defaulted to |
| 201 | + // ignore; ChildProcess.make defaults to pipe, so we set it explicitly. |
| 202 | + const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], { |
| 203 | + cwd: home, |
| 204 | + env: { ...env, ...opts?.env }, |
| 205 | + extendEnv: true, |
| 206 | + stdin: "ignore", |
211 | 207 | }) |
| 208 | + // Pass timeout to appProc.run rather than wrapping with |
| 209 | + // Effect.timeoutOrElse externally: AppProcess.run is itself scoped, so |
| 210 | + // its built-in timeout triggers the acquireRelease kill finalizer |
| 211 | + // inside cross-spawn-spawner *before* surfacing the AppProcessError — |
| 212 | + // guaranteeing the child is dead by the time the test continues. |
| 213 | + // External timeoutOrElse interrupts the run fiber but races the |
| 214 | + // scope close, which can leak the child past the test boundary. |
| 215 | + // |
| 216 | + // Catch AppProcessError (timeout OR spawn failure) and synthesize a |
| 217 | + // non-zero result so the test sees it via the usual `expectExit` |
| 218 | + // path rather than as an unhandled Effect failure. |
| 219 | + const result = yield* appProc.run(command, { timeout: Duration.millis(timeoutMs) }).pipe( |
| 220 | + Effect.catchTag("AppProcessError", (err) => |
| 221 | + Effect.succeed({ |
| 222 | + command: err.command, |
| 223 | + exitCode: err.exitCode ?? -1, |
| 224 | + stdout: Buffer.alloc(0), |
| 225 | + stderr: Buffer.from((err.stderr ?? String(err.cause ?? err.message)) + "\n"), |
| 226 | + stdoutTruncated: false, |
| 227 | + stderrTruncated: false, |
| 228 | + } satisfies AppProcess.RunResult), |
| 229 | + ), |
| 230 | + ) |
| 231 | + return { |
| 232 | + exitCode: result.exitCode, |
| 233 | + stdout: result.stdout.toString(), |
| 234 | + stderr: result.stderr.toString(), |
| 235 | + durationMs: Date.now() - start, |
| 236 | + } |
| 237 | + }) |
212 | 238 |
|
213 | 239 | const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => { |
214 | 240 | const argv: string[] = ["run"] |
@@ -380,7 +406,16 @@ export function withCliFixture<A, E>( |
380 | 406 | return yield* fn({ llm, home, opencode }) |
381 | 407 | // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient` |
382 | 408 | // and hit endpoints on `opencode.serve()` without rolling their own fetch. |
383 | | - }).pipe(Effect.provide(Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.layer))) |
| 409 | + }).pipe( |
| 410 | + Effect.provide( |
| 411 | + Layer.mergeAll( |
| 412 | + TestLLMServer.layer, |
| 413 | + FetchHttpClient.layer, |
| 414 | + AppFileSystem.defaultLayer, |
| 415 | + AppProcess.defaultLayer, |
| 416 | + ), |
| 417 | + ), |
| 418 | + ) |
384 | 419 | } |
385 | 420 |
|
386 | 421 | function parseJsonEvents(stdout: string): Array<Record<string, unknown>> { |
|
0 commit comments