process.test.ts 9.9 KB

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