| import { describe, expect } from "bun:test" |
| import fs from "node:fs/promises" |
| import os from "node:os" |
| import path from "node:path" |
| import { Effect, Exit, Stream } from "effect" |
| import type * as PlatformError from "effect/PlatformError" |
| import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" |
| import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" |
| import { LayerNode } from "@opencode-ai/core/effect/layer-node" |
| import { testEffect } from "../lib/effect" |
|
|
| const live = LayerNode.compile(CrossSpawnSpawner.node) |
| const fx = testEffect(live) |
|
|
| function js(code: string, opts?: ChildProcess.CommandOptions) { |
| return ChildProcess.make("node", ["-e", code], opts) |
| } |
|
|
| function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) { |
| return Stream.runCollect(stream).pipe( |
| Effect.map((chunks) => { |
| const total = chunks.reduce((acc, x) => acc + x.length, 0) |
| const out = new Uint8Array(total) |
| let off = 0 |
| for (const chunk of chunks) { |
| out.set(chunk, off) |
| off += chunk.length |
| } |
| return new TextDecoder("utf-8").decode(out).trim() |
| }), |
| ) |
| } |
|
|
| function alive(pid: number) { |
| try { |
| process.kill(pid, 0) |
| return true |
| } catch { |
| return false |
| } |
| } |
|
|
| async function tmpdir() { |
| const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-test-")) |
| return { |
| path: dir, |
| async [Symbol.asyncDispose]() { |
| await fs.rm(dir, { recursive: true, force: true }) |
| }, |
| } |
| } |
|
|
| async function gone(pid: number, timeout = 5_000) { |
| const end = Date.now() + timeout |
| while (Date.now() < end) { |
| if (!alive(pid)) return true |
| await new Promise((resolve) => setTimeout(resolve, 50)) |
| } |
| return !alive(pid) |
| } |
|
|
| describe("cross-spawn spawner", () => { |
| describe("basic spawning", () => { |
| fx.effect( |
| "captures stdout", |
| Effect.gen(function* () { |
| const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => |
| svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])), |
| ) |
| expect(out).toBe("ok") |
| }), |
| ) |
|
|
| fx.effect( |
| "captures multiple lines", |
| Effect.gen(function* () { |
| const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")') |
| const out = yield* decodeByteStream(handle.stdout) |
| expect(out).toBe("line1\nline2\nline3") |
| }), |
| ) |
|
|
| fx.effect( |
| "returns exit code", |
| Effect.gen(function* () { |
| const handle = yield* js("process.exit(0)") |
| const code = yield* handle.exitCode |
| expect(code).toBe(ChildProcessSpawner.ExitCode(0)) |
| }), |
| ) |
|
|
| fx.effect( |
| "returns non-zero exit code", |
| Effect.gen(function* () { |
| const handle = yield* js("process.exit(42)") |
| const code = yield* handle.exitCode |
| expect(code).toBe(ChildProcessSpawner.ExitCode(42)) |
| }), |
| ) |
| }) |
|
|
| describe("cwd option", () => { |
| fx.effect( |
| "uses cwd when spawning commands", |
| Effect.gen(function* () { |
| const tmp = yield* Effect.acquireRelease( |
| Effect.promise(() => tmpdir()), |
| (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), |
| ) |
| const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => |
| svc.string( |
| ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }), |
| ), |
| ) |
| expect(yield* Effect.promise(() => fs.realpath(out))).toBe(yield* Effect.promise(() => fs.realpath(tmp.path))) |
| }), |
| ) |
|
|
| fx.effect( |
| "fails for invalid cwd", |
| Effect.gen(function* () { |
| const exit = yield* Effect.exit( |
| ChildProcessSpawner.ChildProcessSpawner.use((svc) => |
| svc.spawn(ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" })), |
| ), |
| ) |
| expect(Exit.isFailure(exit)).toBe(true) |
| }), |
| ) |
| }) |
|
|
| describe("env option", () => { |
| fx.effect( |
| "passes environment variables with extendEnv", |
| Effect.gen(function* () { |
| const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', { |
| env: { TEST_VAR: "test_value" }, |
| extendEnv: true, |
| }) |
| const out = yield* decodeByteStream(handle.stdout) |
| expect(out).toBe("test_value") |
| }), |
| ) |
|
|
| fx.effect( |
| "passes multiple environment variables", |
| Effect.gen(function* () { |
| const handle = yield* js( |
| "process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)", |
| { |
| env: { VAR1: "one", VAR2: "two", VAR3: "three" }, |
| extendEnv: true, |
| }, |
| ) |
| const out = yield* decodeByteStream(handle.stdout) |
| expect(out).toBe("one-two-three") |
| }), |
| ) |
| }) |
|
|
| describe("stderr", () => { |
| fx.effect( |
| "captures stderr output", |
| Effect.gen(function* () { |
| const handle = yield* js('process.stderr.write("error message")') |
| const err = yield* decodeByteStream(handle.stderr) |
| expect(err).toBe("error message") |
| }), |
| ) |
|
|
| fx.effect( |
| "captures both stdout and stderr", |
| Effect.gen(function* () { |
| const handle = yield* js( |
| [ |
| "let pending = 2", |
| "const done = () => {", |
| " pending -= 1", |
| " if (pending === 0) setTimeout(() => process.exit(0), 0)", |
| "}", |
| 'process.stdout.write("stdout\\n", done)', |
| 'process.stderr.write("stderr\\n", done)', |
| ].join("\n"), |
| ) |
| const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], { |
| concurrency: 2, |
| }) |
| expect(stdout).toBe("stdout") |
| expect(stderr).toBe("stderr") |
| }), |
| ) |
| }) |
|
|
| describe("combined output (all)", () => { |
| fx.effect( |
| "captures stdout via .all when no stderr", |
| Effect.gen(function* () { |
| const handle = yield* ChildProcess.make("echo", ["hello from stdout"]) |
| const all = yield* decodeByteStream(handle.all) |
| expect(all).toBe("hello from stdout") |
| }), |
| ) |
|
|
| fx.effect( |
| "captures stderr via .all when no stdout", |
| Effect.gen(function* () { |
| const handle = yield* js('process.stderr.write("hello from stderr")') |
| const all = yield* decodeByteStream(handle.all) |
| expect(all).toBe("hello from stderr") |
| }), |
| ) |
| }) |
|
|
| describe("stdin", () => { |
| fx.effect( |
| "allows providing standard input to a command", |
| Effect.gen(function* () { |
| const input = "a b c" |
| const stdin = Stream.make(Buffer.from(input, "utf-8")) |
| const handle = yield* js( |
| 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', |
| { stdin }, |
| ) |
| const out = yield* decodeByteStream(handle.stdout) |
| yield* handle.exitCode |
| expect(out).toBe("a b c") |
| }), |
| ) |
| }) |
|
|
| describe("process control", () => { |
| fx.effect( |
| "kills a running process", |
| Effect.gen(function* () { |
| const exit = yield* Effect.exit( |
| Effect.gen(function* () { |
| const handle = yield* js("setTimeout(() => {}, 10_000)") |
| yield* handle.kill() |
| return yield* handle.exitCode |
| }), |
| ) |
| expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) |
| }), |
| ) |
|
|
| fx.effect( |
| "kills a child when scope exits", |
| Effect.gen(function* () { |
| const pid = yield* Effect.scoped( |
| Effect.gen(function* () { |
| const handle = yield* js("setInterval(() => {}, 10_000)") |
| return Number(handle.pid) |
| }), |
| ) |
| const done = yield* Effect.promise(() => gone(pid)) |
| expect(done).toBe(true) |
| }), |
| ) |
|
|
| fx.effect( |
| "forceKillAfter escalates for stubborn processes", |
| Effect.gen(function* () { |
| if (process.platform === "win32") return |
|
|
| const started = Date.now() |
| const exit = yield* Effect.exit( |
| Effect.gen(function* () { |
| const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)') |
| yield* handle.kill({ forceKillAfter: 100 }) |
| return yield* handle.exitCode |
| }), |
| ) |
|
|
| expect(Date.now() - started).toBeLessThan(1_000) |
| expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) |
| }), |
| ) |
|
|
| fx.effect( |
| "isRunning reflects process state", |
| Effect.gen(function* () { |
| const handle = yield* js('process.stdout.write("done")') |
| yield* handle.exitCode |
| const running = yield* handle.isRunning |
| expect(running).toBe(false) |
| }), |
| ) |
| }) |
|
|
| describe("error handling", () => { |
| fx.effect( |
| "fails for invalid command", |
| Effect.gen(function* () { |
| const exit = yield* Effect.exit( |
| Effect.gen(function* () { |
| const handle = yield* ChildProcess.make("nonexistent-command-12345") |
| return yield* handle.exitCode |
| }), |
| ) |
| expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) |
| }), |
| ) |
| }) |
|
|
| describe("pipeline", () => { |
| fx.effect( |
| "pipes stdout of one command to stdin of another", |
| Effect.gen(function* () { |
| const handle = yield* js('process.stdout.write("hello world")').pipe( |
| ChildProcess.pipeTo( |
| js( |
| 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', |
| ), |
| ), |
| ) |
| const out = yield* decodeByteStream(handle.stdout) |
| yield* handle.exitCode |
| expect(out).toBe("HELLO WORLD") |
| }), |
| ) |
|
|
| fx.effect( |
| "three-stage pipeline", |
| Effect.gen(function* () { |
| const handle = yield* js('process.stdout.write("hello world")').pipe( |
| ChildProcess.pipeTo( |
| js( |
| 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', |
| ), |
| ), |
| ChildProcess.pipeTo( |
| js( |
| 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))', |
| ), |
| ), |
| ) |
| const out = yield* decodeByteStream(handle.stdout) |
| yield* handle.exitCode |
| expect(out).toBe("HELLO-WORLD") |
| }), |
| ) |
|
|
| fx.effect( |
| "pipes stderr with { from: 'stderr' }", |
| Effect.gen(function* () { |
| const handle = yield* js('process.stderr.write("error")').pipe( |
| ChildProcess.pipeTo( |
| js( |
| 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', |
| ), |
| { from: "stderr" }, |
| ), |
| ) |
| const out = yield* decodeByteStream(handle.stdout) |
| yield* handle.exitCode |
| expect(out).toBe("error") |
| }), |
| ) |
|
|
| fx.effect( |
| "pipes combined output with { from: 'all' }", |
| Effect.gen(function* () { |
| const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe( |
| ChildProcess.pipeTo( |
| js( |
| 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', |
| ), |
| { from: "all" }, |
| ), |
| ) |
| const out = yield* decodeByteStream(handle.stdout) |
| yield* handle.exitCode |
| expect(out).toContain("stdout") |
| expect(out).toContain("stderr") |
| }), |
| ) |
| }) |
|
|
| describe("Windows-specific", () => { |
| fx.effect( |
| "uses shell routing on Windows", |
| Effect.gen(function* () { |
| if (process.platform !== "win32") return |
|
|
| const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => |
| svc.string( |
| ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], { |
| shell: true, |
| extendEnv: true, |
| env: { OPENCODE_TEST_SHELL: "ok" }, |
| }), |
| ), |
| ) |
| expect(out).toContain("OPENCODE_TEST_SHELL=ok") |
| }), |
| ) |
|
|
| fx.effect( |
| "runs cmd scripts with spaces on Windows without shell", |
| Effect.gen(function* () { |
| if (process.platform !== "win32") return |
|
|
| const tmp = yield* Effect.acquireRelease( |
| Effect.promise(() => tmpdir()), |
| (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), |
| ) |
| const dir = path.join(tmp.path, "with space") |
| const file = path.join(dir, "echo cmd.cmd") |
|
|
| yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) |
| yield* Effect.promise(() => fs.writeFile(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")) |
|
|
| const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => |
| svc.exitCode( |
| ChildProcess.make(file, ["--stdio"], { |
| stdin: "pipe", |
| stdout: "pipe", |
| stderr: "pipe", |
| }), |
| ), |
| ) |
| expect(code).toBe(ChildProcessSpawner.ExitCode(0)) |
| }), |
| ) |
| }) |
| }) |
|
|