Skip to content

Commit 80e5fb1

Browse files
authored
refactor(test/cli): migrate harness short-lived path to AppProcess + FileSystem (#28366)
1 parent bc6c4c7 commit 80e5fb1

2 files changed

Lines changed: 64 additions & 26 deletions

File tree

packages/opencode/test/cli/help/help-snapshots.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot"
2929
function normalize(text: string): string {
3030
return normalizeForSnapshot(text, {
3131
pathReplacements: [
32-
[new RegExp(`<TMPDIR>${PATH_SEP}oc-cli-[a-z0-9]+`, "g"), "<HOME>"],
32+
// Mixed-case [A-Za-z0-9] because node's mkdtemp suffix is mixed-case
33+
// (the harness now uses FileSystem.makeTempDirectoryScoped under the
34+
// hood). A `[a-z0-9]+` regex would leave uppercase chars trailing.
35+
[new RegExp(`<TMPDIR>${PATH_SEP}oc-cli-[A-Za-z0-9]+`, "g"), "<HOME>"],
3336
[/\s+\[string\] \[default: "<HOME>"\]/g, ' [string] [default: "<HOME>"]'],
3437
],
3538
})

packages/opencode/test/lib/cli-process.ts

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@
1818
// without changing the fixture. Long-lived commands like `serve` will need a
1919
// different return shape — see the TODO at the bottom of OpencodeCli.
2020
import type { TestOptions } from "bun:test"
21+
import { AppFileSystem } from "@opencode-ai/core/filesystem"
22+
import { AppProcess } from "@opencode-ai/core/process"
2123
import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
2224
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
25+
import { ChildProcess } from "effect/unstable/process"
2326
import path from "node:path"
24-
import fs from "node:fs/promises"
25-
import os from "node:os"
26-
import { Process } from "@/util/process"
2727
import { TestLLMServer } from "./llm-server"
2828
import { testProviderConfig } from "./test-provider"
2929
import { it } from "./effect"
@@ -182,33 +182,59 @@ export function withCliFixture<A, E>(
182182
): Effect.Effect<A, E | unknown, Scope.Scope> {
183183
return Effect.gen(function* () {
184184
const llm = yield* TestLLMServer
185+
const fs = yield* AppFileSystem.Service
186+
const appProc = yield* AppProcess.Service
185187

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-" })
191191

192192
const configJson = JSON.stringify(testProviderConfig(llm.url))
193193
const env = isolatedEnv(home, configJson)
194194

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",
211207
})
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+
})
212238

213239
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
214240
const argv: string[] = ["run"]
@@ -380,7 +406,16 @@ export function withCliFixture<A, E>(
380406
return yield* fn({ llm, home, opencode })
381407
// FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`
382408
// 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+
)
384419
}
385420

386421
function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {

0 commit comments

Comments
 (0)