| 1 | import * as DenoChildProcessSpawner from "@effect/platform-deno/DenoChildProcessSpawner" |
| 2 | import * as DenoPath from "@effect/platform-deno/DenoPath" |
| 3 | import { assert, describe, it } from "@effect/vitest" |
| 4 | import * as Effect from "effect/Effect" |
| 5 | import * as FileSystem from "effect/FileSystem" |
| 6 | import * as Layer from "effect/Layer" |
| 7 | import * as PlatformError from "effect/PlatformError" |
| 8 | import * as Predicate from "effect/Predicate" |
| 9 | import { ChildProcess } from "effect/process" |
| 10 | import * as Stream from "effect/Stream" |
| 11 | import * as TestClock from "effect/testing/TestClock" |
| 12 | import * as ChildProcessSpawnerTest from "../../../effect/test/process/ChildProcessSpawnerTest.ts" |
| 13 | |
| 14 | const platformError = (method: string, path: string, cause: unknown) => |
| 15 | PlatformError.systemError({ |
| 16 | _tag: Predicate.hasProperty(cause, "name") && cause.name === "NotFound" ? "NotFound" : "Unknown", |
| 17 | module: "FileSystem", |
| 18 | method, |
| 19 | pathOrDescriptor: path, |
| 20 | cause |
| 21 | }) |
| 22 | |
| 23 | const fileSystem = FileSystem.layerNoop({ |
| 24 | access: (path) => |
| 25 | Effect.tryPromise({ |
| 26 | try: () => Deno.stat(path), |
| 27 | catch: (cause) => platformError("access", path, cause) |
| 28 | }).pipe(Effect.asVoid), |
| 29 | exists: (path) => |
| 30 | Effect.tryPromise({ |
| 31 | try: () => Deno.stat(path).then(() => true), |
| 32 | catch: (cause) => platformError("exists", path, cause) |
| 33 | }).pipe(Effect.catchTag("PlatformError", (error) => |
| 34 | error.reason._tag === "NotFound" ? Effect.succeed(false) : Effect.fail(error))), |
| 35 | makeTempDirectoryScoped: (options) => |
| 36 | Effect.acquireRelease( |
| 37 | Effect.tryPromise({ |
| 38 | try: () => Deno.makeTempDir({ dir: options?.directory, prefix: options?.prefix }), |
| 39 | catch: (cause) => platformError("makeTempDirectoryScoped", options?.directory ?? "", cause) |
| 40 | }), |
| 41 | (path) => Effect.promise(() => Deno.remove(path, { recursive: true })).pipe(Effect.ignore) |
| 42 | ), |
| 43 | writeFile: (path, data) => |
| 44 | Effect.tryPromise({ |
| 45 | try: () => Deno.writeFile(path, data), |
| 46 | catch: (cause) => platformError("writeFile", path, cause) |
| 47 | }) |
| 48 | }) |
| 49 | |
| 50 | const layer = DenoChildProcessSpawner.layer.pipe( |
| 51 | Layer.provideMerge(Layer.mergeAll(DenoPath.layer, fileSystem)) |
| 52 | ) |
| 53 | |
| 54 | const output = (command: ChildProcess.Command) => |
| 55 | Effect.gen(function*() { |
| 56 | const handle = yield* command |
| 57 | return yield* handle.stdout.pipe(Stream.decodeText(), Stream.mkString) |
| 58 | }).pipe(Effect.scoped, Effect.provide(layer)) |
| 59 | |
| 60 | ChildProcessSpawnerTest.suite("DenoChildProcessSpawner", layer, { |
| 61 | processGroups: false, |
| 62 | additionalFds: false |
| 63 | }) |
| 64 | |
| 65 | describe("DenoChildProcessSpawner options", () => { |
| 66 | it.effect("rejects detached", () => |
| 67 | Effect.gen(function*() { |
| 68 | const error = yield* ChildProcess.make("echo", ["test"], { detached: false }).pipe(Effect.flip) |
| 69 | assert.deepStrictEqual( |
| 70 | error.reason, |
| 71 | new PlatformError.BadArgument({ |
| 72 | module: "ChildProcessSpawner", |
| 73 | method: "spawn", |
| 74 | description: "The detached option is unsupported because Deno has no equivalent" |
| 75 | }) |
| 76 | ) |
| 77 | }).pipe(Effect.provide(layer))) |
| 78 | |
| 79 | it.effect("rejects additionalFds", () => |
| 80 | Effect.gen(function*() { |
| 81 | const error = yield* ChildProcess.make("echo", ["test"], { |
| 82 | additionalFds: { fd3: { type: "output" } } |
| 83 | }).pipe(Effect.flip) |
| 84 | assert.deepStrictEqual( |
| 85 | error.reason, |
| 86 | new PlatformError.BadArgument({ |
| 87 | module: "ChildProcessSpawner", |
| 88 | method: "spawn", |
| 89 | description: "The additionalFds option is unsupported because Deno has no equivalent" |
| 90 | }) |
| 91 | ) |
| 92 | }).pipe(Effect.provide(layer))) |
| 93 | |
| 94 | it.effect("rejects additional fd pipe sources", () => |
| 95 | Effect.gen(function*() { |
| 96 | const error = yield* ChildProcess.make("echo", ["test"]).pipe( |
| 97 | ChildProcess.pipeTo(ChildProcess.make("cat"), { from: "fd3" }), |
| 98 | Effect.flip |
| 99 | ) |
| 100 | assert.deepStrictEqual( |
| 101 | error.reason, |
| 102 | new PlatformError.BadArgument({ |
| 103 | module: "ChildProcessSpawner", |
| 104 | method: "spawn", |
| 105 | description: "The additionalFds option is unsupported because Deno has no equivalent" |
| 106 | }) |
| 107 | ) |
| 108 | }).pipe(Effect.provide(layer))) |
| 109 | |
| 110 | it.effect("kills every process in a pipeline", () => |
| 111 | Effect.gen(function*() { |
| 112 | const directory = yield* Effect.acquireRelease( |
| 113 | Effect.promise(() => Deno.makeTempDir()), |
| 114 | (path) => Effect.promise(() => Deno.remove(path, { recursive: true })).pipe(Effect.ignore) |
| 115 | ) |
| 116 | const rootHeartbeat = `${directory}/root-heartbeat` |
| 117 | const childHeartbeat = `${directory}/child-heartbeat` |
| 118 | const handle = yield* ChildProcess.make( |
| 119 | "sh", |
| 120 | ["-c", "while :; do printf x >> \"$1\"; sleep 0.01; done", "pipeline-root", rootHeartbeat] |
| 121 | ).pipe( |
| 122 | ChildProcess.pipeTo( |
| 123 | ChildProcess.make( |
| 124 | "sh", |
| 125 | ["-c", "while :; do printf x >> \"$1\"; sleep 0.01; done", "pipeline-child", childHeartbeat] |
| 126 | ) |
| 127 | ) |
| 128 | ) |
| 129 | |
| 130 | yield* TestClock.withLive(Effect.sleep("100 millis")) |
| 131 | yield* handle.kill({ killSignal: "SIGKILL" }) |
| 132 | const rootSizeAfterKill = (yield* Effect.promise(() => Deno.stat(rootHeartbeat))).size |
| 133 | const childSizeAfterKill = (yield* Effect.promise(() => Deno.stat(childHeartbeat))).size |
| 134 | yield* TestClock.withLive(Effect.sleep("100 millis")) |
| 135 | const rootFinalSize = (yield* Effect.promise(() => Deno.stat(rootHeartbeat))).size |
| 136 | const childFinalSize = (yield* Effect.promise(() => Deno.stat(childHeartbeat))).size |
| 137 | |
| 138 | assert.strictEqual(rootFinalSize, rootSizeAfterKill) |
| 139 | assert.strictEqual(childFinalSize, childSizeAfterKill) |
| 140 | }).pipe(Effect.provide(layer))) |
| 141 | |
| 142 | it.effect("emulates shell true without escaping joined arguments", () => |
| 143 | Effect.gen(function*() { |
| 144 | const result = yield* output(ChildProcess.make("printf", ["[%s]", "a b"], { shell: true })) |
| 145 | assert.strictEqual(result, "[a][b]") |
| 146 | })) |
| 147 | |
| 148 | it.effect("uses a custom shell", () => |
| 149 | Effect.gen(function*() { |
| 150 | const result = yield* output(ChildProcess.make("printf", ["custom-shell"], { shell: "/bin/sh" })) |
| 151 | assert.strictEqual(result, "custom-shell") |
| 152 | })) |
| 153 | }) |