pty-session.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { describe, expect } from "bun:test"
  2. import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
  3. import { Config } from "@opencode-ai/core/config"
  4. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  5. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  6. import { EventV2 } from "@opencode-ai/core/event"
  7. import { Location } from "@opencode-ai/core/location"
  8. import { Pty } from "@opencode-ai/core/pty"
  9. import type { PtyID } from "@opencode-ai/core/pty/schema"
  10. import { AbsolutePath } from "@opencode-ai/core/schema"
  11. import { location } from "../fixture/location"
  12. import { testEffect } from "../lib/effect"
  13. type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
  14. const locationLayer = Layer.succeed(
  15. Location.Service,
  16. Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
  17. )
  18. const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
  19. const it = testEffect(
  20. AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
  21. [Config.node, configLayer],
  22. [Location.node, locationLayer],
  23. ]),
  24. )
  25. const ptyTest = process.platform === "win32" ? it.live.skip : it.live
  26. const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
  27. const source = yield* EventV2.Service
  28. const events = yield* Queue.unbounded<PtyEvent>()
  29. const unsubscribe = yield* source.listen((event) => {
  30. if (event.type === Pty.Event.Created.type)
  31. Queue.offerUnsafe(events, { type: "created", id: (event.data as typeof Pty.Event.Created.data.Type).info.id })
  32. if (event.type === Pty.Event.Exited.type)
  33. Queue.offerUnsafe(events, { type: "exited", id: (event.data as typeof Pty.Event.Exited.data.Type).id })
  34. if (event.type === Pty.Event.Deleted.type)
  35. Queue.offerUnsafe(events, { type: "deleted", id: (event.data as typeof Pty.Event.Deleted.data.Type).id })
  36. return Effect.void
  37. })
  38. yield* Effect.addFinalizer(() => unsubscribe)
  39. return events
  40. })
  41. const createPty = Effect.fn("PtySessionTest.createPty")(function* (command: string, args: string[] = []) {
  42. const pty = yield* Pty.Service
  43. return yield* Effect.acquireRelease(
  44. pty.create({ command, args, cwd: "/tmp", env: { TERM: "xterm-256color", OPENCODE_TERMINAL: "1" } }),
  45. (info) => pty.remove(info.id).pipe(Effect.ignore),
  46. )
  47. })
  48. const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number) =>
  49. Effect.gen(function* () {
  50. const picked: Array<PtyEvent["type"]> = []
  51. while (picked.length < count) {
  52. const evt = yield* Queue.take(events)
  53. if (evt.id === id) picked.push(evt.type)
  54. }
  55. return picked
  56. }).pipe(
  57. Effect.timeoutOrElse({
  58. duration: "5 seconds",
  59. orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
  60. }),
  61. )
  62. const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: PtyID, cursor?: number) {
  63. const pty = yield* Pty.Service
  64. const output = yield* Queue.unbounded<string>()
  65. const ended = yield* Deferred.make<{ exitCode?: number }>()
  66. const attachment = yield* pty.attach(id, {
  67. cursor,
  68. onData: (chunk) => Queue.offerUnsafe(output, chunk),
  69. onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
  70. })
  71. attachment.activate()
  72. return { attachment, output, ended }
  73. })
  74. const waitForOutput = (output: Queue.Queue<string>, text: string) =>
  75. Effect.gen(function* () {
  76. let received = ""
  77. while (!received.includes(text)) received += yield* Queue.take(output)
  78. return received
  79. }).pipe(
  80. Effect.timeoutOrElse({
  81. duration: "5 seconds",
  82. orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
  83. }),
  84. )
  85. describe("Pty.shells", () => {
  86. it.live("lists available shells", () =>
  87. Effect.gen(function* () {
  88. const pty = yield* Pty.Service
  89. const shells = yield* pty.shells()
  90. expect(shells.length).toBeGreaterThan(0)
  91. expect(shells.every((shell) => shell.path && shell.name && typeof shell.acceptable === "boolean")).toBe(true)
  92. }),
  93. )
  94. })
  95. describe("pty", () => {
  96. it.live("returns typed not found errors for missing sessions", () =>
  97. Effect.gen(function* () {
  98. const pty = yield* Pty.Service
  99. const id = "pty_missing" as PtyID
  100. for (const result of [
  101. yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
  102. yield* pty.update(id, { title: "missing" }).pipe(Effect.asVoid, Effect.exit),
  103. yield* pty.remove(id).pipe(Effect.exit),
  104. yield* pty.write(id, "input").pipe(Effect.exit),
  105. yield* pty.attach(id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.asVoid, Effect.exit),
  106. ]) {
  107. expect(Exit.isFailure(result)).toBe(true)
  108. if (Exit.isFailure(result))
  109. expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
  110. }
  111. }),
  112. )
  113. ptyTest("retains exited sessions until removed", () =>
  114. Effect.gen(function* () {
  115. const pty = yield* Pty.Service
  116. const events = yield* subscribePtyEvents()
  117. const info = yield* createPty("/usr/bin/env", ["sh", "-c", "exit 3"])
  118. expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"])
  119. const exited = yield* pty.get(info.id)
  120. expect(exited.status).toBe("exited")
  121. expect(exited.exitCode).toBe(3)
  122. yield* pty.remove(info.id)
  123. expect(yield* waitForEvents(events, info.id, 1)).toEqual(["deleted"])
  124. const missing = yield* pty.get(info.id).pipe(Effect.exit)
  125. expect(Exit.isFailure(missing)).toBe(true)
  126. }),
  127. )
  128. ptyTest("replays buffered output and streams live output to attachments", () =>
  129. Effect.gen(function* () {
  130. const pty = yield* Pty.Service
  131. const info = yield* createPty("cat")
  132. yield* pty.write(info.id, "AAA\n")
  133. const first = yield* attachCollecting(info.id)
  134. expect(yield* waitForOutput(first.output, "AAA")).toContain("AAA")
  135. first.attachment.write("BBB\n")
  136. yield* waitForOutput(first.output, "BBB")
  137. // A later attachment replays everything already buffered.
  138. const replayed = yield* attachCollecting(info.id)
  139. expect(replayed.attachment.replay).toContain("AAA")
  140. expect(replayed.attachment.replay).toContain("BBB")
  141. expect(replayed.attachment.cursor).toBeGreaterThan(0)
  142. // Tail attachments skip the buffer and only see subsequent output.
  143. const tail = yield* attachCollecting(info.id, -1)
  144. expect(tail.attachment.replay).toBe("")
  145. expect(tail.attachment.cursor).toBe(replayed.attachment.cursor)
  146. }),
  147. )
  148. ptyTest("stops delivering output after detach", () =>
  149. Effect.gen(function* () {
  150. const pty = yield* Pty.Service
  151. const info = yield* createPty("cat")
  152. const attached = yield* attachCollecting(info.id, -1)
  153. attached.attachment.detach()
  154. yield* pty.write(info.id, "AAA\n")
  155. const verify = yield* attachCollecting(info.id)
  156. yield* waitForOutput(verify.output, "AAA")
  157. const leaked = yield* Queue.poll(attached.output)
  158. expect(leaked._tag).toBe("None")
  159. }),
  160. )
  161. ptyTest("isolates output between sessions", () =>
  162. Effect.gen(function* () {
  163. const pty = yield* Pty.Service
  164. const a = yield* createPty("cat")
  165. const b = yield* createPty("cat")
  166. const attachedA = yield* attachCollecting(a.id)
  167. const attachedB = yield* attachCollecting(b.id)
  168. yield* pty.write(a.id, "AAA\n")
  169. yield* waitForOutput(attachedA.output, "AAA")
  170. const leaked = yield* Queue.poll(attachedB.output)
  171. expect(leaked._tag).toBe("None")
  172. }),
  173. )
  174. ptyTest("notifies attachments with the exit code and rejects attach after exit", () =>
  175. Effect.gen(function* () {
  176. const pty = yield* Pty.Service
  177. const events = yield* subscribePtyEvents()
  178. const info = yield* createPty("cat")
  179. const attached = yield* attachCollecting(info.id)
  180. yield* pty.write(info.id, "\u0004")
  181. expect(yield* Deferred.await(attached.ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 0 })
  182. yield* waitForEvents(events, info.id, 2)
  183. const result = yield* pty.attach(info.id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.exit)
  184. expect(Exit.isFailure(result)).toBe(true)
  185. if (Exit.isFailure(result))
  186. expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id })
  187. }),
  188. )
  189. })
  190. const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
  191. const configuredIt = testEffect(
  192. AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
  193. [
  194. Config.node,
  195. Layer.mock(Config.Service)({
  196. entries: () =>
  197. Effect.succeed(
  198. configuredShell
  199. ? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })]
  200. : [],
  201. ),
  202. }),
  203. ],
  204. [Location.node, locationLayer],
  205. ]),
  206. )
  207. const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
  208. describe("pty create defaults", () => {
  209. configuredTest("defaults command, login args, and cwd from config and location", () =>
  210. Effect.gen(function* () {
  211. if (!configuredShell) return
  212. const pty = yield* Pty.Service
  213. const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) =>
  214. pty.remove(created.id).pipe(Effect.ignore),
  215. )
  216. expect(info.command).toBe(configuredShell)
  217. expect(info.args).toEqual(["-l"])
  218. expect(info.cwd).toBe("/tmp")
  219. expect(info.title).toBe("configured")
  220. }),
  221. )
  222. })