process.test.ts 13 KB

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