process.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Exit, Stream } from "effect"
  3. import { ChildProcess } from "effect/unstable/process"
  4. import { AppProcess } from "@opencode-ai/core/process"
  5. import { testEffect } from "../lib/effect"
  6. const it = testEffect(AppProcess.defaultLayer)
  7. const NODE = process.execPath
  8. const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
  9. describe("AppProcess", () => {
  10. describe("run", () => {
  11. it.effect(
  12. "captures stdout and exit code zero",
  13. Effect.gen(function* () {
  14. const svc = yield* AppProcess.Service
  15. const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
  16. expect(result.exitCode).toBe(0)
  17. expect(result.stdout.toString("utf8")).toBe("hi\n")
  18. expect(result.truncated).toBe(false)
  19. }),
  20. )
  21. it.effect(
  22. "non-zero exit returns RunResult; caller can require success",
  23. Effect.gen(function* () {
  24. const svc = yield* AppProcess.Service
  25. const result = yield* svc.run(cmd("-e", "process.exit(1)"))
  26. expect(result.exitCode).toBe(1)
  27. }),
  28. )
  29. it.effect(
  30. "requireSuccess fails on non-zero exit",
  31. Effect.gen(function* () {
  32. const svc = yield* AppProcess.Service
  33. const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)))
  34. expect(Exit.isFailure(exit)).toBe(true)
  35. if (Exit.isFailure(exit)) {
  36. const reason = exit.cause.reasons[0]
  37. if (reason && reason._tag === "Fail") {
  38. expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
  39. expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
  40. } else {
  41. throw new Error("expected fail reason")
  42. }
  43. }
  44. }),
  45. )
  46. it.effect(
  47. "requireSuccess succeeds on exit 0",
  48. Effect.gen(function* () {
  49. const svc = yield* AppProcess.Service
  50. const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess))
  51. expect(result.exitCode).toBe(0)
  52. }),
  53. )
  54. it.effect(
  55. "requireExitIn allowlists multiple exit codes",
  56. Effect.gen(function* () {
  57. const svc = yield* AppProcess.Service
  58. const requireZeroOrOne = AppProcess.requireExitIn([0, 1])
  59. const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne))
  60. expect(okZero.exitCode).toBe(0)
  61. const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
  62. expect(okOne.exitCode).toBe(1)
  63. const exit = yield* Effect.exit(
  64. svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)),
  65. )
  66. expect(Exit.isFailure(exit)).toBe(true)
  67. if (Exit.isFailure(exit)) {
  68. const reason = exit.cause.reasons[0]
  69. if (reason && reason._tag === "Fail") {
  70. expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
  71. expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
  72. }
  73. }
  74. }),
  75. )
  76. it.effect(
  77. "truncates output when maxOutputBytes is set",
  78. Effect.gen(function* () {
  79. const svc = yield* AppProcess.Service
  80. const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
  81. expect(result.exitCode).toBe(0)
  82. expect(result.truncated).toBe(true)
  83. expect(result.stdout.length).toBe(5)
  84. expect(result.stdout.toString("utf8")).toBe("01234")
  85. }),
  86. )
  87. it.effect(
  88. "result includes command description",
  89. Effect.gen(function* () {
  90. const svc = yield* AppProcess.Service
  91. const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')"))
  92. expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
  93. }),
  94. )
  95. })
  96. describe("inherited platform methods", () => {
  97. it.effect(
  98. "string returns stdout as string",
  99. Effect.gen(function* () {
  100. const svc = yield* AppProcess.Service
  101. const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')"))
  102. expect(out).toBe("hi\n")
  103. }),
  104. )
  105. it.effect(
  106. "lines returns the platform's array of lines",
  107. Effect.gen(function* () {
  108. const svc = yield* AppProcess.Service
  109. const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')"))
  110. expect(Array.from(out)).toEqual(["a", "b"])
  111. }),
  112. )
  113. })
  114. describe("runStream", () => {
  115. it.live(
  116. "emits lines incrementally and ends cleanly on exit 0",
  117. Effect.gen(function* () {
  118. const svc = yield* AppProcess.Service
  119. const result = yield* svc
  120. .runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')"))
  121. .pipe(Stream.runCollect)
  122. expect(Array.from(result)).toEqual(["one", "two", "three"])
  123. }),
  124. )
  125. it.live(
  126. "fails with AppProcessError when exit not in okExitCodes",
  127. Effect.gen(function* () {
  128. const svc = yield* AppProcess.Service
  129. const exit = yield* Effect.exit(
  130. svc
  131. .runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] })
  132. .pipe(Stream.runCollect),
  133. )
  134. expect(Exit.isFailure(exit)).toBe(true)
  135. if (Exit.isFailure(exit)) {
  136. const reason = exit.cause.reasons[0]
  137. if (reason && reason._tag === "Fail") {
  138. expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
  139. }
  140. }
  141. }),
  142. )
  143. it.live(
  144. "okExitCodes allowlist treats non-zero as success",
  145. Effect.gen(function* () {
  146. const svc = yield* AppProcess.Service
  147. const result = yield* svc
  148. .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
  149. .pipe(Stream.runCollect)
  150. expect(Array.from(result)).toEqual(["only"])
  151. }),
  152. )
  153. it.live(
  154. "without okExitCodes, never fails on exit code",
  155. Effect.gen(function* () {
  156. const svc = yield* AppProcess.Service
  157. const result = yield* svc
  158. .runStream(cmd("-e", "console.log('only'); process.exit(7)"))
  159. .pipe(Stream.runCollect)
  160. expect(Array.from(result)).toEqual(["only"])
  161. }),
  162. )
  163. it.live(
  164. "AbortSignal interrupts the stream",
  165. Effect.gen(function* () {
  166. const svc = yield* AppProcess.Service
  167. const controller = new AbortController()
  168. setTimeout(() => controller.abort(), 50)
  169. const exit = yield* Effect.exit(
  170. svc
  171. .runStream(cmd("-e", "setInterval(() => console.log('tick'), 100); setTimeout(() => {}, 60_000)"), {
  172. signal: controller.signal,
  173. })
  174. .pipe(Stream.runCollect),
  175. )
  176. expect(Exit.isFailure(exit)).toBe(true)
  177. }),
  178. )
  179. })
  180. describe("spawn (inherited)", () => {
  181. it.live(
  182. "returns the platform ChildProcessHandle for advanced use",
  183. Effect.scoped(
  184. Effect.gen(function* () {
  185. const svc = yield* AppProcess.Service
  186. const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)"))
  187. expect(yield* handle.isRunning).toBe(true)
  188. yield* handle.kill()
  189. }),
  190. ),
  191. )
  192. })
  193. })