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<string>;}; // Accesses private properties of ChildProcessCommand for testing purposesfunction asChildProcessCommand(command: unknown): ChildProcessCommand { return command as ChildProcessCommand;} function makeHandle(input: { readonly stdout?: string | Stream.Stream<Uint8Array>; readonly stderr?: string | Stream.Stream<Uint8Array>; readonly code?: number; readonly stdin?: ChildProcessSpawner.ChildProcessHandle["stdin"]; readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;}) { 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<ChildProcessSpawner.ChildProcessHandle>,) { 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<void>(); 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 }); } });});
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<string>;
};
// 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<Uint8Array>;
readonly stderr?: string | Stream.Stream<Uint8Array>;
readonly code?: number;
readonly stdin?: ChildProcessSpawner.ChildProcessHandle["stdin"];
readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
}) {
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<ChildProcessSpawner.ChildProcessHandle>,
) {
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<void>();
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 });
}
});
});