| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- import { describe, expect } from "bun:test"
- import fs from "fs/promises"
- import { realpathSync } from "node:fs"
- import { tmpdir } from "node:os"
- import path from "node:path"
- import { Effect, Exit, Fiber, Stream } from "effect"
- import { ChildProcess } from "effect/unstable/process"
- import { LayerNode } from "@opencode-ai/core/effect/layer-node"
- import { AppProcess } from "@opencode-ai/core/process"
- import { testEffect } from "../lib/effect"
- const it = testEffect(LayerNode.compile(AppProcess.node))
- const NODE = process.execPath
- const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
- const waitForFile = (file: string) =>
- Effect.promise(async () => {
- while (true) {
- try {
- return await fs.readFile(file, "utf8")
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
- await new Promise<void>((resolve) => setTimeout(resolve, 10))
- }
- }
- })
- describe("AppProcess", () => {
- describe("run", () => {
- it.effect(
- "captures stdout and exit code zero",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
- expect(result.exitCode).toBe(0)
- expect(result.stdout.toString("utf8")).toBe("hi\n")
- expect(result.stdoutTruncated).toBe(false)
- expect(result.stderrTruncated).toBe(false)
- }),
- )
- it.effect(
- "captures stdout and stderr in emission order",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const script = [
- 'process.stdout.write("out 1\\n")',
- 'setTimeout(() => process.stderr.write("err 1\\n"), 10)',
- 'setTimeout(() => process.stdout.write("out 2\\n"), 20)',
- ].join(";")
- const result = yield* svc.run(cmd("-e", script), { combineOutput: true })
- expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n")
- expect(result.stdout.toString("utf8")).toBe("")
- expect(result.stderr.toString("utf8")).toBe("")
- }),
- )
- it.effect(
- "non-zero exit returns RunResult; caller can require success",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const result = yield* svc.run(cmd("-e", "process.exit(1)"))
- expect(result.exitCode).toBe(1)
- }),
- )
- it.effect(
- "requireSuccess fails on non-zero exit",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const exit = yield* Effect.exit(
- svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
- )
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- const reason = exit.cause.reasons[0]
- if (reason && reason._tag === "Fail") {
- expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
- expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
- expect((reason.error as AppProcess.AppProcessError).message).toContain("Command failed (exit 1)")
- } else {
- throw new Error("expected fail reason")
- }
- }
- }),
- )
- it.effect(
- "requireSuccess succeeds on exit 0",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess))
- expect(result.exitCode).toBe(0)
- }),
- )
- it.effect(
- "requireExitIn allowlists multiple exit codes",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const requireZeroOrOne = AppProcess.requireExitIn([0, 1])
- const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne))
- expect(okZero.exitCode).toBe(0)
- const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
- expect(okOne.exitCode).toBe(1)
- const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- const reason = exit.cause.reasons[0]
- if (reason && reason._tag === "Fail") {
- expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
- expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
- }
- }
- }),
- )
- it.effect(
- "truncates stdout when maxOutputBytes is set",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
- expect(result.exitCode).toBe(0)
- expect(result.stdoutTruncated).toBe(true)
- expect(result.stderrTruncated).toBe(false)
- expect(result.stdout.length).toBe(5)
- expect(result.stdout.toString("utf8")).toBe("01234")
- }),
- )
- it.effect(
- "truncates stderr when maxErrorBytes is set",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const result = yield* svc.run(cmd("-e", "process.stderr.write('0123456789')"), { maxErrorBytes: 5 })
- expect(result.exitCode).toBe(0)
- expect(result.stdoutTruncated).toBe(false)
- expect(result.stderrTruncated).toBe(true)
- expect(result.stderr.length).toBe(5)
- expect(result.stderr.toString("utf8")).toBe("01234")
- }),
- )
- it.effect(
- "result includes command description",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')"))
- expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
- }),
- )
- if (process.platform !== "win32") {
- it.live(
- "timeout cleans up the scoped child process",
- Effect.acquireUseRelease(
- Effect.promise(() => fs.mkdtemp(path.join(tmpdir(), "opencode-process-timeout-"))),
- (directory) => {
- const ready = path.join(directory, "ready")
- const settled = path.join(directory, "settled")
- const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
- return Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "250 millis" }))
- expect(Exit.isFailure(exit)).toBe(true)
- expect(yield* waitForFile(ready)).toMatch(/^\d+$/)
- expect(yield* waitForFile(settled)).toBe("settled")
- })
- },
- (directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })),
- ),
- 5_000,
- )
- it.live(
- "fiber interruption cleans up the scoped child process after readiness",
- Effect.acquireUseRelease(
- Effect.promise(() => fs.mkdtemp(path.join(tmpdir(), "opencode-process-interrupt-"))),
- (directory) => {
- const ready = path.join(directory, "ready")
- const settled = path.join(directory, "settled")
- const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
- return Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const fiber = yield* svc.run(cmd("-e", script)).pipe(Effect.forkChild)
- expect(yield* waitForFile(ready)).toMatch(/^\d+$/)
- yield* Fiber.interrupt(fiber)
- expect(yield* waitForFile(settled)).toBe("settled")
- })
- },
- (directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })),
- ),
- 5_000,
- )
- }
- })
- describe("inherited platform methods", () => {
- it.effect(
- "string returns stdout as string",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')"))
- expect(out).toBe("hi\n")
- }),
- )
- it.effect(
- "lines returns the platform's array of lines",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')"))
- expect(Array.from(out)).toEqual(["a", "b"])
- }),
- )
- })
- describe("run with stdin option", () => {
- const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
- it.effect(
- "feeds a string to stdin and returns it on stdout",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "hello" })
- expect(result.exitCode).toBe(0)
- expect(result.stdout.toString("utf8")).toBe("hello")
- }),
- )
- it.effect(
- "feeds a Uint8Array to stdin",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const bytes = new TextEncoder().encode("bytes")
- const result = yield* svc.run(cmd("-e", echoStdin), { stdin: bytes })
- expect(result.exitCode).toBe(0)
- expect(result.stdout.toString("utf8")).toBe("bytes")
- }),
- )
- it.effect(
- "feeds a Stream of Uint8Array chunks to stdin",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const enc = new TextEncoder()
- const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")])
- const result = yield* svc.run(cmd("-e", echoStdin), { stdin: stream })
- expect(result.exitCode).toBe(0)
- expect(result.stdout.toString("utf8")).toBe("one-two-three")
- }),
- )
- it.effect(
- "completes correctly with empty input",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "" })
- expect(result.exitCode).toBe(0)
- expect(result.stdout.toString("utf8")).toBe("")
- }),
- )
- it.effect(
- "carries existing Command options like env",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const script =
- "process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))"
- const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true })
- const result = yield* svc.run(command, { stdin: "payload" })
- expect(result.exitCode).toBe(0)
- expect(result.stdout.toString("utf8")).toBe("envset:payload")
- }),
- )
- it.effect(
- "carries existing Command options like cwd",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const dir = realpathSync(tmpdir())
- const script =
- "process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))"
- const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir })
- const result = yield* svc.run(command, { stdin: "ok" })
- expect(result.exitCode).toBe(0)
- const [cwd, stdin] = result.stdout.toString("utf8").split("|")
- expect(realpathSync(cwd)).toBe(dir)
- expect(stdin).toBe("ok")
- }),
- )
- })
- describe("runStream", () => {
- it.live(
- "emits lines incrementally and ends cleanly on exit 0",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const result = yield* svc
- .runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')"))
- .pipe(Stream.runCollect)
- expect(Array.from(result)).toEqual(["one", "two", "three"])
- }),
- )
- it.live(
- "okExitCodes determines whether a non-zero exit fails the stream",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const allowed = yield* svc
- .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
- .pipe(Stream.runCollect)
- expect(Array.from(allowed)).toEqual(["only"])
- const exit = yield* Effect.exit(
- svc
- .runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
- .pipe(Stream.runCollect),
- )
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- const reason = exit.cause.reasons[0]
- if (reason && reason._tag === "Fail") {
- expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
- }
- }
- }),
- )
- it.live(
- "without okExitCodes, never fails on exit code",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const result = yield* svc.runStream(cmd("-e", "console.log('only'); process.exit(7)")).pipe(Stream.runCollect)
- expect(Array.from(result)).toEqual(["only"])
- }),
- )
- it.live(
- "AbortSignal interrupts the stream",
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const controller = new AbortController()
- controller.abort()
- const exit = yield* Effect.exit(
- svc
- .runStream(cmd("-e", "setInterval(() => {}, 60_000)"), { signal: controller.signal })
- .pipe(Stream.runCollect),
- )
- expect(Exit.isFailure(exit)).toBe(true)
- }),
- )
- })
- describe("spawn (inherited)", () => {
- it.live(
- "returns the platform ChildProcessHandle for advanced use",
- Effect.scoped(
- Effect.gen(function* () {
- const svc = yield* AppProcess.Service
- const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)"))
- expect(yield* handle.isRunning).toBe(true)
- yield* handle.kill()
- }),
- ),
- )
- })
- })
|