cross-spawn-spawner.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import { NodeFileSystem, NodePath } from "@effect/platform-node"
  2. import { describe, expect } from "bun:test"
  3. import fs from "node:fs/promises"
  4. import path from "node:path"
  5. import { Effect, Exit, Layer, Stream } from "effect"
  6. import type * as PlatformError from "effect/PlatformError"
  7. import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
  8. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  9. import { tmpdir } from "../fixture/fixture"
  10. import { testEffect } from "../lib/effect"
  11. const live = CrossSpawnSpawner.defaultLayer
  12. const fx = testEffect(live)
  13. function js(code: string, opts?: ChildProcess.CommandOptions) {
  14. return ChildProcess.make("node", ["-e", code], opts)
  15. }
  16. function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
  17. return Stream.runCollect(stream).pipe(
  18. Effect.map((chunks) => {
  19. const total = chunks.reduce((acc, x) => acc + x.length, 0)
  20. const out = new Uint8Array(total)
  21. let off = 0
  22. for (const chunk of chunks) {
  23. out.set(chunk, off)
  24. off += chunk.length
  25. }
  26. return new TextDecoder("utf-8").decode(out).trim()
  27. }),
  28. )
  29. }
  30. function alive(pid: number) {
  31. try {
  32. process.kill(pid, 0)
  33. return true
  34. } catch {
  35. return false
  36. }
  37. }
  38. async function gone(pid: number, timeout = 5_000) {
  39. const end = Date.now() + timeout
  40. while (Date.now() < end) {
  41. if (!alive(pid)) return true
  42. await Bun.sleep(50)
  43. }
  44. return !alive(pid)
  45. }
  46. describe("cross-spawn spawner", () => {
  47. describe("basic spawning", () => {
  48. fx.effect(
  49. "captures stdout",
  50. Effect.gen(function* () {
  51. const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
  52. svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])),
  53. )
  54. expect(out).toBe("ok")
  55. }),
  56. )
  57. fx.effect(
  58. "captures multiple lines",
  59. Effect.gen(function* () {
  60. const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")')
  61. const out = yield* decodeByteStream(handle.stdout)
  62. expect(out).toBe("line1\nline2\nline3")
  63. }),
  64. )
  65. fx.effect(
  66. "returns exit code",
  67. Effect.gen(function* () {
  68. const handle = yield* js("process.exit(0)")
  69. const code = yield* handle.exitCode
  70. expect(code).toBe(ChildProcessSpawner.ExitCode(0))
  71. }),
  72. )
  73. fx.effect(
  74. "returns non-zero exit code",
  75. Effect.gen(function* () {
  76. const handle = yield* js("process.exit(42)")
  77. const code = yield* handle.exitCode
  78. expect(code).toBe(ChildProcessSpawner.ExitCode(42))
  79. }),
  80. )
  81. })
  82. describe("cwd option", () => {
  83. fx.effect(
  84. "uses cwd when spawning commands",
  85. Effect.gen(function* () {
  86. const tmp = yield* Effect.acquireRelease(
  87. Effect.promise(() => tmpdir()),
  88. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  89. )
  90. const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
  91. svc.string(
  92. ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
  93. ),
  94. )
  95. expect(out).toBe(tmp.path)
  96. }),
  97. )
  98. fx.effect(
  99. "fails for invalid cwd",
  100. Effect.gen(function* () {
  101. const exit = yield* Effect.exit(
  102. ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(),
  103. )
  104. expect(Exit.isFailure(exit)).toBe(true)
  105. }),
  106. )
  107. })
  108. describe("env option", () => {
  109. fx.effect(
  110. "passes environment variables with extendEnv",
  111. Effect.gen(function* () {
  112. const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', {
  113. env: { TEST_VAR: "test_value" },
  114. extendEnv: true,
  115. })
  116. const out = yield* decodeByteStream(handle.stdout)
  117. expect(out).toBe("test_value")
  118. }),
  119. )
  120. fx.effect(
  121. "passes multiple environment variables",
  122. Effect.gen(function* () {
  123. const handle = yield* js(
  124. "process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)",
  125. {
  126. env: { VAR1: "one", VAR2: "two", VAR3: "three" },
  127. extendEnv: true,
  128. },
  129. )
  130. const out = yield* decodeByteStream(handle.stdout)
  131. expect(out).toBe("one-two-three")
  132. }),
  133. )
  134. })
  135. describe("stderr", () => {
  136. fx.effect(
  137. "captures stderr output",
  138. Effect.gen(function* () {
  139. const handle = yield* js('process.stderr.write("error message")')
  140. const err = yield* decodeByteStream(handle.stderr)
  141. expect(err).toBe("error message")
  142. }),
  143. )
  144. fx.effect(
  145. "captures both stdout and stderr",
  146. Effect.gen(function* () {
  147. const handle = yield* js(
  148. [
  149. "let pending = 2",
  150. "const done = () => {",
  151. " pending -= 1",
  152. " if (pending === 0) setTimeout(() => process.exit(0), 0)",
  153. "}",
  154. 'process.stdout.write("stdout\\n", done)',
  155. 'process.stderr.write("stderr\\n", done)',
  156. ].join("\n"),
  157. )
  158. const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
  159. expect(stdout).toBe("stdout")
  160. expect(stderr).toBe("stderr")
  161. }),
  162. )
  163. })
  164. describe("combined output (all)", () => {
  165. fx.effect(
  166. "captures stdout via .all when no stderr",
  167. Effect.gen(function* () {
  168. const handle = yield* ChildProcess.make("echo", ["hello from stdout"])
  169. const all = yield* decodeByteStream(handle.all)
  170. expect(all).toBe("hello from stdout")
  171. }),
  172. )
  173. fx.effect(
  174. "captures stderr via .all when no stdout",
  175. Effect.gen(function* () {
  176. const handle = yield* js('process.stderr.write("hello from stderr")')
  177. const all = yield* decodeByteStream(handle.all)
  178. expect(all).toBe("hello from stderr")
  179. }),
  180. )
  181. })
  182. describe("stdin", () => {
  183. fx.effect(
  184. "allows providing standard input to a command",
  185. Effect.gen(function* () {
  186. const input = "a b c"
  187. const stdin = Stream.make(Buffer.from(input, "utf-8"))
  188. const handle = yield* js(
  189. 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
  190. { stdin },
  191. )
  192. const out = yield* decodeByteStream(handle.stdout)
  193. yield* handle.exitCode
  194. expect(out).toBe("a b c")
  195. }),
  196. )
  197. })
  198. describe("process control", () => {
  199. fx.effect(
  200. "kills a running process",
  201. Effect.gen(function* () {
  202. const exit = yield* Effect.exit(
  203. Effect.gen(function* () {
  204. const handle = yield* js("setTimeout(() => {}, 10_000)")
  205. yield* handle.kill()
  206. return yield* handle.exitCode
  207. }),
  208. )
  209. expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
  210. }),
  211. )
  212. fx.effect(
  213. "kills a child when scope exits",
  214. Effect.gen(function* () {
  215. const pid = yield* Effect.scoped(
  216. Effect.gen(function* () {
  217. const handle = yield* js("setInterval(() => {}, 10_000)")
  218. return Number(handle.pid)
  219. }),
  220. )
  221. const done = yield* Effect.promise(() => gone(pid))
  222. expect(done).toBe(true)
  223. }),
  224. )
  225. fx.effect(
  226. "forceKillAfter escalates for stubborn processes",
  227. Effect.gen(function* () {
  228. if (process.platform === "win32") return
  229. const started = Date.now()
  230. const exit = yield* Effect.exit(
  231. Effect.gen(function* () {
  232. const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)')
  233. yield* handle.kill({ forceKillAfter: 100 })
  234. return yield* handle.exitCode
  235. }),
  236. )
  237. expect(Date.now() - started).toBeLessThan(1_000)
  238. expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
  239. }),
  240. )
  241. fx.effect(
  242. "isRunning reflects process state",
  243. Effect.gen(function* () {
  244. const handle = yield* js('process.stdout.write("done")')
  245. yield* handle.exitCode
  246. const running = yield* handle.isRunning
  247. expect(running).toBe(false)
  248. }),
  249. )
  250. })
  251. describe("error handling", () => {
  252. fx.effect(
  253. "fails for invalid command",
  254. Effect.gen(function* () {
  255. const exit = yield* Effect.exit(
  256. Effect.gen(function* () {
  257. const handle = yield* ChildProcess.make("nonexistent-command-12345")
  258. return yield* handle.exitCode
  259. }),
  260. )
  261. expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
  262. }),
  263. )
  264. })
  265. describe("pipeline", () => {
  266. fx.effect(
  267. "pipes stdout of one command to stdin of another",
  268. Effect.gen(function* () {
  269. const handle = yield* js('process.stdout.write("hello world")').pipe(
  270. ChildProcess.pipeTo(
  271. js(
  272. 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
  273. ),
  274. ),
  275. )
  276. const out = yield* decodeByteStream(handle.stdout)
  277. yield* handle.exitCode
  278. expect(out).toBe("HELLO WORLD")
  279. }),
  280. )
  281. fx.effect(
  282. "three-stage pipeline",
  283. Effect.gen(function* () {
  284. const handle = yield* js('process.stdout.write("hello world")').pipe(
  285. ChildProcess.pipeTo(
  286. js(
  287. 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
  288. ),
  289. ),
  290. ChildProcess.pipeTo(
  291. js(
  292. 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))',
  293. ),
  294. ),
  295. )
  296. const out = yield* decodeByteStream(handle.stdout)
  297. yield* handle.exitCode
  298. expect(out).toBe("HELLO-WORLD")
  299. }),
  300. )
  301. fx.effect(
  302. "pipes stderr with { from: 'stderr' }",
  303. Effect.gen(function* () {
  304. const handle = yield* js('process.stderr.write("error")').pipe(
  305. ChildProcess.pipeTo(
  306. js(
  307. 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
  308. ),
  309. { from: "stderr" },
  310. ),
  311. )
  312. const out = yield* decodeByteStream(handle.stdout)
  313. yield* handle.exitCode
  314. expect(out).toBe("error")
  315. }),
  316. )
  317. fx.effect(
  318. "pipes combined output with { from: 'all' }",
  319. Effect.gen(function* () {
  320. const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe(
  321. ChildProcess.pipeTo(
  322. js(
  323. 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
  324. ),
  325. { from: "all" },
  326. ),
  327. )
  328. const out = yield* decodeByteStream(handle.stdout)
  329. yield* handle.exitCode
  330. expect(out).toContain("stdout")
  331. expect(out).toContain("stderr")
  332. }),
  333. )
  334. })
  335. describe("Windows-specific", () => {
  336. fx.effect(
  337. "uses shell routing on Windows",
  338. Effect.gen(function* () {
  339. if (process.platform !== "win32") return
  340. const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
  341. svc.string(
  342. ChildProcess.make("set", ["KILO_TEST_SHELL"], {
  343. shell: true,
  344. extendEnv: true,
  345. env: { KILO_TEST_SHELL: "ok" },
  346. }),
  347. ),
  348. )
  349. expect(out).toContain("KILO_TEST_SHELL=ok")
  350. }),
  351. )
  352. fx.effect(
  353. "runs cmd scripts with spaces on Windows without shell",
  354. Effect.gen(function* () {
  355. if (process.platform !== "win32") return
  356. const tmp = yield* Effect.acquireRelease(
  357. Effect.promise(() => tmpdir()),
  358. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  359. )
  360. const dir = path.join(tmp.path, "with space")
  361. const file = path.join(dir, "echo cmd.cmd")
  362. yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
  363. yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n"))
  364. const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
  365. svc.exitCode(
  366. ChildProcess.make(file, ["--stdio"], {
  367. stdin: "pipe",
  368. stdout: "pipe",
  369. stderr: "pipe",
  370. }),
  371. ),
  372. )
  373. expect(code).toBe(ChildProcessSpawner.ExitCode(0))
  374. }),
  375. )
  376. })
  377. })