process.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import { describe, expect } from "bun:test"
  2. import { realpathSync } from "node:fs"
  3. import { tmpdir } from "node:os"
  4. import { Effect, Exit, Stream } from "effect"
  5. import { ChildProcess } from "effect/unstable/process"
  6. import { AppProcess } from "@opencode-ai/core/process"
  7. import { testEffect } from "../lib/effect"
  8. const it = testEffect(AppProcess.defaultLayer)
  9. const NODE = process.execPath
  10. const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
  11. describe("AppProcess", () => {
  12. describe("run", () => {
  13. it.effect(
  14. "captures stdout and exit code zero",
  15. Effect.gen(function* () {
  16. const svc = yield* AppProcess.Service
  17. const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
  18. expect(result.exitCode).toBe(0)
  19. expect(result.stdout.toString("utf8")).toBe("hi\n")
  20. expect(result.stdoutTruncated).toBe(false)
  21. expect(result.stderrTruncated).toBe(false)
  22. }),
  23. )
  24. it.effect(
  25. "non-zero exit returns RunResult; caller can require success",
  26. Effect.gen(function* () {
  27. const svc = yield* AppProcess.Service
  28. const result = yield* svc.run(cmd("-e", "process.exit(1)"))
  29. expect(result.exitCode).toBe(1)
  30. }),
  31. )
  32. it.effect(
  33. "requireSuccess fails on non-zero exit",
  34. Effect.gen(function* () {
  35. const svc = yield* AppProcess.Service
  36. const exit = yield* Effect.exit(
  37. svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
  38. )
  39. expect(Exit.isFailure(exit)).toBe(true)
  40. if (Exit.isFailure(exit)) {
  41. const reason = exit.cause.reasons[0]
  42. if (reason && reason._tag === "Fail") {
  43. expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
  44. expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
  45. } else {
  46. throw new Error("expected fail reason")
  47. }
  48. }
  49. }),
  50. )
  51. it.effect(
  52. "requireSuccess succeeds on exit 0",
  53. Effect.gen(function* () {
  54. const svc = yield* AppProcess.Service
  55. const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess))
  56. expect(result.exitCode).toBe(0)
  57. }),
  58. )
  59. it.effect(
  60. "requireExitIn allowlists multiple exit codes",
  61. Effect.gen(function* () {
  62. const svc = yield* AppProcess.Service
  63. const requireZeroOrOne = AppProcess.requireExitIn([0, 1])
  64. const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne))
  65. expect(okZero.exitCode).toBe(0)
  66. const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
  67. expect(okOne.exitCode).toBe(1)
  68. const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
  69. expect(Exit.isFailure(exit)).toBe(true)
  70. if (Exit.isFailure(exit)) {
  71. const reason = exit.cause.reasons[0]
  72. if (reason && reason._tag === "Fail") {
  73. expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
  74. expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
  75. }
  76. }
  77. }),
  78. )
  79. it.effect(
  80. "truncates stdout when maxOutputBytes is set",
  81. Effect.gen(function* () {
  82. const svc = yield* AppProcess.Service
  83. const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
  84. expect(result.exitCode).toBe(0)
  85. expect(result.stdoutTruncated).toBe(true)
  86. expect(result.stderrTruncated).toBe(false)
  87. expect(result.stdout.length).toBe(5)
  88. expect(result.stdout.toString("utf8")).toBe("01234")
  89. }),
  90. )
  91. it.effect(
  92. "truncates stderr when maxErrorBytes is set",
  93. Effect.gen(function* () {
  94. const svc = yield* AppProcess.Service
  95. const result = yield* svc.run(cmd("-e", "process.stderr.write('0123456789')"), { maxErrorBytes: 5 })
  96. expect(result.exitCode).toBe(0)
  97. expect(result.stdoutTruncated).toBe(false)
  98. expect(result.stderrTruncated).toBe(true)
  99. expect(result.stderr.length).toBe(5)
  100. expect(result.stderr.toString("utf8")).toBe("01234")
  101. }),
  102. )
  103. it.effect(
  104. "result includes command description",
  105. Effect.gen(function* () {
  106. const svc = yield* AppProcess.Service
  107. const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')"))
  108. expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
  109. }),
  110. )
  111. })
  112. describe("inherited platform methods", () => {
  113. it.effect(
  114. "string returns stdout as string",
  115. Effect.gen(function* () {
  116. const svc = yield* AppProcess.Service
  117. const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')"))
  118. expect(out).toBe("hi\n")
  119. }),
  120. )
  121. it.effect(
  122. "lines returns the platform's array of lines",
  123. Effect.gen(function* () {
  124. const svc = yield* AppProcess.Service
  125. const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')"))
  126. expect(Array.from(out)).toEqual(["a", "b"])
  127. }),
  128. )
  129. })
  130. describe("run with stdin option", () => {
  131. const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
  132. it.effect(
  133. "feeds a string to stdin and returns it on stdout",
  134. Effect.gen(function* () {
  135. const svc = yield* AppProcess.Service
  136. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "hello" })
  137. expect(result.exitCode).toBe(0)
  138. expect(result.stdout.toString("utf8")).toBe("hello")
  139. }),
  140. )
  141. it.effect(
  142. "feeds a Uint8Array to stdin",
  143. Effect.gen(function* () {
  144. const svc = yield* AppProcess.Service
  145. const bytes = new TextEncoder().encode("bytes")
  146. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: bytes })
  147. expect(result.exitCode).toBe(0)
  148. expect(result.stdout.toString("utf8")).toBe("bytes")
  149. }),
  150. )
  151. it.effect(
  152. "feeds a Stream of Uint8Array chunks to stdin",
  153. Effect.gen(function* () {
  154. const svc = yield* AppProcess.Service
  155. const enc = new TextEncoder()
  156. const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")])
  157. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: stream })
  158. expect(result.exitCode).toBe(0)
  159. expect(result.stdout.toString("utf8")).toBe("one-two-three")
  160. }),
  161. )
  162. it.effect(
  163. "completes correctly with empty input",
  164. Effect.gen(function* () {
  165. const svc = yield* AppProcess.Service
  166. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "" })
  167. expect(result.exitCode).toBe(0)
  168. expect(result.stdout.toString("utf8")).toBe("")
  169. }),
  170. )
  171. it.effect(
  172. "carries existing Command options like env",
  173. Effect.gen(function* () {
  174. const svc = yield* AppProcess.Service
  175. const script =
  176. "process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))"
  177. const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true })
  178. const result = yield* svc.run(command, { stdin: "payload" })
  179. expect(result.exitCode).toBe(0)
  180. expect(result.stdout.toString("utf8")).toBe("envset:payload")
  181. }),
  182. )
  183. it.effect(
  184. "carries existing Command options like cwd",
  185. Effect.gen(function* () {
  186. const svc = yield* AppProcess.Service
  187. const dir = realpathSync(tmpdir())
  188. const script =
  189. "process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))"
  190. const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir })
  191. const result = yield* svc.run(command, { stdin: "ok" })
  192. expect(result.exitCode).toBe(0)
  193. const [cwd, stdin] = result.stdout.toString("utf8").split("|")
  194. expect(realpathSync(cwd)).toBe(dir)
  195. expect(stdin).toBe("ok")
  196. }),
  197. )
  198. })
  199. describe("runStream", () => {
  200. it.live(
  201. "emits lines incrementally and ends cleanly on exit 0",
  202. Effect.gen(function* () {
  203. const svc = yield* AppProcess.Service
  204. const result = yield* svc
  205. .runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')"))
  206. .pipe(Stream.runCollect)
  207. expect(Array.from(result)).toEqual(["one", "two", "three"])
  208. }),
  209. )
  210. it.live(
  211. "okExitCodes determines whether a non-zero exit fails the stream",
  212. Effect.gen(function* () {
  213. const svc = yield* AppProcess.Service
  214. const allowed = yield* svc
  215. .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
  216. .pipe(Stream.runCollect)
  217. expect(Array.from(allowed)).toEqual(["only"])
  218. const exit = yield* Effect.exit(
  219. svc
  220. .runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
  221. .pipe(Stream.runCollect),
  222. )
  223. expect(Exit.isFailure(exit)).toBe(true)
  224. if (Exit.isFailure(exit)) {
  225. const reason = exit.cause.reasons[0]
  226. if (reason && reason._tag === "Fail") {
  227. expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
  228. }
  229. }
  230. }),
  231. )
  232. it.live(
  233. "without okExitCodes, never fails on exit code",
  234. Effect.gen(function* () {
  235. const svc = yield* AppProcess.Service
  236. const result = yield* svc.runStream(cmd("-e", "console.log('only'); process.exit(7)")).pipe(Stream.runCollect)
  237. expect(Array.from(result)).toEqual(["only"])
  238. }),
  239. )
  240. it.live(
  241. "AbortSignal interrupts the stream",
  242. Effect.gen(function* () {
  243. const svc = yield* AppProcess.Service
  244. const controller = new AbortController()
  245. controller.abort()
  246. const exit = yield* Effect.exit(
  247. svc
  248. .runStream(cmd("-e", "setInterval(() => {}, 60_000)"), { signal: controller.signal })
  249. .pipe(Stream.runCollect),
  250. )
  251. expect(Exit.isFailure(exit)).toBe(true)
  252. }),
  253. )
  254. })
  255. describe("spawn (inherited)", () => {
  256. it.live(
  257. "returns the platform ChildProcessHandle for advanced use",
  258. Effect.scoped(
  259. Effect.gen(function* () {
  260. const svc = yield* AppProcess.Service
  261. const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)"))
  262. expect(yield* handle.isRunning).toBe(true)
  263. yield* handle.kill()
  264. }),
  265. ),
  266. )
  267. })
  268. })