cross-spawn-spawner.test.ts 13 KB

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