( fn: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>, )
| 187 | // the caller doesn't need to wire it up — the fixture's lifetime is tied to |
| 188 | // the surrounding Scope. |
| 189 | export function withCliFixture<A, E>( |
| 190 | fn: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>, |
| 191 | ): Effect.Effect<A, E | unknown, Scope.Scope> { |
| 192 | return Effect.gen(function* () { |
| 193 | const llm = yield* TestLLMServer |
| 194 | const fs = yield* FSUtil.Service |
| 195 | const appProc = yield* AppProcess.Service |
| 196 | |
| 197 | const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" }) |
| 198 | yield* Effect.addFinalizer(() => |
| 199 | fs |
| 200 | .remove(home, { recursive: true }) |
| 201 | .pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore), |
| 202 | ) |
| 203 | |
| 204 | const configJson = JSON.stringify(testProviderConfig(llm.url)) |
| 205 | const env = isolatedEnv(home, configJson) |
| 206 | |
| 207 | const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) { |
| 208 | const start = Date.now() |
| 209 | const timeoutMs = opts?.timeoutMs ?? 30_000 |
| 210 | // stdin: "ignore" so the child doesn't see a piped stdin and block |
| 211 | // on `Bun.stdin.text()` (see src/cli/cmd/run.ts — non-TTY stdin is |
| 212 | // consumed as the prompt). The old Process.run wrapper defaulted to |
| 213 | // ignore; ChildProcess.make defaults to pipe, so we set it explicitly. |
| 214 | const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], { |
| 215 | cwd: home, |
| 216 | env: { ...env, ...opts?.env }, |
| 217 | extendEnv: true, |
| 218 | stdin: "ignore", |
| 219 | }) |
| 220 | // Pass timeout to appProc.run rather than wrapping with |
| 221 | // Effect.timeoutOrElse externally: AppProcess.run is itself scoped, so |
| 222 | // its built-in timeout triggers the acquireRelease kill finalizer |
| 223 | // inside cross-spawn-spawner *before* surfacing the AppProcessError — |
| 224 | // guaranteeing the child is dead by the time the test continues. |
| 225 | // External timeoutOrElse interrupts the run fiber but races the |
| 226 | // scope close, which can leak the child past the test boundary. |
| 227 | // |
| 228 | // Catch AppProcessError (timeout OR spawn failure) and synthesize a |
| 229 | // non-zero result so the test sees it via the usual `expectExit` |
| 230 | // path rather than as an unhandled Effect failure. |
| 231 | const result = yield* appProc.run(command, { timeout: Duration.millis(timeoutMs) }).pipe( |
| 232 | Effect.catchTag("AppProcessError", (err) => |
| 233 | Effect.succeed({ |
| 234 | command: err.command, |
| 235 | exitCode: err.exitCode ?? -1, |
| 236 | stdout: Buffer.alloc(0), |
| 237 | stderr: Buffer.from((err.stderr ?? String(err.cause ?? err.message)) + "\n"), |
| 238 | stdoutTruncated: false, |
| 239 | stderrTruncated: false, |
| 240 | } satisfies AppProcess.RunResult), |
| 241 | ), |
| 242 | ) |
| 243 | return { |
| 244 | exitCode: result.exitCode, |
| 245 | stdout: normalizeLines(result.stdout.toString()), |
| 246 | stderr: normalizeLines(result.stderr.toString()), |
no test coverage detected