process.test.ts 14 KB

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