import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; import { isWindowsCommandNotFound, ProcessOutputLimitError, ProcessRunner, ProcessTimeoutError, layer as ProcessRunnerLive, type ProcessRunInput, } from "./processRunner.ts"; type ChildProcessCommand = { readonly command: string; readonly args: ReadonlyArray; }; // Accesses private properties of ChildProcessCommand for testing purposes function asChildProcessCommand(command: unknown): ChildProcessCommand { return command as ChildProcessCommand; } function makeHandle(input: { readonly stdout?: string | Stream.Stream; readonly stderr?: string | Stream.Stream; readonly code?: number; readonly stdin?: ChildProcessSpawner.ChildProcessHandle["stdin"]; readonly exitCode?: Effect.Effect; }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: input.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(input.code ?? 0)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: input.stdin ?? Sink.drain, stdout: typeof input.stdout === "string" ? Stream.encodeText(Stream.make(input.stdout)) : (input.stdout ?? Stream.empty), stderr: typeof input.stderr === "string" ? Stream.encodeText(Stream.make(input.stderr)) : (input.stderr ?? Stream.empty), all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); } function makeSpawner( f: (command: ChildProcessCommand) => Effect.Effect, ) { return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command))); } const runWith = (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) => (input: ProcessRunInput) => Effect.service(ProcessRunner).pipe( Effect.flatMap((runner) => runner.run({ ...input, shell: input.shell ?? false, }), ), Effect.provide( ProcessRunnerLive.pipe( Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), ), ), ); describe("runProcess", () => { it.effect("collects stdout through an injected ChildProcessSpawner", () => Effect.gen(function* () { const spawner = makeSpawner((command) => Effect.sync(() => { expect(command.command).toBe("fake"); expect(command.args).toEqual(["stdout-bytes", "32"]); return makeHandle({ stdout: "x".repeat(32) }); }), ); const result = yield* runWith(spawner)({ command: "fake", args: ["stdout-bytes", "32"], }); expect(result.code).toBe(0); expect(result.stdout).toBe("x".repeat(32)); expect(result.timedOut).toBe(false); }), ); it.effect("runs through the ProcessRunner service", () => { const spawner = makeSpawner((command) => Effect.sync(() => { expect(command.command).toBe("fake"); expect(command.args).toEqual(["--service"]); return makeHandle({ stdout: "service ok" }); }), ); const layer = ProcessRunnerLive.pipe( Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), ); return Effect.gen(function* () { const runner = yield* ProcessRunner; const result = yield* runner.run({ command: "fake", args: ["--service"], }); expect(result.stdout).toBe("service ok"); }).pipe(Effect.provide(layer)); }); it.effect("fails when output exceeds max buffer in default mode", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); const error = yield* runWith(spawner)({ command: "fake", args: ["stdout-bytes", "2048"], maxOutputBytes: 128, }).pipe(Effect.flip); expect(error).toBeInstanceOf(ProcessOutputLimitError); }), ); it.effect("fails fast on output limit before timeout for long-running output", () => Effect.gen(function* () { const textChunk = "x".repeat(64); const spawner = makeSpawner(() => Effect.succeed( makeHandle({ stdout: Stream.fromIterable(Array.from({ length: 10 }, () => textChunk)).pipe( Stream.encodeText, ), exitCode: Effect.never, }), ), ); const error = yield* runWith(spawner)({ command: "fake", args: ["spam-stdout"], maxOutputBytes: 128, timeout: "2 seconds", }).pipe(Effect.flip); expect(error).toBeInstanceOf(ProcessOutputLimitError); }), ); it.effect("truncates output when outputMode is truncate", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); const result = yield* runWith(spawner)({ command: "fake", args: ["stdout-bytes", "2048"], maxOutputBytes: 128, outputMode: "truncate", }); expect(result.code).toBe(0); expect(result.stdout.length).toBeLessThanOrEqual(128); expect(result.stdoutTruncated).toBe(true); expect(result.stderrTruncated).toBe(false); }), ); it.effect("writes stdin before waiting for exit", () => Effect.gen(function* () { const stdinWritten = yield* Deferred.make(); const decoder = new TextDecoder(); const spawner = makeSpawner(() => Effect.succeed( makeHandle({ stdout: "stdin payload", stdin: Sink.forEach((chunk: Uint8Array) => { const text = decoder.decode(chunk, { stream: true }); return text.includes("stdin payload") ? Deferred.succeed(stdinWritten, undefined) : Effect.void; }), exitCode: Deferred.await(stdinWritten).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), }), ), ); const result = yield* runWith(spawner)({ command: "fake", args: ["stdin-echo"], stdin: "stdin payload", }); expect(result.stdout).toBe("stdin payload"); expect(result.code).toBe(0); }), ); it.effect("returns output for non-zero exit codes", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stderr: "boom", code: 2 }))); const result = yield* runWith(spawner)({ command: "fake", args: ["stderr-exit", "boom", "2"], }); expect(result.code).toBe(2); expect(result.stderr).toBe("boom"); }), ); it.effect("fails on timeout", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed( makeHandle({ exitCode: Effect.never, }), ), ); const errorFiber = yield* runWith(spawner)({ command: "fake", args: ["sleep"], timeout: "50 millis", }).pipe(Effect.flip, Effect.forkScoped); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.millis(50)); const error = yield* Fiber.join(errorFiber); expect(error).toBeInstanceOf(ProcessTimeoutError); }), ); it.effect("returns a synthetic timed out result when timeoutBehavior is timedOutResult", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed( makeHandle({ exitCode: Effect.never, }), ), ); const resultFiber = yield* runWith(spawner)({ command: "fake", args: ["sleep"], timeout: "50 millis", timeoutBehavior: "timedOutResult", }).pipe(Effect.forkScoped); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.millis(50)); const result = yield* Fiber.join(resultFiber); expect(result).toMatchObject({ stdout: "", stderr: "", code: null, timedOut: true, stdoutTruncated: false, stderrTruncated: false, }); }), ); }); describe("isWindowsCommandNotFound", () => { it("matches the localized German cmd.exe error text", () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32", configurable: true }); try { expect( isWindowsCommandNotFound( 1, "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", ), ).toBe(true); } finally { Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); } }); });