cross-spawn-spawner.test.ts 13 KB

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