1import { assert, describe, it } from "@effect/vitest"
2import * as Effect from "effect/Effect"
3import * as Layer from "effect/Layer"
4import type * as PlatformError from "effect/PlatformError"
5import { ChildProcess, ChildProcessSpawner } from "effect/process"
6import * as Sink from "effect/Sink"
7import * as Stream from "effect/Stream"
8
9const MockExecutorLayer = Layer.succeed(
10 ChildProcessSpawner.ChildProcessSpawner,
11 ChildProcessSpawner.make(Effect.fnUntraced(function*(command) {
12 // For piped commands, flatten to get the first command info
13 let cmd = command
14 while (cmd._tag === "PipedCommand") {
15 cmd = cmd.left
16 }
17 const executable = cmd._tag === "StandardCommand"
18 ? cmd.command
19 : "templated"
20 const output = new TextEncoder().encode(`mock output for ${executable}`)
21 let isReferenced = true
22 const reref = Effect.sync(() => {
23 if (!isReferenced) {
24 isReferenced = true
25 }
26 })
27 return ChildProcessSpawner.makeHandle({
28 pid: ChildProcessSpawner.ProcessId(12345),
29 stdin: Sink.forEach<Uint8Array, void, never, never>((_chunk) => Effect.void),
30 stdout: Stream.fromIterable([output]),
31 stderr: Stream.fromIterable([new TextEncoder().encode("")]),
32 all: Stream.fromIterable([output]),
33 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
34 isRunning: Effect.succeed(false),
35 kill: () => Effect.void,
36 getInputFd: () => Sink.drain,
37 getOutputFd: () => Stream.empty,
38 unref: Effect.sync(() => {
39 if (isReferenced) {
40 isReferenced = false
41 }
42 return reref
43 })
44 })
45 }))
46)
47
48describe("ChildProcess", () => {
49 describe("make", () => {
50 it("template literal form should create a standard command", () => {
51 const cmd = ChildProcess.make`echo hello`
52 assert(ChildProcess.isStandardCommand(cmd))
53 assert.strictEqual(cmd.command, "echo")
54 assert.deepStrictEqual(cmd.args, ["hello"])
55 })
56
57 it("preserves astral Unicode escapes in template arguments", () => {
58 const cmd = ChildProcess.make`echo \u{1F600} tail`
59 assert(ChildProcess.isStandardCommand(cmd))
60 assert.deepStrictEqual(cmd.args, ["😀", "tail"])
61 })
62
63 it("array form should create a standard command", () => {
64 const cmd = ChildProcess.make("node", ["--version"])
65 assert.strictEqual(cmd._tag, "StandardCommand")
66 assert.isTrue(ChildProcess.isStandardCommand(cmd))
67 assert.strictEqual(cmd.command, "node")
68 assert.deepStrictEqual(cmd.args, ["--version"])
69 })
70
71 it("options form should pass options to templated command", () => {
72 const cmd = ChildProcess.make({ cwd: "/tmp" })`ls -la`
73 assert(ChildProcess.isStandardCommand(cmd))
74 assert.strictEqual(cmd.options.cwd, "/tmp")
75 })
76
77 it("array form with options should work", () => {
78 const cmd = ChildProcess.make("git", ["status"], { cwd: "/app" })
79 assert.strictEqual(cmd._tag, "StandardCommand")
80 assert.strictEqual(cmd.command, "git")
81 assert.deepStrictEqual(cmd.args, ["status"])
82 assert.strictEqual(cmd.options.cwd, "/app")
83 })
84 })
85
86 describe("spawn", () => {
87 it.effect("should spawn a templated command", () =>
88 Effect.gen(function*() {
89 const cmd = ChildProcess.make`echo hello`
90 const handle = yield* cmd
91 assert.strictEqual(handle.pid, ChildProcessSpawner.ProcessId(12345))
92 }).pipe(Effect.provide(MockExecutorLayer)))
93
94 it.effect("should spawn a standard command", () =>
95 Effect.gen(function*() {
96 const cmd = ChildProcess.make("node", ["--version"])
97 const handle = yield* cmd
98 assert.strictEqual(handle.pid, ChildProcessSpawner.ProcessId(12345))
99 }).pipe(Effect.provide(MockExecutorLayer)))
100
101 it.effect("should return a process handle", () =>
102 Effect.gen(function*() {
103 const cmd = ChildProcess.make`long-running-process`
104 const handle = yield* cmd
105 assert.strictEqual(handle.pid, ChildProcessSpawner.ProcessId(12345))
106 }).pipe(Effect.provide(MockExecutorLayer)))
107
108 it.effect("collects stdout through Stream APIs", () =>
109 Effect.gen(function*() {
110 const cmd = ChildProcess.make("cat", ["file.txt"])
111 const handle = yield* cmd
112 const chunks = yield* Stream.runCollect(handle.stdout)
113 assert.isTrue(chunks.length > 0)
114 }).pipe(Effect.provide(MockExecutorLayer)))
115
116 it.effect("should allow waiting for exit code", () =>
117 Effect.gen(function*() {
118 const cmd = ChildProcess.make`echo test`
119 const handle = yield* cmd
120 const exitCode = yield* handle.exitCode
121 assert.strictEqual(exitCode, ChildProcessSpawner.ExitCode(0))
122 }).pipe(Effect.provide(MockExecutorLayer)))
123
124 it.effect("should unref a process and return a reref effect", () =>
125 Effect.gen(function*() {
126 const cmd = ChildProcess.make`echo test`
127 const handle = yield* cmd
128 const reref = yield* handle.unref
129 assert.isDefined(reref)
130 yield* reref
131 }).pipe(Effect.provide(MockExecutorLayer)))
132
133 it.effect("should allow restoring the reference within acquireRelease", () =>
134 Effect.gen(function*() {
135 const handle = yield* ChildProcess.make`echo test`
136 // @effect-diagnostics-next-line floatingEffect:off
137 yield* Effect.acquireRelease(handle.unref, (reref) => Effect.ignore(reref))
138 }).pipe(Effect.provide(MockExecutorLayer)))
139 })
140
141 describe("pipeTo", () => {
142 it("should create a piped command", () => {
143 const pipeline = ChildProcess.make`cat file.txt`.pipe(
144 ChildProcess.pipeTo(ChildProcess.make`grep pattern`),
145 ChildProcess.pipeTo(ChildProcess.make`wc -l`)
146 )
147 assert.strictEqual(pipeline._tag, "PipedCommand")
148 })
149
150 it.effect("should allow spawning a pipeline", () =>
151 Effect.gen(function*() {
152 const pipeline = ChildProcess.make`cat file.txt`.pipe(
153 ChildProcess.pipeTo(ChildProcess.make`grep pattern`)
154 )
155 const handle = yield* pipeline
156 assert.strictEqual(handle.pid, ChildProcessSpawner.ProcessId(12345))
157 }).pipe(Effect.provide(MockExecutorLayer)))
158 })
159
160 describe("setCwd", () => {
161 it("should set cwd on standard commands", () => {
162 const standard = ChildProcess.make("ls", ["-la"], { env: { TEST: "1" } })
163 const updatedStandard = ChildProcess.setCwd(standard, "/tmp")
164 assert(updatedStandard._tag === "StandardCommand")
165 assert.strictEqual(updatedStandard.options.cwd, "/tmp")
166 assert.deepStrictEqual(updatedStandard.options.env, { TEST: "1" })
167 })
168
169 it("should set cwd on all commands in a pipeline", () => {
170 const pipeline = ChildProcess.make`cat file`.pipe(
171 ChildProcess.pipeTo(ChildProcess.make`grep pattern`)
172 )
173 const updated = ChildProcess.setCwd(pipeline, "/tmp")
174 assert.strictEqual(updated._tag, "PipedCommand")
175 if (updated._tag === "PipedCommand") {
176 const left = updated.left
177 const right = updated.right
178 assert(left._tag === "StandardCommand")
179 assert.strictEqual(left.options.cwd, "/tmp")
180 assert(right._tag === "StandardCommand")
181 assert.strictEqual(right.options.cwd, "/tmp")
182 }
183 })
184 })
185
186 describe("prefix", () => {
187 it("should prefix a standard command and preserve options", () => {
188 const command = ChildProcess.make("echo", ["hello"], { cwd: "/tmp", env: { TEST: "1" } })
189 const prefixed = command.pipe(ChildProcess.prefix`time`)
190 assert(prefixed._tag === "StandardCommand")
191 assert.strictEqual(prefixed.command, "time")
192 assert.deepStrictEqual(prefixed.args, ["echo", "hello"])
193 assert.strictEqual(prefixed.options.cwd, "/tmp")
194 assert.deepStrictEqual(prefixed.options.env, { TEST: "1" })
195 })
196
197 it("should prefix the leftmost command in a pipeline", () => {
198 const pipeline = ChildProcess.make`cat file`.pipe(
199 ChildProcess.pipeTo(ChildProcess.make`grep pattern`),
200 ChildProcess.pipeTo(ChildProcess.make`wc -l`, { from: "stderr" })
201 )
202
203 const prefixed = pipeline.pipe(ChildProcess.prefix`time`)
204 assert(prefixed._tag === "PipedCommand")
205 assert.strictEqual(prefixed.options.from, "stderr")
206
207 const left = prefixed.left
208 assert(left._tag === "PipedCommand")
209
210 const leftmost = left.left
211 assert(leftmost._tag === "StandardCommand")
212 assert.strictEqual(leftmost.command, "time")
213 assert.deepStrictEqual(leftmost.args, ["cat", "file"])
214
215 const middle = left.right
216 assert(middle._tag === "StandardCommand")
217 assert.strictEqual(middle.command, "grep")
218 assert.deepStrictEqual(middle.args, ["pattern"])
219
220 const right = prefixed.right
221 assert(right._tag === "StandardCommand")
222 assert.strictEqual(right.command, "wc")
223 assert.deepStrictEqual(right.args, ["-l"])
224 })
225 })
226
227 describe("guards", () => {
228 it("isCommand should detect commands", () => {
229 const cmd = ChildProcess.make`echo hello`
230 assert.isTrue(ChildProcess.isCommand(cmd))
231 assert.isFalse(ChildProcess.isCommand({ _tag: "Other" }))
232 assert.isFalse(ChildProcess.isCommand(null))
233 })
234
235 it("isStandardCommand should detect standard commands", () => {
236 const standard = ChildProcess.make("echo", ["hello"])
237 const templated = ChildProcess.make`echo hello`
238 const piped = ChildProcess.make`cat file`.pipe(
239 ChildProcess.pipeTo(ChildProcess.make`grep pattern`)
240 )
241 assert.isTrue(ChildProcess.isStandardCommand(standard))
242 assert.isTrue(ChildProcess.isStandardCommand(templated))
243 assert.isFalse(ChildProcess.isStandardCommand(piped))
244 })
245
246 it("isPipedCommand should detect piped commands", () => {
247 const single = ChildProcess.make`echo hello`
248 const piped = ChildProcess.make`cat file`.pipe(
249 ChildProcess.pipeTo(ChildProcess.make`grep pattern`)
250 )
251 assert.isFalse(ChildProcess.isPipedCommand(single))
252 assert.isTrue(ChildProcess.isPipedCommand(piped))
253 })
254 })
255
256 describe("additionalFds", () => {
257 describe("parseFdName", () => {
258 it("should parse valid fd names", () => {
259 assert.strictEqual(ChildProcess.parseFdName("fd3"), 3)
260 assert.strictEqual(ChildProcess.parseFdName("fd4"), 4)
261 assert.strictEqual(ChildProcess.parseFdName("fd10"), 10)
262 assert.strictEqual(ChildProcess.parseFdName("fd100"), 100)
263 })
264
265 it("should reject invalid fd names", () => {
266 assert.isUndefined(ChildProcess.parseFdName("stdin"))
267 assert.isUndefined(ChildProcess.parseFdName("stdout"))
268 assert.isUndefined(ChildProcess.parseFdName("stderr"))
269 assert.isUndefined(ChildProcess.parseFdName("fd0"))
270 assert.isUndefined(ChildProcess.parseFdName("fd1"))
271 assert.isUndefined(ChildProcess.parseFdName("fd2"))
272 assert.isUndefined(ChildProcess.parseFdName("invalid"))
273 assert.isUndefined(ChildProcess.parseFdName("fd"))
274 assert.isUndefined(ChildProcess.parseFdName(""))
275 })
276 })
277
278 describe("fdName", () => {
279 it("should create fd names from numbers", () => {
280 assert.strictEqual(ChildProcess.fdName(3), "fd3")
281 assert.strictEqual(ChildProcess.fdName(4), "fd4")
282 assert.strictEqual(ChildProcess.fdName(10), "fd10")
283 })
284 })
285
286 describe("command options", () => {
287 it("should accept additionalFds option with output fd", () => {
288 const cmd = ChildProcess.make("my-program", [], {
289 additionalFds: { fd3: { type: "output" } }
290 })
291 assert.strictEqual(cmd._tag, "StandardCommand")
292 assert.isDefined(cmd.options.additionalFds)
293 assert.strictEqual(cmd.options.additionalFds!.fd3.type, "output")
294 })
295
296 it("should accept additionalFds option with input fd", () => {
297 const cmd = ChildProcess.make("my-program", [], {
298 additionalFds: { fd3: { type: "input" } }
299 })
300 assert.strictEqual(cmd._tag, "StandardCommand")
301 assert.isDefined(cmd.options.additionalFds)
302 assert.strictEqual(cmd.options.additionalFds!.fd3.type, "input")
303 })
304
305 it("should accept multiple additional fds", () => {
306 const cmd = ChildProcess.make("my-program", [], {
307 additionalFds: {
308 fd3: { type: "output" },
309 fd4: { type: "input" },
310 fd5: { type: "output" }
311 }
312 })
313 assert.strictEqual(cmd._tag, "StandardCommand")
314 assert.isDefined(cmd.options.additionalFds)
315 assert.strictEqual(cmd.options.additionalFds!.fd3.type, "output")
316 assert.strictEqual(cmd.options.additionalFds!.fd4.type, "input")
317 assert.strictEqual(cmd.options.additionalFds!.fd5.type, "output")
318 })
319
320 it("should accept additionalFds with streams", () => {
321 const inputStream = Stream.fromIterable([new TextEncoder().encode("test")])
322 const cmd = ChildProcess.make("my-program", [], {
323 additionalFds: { fd3: { type: "input", stream: inputStream } }
324 })
325 assert.strictEqual(cmd._tag, "StandardCommand")
326 assert.isDefined(cmd.options.additionalFds)
327 assert.strictEqual(cmd.options.additionalFds!.fd3.type, "input")
328 assert.propertyVal(cmd.options.additionalFds!.fd3, "stream", inputStream)
329 })
330
331 it("should accept additionalFds with sinks", () => {
332 const outputSink: Sink.Sink<
333 Uint8Array<ArrayBufferLike>,
334 Uint8Array<ArrayBufferLike>,
335 never,
336 PlatformError.PlatformError
337 > = Sink.succeed(new Uint8Array())
338 const cmd = ChildProcess.make("my-program", [], {
339 additionalFds: { fd3: { type: "output", sink: outputSink } }
340 })
341 assert.strictEqual(cmd._tag, "StandardCommand")
342 assert.isDefined(cmd.options.additionalFds)
343 assert.strictEqual(cmd.options.additionalFds!.fd3.type, "output")
344 assert.propertyVal(cmd.options.additionalFds!.fd3, "sink", outputSink)
345 })
346 })
347
348 describe("ChildProcessHandle additionalFds", () => {
349 it.effect("should have additionalFds accessor on handle", () =>
350 Effect.gen(function*() {
351 const cmd = ChildProcess.make`echo hello`
352 const handle = yield* cmd
353 assert.isDefined(handle.getInputFd)
354 assert.isDefined(handle.getOutputFd)
355 }).pipe(Effect.provide(MockExecutorLayer)))
356 })
357 })
358})