pty-session.test.ts 3.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. import { describe, expect } from "bun:test"
  2. import { Cause, Effect, Exit, Layer, Queue } from "effect"
  3. import { EventV2 } from "@opencode-ai/core/event"
  4. import { Location } from "@opencode-ai/core/location"
  5. import { Pty } from "@opencode-ai/core/pty"
  6. import type { PtyID } from "@opencode-ai/core/pty/schema"
  7. import { AbsolutePath } from "@opencode-ai/core/schema"
  8. import { location } from "../fixture/location"
  9. import { testEffect } from "../lib/effect"
  10. type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
  11. const locationLayer = Layer.succeed(
  12. Location.Service,
  13. Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
  14. )
  15. const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
  16. const ptyTest = process.platform === "win32" ? it.live.skip : it.live
  17. const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
  18. const source = yield* EventV2.Service
  19. const events = yield* Queue.unbounded<PtyEvent>()
  20. const unsubscribe = yield* source.listen((event) => {
  21. if (event.type === Pty.Event.Created.type)
  22. Queue.offerUnsafe(events, { type: "created", id: (event.data as typeof Pty.Event.Created.data.Type).info.id })
  23. if (event.type === Pty.Event.Exited.type)
  24. Queue.offerUnsafe(events, { type: "exited", id: (event.data as typeof Pty.Event.Exited.data.Type).id })
  25. if (event.type === Pty.Event.Deleted.type)
  26. Queue.offerUnsafe(events, { type: "deleted", id: (event.data as typeof Pty.Event.Deleted.data.Type).id })
  27. return Effect.void
  28. })
  29. yield* Effect.addFinalizer(() => unsubscribe)
  30. return events
  31. })
  32. const createPty = Effect.fn("PtySessionTest.createPty")(function* (command: string, args: string[] = []) {
  33. const pty = yield* Pty.Service
  34. return yield* Effect.acquireRelease(
  35. pty.create({ command, args, cwd: "/tmp", env: { TERM: "xterm-256color", OPENCODE_TERMINAL: "1" } }),
  36. (info) => pty.remove(info.id).pipe(Effect.ignore),
  37. )
  38. })
  39. const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number) =>
  40. Effect.gen(function* () {
  41. const picked: Array<PtyEvent["type"]> = []
  42. while (picked.length < count) {
  43. const evt = yield* Queue.take(events)
  44. if (evt.id === id) picked.push(evt.type)
  45. }
  46. return picked
  47. }).pipe(
  48. Effect.timeoutOrElse({
  49. duration: "5 seconds",
  50. orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
  51. }),
  52. )
  53. describe("pty", () => {
  54. it.live("returns typed not found errors for missing sessions", () =>
  55. Effect.gen(function* () {
  56. const pty = yield* Pty.Service
  57. const id = "pty_missing" as PtyID
  58. let closed = false
  59. const socket = { readyState: 1, send: () => {}, close: () => void (closed = true) }
  60. for (const result of [
  61. yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
  62. yield* pty.update(id, { title: "missing" }).pipe(Effect.asVoid, Effect.exit),
  63. yield* pty.remove(id).pipe(Effect.exit),
  64. yield* pty.resize(id, 80, 24).pipe(Effect.exit),
  65. yield* pty.write(id, "input").pipe(Effect.exit),
  66. yield* pty.connect(id, socket).pipe(Effect.asVoid, Effect.exit),
  67. ]) {
  68. expect(Exit.isFailure(result)).toBe(true)
  69. if (Exit.isFailure(result))
  70. expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
  71. }
  72. expect(closed).toBe(true)
  73. }),
  74. )
  75. ptyTest("publishes created, exited, deleted in order for a short-lived process", () =>
  76. Effect.gen(function* () {
  77. const events = yield* subscribePtyEvents()
  78. const info = yield* createPty("/usr/bin/env", ["sh", "-c", "sleep 0.1"])
  79. expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"])
  80. }),
  81. )
  82. })