| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412 |
- import { NodeFileSystem, NodePath } from "@effect/platform-node"
- import { describe, expect } from "bun:test"
- import fs from "node:fs/promises"
- import path from "node:path"
- import { Effect, Exit, Layer, Stream } from "effect"
- import type * as PlatformError from "effect/PlatformError"
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
- import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
- import { tmpdir } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- const live = CrossSpawnSpawner.defaultLayer
- 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 gone(pid: number, timeout = 5_000) {
- const end = Date.now() + timeout
- while (Date.now() < end) {
- if (!alive(pid)) return true
- await Bun.sleep(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(out).toBe(tmp.path)
- }),
- )
- fx.effect(
- "fails for invalid cwd",
- Effect.gen(function* () {
- const exit = yield* Effect.exit(
- ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(),
- )
- 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)])
- 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", ["KILO_TEST_SHELL"], {
- shell: true,
- extendEnv: true,
- env: { KILO_TEST_SHELL: "ok" },
- }),
- ),
- )
- expect(out).toContain("KILO_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(() => Bun.write(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))
- }),
- )
- })
- })
|