1import * as DenoChildProcessSpawner from "@effect/platform-deno/DenoChildProcessSpawner"
2import * as DenoPath from "@effect/platform-deno/DenoPath"
3import { assert, describe, it } from "@effect/vitest"
4import * as Effect from "effect/Effect"
5import * as FileSystem from "effect/FileSystem"
6import * as Layer from "effect/Layer"
7import * as PlatformError from "effect/PlatformError"
8import * as Predicate from "effect/Predicate"
9import { ChildProcess } from "effect/process"
10import * as Stream from "effect/Stream"
11import * as TestClock from "effect/testing/TestClock"
12import * as ChildProcessSpawnerTest from "../../../effect/test/process/ChildProcessSpawnerTest.ts"
13
14const 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
23const 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
50const layer = DenoChildProcessSpawner.layer.pipe(
51 Layer.provideMerge(Layer.mergeAll(DenoPath.layer, fileSystem))
52)
53
54const 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
60ChildProcessSpawnerTest.suite("DenoChildProcessSpawner", layer, {
61 processGroups: false,
62 additionalFds: false
63})
64
65describe("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})