httpapi-pty.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import { NodeHttpServer, NodeServices } from "@effect/platform-node"
  3. import { Flag } from "@opencode-ai/core/flag/flag"
  4. import { PtyID } from "../../src/pty/schema"
  5. import { Instance } from "../../src/project/instance"
  6. import { Server } from "../../src/server/server"
  7. import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
  8. import * as Log from "@opencode-ai/core/util/log"
  9. import { resetDatabase } from "../fixture/db"
  10. import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture"
  11. import { Config, Effect, Layer, Queue, Schema } from "effect"
  12. import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
  13. import * as Socket from "effect/unstable/socket/Socket"
  14. import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
  15. import { Pty } from "../../src/pty"
  16. import { testEffect } from "../lib/effect"
  17. void Log.init({ print: false })
  18. const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
  19. const testPty = process.platform === "win32" ? test.skip : test
  20. const testStateLayer = Layer.effectDiscard(
  21. Effect.gen(function* () {
  22. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
  23. yield* Effect.promise(() => resetDatabase())
  24. yield* Effect.addFinalizer(() =>
  25. Effect.promise(async () => {
  26. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
  27. await resetDatabase()
  28. }),
  29. )
  30. }),
  31. )
  32. const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
  33. ExperimentalHttpApiServer.routes,
  34. { disableListenLog: true, disableLogger: true },
  35. )
  36. const effectIt = testEffect(
  37. Layer.mergeAll(
  38. testStateLayer,
  39. Socket.layerWebSocketConstructorGlobal,
  40. servedRoutes.pipe(
  41. Layer.provide(Socket.layerWebSocketConstructorGlobal),
  42. Layer.provideMerge(NodeHttpServer.layerTest),
  43. Layer.provideMerge(NodeServices.layer),
  44. ),
  45. ),
  46. )
  47. function app() {
  48. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
  49. return Server.Default().app
  50. }
  51. function serverUrl() {
  52. return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
  53. }
  54. const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
  55. afterEach(async () => {
  56. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
  57. await disposeAllInstances()
  58. await resetDatabase()
  59. })
  60. describe("pty HttpApi bridge", () => {
  61. test("serves available shell list through experimental Effect routes", async () => {
  62. await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
  63. const response = await app().request(PtyPaths.shells, { headers: { "x-opencode-directory": tmp.path } })
  64. expect(response.status).toBe(200)
  65. expect(await response.json()).toEqual(
  66. expect.arrayContaining([
  67. expect.objectContaining({
  68. path: expect.any(String),
  69. name: expect.any(String),
  70. acceptable: expect.any(Boolean),
  71. }),
  72. ]),
  73. )
  74. })
  75. testPty("serves PTY JSON routes through experimental Effect routes", async () => {
  76. await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
  77. const headers = { "x-opencode-directory": tmp.path }
  78. const list = await app().request(PtyPaths.list, { headers })
  79. expect(list.status).toBe(200)
  80. expect(await list.json()).toEqual([])
  81. const created = await app().request(PtyPaths.create, {
  82. method: "POST",
  83. headers: { ...headers, "content-type": "application/json" },
  84. body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"], title: "demo" }),
  85. })
  86. expect(created.status).toBe(200)
  87. const info = await created.json()
  88. try {
  89. expect(info).toMatchObject({ title: "demo", command: "/usr/bin/env", status: "running" })
  90. const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
  91. expect(found.status).toBe(200)
  92. expect(await found.json()).toMatchObject({ id: info.id, title: "demo" })
  93. const updated = await app().request(PtyPaths.update.replace(":ptyID", info.id), {
  94. method: "PUT",
  95. headers: { ...headers, "content-type": "application/json" },
  96. body: JSON.stringify({ title: "renamed", size: { cols: 80, rows: 24 } }),
  97. })
  98. expect(updated.status).toBe(200)
  99. expect(await updated.json()).toMatchObject({ id: info.id, title: "renamed" })
  100. } finally {
  101. await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers })
  102. }
  103. const missing = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
  104. expect(missing.status).toBe(404)
  105. })
  106. test("returns 404 for missing PTY websocket before upgrade", async () => {
  107. await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
  108. const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {
  109. headers: { "x-opencode-directory": tmp.path },
  110. })
  111. expect(response.status).toBe(404)
  112. })
  113. ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
  114. "serves PTY websocket output and input through Effect routes",
  115. () =>
  116. Effect.gen(function* () {
  117. const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
  118. const created = yield* HttpClientRequest.post(PtyPaths.create).pipe(
  119. directoryHeader(dir),
  120. HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }),
  121. Effect.flatMap(HttpClient.execute),
  122. )
  123. expect(created.status).toBe(200)
  124. const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json)
  125. const socket = yield* Socket.makeWebSocket(
  126. `${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`,
  127. { closeCodeIsError: () => false },
  128. )
  129. const messages = yield* Queue.unbounded<string>()
  130. yield* socket
  131. .runRaw((message) =>
  132. Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
  133. )
  134. .pipe(Effect.catch(() => Effect.void))
  135. .pipe(Effect.forkScoped)
  136. const write = yield* socket.writer
  137. const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
  138. Effect.gen(function* () {
  139. const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
  140. if (next.includes(expected)) return next
  141. return yield* takeUntil(expected, next)
  142. })
  143. yield* write("ping-route\n")
  144. expect(yield* takeUntil("ping-route")).toContain("ping-route")
  145. yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
  146. const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe(
  147. directoryHeader(dir),
  148. HttpClient.execute,
  149. )
  150. expect(removed.status).toBe(200)
  151. }),
  152. )
  153. })