1import { describe, expect, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as PlatformError from "effect/PlatformError";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import { TestClock } from "effect/testing";
11import { ChildProcessSpawner } from "effect/process";
12import * as HostProcess from "@t3tools/shared/HostProcess";
13import { SpawnExecutableResolution } from "@t3tools/shared/shell";
14
15import * as ProcessRunner from "./processRunner.ts";
16
17type ChildProcessCommand = {
18 readonly command: string;
19 readonly args: ReadonlyArray<string>;
20 readonly options: {
21 readonly shell?: boolean | string;
22 };
23};
24
25// Accesses private properties of ChildProcessCommand for testing purposes
26function asChildProcessCommand(command: unknown): ChildProcessCommand {
27 return command as ChildProcessCommand;
28}
29
30function makeHandle(input: {
31 readonly stdout?: string | Stream.Stream<Uint8Array>;
32 readonly stderr?: string | Stream.Stream<Uint8Array>;
33 readonly code?: number;
34 readonly stdin?: ChildProcessSpawner.ChildProcessHandle["stdin"];
35 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
36}) {
37 return ChildProcessSpawner.makeHandle({
38 pid: ChildProcessSpawner.ProcessId(1),
39 exitCode: input.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(input.code ?? 0)),
40 isRunning: Effect.succeed(false),
41 kill: () => Effect.void,
42 unref: Effect.succeed(Effect.void),
43 stdin: input.stdin ?? Sink.drain,
44 stdout:
45 typeof input.stdout === "string"
46 ? Stream.encodeText(Stream.make(input.stdout))
47 : (input.stdout ?? Stream.empty),
48 stderr:
49 typeof input.stderr === "string"
50 ? Stream.encodeText(Stream.make(input.stderr))
51 : (input.stderr ?? Stream.empty),
52 all: Stream.empty,
53 getInputFd: () => Sink.drain,
54 getOutputFd: () => Stream.empty,
55 });
56}
57
58function makeSpawner(
59 f: (
60 command: ChildProcessCommand,
61 ) => Effect.Effect<ChildProcessSpawner.ChildProcessHandle, PlatformError.PlatformError>,
62) {
63 return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command)));
64}
65
66const runWith =
67 (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) =>
68 (input: ProcessRunner.ProcessRunInput) =>
69 Effect.service(ProcessRunner.ProcessRunner).pipe(
70 Effect.flatMap((runner) =>
71 runner.run({
72 ...input,
73 }),
74 ),
75 Effect.provide(
76 ProcessRunner.layer.pipe(
77 Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
78 ),
79 ),
80 );
81
82describe("runProcess", () => {
83 it.effect("collects stdout through an injected ChildProcessSpawner", () =>
84 Effect.gen(function* () {
85 const spawner = makeSpawner((command) =>
86 Effect.sync(() => {
87 expect(command.command).toBe("fake");
88 expect(command.args).toEqual(["stdout-bytes", "32"]);
89 return makeHandle({ stdout: "x".repeat(32) });
90 }),
91 );
92
93 const result = yield* runWith(spawner)({
94 command: "fake",
95 args: ["stdout-bytes", "32"],
96 });
97
98 expect(result.code).toBe(0);
99 expect(result.stdout).toBe("x".repeat(32));
100 expect(result.timedOut).toBe(false);
101 }),
102 );
103
104 it.effect("runs through the ProcessRunner service", () => {
105 const spawner = makeSpawner((command) =>
106 Effect.sync(() => {
107 expect(command.command).toBe("fake");
108 expect(command.args).toEqual(["--service"]);
109 return makeHandle({ stdout: "service ok" });
110 }),
111 );
112 const layer = ProcessRunner.layer.pipe(
113 Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
114 );
115
116 return Effect.gen(function* () {
117 const runner = yield* ProcessRunner.ProcessRunner;
118 const result = yield* runner.run({
119 command: "fake",
120 args: ["--service"],
121 });
122
123 expect(result.stdout).toBe("service ok");
124 }).pipe(Effect.provide(layer));
125 });
126
127 it.effect("resolves and escapes Windows command shims before spawning", () => {
128 const spawner = makeSpawner((command) =>
129 Effect.sync(() => {
130 expect(command.command).toBe(
131 [
132 '^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"',
133 '^"repos^"',
134 '^"pr^"',
135 '^"list^"',
136 '^"--source-branch^"',
137 '^"feature^ ^&^ release^"',
138 ].join(" "),
139 );
140 expect(command.args).toEqual([]);
141 expect(command.options.shell).toBe(true);
142 return makeHandle({ stdout: "[]" });
143 }),
144 );
145
146 return runWith(spawner)({
147 command: "az",
148 args: ["repos", "pr", "list", "--source-branch", "feature & release"],
149 env: { AZURE_CONFIG_DIR: "C:\\Users\\tester\\.azure" },
150 }).pipe(
151 Effect.provideService(HostProcess.Platform, "win32"),
152 Effect.provideService(HostProcess.Environment, {
153 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
154 PATHEXT: ".COM;.EXE;.BAT;.CMD",
155 }),
156 Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) =>
157 env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" &&
158 env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure"
159 ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd"
160 : undefined,
161 ),
162 Effect.map((result) => {
163 expect(result.stdout).toBe("[]");
164 }),
165 );
166 });
167
168 it.effect("preserves resolved spawn context and cause", () =>
169 Effect.gen(function* () {
170 const cause = PlatformError.systemError({
171 _tag: "PermissionDenied",
172 module: "ChildProcessSpawner",
173 method: "spawn",
174 pathOrDescriptor: "/actual/fake",
175 });
176 const spawner = makeSpawner(() => Effect.fail(cause));
177
178 const error = yield* runWith(spawner)({
179 command: "fake",
180 args: ["--flag", "secret-token-value"],
181 cwd: "/logical",
182 spawnCwd: "/actual",
183 }).pipe(Effect.flip);
184
185 expect(error._tag).toBe("ProcessSpawnError");
186 if (error._tag !== "ProcessSpawnError") {
187 return expect.fail("Expected ProcessSpawnError");
188 }
189 expect(error).toMatchObject({
190 command: "fake",
191 argumentCount: 2,
192 cwd: "/logical",
193 spawnCwd: "/actual",
194 resolvedCommand: "fake",
195 resolvedArgumentCount: 2,
196 shell: false,
197 });
198 expect(error.cause).toBe(cause);
199 expect(error.message).toBe("Failed to spawn process 'fake' in '/actual'");
200 expect(error).not.toHaveProperty("args");
201 expect(error).not.toHaveProperty("resolvedArgs");
202 expect(error.message).not.toContain("secret-token-value");
203 }),
204 );
205
206 it.effect("fails when output exceeds max buffer in default mode", () =>
207 Effect.gen(function* () {
208 const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) })));
209
210 const error = yield* runWith(spawner)({
211 command: "fake",
212 args: ["stdout-bytes", "2048"],
213 maxOutputBytes: 128,
214 }).pipe(Effect.flip);
215
216 expect(error._tag).toBe("ProcessOutputLimitError");
217 if (error._tag !== "ProcessOutputLimitError") {
218 return expect.fail("Expected ProcessOutputLimitError");
219 }
220 expect(error).toMatchObject({
221 stream: "stdout",
222 maxBytes: 128,
223 observedBytes: 2048,
224 });
225 expect(error.message).toBe(
226 "Process 'fake' stdout produced 2048 bytes, exceeding the 128 byte limit",
227 );
228 }),
229 );
230
231 it.effect("accepts output at the byte limit followed by an empty chunk", () =>
232 Effect.gen(function* () {
233 const output = new TextEncoder().encode("exactly");
234 const spawner = makeSpawner(() =>
235 Effect.succeed(
236 makeHandle({
237 stdout: Stream.make(output, new Uint8Array()),
238 }),
239 ),
240 );
241
242 const result = yield* runWith(spawner)({
243 command: "fake",
244 args: ["exact-limit"],
245 maxOutputBytes: output.byteLength,
246 });
247
248 expect(result.stdout).toBe("exactly");
249 }),
250 );
251
252 it.effect("fails fast on output limit before timeout for long-running output", () =>
253 Effect.gen(function* () {
254 const textChunk = "x".repeat(64);
255 const spawner = makeSpawner(() =>
256 Effect.succeed(
257 makeHandle({
258 stdout: Stream.fromIterable(Array.from({ length: 10 }, () => textChunk)).pipe(
259 Stream.encodeText,
260 ),
261 exitCode: Effect.never,
262 }),
263 ),
264 );
265
266 const error = yield* runWith(spawner)({
267 command: "fake",
268 args: ["spam-stdout"],
269 maxOutputBytes: 128,
270 timeout: "2 seconds",
271 }).pipe(Effect.flip);
272
273 expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError);
274 }),
275 );
276
277 it.effect("truncates output when outputMode is truncate", () =>
278 Effect.gen(function* () {
279 const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) })));
280
281 const result = yield* runWith(spawner)({
282 command: "fake",
283 args: ["stdout-bytes", "2048"],
284 maxOutputBytes: 128,
285 outputMode: "truncate",
286 });
287
288 expect(result.code).toBe(0);
289 expect(result.stdout.length).toBeLessThanOrEqual(128);
290 expect(result.stdoutTruncated).toBe(true);
291 expect(result.stderrTruncated).toBe(false);
292 }),
293 );
294
295 it.effect("writes stdin before waiting for exit", () =>
296 Effect.gen(function* () {
297 const stdinWritten = yield* Deferred.make<void>();
298 const decoder = new TextDecoder();
299 const spawner = makeSpawner(() =>
300 Effect.succeed(
301 makeHandle({
302 stdout: "stdin payload",
303 stdin: Sink.forEach((chunk: Uint8Array) => {
304 const text = decoder.decode(chunk, { stream: true });
305 return text.includes("stdin payload")
306 ? Deferred.succeed(stdinWritten, undefined)
307 : Effect.void;
308 }),
309 exitCode: Deferred.await(stdinWritten).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
310 }),
311 ),
312 );
313
314 const result = yield* runWith(spawner)({
315 command: "fake",
316 args: ["stdin-echo"],
317 stdin: "stdin payload",
318 });
319
320 expect(result.stdout).toBe("stdin payload");
321 expect(result.code).toBe(0);
322 }),
323 );
324
325 it.effect("returns output for non-zero exit codes", () =>
326 Effect.gen(function* () {
327 const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stderr: "boom", code: 2 })));
328
329 const result = yield* runWith(spawner)({
330 command: "fake",
331 args: ["stderr-exit", "boom", "2"],
332 });
333
334 expect(result.code).toBe(2);
335 expect(result.stderr).toBe("boom");
336 }),
337 );
338
339 it.effect("fails on timeout", () =>
340 Effect.gen(function* () {
341 const spawner = makeSpawner(() =>
342 Effect.succeed(
343 makeHandle({
344 exitCode: Effect.never,
345 }),
346 ),
347 );
348 const errorFiber = yield* runWith(spawner)({
349 command: "fake",
350 args: ["sleep"],
351 cwd: "/logical",
352 spawnCwd: "/actual",
353 timeout: "50 millis",
354 }).pipe(Effect.flip, Effect.forkScoped);
355
356 yield* Effect.yieldNow;
357 yield* TestClock.adjust(Duration.millis(50));
358 const error = yield* Fiber.join(errorFiber);
359
360 expect(error._tag).toBe("ProcessTimeoutError");
361 if (error._tag !== "ProcessTimeoutError") {
362 return expect.fail("Expected ProcessTimeoutError");
363 }
364 expect(error).toMatchObject({
365 command: "fake",
366 argumentCount: 1,
367 cwd: "/logical",
368 spawnCwd: "/actual",
369 timeoutMs: 50,
370 });
371 expect(error.message).toBe("Process 'fake' in '/actual' timed out after 50ms");
372 }),
373 );
374
375 it.effect("returns a synthetic timed out result when timeoutBehavior is timedOutResult", () =>
376 Effect.gen(function* () {
377 const spawner = makeSpawner(() =>
378 Effect.succeed(
379 makeHandle({
380 exitCode: Effect.never,
381 }),
382 ),
383 );
384 const resultFiber = yield* runWith(spawner)({
385 command: "fake",
386 args: ["sleep"],
387 timeout: "50 millis",
388 timeoutBehavior: "timedOutResult",
389 }).pipe(Effect.forkScoped);
390
391 yield* Effect.yieldNow;
392 yield* TestClock.adjust(Duration.millis(50));
393 const result = yield* Fiber.join(resultFiber);
394
395 expect(result).toMatchObject({
396 stdout: "",
397 stderr: "",
398 code: null,
399 timedOut: true,
400 stdoutTruncated: false,
401 stderrTruncated: false,
402 });
403 }),
404 );
405});
406
407describe("isWindowsCommandNotFound", () => {
408 it.effect("matches the localized German cmd.exe error text", () =>
409 Effect.gen(function* () {
410 const isCommandNotFound = yield* ProcessRunner.isWindowsCommandNotFound(
411 1,
412 "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt",
413 ).pipe(Effect.provideService(HostProcess.Platform, "win32"));
414 expect(isCommandNotFound).toBe(true);
415 }),
416 );
417});
418
419describe("commandName", () => {
420 it("drops the directory from POSIX and Windows paths", () => {
421 expect(ProcessRunner.commandName("/Users/me/.local/bin/claude")).toBe("claude");
422 expect(ProcessRunner.commandName("C:\\Program Files\\nodejs\\npx.cmd")).toBe("npx.cmd");
423 expect(ProcessRunner.commandName("git")).toBe("git");
424 });
425});