process.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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(
  96. cmd("-e", "process.stderr.write('0123456789')"),
  97. { maxErrorBytes: 5 },
  98. )
  99. expect(result.exitCode).toBe(0)
  100. expect(result.stdoutTruncated).toBe(false)
  101. expect(result.stderrTruncated).toBe(true)
  102. expect(result.stderr.length).toBe(5)
  103. expect(result.stderr.toString("utf8")).toBe("01234")
  104. }),
  105. )
  106. it.effect(
  107. "result includes command description",
  108. Effect.gen(function* () {
  109. const svc = yield* AppProcess.Service
  110. const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')"))
  111. expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
  112. }),
  113. )
  114. })
  115. describe("inherited platform methods", () => {
  116. it.effect(
  117. "string returns stdout as string",
  118. Effect.gen(function* () {
  119. const svc = yield* AppProcess.Service
  120. const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')"))
  121. expect(out).toBe("hi\n")
  122. }),
  123. )
  124. it.effect(
  125. "lines returns the platform's array of lines",
  126. Effect.gen(function* () {
  127. const svc = yield* AppProcess.Service
  128. const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')"))
  129. expect(Array.from(out)).toEqual(["a", "b"])
  130. }),
  131. )
  132. })
  133. describe("run with stdin option", () => {
  134. const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
  135. it.effect(
  136. "feeds a string to stdin and returns it on stdout",
  137. Effect.gen(function* () {
  138. const svc = yield* AppProcess.Service
  139. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "hello" })
  140. expect(result.exitCode).toBe(0)
  141. expect(result.stdout.toString("utf8")).toBe("hello")
  142. }),
  143. )
  144. it.effect(
  145. "feeds a Uint8Array to stdin",
  146. Effect.gen(function* () {
  147. const svc = yield* AppProcess.Service
  148. const bytes = new TextEncoder().encode("bytes")
  149. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: bytes })
  150. expect(result.exitCode).toBe(0)
  151. expect(result.stdout.toString("utf8")).toBe("bytes")
  152. }),
  153. )
  154. it.effect(
  155. "feeds a Stream of Uint8Array chunks to stdin",
  156. Effect.gen(function* () {
  157. const svc = yield* AppProcess.Service
  158. const enc = new TextEncoder()
  159. const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")])
  160. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: stream })
  161. expect(result.exitCode).toBe(0)
  162. expect(result.stdout.toString("utf8")).toBe("one-two-three")
  163. }),
  164. )
  165. it.effect(
  166. "completes correctly with empty input",
  167. Effect.gen(function* () {
  168. const svc = yield* AppProcess.Service
  169. const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "" })
  170. expect(result.exitCode).toBe(0)
  171. expect(result.stdout.toString("utf8")).toBe("")
  172. }),
  173. )
  174. it.effect(
  175. "carries existing Command options like env",
  176. Effect.gen(function* () {
  177. const svc = yield* AppProcess.Service
  178. const script =
  179. "process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))"
  180. const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true })
  181. const result = yield* svc.run(command, { stdin: "payload" })
  182. expect(result.exitCode).toBe(0)
  183. expect(result.stdout.toString("utf8")).toBe("envset:payload")
  184. }),
  185. )
  186. it.effect(
  187. "carries existing Command options like cwd",
  188. Effect.gen(function* () {
  189. const svc = yield* AppProcess.Service
  190. const dir = realpathSync(tmpdir())
  191. const script =
  192. "process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))"
  193. const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir })
  194. const result = yield* svc.run(command, { stdin: "ok" })
  195. expect(result.exitCode).toBe(0)
  196. const [cwd, stdin] = result.stdout.toString("utf8").split("|")
  197. expect(realpathSync(cwd)).toBe(dir)
  198. expect(stdin).toBe("ok")
  199. }),
  200. )
  201. })
  202. describe("runStream", () => {
  203. it.live(
  204. "emits lines incrementally and ends cleanly on exit 0",
  205. Effect.gen(function* () {
  206. const svc = yield* AppProcess.Service
  207. const result = yield* svc
  208. .runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')"))
  209. .pipe(Stream.runCollect)
  210. expect(Array.from(result)).toEqual(["one", "two", "three"])
  211. }),
  212. )
  213. it.live(
  214. "okExitCodes determines whether a non-zero exit fails the stream",
  215. Effect.gen(function* () {
  216. const svc = yield* AppProcess.Service
  217. const allowed = yield* svc
  218. .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
  219. .pipe(Stream.runCollect)
  220. expect(Array.from(allowed)).toEqual(["only"])
  221. const exit = yield* Effect.exit(
  222. svc
  223. .runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
  224. .pipe(Stream.runCollect),
  225. )
  226. expect(Exit.isFailure(exit)).toBe(true)
  227. if (Exit.isFailure(exit)) {
  228. const reason = exit.cause.reasons[0]
  229. if (reason && reason._tag === "Fail") {
  230. expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
  231. }
  232. }
  233. }),
  234. )
  235. it.live(
  236. "without okExitCodes, never fails on exit code",
  237. Effect.gen(function* () {
  238. const svc = yield* AppProcess.Service
  239. const result = yield* svc.runStream(cmd("-e", "console.log('only'); process.exit(7)")).pipe(Stream.runCollect)
  240. expect(Array.from(result)).toEqual(["only"])
  241. }),
  242. )
  243. it.live(
  244. "AbortSignal interrupts the stream",
  245. Effect.gen(function* () {
  246. const svc = yield* AppProcess.Service
  247. const controller = new AbortController()
  248. controller.abort()
  249. const exit = yield* Effect.exit(
  250. svc
  251. .runStream(cmd("-e", "setInterval(() => {}, 60_000)"), { signal: controller.signal })
  252. .pipe(Stream.runCollect),
  253. )
  254. expect(Exit.isFailure(exit)).toBe(true)
  255. }),
  256. )
  257. })
  258. describe("spawn (inherited)", () => {
  259. it.live(
  260. "returns the platform ChildProcessHandle for advanced use",
  261. Effect.scoped(
  262. Effect.gen(function* () {
  263. const svc = yield* AppProcess.Service
  264. const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)"))
  265. expect(yield* handle.isRunning).toBe(true)
  266. yield* handle.kill()
  267. }),
  268. ),
  269. )
  270. })
  271. })