httpapi-json-parity.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { Flag } from "@opencode-ai/core/flag/flag"
  4. import { ModelID, ProviderID } from "../../src/provider/schema"
  5. import { Instance } from "../../src/project/instance"
  6. import { Server } from "../../src/server/server"
  7. import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
  8. import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
  9. import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
  10. import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
  11. import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
  12. import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
  13. import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
  14. import { MessageID, PartID } from "../../src/session/schema"
  15. import { Session } from "@/session/session"
  16. import * as Log from "@opencode-ai/core/util/log"
  17. import { resetDatabase } from "../fixture/db"
  18. import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
  19. import { it } from "../lib/effect"
  20. void Log.init({ print: false })
  21. const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
  22. function app(experimental: boolean) {
  23. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
  24. return experimental ? Server.Default().app : Server.Legacy().app
  25. }
  26. type TestApp = ReturnType<typeof app>
  27. function pathFor(path: string, params: Record<string, string>) {
  28. return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
  29. }
  30. const seedSessions = Effect.gen(function* () {
  31. const svc = yield* Session.Service
  32. const parent = yield* svc.create({ title: "parent" })
  33. yield* svc.create({ title: "child", parentID: parent.id })
  34. const message = yield* svc.updateMessage({
  35. id: MessageID.ascending(),
  36. role: "user",
  37. sessionID: parent.id,
  38. agent: "build",
  39. model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
  40. time: { created: Date.now() },
  41. })
  42. yield* svc.updatePart({
  43. id: PartID.ascending(),
  44. sessionID: parent.id,
  45. messageID: message.id,
  46. type: "text",
  47. text: "hello",
  48. })
  49. return { parent, message }
  50. })
  51. function withTmp<A, E, R>(
  52. options: Parameters<typeof tmpdir>[0],
  53. fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
  54. ) {
  55. return Effect.acquireRelease(
  56. Effect.promise(() => tmpdir(options)),
  57. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  58. ).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path))))
  59. }
  60. function readJson(label: string, serverApp: TestApp, path: string, headers: HeadersInit) {
  61. return Effect.promise(async () => {
  62. const response = await serverApp.request(path, { headers })
  63. if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`)
  64. return await response.json()
  65. })
  66. }
  67. function expectJsonParity(input: {
  68. label: string
  69. legacy: TestApp
  70. httpapi: TestApp
  71. path: string
  72. headers: HeadersInit
  73. }) {
  74. return Effect.gen(function* () {
  75. const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers)
  76. const httpapi = yield* readJson(input.label, input.httpapi, input.path, input.headers)
  77. expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy })
  78. return httpapi
  79. })
  80. }
  81. afterEach(async () => {
  82. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
  83. await disposeAllInstances()
  84. await resetDatabase()
  85. })
  86. describe("HttpApi JSON parity", () => {
  87. it.live(
  88. "matches legacy JSON shape for safe GET endpoints",
  89. withTmp(
  90. {
  91. git: true,
  92. config: {
  93. formatter: false,
  94. lsp: false,
  95. mcp: {
  96. demo: {
  97. type: "local",
  98. command: ["echo", "demo"],
  99. enabled: false,
  100. },
  101. },
  102. },
  103. },
  104. (tmp) =>
  105. Effect.gen(function* () {
  106. yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n"))
  107. const headers = { "x-opencode-directory": tmp.path }
  108. const legacy = app(false)
  109. const httpapi = app(true)
  110. yield* Effect.forEach(
  111. [
  112. { label: "global.health", path: GlobalPaths.health, headers: {} },
  113. { label: "global.config", path: GlobalPaths.config, headers: {} },
  114. { label: "instance.path", path: InstancePaths.path, headers },
  115. { label: "instance.vcs", path: InstancePaths.vcs, headers },
  116. { label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers },
  117. { label: "instance.command", path: InstancePaths.command, headers },
  118. { label: "instance.agent", path: InstancePaths.agent, headers },
  119. { label: "instance.skill", path: InstancePaths.skill, headers },
  120. { label: "instance.lsp", path: InstancePaths.lsp, headers },
  121. { label: "instance.formatter", path: InstancePaths.formatter, headers },
  122. { label: "config.get", path: "/config", headers },
  123. { label: "config.providers", path: "/config/providers", headers },
  124. { label: "project.list", path: "/project", headers },
  125. { label: "project.current", path: "/project/current", headers },
  126. { label: "provider.list", path: "/provider", headers },
  127. { label: "provider.auth", path: "/provider/auth", headers },
  128. { label: "permission.list", path: "/permission", headers },
  129. { label: "question.list", path: "/question", headers },
  130. { label: "mcp.status", path: McpPaths.status, headers },
  131. { label: "pty.shells", path: PtyPaths.shells, headers },
  132. { label: "pty.list", path: PtyPaths.list, headers },
  133. { label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers },
  134. {
  135. label: "file.content",
  136. path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`,
  137. headers,
  138. },
  139. { label: "file.status", path: FilePaths.status, headers },
  140. {
  141. label: "find.file",
  142. path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`,
  143. headers,
  144. },
  145. {
  146. label: "find.text",
  147. path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`,
  148. headers,
  149. },
  150. {
  151. label: "find.symbol",
  152. path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`,
  153. headers,
  154. },
  155. { label: "experimental.console", path: ExperimentalPaths.console, headers },
  156. { label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers },
  157. { label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers },
  158. { label: "experimental.worktree", path: ExperimentalPaths.worktree, headers },
  159. { label: "experimental.resource", path: ExperimentalPaths.resource, headers },
  160. ],
  161. (input) => expectJsonParity({ ...input, legacy, httpapi }),
  162. { concurrency: 1 },
  163. )
  164. }),
  165. ),
  166. )
  167. it.live(
  168. "matches legacy JSON shape for session read endpoints",
  169. withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
  170. Effect.gen(function* () {
  171. const headers = { "x-opencode-directory": tmp.path }
  172. const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer))
  173. const legacy = app(false)
  174. const httpapi = app(true)
  175. const rootsFalse = yield* expectJsonParity({
  176. label: "session.list roots false",
  177. legacy,
  178. httpapi,
  179. path: `${SessionPaths.list}?roots=false`,
  180. headers,
  181. })
  182. expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id)
  183. expect((rootsFalse as Session.Info[]).length).toBe(2)
  184. const experimentalRootsFalse = yield* expectJsonParity({
  185. label: "experimental.session roots false",
  186. legacy,
  187. httpapi,
  188. path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`,
  189. headers,
  190. })
  191. expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2)
  192. const experimentalArchivedFalse = yield* expectJsonParity({
  193. label: "experimental.session archived false",
  194. legacy,
  195. httpapi,
  196. path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", archived: "false" })}`,
  197. headers,
  198. })
  199. expect((experimentalArchivedFalse as Session.GlobalInfo[]).length).toBe(2)
  200. yield* Effect.forEach(
  201. [
  202. { label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers },
  203. { label: "session.list all", path: SessionPaths.list, headers },
  204. { label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers },
  205. {
  206. label: "session.children",
  207. path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }),
  208. headers,
  209. },
  210. {
  211. label: "session.messages",
  212. path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }),
  213. headers,
  214. },
  215. {
  216. label: "session.messages empty before",
  217. path: `${pathFor(SessionPaths.messages, { sessionID: seeded.parent.id })}?before=`,
  218. headers,
  219. },
  220. {
  221. label: "session.message",
  222. path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }),
  223. headers,
  224. },
  225. {
  226. label: "experimental.session",
  227. path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`,
  228. headers,
  229. },
  230. ],
  231. (input) => expectJsonParity({ ...input, legacy, httpapi }),
  232. { concurrency: 1 },
  233. )
  234. }),
  235. ),
  236. )
  237. })