1import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe";
2import { describe, expect, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as PlatformError from "effect/PlatformError";
7import * as Sink from "effect/Sink";
8import * as Stream from "effect/Stream";
9import { ChildProcessSpawner } from "effect/process";
10import * as HostProcess from "@t3tools/shared/HostProcess";
11import { SpawnExecutableResolution } from "@t3tools/shared/shell";
12
13import * as ProcessRunner from "./processRunner.ts";
14
15type ChildProcessCommand = {
16 readonly command: string;
17 readonly args: ReadonlyArray<string>;
18 readonly options: {
19 readonly shell?: boolean | string;
20 };
21};
22
23function asChildProcessCommand(command: unknown): ChildProcessCommand {
24 return command as ChildProcessCommand;
25}
26
27function makeHandle(input: {
28 readonly stdout?: string | Stream.Stream<Uint8Array>;
29 readonly stderr?: string | Stream.Stream<Uint8Array>;
30 readonly code?: number;
31 readonly stdin?: ChildProcessSpawner.ChildProcessHandle["stdin"];
32 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
33}) {
34 return ChildProcessSpawner.makeHandle({
35 pid: ChildProcessSpawner.ProcessId(1),
36 exitCode: input.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(input.code ?? 0)),
37 isRunning: Effect.succeed(false),
38 kill: () => Effect.void,
39 unref: Effect.succeed(Effect.void),
40 stdin: input.stdin ?? Sink.drain,
41 stdout:
42 typeof input.stdout === "string"
43 ? Stream.encodeText(Stream.make(input.stdout))
44 : (input.stdout ?? Stream.empty),
45 stderr:
46 typeof input.stderr === "string"
47 ? Stream.encodeText(Stream.make(input.stderr))
48 : (input.stderr ?? Stream.empty),
49 all: Stream.empty,
50 getInputFd: () => Sink.drain,
51 getOutputFd: () => Stream.empty,
52 });
53}
54
55function makeSpawner(
56 f: (
57 command: ChildProcessCommand,
58 ) => Effect.Effect<ChildProcessSpawner.ChildProcessHandle, PlatformError.PlatformError>,
59) {
60 return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command)));
61}
62
63const runWith =
64 (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) =>
65 (input: ProcessRunner.ProcessRunInput) =>
66 Effect.service(ProcessRunner.ProcessRunner).pipe(
67 Effect.flatMap((runner) =>
68 runner.run({
69 ...input,
70 }),
71 ),
72 Effect.provide(
73 ProcessRunner.layer.pipe(
74 Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
75 ),
76 ),
77 );
78
79describe("runProcess", () => {
80 it.effect("resolves and escapes Windows command shims before spawning", () => {
81 const spawner = makeSpawner((command) =>
82 Effect.sync(() => {
83 expect(command.command).toBe(
84 [
85 '^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"',
86 '^"repos^"',
87 '^"pr^"',
88 '^"list^"',
89 '^"--source-branch^"',
90 '^"feature^ ^&^ release^"',
91 ].join(" "),
92 );
93 expect(command.args).toEqual([]);
94 expect(command.options.shell).toBe(true);
95 return makeHandle({ stdout: "[]" });
96 }),
97 );
98
99 return runWith(spawner)({
100 command: "az",
101 args: ["repos", "pr", "list", "--source-branch", "feature & release"],
102 env: { AZURE_CONFIG_DIR: "C:\\Users\\tester\\.azure" },
103 }).pipe(
104 Effect.provideService(HostProcess.Platform, "win32"),
105 Effect.provideService(HostProcess.Environment, {
106 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
107 PATHEXT: ".COM;.EXE;.BAT;.CMD",
108 }),
109 Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) =>
110 env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" &&
111 env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure"
112 ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd"
113 : undefined,
114 ),
115 Effect.map((result) => {
116 expect(result.stdout).toBe("[]");
117 }),
118 );
119 });
120
121 it.effect("types spawn failures that the platform spawner throws synchronously", () =>
122 Effect.gen(function* () {
123 // File-valued PATH entries make spawn throw ENOTDIR synchronously, arriving through Effect.callback as a defect.
124 const defect = Object.assign(new Error("spawn jj ENOTDIR"), { code: "ENOTDIR" });
125 const spawner = makeSpawner(() =>
126 Effect.callback<never, never>(() => {
127 throw defect;
128 }),
129 );
130
131 const error = yield* runWith(spawner)({
132 command: "jj",
133 args: ["--version"],
134 cwd: "/repo",
135 }).pipe(Effect.flip);
136
137 expect(error._tag).toBe("ProcessSpawnError");
138 if (error._tag !== "ProcessSpawnError") {
139 return expect.fail("Expected ProcessSpawnError");
140 }
141 expect(error).toMatchObject({ command: "jj", argumentCount: 1, cwd: "/repo" });
142 expect(error.cause).toBe(defect);
143 }),
144 );
145
146 it.effect("fails when output exceeds max buffer in default mode", () =>
147 Effect.gen(function* () {
148 const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) })));
149
150 const error = yield* runWith(spawner)({
151 command: "fake",
152 args: ["stdout-bytes", "2048"],
153 maxOutputBytes: 128,
154 }).pipe(Effect.flip);
155
156 expect(error._tag).toBe("ProcessOutputLimitError");
157 if (error._tag !== "ProcessOutputLimitError") {
158 return expect.fail("Expected ProcessOutputLimitError");
159 }
160 expect(error).toMatchObject({
161 stream: "stdout",
162 maxBytes: 128,
163 observedBytes: 2048,
164 });
165 expect(error.message).toBe(
166 "Process 'fake' stdout produced 2048 bytes, exceeding the 128 byte limit",
167 );
168 }),
169 );
170
171 it.effect("accepts output at the byte limit followed by an empty chunk", () =>
172 Effect.gen(function* () {
173 const output = new TextEncoder().encode("exactly");
174 const spawner = makeSpawner(() =>
175 Effect.succeed(
176 makeHandle({
177 stdout: Stream.make(output, new Uint8Array()),
178 }),
179 ),
180 );
181
182 const result = yield* runWith(spawner)({
183 command: "fake",
184 args: ["exact-limit"],
185 maxOutputBytes: output.byteLength,
186 });
187
188 expect(result.stdout).toBe("exactly");
189 }),
190 );
191
192 it.effect("fails fast on output limit before timeout for long-running output", () =>
193 Effect.gen(function* () {
194 const textChunk = "x".repeat(64);
195 const spawner = makeSpawner(() =>
196 Effect.succeed(
197 makeHandle({
198 stdout: Stream.fromIterable(Array.from({ length: 10 }, () => textChunk)).pipe(
199 Stream.encodeText,
200 ),
201 exitCode: Effect.never,
202 }),
203 ),
204 );
205
206 const error = yield* runWith(spawner)({
207 command: "fake",
208 args: ["spam-stdout"],
209 maxOutputBytes: 128,
210 timeout: "2 seconds",
211 }).pipe(Effect.flip);
212
213 expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError);
214 }),
215 );
216
217 it.effect("truncates output when outputMode is truncate", () =>
218 Effect.gen(function* () {
219 const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) })));
220
221 const result = yield* runWith(spawner)({
222 command: "fake",
223 args: ["stdout-bytes", "2048"],
224 maxOutputBytes: 128,
225 outputMode: "truncate",
226 });
227
228 expect(result.code).toBe(0);
229 expect(result.stdout.length).toBeLessThanOrEqual(128);
230 expect(result.stdoutTruncated).toBe(true);
231 expect(result.stderrTruncated).toBe(false);
232 }),
233 );
234
235 it.effect("writes stdin before waiting for exit", () =>
236 Effect.gen(function* () {
237 const stdinWritten = yield* Deferred.make<void>();
238 const decoder = new TextDecoder();
239 const spawner = makeSpawner(() =>
240 Effect.succeed(
241 makeHandle({
242 stdout: "stdin payload",
243 stdin: Sink.forEach((chunk: Uint8Array) => {
244 const text = decoder.decode(chunk, { stream: true });
245 return text.includes("stdin payload")
246 ? Deferred.succeed(stdinWritten, undefined)
247 : Effect.void;
248 }),
249 exitCode: Deferred.await(stdinWritten).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
250 }),
251 ),
252 );
253
254 const result = yield* runWith(spawner)({
255 command: "fake",
256 args: ["stdin-echo"],
257 stdin: "stdin payload",
258 });
259
260 expect(result.stdout).toBe("stdin payload");
261 expect(result.code).toBe(0);
262 }),
263 );
264});
265
266describe("isWindowsCommandNotFound", () => {
267 it.effect("matches the localized German cmd.exe error text", () =>
268 Effect.gen(function* () {
269 const isCommandNotFound = yield* isWindowsCommandNotFound(
270 1,
271 "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt",
272 ).pipe(Effect.provideService(HostProcess.Platform, "win32"));
273 expect(isCommandNotFound).toBe(true);
274 }),
275 );
276});