2026-03-24 14:35:24 +00:00
import { describe , expect } from "bun:test"
import fs from "node:fs/promises"
2026-04-25 18:22:49 +00:00
import os from "node:os"
2026-03-24 14:35:24 +00:00
import path from "node:path"
2026-04-16 01:33:54 +00:00
import { Effect , Exit , Stream } from "effect"
2026-03-24 14:35:24 +00:00
import type * as PlatformError from "effect/PlatformError"
import { ChildProcess , ChildProcessSpawner } from "effect/unstable/process"
2026-04-25 18:30:16 +00:00
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
2026-06-27 18:10:07 +00:00
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
2026-03-24 14:35:24 +00:00
import { testEffect } from "../lib/effect"
2026-06-27 18:10:07 +00:00
const live = LayerNode . compile ( CrossSpawnSpawner . node )
2026-03-24 14:35:24 +00:00
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
}
}
2026-04-25 18:22:49 +00:00
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 } )
} ,
}
}
2026-03-24 14:35:24 +00:00
async function gone ( pid : number , timeout = 5 _000 ) {
const end = Date . now ( ) + timeout
while ( Date . now ( ) < end ) {
if ( ! alive ( pid ) ) return true
2026-04-25 18:22:49 +00:00
await new Promise ( ( resolve ) = > setTimeout ( resolve , 50 ) )
2026-03-24 14:35:24 +00:00
}
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 } ) ,
) ,
)
2026-05-20 19:09:19 +00:00
expect ( yield * Effect . promise ( ( ) = > fs . realpath ( out ) ) ) . toBe ( yield * Effect . promise ( ( ) = > fs . realpath ( tmp . path ) ) )
2026-03-24 14:35:24 +00:00
} ) ,
)
fx . effect (
"fails for invalid cwd" ,
Effect . gen ( function * ( ) {
const exit = yield * Effect . exit (
2026-05-20 19:09:19 +00:00
ChildProcessSpawner . ChildProcessSpawner . use ( ( svc ) = >
svc . spawn ( ChildProcess . make ( "echo" , [ "test" ] , { cwd : "/nonexistent/directory/path" } ) ) ,
) ,
2026-03-24 14:35:24 +00:00
)
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 * ( ) {
2026-04-02 18:17:28 +00:00
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" ) ,
)
2026-04-22 08:18:44 +00:00
const [ stdout , stderr ] = yield * Effect . all ( [ decodeByteStream ( handle . stdout ) , decodeByteStream ( handle . stderr ) ] , {
concurrency : 2 ,
} )
2026-03-24 14:35:24 +00:00
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 } ) )
2026-04-25 18:22:49 +00:00
yield * Effect . promise ( ( ) = > fs . writeFile ( file , "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n" ) )
2026-03-24 14:35:24 +00:00
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 ) )
} ) ,
)
} )
} )