| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import * as Effect from "effect/Effect" |
| 3 | import * as Layer from "effect/Layer" |
| 4 | import type * as PlatformError from "effect/PlatformError" |
| 5 | import { ChildProcess, ChildProcessSpawner } from "effect/process" |
| 6 | import * as Sink from "effect/Sink" |
| 7 | import * as Stream from "effect/Stream" |
| 8 | |
| 9 | const 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 | |
| 48 | describe("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 | }) |