Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/opencode/test/cli/help/help-snapshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot"
function normalize(text: string): string {
return normalizeForSnapshot(text, {
pathReplacements: [
[new RegExp(`<TMPDIR>${PATH_SEP}oc-cli-[a-z0-9]+`, "g"), "<HOME>"],
// Mixed-case [A-Za-z0-9] because node's mkdtemp suffix is mixed-case
// (the harness now uses FileSystem.makeTempDirectoryScoped under the
// hood). A `[a-z0-9]+` regex would leave uppercase chars trailing.
[new RegExp(`<TMPDIR>${PATH_SEP}oc-cli-[A-Za-z0-9]+`, "g"), "<HOME>"],
[/\s+\[string\] \[default: "<HOME>"\]/g, ' [string] [default: "<HOME>"]'],
],
})
Expand Down
85 changes: 60 additions & 25 deletions packages/opencode/test/lib/cli-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@
// without changing the fixture. Long-lived commands like `serve` will need a
// different return shape — see the TODO at the bottom of OpencodeCli.
import type { TestOptions } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { AppProcess } from "@opencode-ai/core/process"
import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import path from "node:path"
import fs from "node:fs/promises"
import os from "node:os"
import { Process } from "@/util/process"
import { TestLLMServer } from "./llm-server"
import { testProviderConfig } from "./test-provider"
import { it } from "./effect"
Expand Down Expand Up @@ -182,33 +182,59 @@ export function withCliFixture<A, E>(
): Effect.Effect<A, E | unknown, Scope.Scope> {
return Effect.gen(function* () {
const llm = yield* TestLLMServer
const fs = yield* AppFileSystem.Service
const appProc = yield* AppProcess.Service

const home = path.join(os.tmpdir(), "oc-cli-" + Math.random().toString(36).slice(2))
yield* Effect.promise(() => fs.mkdir(home, { recursive: true }))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(home, { recursive: true, force: true }).catch(() => undefined)),
)
// FileSystem.makeTempDirectoryScoped handles both creation and scope-tied
// cleanup — replaces the old mkdir + addFinalizer pair.
const home = yield* fs.makeTempDirectoryScoped({ prefix: "oc-cli-" })

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

const spawn = (args: string[], opts?: SpawnOpts): Effect.Effect<RunResult> =>
Effect.promise(async () => {
const start = Date.now()
// Process.run pipes stdout/stderr by default and returns them as Buffers.
const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], {
cwd: home,
timeout: opts?.timeoutMs ?? 30_000,
env: { ...process.env, ...env, ...opts?.env },
nothrow: true,
})
return {
exitCode: result.code,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
durationMs: Date.now() - start,
}
const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) {
const start = Date.now()
const timeoutMs = opts?.timeoutMs ?? 30_000
// stdin: "ignore" so the child doesn't see a piped stdin and block
// 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], {
cwd: home,
env: { ...env, ...opts?.env },
extendEnv: true,
stdin: "ignore",
})
// Pass timeout to appProc.run rather than wrapping with
// Effect.timeoutOrElse externally: AppProcess.run is itself scoped, so
// its built-in timeout triggers the acquireRelease kill finalizer
// inside cross-spawn-spawner *before* surfacing the AppProcessError —
// guaranteeing the child is dead by the time the test continues.
// External timeoutOrElse interrupts the run fiber but races the
// scope close, which can leak the child past the test boundary.
//
// Catch AppProcessError (timeout OR spawn failure) and synthesize a
// non-zero result so the test sees it via the usual `expectExit`
// path rather than as an unhandled Effect failure.
const result = yield* appProc.run(command, { timeout: Duration.millis(timeoutMs) }).pipe(
Effect.catchTag("AppProcessError", (err) =>
Effect.succeed({
command: err.command,
exitCode: err.exitCode ?? -1,
stdout: Buffer.alloc(0),
stderr: Buffer.from((err.stderr ?? String(err.cause ?? err.message)) + "\n"),
stdoutTruncated: false,
stderrTruncated: false,
} satisfies AppProcess.RunResult),
),
)
return {
exitCode: result.exitCode,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
durationMs: Date.now() - start,
}
})

const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
const argv: string[] = ["run"]
Expand Down Expand Up @@ -380,7 +406,16 @@ export function withCliFixture<A, E>(
return yield* fn({ llm, home, opencode })
// FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`
// and hit endpoints on `opencode.serve()` without rolling their own fetch.
}).pipe(Effect.provide(Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.layer)))
}).pipe(
Effect.provide(
Layer.mergeAll(
TestLLMServer.layer,
FetchHttpClient.layer,
AppFileSystem.defaultLayer,
AppProcess.defaultLayer,
),
),
)
}

function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
Expand Down
Loading