process.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import { describe, expect } from "bun:test"
  2. import fs from "fs/promises"
  3. import { realpathSync } from "node:fs"
  4. import { tmpdir } from "node:os"
  5. import path from "node:path"
  6. import { Effect, Exit, Fiber, Stream } from "effect"
  7. import { ChildProcess } from "effect/unstable/process"
  8. import { AppProcess } from "@opencode-ai/core/process"
  9. import { testEffect } from "../lib/effect"
  10. const it = testEffect(AppProcess.defaultLayer)
  11. const NODE = process.execPath
  12. const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
  13. const waitForFile = (file: string) =>
  14. Effect.promise(async () => {
  15. while (true) {
  16. try {
  17. return await fs.readFile(file, "utf8")
  18. } catch (error) {
  19. if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
  20. await new Promise<void>((resolve) => setTimeout(resolve, 10))
  21. }
  22. }
  23. })
  24. describe("AppProcess", () => {
  25. describe("run", () => {
  26. it.effect(
  27. "captures stdout and exit code zero",
  28. Effect.gen(function* () {
  29. const svc = yield* AppProcess.Service
  30. const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
  31. expect(result.exitCode).toBe(0)
  32. expect(result.stdout.toString("utf8")).toBe("hi\n")
  33. expect(result.stdoutTruncated).toBe(false)
  34. expect(result.stderrTruncated).toBe(false)
  35. }),
  36. )
  37. it.effect(
  38. "captures stdout and stderr in emission order",
  39. Effect.gen(function* () {
  40. const svc = yield* AppProcess.Service
  41. const script = [
  42. 'process.stdout.write("out 1\\n")',
  43. 'setTimeout(() => process.stderr.write("err 1\\n"), 10)',
  44. 'setTimeout(() => process.stdout.write("out 2\\n"), 20)',
  45. ].join(";")
  46. const result = yield* svc.run(cmd("-e", script), { combineOutput: true })
  47. expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n")
  48. expect(result.stdout.toString("utf8")).toBe("")
  49. expect(result.stderr.toString("utf8")).toBe("")
  50. }),
  51. )
  52. it.effect(
  53. "non-zero exit returns RunResult; caller can require success",
  54. Effect.gen(function* () {
  55. const svc = yield* AppProcess.Service
  56. const result = yield* svc.run(cmd("-e", "process.exit(1)"))
  57. expect(result.exitCode).toBe(1)
  58. }),
  59. )
  60. it.effect(
  61. "requireSuccess fails on non-zero exit",
  62. Effect.gen(function* () {
  63. const svc = yield* AppProcess.Service
  64. const exit = yield* Effect.exit(
  65. svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
  66. )
  67. expect(Exit.isFailure(exit)).toBe(true)
  68. if (Exit.isFailure(exit)) {
  69. const reason = exit.cause.reasons[0]
  70. if (reason && reason._tag === "Fail") {
  71. expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
  72. expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
  73. expect((reason.error as AppProcess.AppProcessError).message).toContain("Command failed (exit 1)")
  74. } else {
  75. throw new Error("expected fail reason")
  76. }
  77. }
  78. }),
  79. )
  80. it.effect(
  81. "requireSuccess succeeds on exit 0",
  82. Effect.gen(function* () {
  83. const svc = yield* AppProcess.Service
  84. const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess))
  85. expect(result.exitCode).toBe(0)
  86. }),
  87. )
  88. it.effect(
  89. "requireExitIn allowlists multiple exit codes",
  90. Effect.gen(function* () {
  91. const svc = yield* AppProcess.Service
  92. const requireZeroOrOne = AppProcess.requireExitIn([0, 1])
  93. const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne))
  94. expect(okZero.exitCode).toBe(0)
  95. const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
  96. expect(okOne.exitCode).toBe(1)
  97. const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
  98. expect(Exit.isFailure(exit)).toBe(true)
  99. if (Exit.isFailure(exit)) {
  100. const reason = exit.cause.reasons[0]
  101. if (reason && reason._tag === "Fail") {
  102. expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
  103. expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
  104. }
  105. }
  106. }),
  107. )
  108. it.effect(
  109. "truncates stdout when maxOutputBytes is set",
  110. Effect.gen(function* () {
  111. const svc = yield* AppProcess.Service
  112. const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
  113. expect(result.exitCode).toBe(0)
  114. expect(result.stdoutTruncated).toBe(true)
  115. expect(result.stderrTruncated).toBe(false)
  116. expect(result.stdout.length).toBe(5)
  117. expect(result.stdout.toString("utf8")).toBe("01234")
  118. }),
  119. )
  120. it.effect(
  121. "truncates stderr when maxErrorBytes is set",
  122. Effect.gen(function* () {
  123. const svc = yield* AppProcess.Service
  124. const result = yield* svc.run(cmd("-e", "process.stderr.write('0123456789')"), { maxErrorBytes: 5 })
  125. expect(result.exitCode).toBe(0)
  126. expect(result.stdoutTruncated).toBe(false)
  127. expect(result.stderrTruncated).toBe(true)
  128. expect(result.stderr.length).toBe(5)
  129. expect(result.stderr.toString("utf8")).toBe("01234")
  130. }),
  131. )
  132. it.effect(
  133. "result includes command description",
  134. Effect.gen(function* () {
  135. const svc = yield* AppProcess.Service
  136. const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')"))
  137. expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
  138. }),
  139. )
  140. if (process.platform !== "win32") {
  141. it.live(
  142. "timeout cleans up the scoped child process",
  143. Effect.acquireUseRelease(
  144. Effect.promise(() => fs.mkdtemp(path.join(tmpdir(), "opencode-process-timeout-"))),
  145. (directory) => {
  146. const ready = path.join(directory, "ready")
  147. const settled = path.join(directory, "settled")
  148. const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
  149. return Effect.gen(function* () {
  150. const svc = yield* AppProcess.Service
  151. const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "250 millis" }))
  152. expect(Exit.isFailure(exit)).toBe(true)
  153. expect(yield* waitForFile(ready)).toMatch(/^\d+$/)
  154. expect(yield* waitForFile(settled)).toBe("settled")
  155. })
  156. },
  157. (directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })),
  158. ),
  159. 5_000,
  160. )
  161. it.live(
  162. "fiber interruption cleans up the scoped child process after readiness",
  163. Effect.acquireUseRelease(
  164. Effect.promise(() => fs.mkdtemp(path.join(tmpdir(), "opencode-process-interrupt-"))),
  165. (directory) => {
  166. const ready = path.join(directory, "ready")
  167. const settled = path.join(directory, "settled")
  168. const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
  169. return Effect.gen(function* () {
  170. const svc = yield* AppProcess.Service
  171. const fiber = yield* svc.run(cmd("-e", script)).pipe(Effect.forkChild)
  172. expect(yield* waitForFile(ready)).toMatch(/^\d+$/)
  173. yield* Fiber.interrupt(fiber)
  174. expect(yield* waitForFile(settled)).toBe("settled")
  175. })
  176. },
  177. (directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })),
  178. ),
  179. 5_000,
  180. )
  181. }
  182. })
  183. describe("inherited platform methods", () => {
  184. it.effect(
  185. "string returns stdout as string",
  186. Effect.gen(function* () {
  187. const svc = yield* AppProcess.Service
  188. const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')"))
  189. expect(out).toBe("hi\n")
  190. }),
  191. )
  192. it.effect(
  193. "lines returns the platform's array of lines",
  194. Effect.gen(function* () {
  195. const svc = yield* AppProcess.Service
  196. const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')"))
  197. expect(Array.from(out)).toEqual(["a", "b"])
  198. }),
  199. )
  200. })
  201. describe("run with stdin option", () => {
  202. const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
  203. it.effect(
  204. "feeds a string to stdin and returns it on stdout",
  205. Effect.gen(function* () {
  206. const svc = yield* AppProcess.Service
  207. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "hello" })
  208. expect(result.exitCode).toBe(0)
  209. expect(result.stdout.toString("utf8")).toBe("hello")
  210. }),
  211. )
  212. it.effect(
  213. "feeds a Uint8Array to stdin",
  214. Effect.gen(function* () {
  215. const svc = yield* AppProcess.Service
  216. const bytes = new TextEncoder().encode("bytes")
  217. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: bytes })
  218. expect(result.exitCode).toBe(0)
  219. expect(result.stdout.toString("utf8")).toBe("bytes")
  220. }),
  221. )
  222. it.effect(
  223. "feeds a Stream of Uint8Array chunks to stdin",
  224. Effect.gen(function* () {
  225. const svc = yield* AppProcess.Service
  226. const enc = new TextEncoder()
  227. const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")])
  228. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: stream })
  229. expect(result.exitCode).toBe(0)
  230. expect(result.stdout.toString("utf8")).toBe("one-two-three")
  231. }),
  232. )
  233. it.effect(
  234. "completes correctly with empty input",
  235. Effect.gen(function* () {
  236. const svc = yield* AppProcess.Service
  237. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "" })
  238. expect(result.exitCode).toBe(0)
  239. expect(result.stdout.toString("utf8")).toBe("")
  240. }),
  241. )
  242. it.effect(
  243. "carries existing Command options like env",
  244. Effect.gen(function* () {
  245. const svc = yield* AppProcess.Service
  246. const script =
  247. "process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))"
  248. const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true })
  249. const result = yield* svc.run(command, { stdin: "payload" })
  250. expect(result.exitCode).toBe(0)
  251. expect(result.stdout.toString("utf8")).toBe("envset:payload")
  252. }),
  253. )
  254. it.effect(
  255. "carries existing Command options like cwd",
  256. Effect.gen(function* () {
  257. const svc = yield* AppProcess.Service
  258. const dir = realpathSync(tmpdir())
  259. const script =
  260. "process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))"
  261. const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir })
  262. const result = yield* svc.run(command, { stdin: "ok" })
  263. expect(result.exitCode).toBe(0)
  264. const [cwd, stdin] = result.stdout.toString("utf8").split("|")
  265. expect(realpathSync(cwd)).toBe(dir)
  266. expect(stdin).toBe("ok")
  267. }),
  268. )
  269. })
  270. describe("runStream", () => {
  271. it.live(
  272. "emits lines incrementally and ends cleanly on exit 0",
  273. Effect.gen(function* () {
  274. const svc = yield* AppProcess.Service
  275. const result = yield* svc
  276. .runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')"))
  277. .pipe(Stream.runCollect)
  278. expect(Array.from(result)).toEqual(["one", "two", "three"])
  279. }),
  280. )
  281. it.live(
  282. "okExitCodes determines whether a non-zero exit fails the stream",
  283. Effect.gen(function* () {
  284. const svc = yield* AppProcess.Service
  285. const allowed = yield* svc
  286. .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
  287. .pipe(Stream.runCollect)
  288. expect(Array.from(allowed)).toEqual(["only"])
  289. const exit = yield* Effect.exit(
  290. svc
  291. .runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
  292. .pipe(Stream.runCollect),
  293. )
  294. expect(Exit.isFailure(exit)).toBe(true)
  295. if (Exit.isFailure(exit)) {
  296. const reason = exit.cause.reasons[0]
  297. if (reason && reason._tag === "Fail") {
  298. expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
  299. }
  300. }
  301. }),
  302. )
  303. it.live(
  304. "without okExitCodes, never fails on exit code",
  305. Effect.gen(function* () {
  306. const svc = yield* AppProcess.Service
  307. const result = yield* svc.runStream(cmd("-e", "console.log('only'); process.exit(7)")).pipe(Stream.runCollect)
  308. expect(Array.from(result)).toEqual(["only"])
  309. }),
  310. )
  311. it.live(
  312. "AbortSignal interrupts the stream",
  313. Effect.gen(function* () {
  314. const svc = yield* AppProcess.Service
  315. const controller = new AbortController()
  316. controller.abort()
  317. const exit = yield* Effect.exit(
  318. svc
  319. .runStream(cmd("-e", "setInterval(() => {}, 60_000)"), { signal: controller.signal })
  320. .pipe(Stream.runCollect),
  321. )
  322. expect(Exit.isFailure(exit)).toBe(true)
  323. }),
  324. )
  325. })
  326. describe("spawn (inherited)", () => {
  327. it.live(
  328. "returns the platform ChildProcessHandle for advanced use",
  329. Effect.scoped(
  330. Effect.gen(function* () {
  331. const svc = yield* AppProcess.Service
  332. const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)"))
  333. expect(yield* handle.isRunning).toBe(true)
  334. yield* handle.kill()
  335. }),
  336. ),
  337. )
  338. })
  339. })