process.test.ts 13 KB

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