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 PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ProcessRunner from "./processRunner.ts"; type ChildProcessCommand = { readonly command: string; readonly args: ReadonlyArray; readonly options: { readonly shell?: boolean | 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; 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: ProcessRunner.ProcessRunInput) => Effect.service(ProcessRunner.ProcessRunner).pipe( Effect.flatMap((runner) => runner.run({ ...input, }), ), Effect.provide( ProcessRunner.layer.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 = ProcessRunner.layer.pipe( Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), ); return Effect.gen(function* () { const runner = yield* ProcessRunner.ProcessRunner; const result = yield* runner.run({ command: "fake", args: ["--service"], }); expect(result.stdout).toBe("service ok"); }).pipe(Effect.provide(layer)); }); it.effect("resolves and escapes Windows command shims before spawning", () => { const spawner = makeSpawner((command) => Effect.sync(() => { expect(command.command).toBe('^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"'); expect(command.args).toEqual([ '^"repos^"', '^"pr^"', '^"list^"', '^"--source-branch^"', '^"feature^ ^&^ release^"', ]); expect(command.options.shell).toBe(true); return makeHandle({ stdout: "[]" }); }), ); return runWith(spawner)({ command: "az", args: ["repos", "pr", "list", "--source-branch", "feature & release"], env: { AZURE_CONFIG_DIR: "C:\\Users\\tester\\.azure" }, }).pipe( Effect.provideService(HostProcessPlatform, "win32"), Effect.provideService(HostProcessEnvironment, { PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", PATHEXT: ".COM;.EXE;.BAT;.CMD", }), Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" && env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure" ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd" : undefined, ), Effect.map((result) => { expect(result.stdout).toBe("[]"); }), ); }); it.effect("preserves resolved spawn context and cause", () => Effect.gen(function* () { const cause = PlatformError.systemError({ _tag: "PermissionDenied", module: "ChildProcessSpawner", method: "spawn", pathOrDescriptor: "/actual/fake", }); const spawner = makeSpawner(() => Effect.fail(cause)); const error = yield* runWith(spawner)({ command: "fake", args: ["--flag", "secret-token-value"], cwd: "/logical", spawnCwd: "/actual", }).pipe(Effect.flip); expect(error._tag).toBe("ProcessSpawnError"); if (error._tag !== "ProcessSpawnError") { return expect.fail("Expected ProcessSpawnError"); } expect(error).toMatchObject({ command: "fake", argumentCount: 2, cwd: "/logical", spawnCwd: "/actual", resolvedCommand: "fake", resolvedArgumentCount: 2, shell: false, }); expect(error.cause).toBe(cause); expect(error.message).toBe("Failed to spawn process 'fake' in '/actual'"); expect(error).not.toHaveProperty("args"); expect(error).not.toHaveProperty("resolvedArgs"); expect(error.message).not.toContain("secret-token-value"); }), ); 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._tag).toBe("ProcessOutputLimitError"); if (error._tag !== "ProcessOutputLimitError") { return expect.fail("Expected ProcessOutputLimitError"); } expect(error).toMatchObject({ stream: "stdout", maxBytes: 128, observedBytes: 2048, }); expect(error.message).toBe( "Process 'fake' stdout produced 2048 bytes, exceeding the 128 byte limit", ); }), ); it.effect("accepts output at the byte limit followed by an empty chunk", () => Effect.gen(function* () { const output = new TextEncoder().encode("exactly"); const spawner = makeSpawner(() => Effect.succeed( makeHandle({ stdout: Stream.make(output, new Uint8Array()), }), ), ); const result = yield* runWith(spawner)({ command: "fake", args: ["exact-limit"], maxOutputBytes: output.byteLength, }); expect(result.stdout).toBe("exactly"); }), ); 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(ProcessRunner.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"], cwd: "/logical", spawnCwd: "/actual", 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._tag).toBe("ProcessTimeoutError"); if (error._tag !== "ProcessTimeoutError") { return expect.fail("Expected ProcessTimeoutError"); } expect(error).toMatchObject({ command: "fake", argumentCount: 1, cwd: "/logical", spawnCwd: "/actual", timeoutMs: 50, }); expect(error.message).toBe("Process 'fake' in '/actual' timed out after 50ms"); }), ); 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.effect("matches the localized German cmd.exe error text", () => Effect.gen(function* () { const isCommandNotFound = yield* ProcessRunner.isWindowsCommandNotFound( 1, "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", ).pipe(Effect.provideService(HostProcessPlatform, "win32")); expect(isCommandNotFound).toBe(true); }), ); });