httpapi-experimental.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { Deferred, Effect, Fiber, Layer } from "effect"
  3. import { HttpClient, HttpClientResponse } from "effect/unstable/http"
  4. import { eq } from "drizzle-orm"
  5. import { GlobalBus, type GlobalEvent } from "@/bus/global"
  6. import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
  7. import { Session } from "@/session/session"
  8. import { SessionTable } from "@opencode-ai/core/session/sql"
  9. import { Database } from "@opencode-ai/core/database/database"
  10. import { AccountV2 } from "@opencode-ai/core/account"
  11. import { AccountTable } from "@opencode-ai/core/account/sql"
  12. import * as Log from "@opencode-ai/core/util/log"
  13. import { Worktree } from "../../src/worktree"
  14. import { resetDatabase } from "../fixture/db"
  15. import { disposeAllInstances, TestInstance } from "../fixture/fixture"
  16. import { testEffect } from "../lib/effect"
  17. import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
  18. void Log.init({ print: false })
  19. const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
  20. const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance
  21. function request(path: string, directory: string, init: RequestInit = {}) {
  22. return requestInDirectory(path, directory, init)
  23. }
  24. function createSession(input?: Session.CreateInput) {
  25. return Session.use.create(input)
  26. }
  27. function json<T>(response: HttpClientResponse.HttpClientResponse) {
  28. return response.json.pipe(Effect.map((value) => value as T))
  29. }
  30. function waitReady(input: { directory?: string; name?: string }) {
  31. return Effect.gen(function* () {
  32. const ready = yield* Deferred.make<void>()
  33. const on = (event: GlobalEvent) => {
  34. if (event.payload.type !== Worktree.Event.Ready.type) return
  35. if (input.directory && event.directory !== input.directory) return
  36. if (input.name && event.payload.properties.name !== input.name) return
  37. Deferred.doneUnsafe(ready, Effect.void)
  38. }
  39. GlobalBus.on("event", on)
  40. yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
  41. return yield* Deferred.await(ready).pipe(
  42. Effect.timeoutOrElse({
  43. duration: "10 seconds",
  44. orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")),
  45. }),
  46. )
  47. })
  48. }
  49. function insertAccount() {
  50. return Effect.acquireRelease(
  51. Effect.gen(function* () {
  52. const { db } = yield* Database.Service
  53. yield* db
  54. .insert(AccountTable)
  55. .values({
  56. id: AccountV2.ID.make("account-test"),
  57. email: "test@example.com",
  58. url: "https://console.example.com",
  59. access_token: AccountV2.AccessToken.make("access"),
  60. refresh_token: AccountV2.RefreshToken.make("refresh"),
  61. time_created: Date.now(),
  62. time_updated: Date.now(),
  63. })
  64. .run()
  65. .pipe(Effect.orDie)
  66. return "account-test"
  67. }),
  68. (id) =>
  69. Database.Service.use(({ db }) =>
  70. db
  71. .delete(AccountTable)
  72. .where(eq(AccountTable.id, AccountV2.ID.make(id)))
  73. .run()
  74. .pipe(Effect.orDie),
  75. ),
  76. )
  77. }
  78. function setSessionUpdated(session: Session.Info, updated: number) {
  79. return Effect.gen(function* () {
  80. const { db } = yield* Database.Service
  81. yield* db
  82. .update(SessionTable)
  83. .set({ time_updated: updated })
  84. .where(eq(SessionTable.id, session.id))
  85. .run()
  86. .pipe(Effect.orDie)
  87. })
  88. }
  89. function withCreatedWorktree(
  90. directory: string,
  91. use: (info: Worktree.Info) => Effect.Effect<void, unknown, HttpClient.HttpClient>,
  92. ) {
  93. const name = "api-test"
  94. const headers = { "content-type": "application/json" }
  95. return Effect.acquireUseRelease(
  96. Effect.gen(function* () {
  97. const ready = yield* waitReady({ name }).pipe(Effect.forkScoped)
  98. const created = yield* request(ExperimentalPaths.worktree, directory, {
  99. method: "POST",
  100. headers,
  101. body: JSON.stringify({ name }),
  102. })
  103. expect(created.status).toBe(200)
  104. const info = yield* json<Worktree.Info>(created)
  105. expect(info).toMatchObject({ name, branch: "opencode/api-test" })
  106. yield* Fiber.join(ready)
  107. return info
  108. }),
  109. use,
  110. (info) =>
  111. Effect.gen(function* () {
  112. const removed = yield* request(ExperimentalPaths.worktree, directory, {
  113. method: "DELETE",
  114. headers,
  115. body: JSON.stringify({ directory: info.directory }),
  116. })
  117. if (removed.status !== 200) return yield* Effect.fail(new Error(`failed to remove worktree: ${removed.status}`))
  118. const ok = yield* json<boolean>(removed)
  119. if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${info.directory}`))
  120. }),
  121. )
  122. }
  123. afterEach(async () => {
  124. await disposeAllInstances()
  125. await resetDatabase()
  126. })
  127. describe("experimental HttpApi", () => {
  128. it.instance(
  129. "serves read-only experimental endpoints through the default server app",
  130. () =>
  131. Effect.gen(function* () {
  132. const tmp = yield* TestInstance
  133. const directory = tmp.directory
  134. const [consoleState, consoleOrgs, toolList, toolIDs, worktrees, resources] = yield* Effect.all(
  135. [
  136. request(ExperimentalPaths.console, directory),
  137. request(ExperimentalPaths.consoleOrgs, directory),
  138. request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, directory),
  139. request(ExperimentalPaths.toolIDs, directory),
  140. request(ExperimentalPaths.worktree, directory),
  141. request(ExperimentalPaths.resource, directory),
  142. ],
  143. { concurrency: "unbounded" },
  144. )
  145. expect(consoleState.status).toBe(200)
  146. expect(yield* json(consoleState)).toEqual({
  147. consoleManagedProviders: [],
  148. switchableOrgCount: 0,
  149. })
  150. expect(consoleOrgs.status).toBe(200)
  151. expect(yield* json(consoleOrgs)).toEqual({ orgs: [] })
  152. expect(toolList.status).toBe(200)
  153. expect(yield* json<unknown[]>(toolList)).toContainEqual(
  154. expect.objectContaining({
  155. id: "bash",
  156. description: expect.any(String),
  157. parameters: expect.any(Object),
  158. }),
  159. )
  160. expect(toolIDs.status).toBe(200)
  161. expect(yield* json(toolIDs)).toContain("bash")
  162. expect(worktrees.status).toBe(200)
  163. expect(yield* json(worktrees)).toEqual([])
  164. expect(resources.status).toBe(200)
  165. expect(yield* json(resources)).toEqual({})
  166. }),
  167. {
  168. config: {
  169. formatter: false,
  170. lsp: false,
  171. mcp: {
  172. demo: {
  173. type: "local",
  174. command: ["echo", "demo"],
  175. enabled: false,
  176. },
  177. },
  178. },
  179. },
  180. )
  181. it.instance("returns declared worktree errors", () =>
  182. Effect.gen(function* () {
  183. const tmp = yield* TestInstance
  184. const response = yield* request(ExperimentalPaths.worktree, tmp.directory, {
  185. method: "POST",
  186. headers: { "content-type": "application/json" },
  187. body: JSON.stringify({}),
  188. })
  189. expect(response.status).toBe(400)
  190. expect(yield* json(response)).toEqual({
  191. name: "WorktreeNotGitError",
  192. data: { message: "Worktrees are only supported for git projects" },
  193. })
  194. }),
  195. )
  196. it.instance(
  197. "serves Console org switch through the default server app",
  198. () =>
  199. Effect.gen(function* () {
  200. const tmp = yield* TestInstance
  201. const accountID = yield* insertAccount()
  202. const switched = yield* request(ExperimentalPaths.consoleSwitch, tmp.directory, {
  203. method: "POST",
  204. headers: { "content-type": "application/json" },
  205. body: JSON.stringify({ accountID, orgID: "org-test" }),
  206. })
  207. expect(switched.status).toBe(200)
  208. expect(yield* json(switched)).toBe(true)
  209. }),
  210. { config: { formatter: false, lsp: false } },
  211. )
  212. it.instance(
  213. "serves global session list through the default server app",
  214. () =>
  215. Effect.gen(function* () {
  216. const tmp = yield* TestInstance
  217. const first = yield* createSession({ title: "page-one" })
  218. const second = yield* createSession({ title: "page-two" })
  219. yield* setSessionUpdated(first, 1)
  220. yield* setSessionUpdated(second, 2)
  221. const page = yield* request(
  222. `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.directory, limit: "1" })}`,
  223. tmp.directory,
  224. )
  225. expect(page.status).toBe(200)
  226. expect(page.headers["x-next-cursor"]).toBeTruthy()
  227. const body = yield* json<Session.GlobalInfo[]>(page)
  228. expect(body.map((session) => session.id)).toEqual([second.id])
  229. expect(body[0].project?.id).toBe(second.projectID)
  230. const next = yield* request(
  231. `${ExperimentalPaths.session}?${new URLSearchParams({
  232. directory: tmp.directory,
  233. limit: "10",
  234. cursor: body[0].time.updated.toString(),
  235. })}`,
  236. tmp.directory,
  237. )
  238. expect(next.status).toBe(200)
  239. expect((yield* json<Session.GlobalInfo[]>(next)).map((session) => session.id)).toContain(first.id)
  240. }),
  241. { git: true, config: { formatter: false, lsp: false } },
  242. )
  243. testWorktreeMutations(
  244. "serves worktree mutations through the default server app",
  245. () =>
  246. Effect.gen(function* () {
  247. const tmp = yield* TestInstance
  248. yield* withCreatedWorktree(tmp.directory, (info) =>
  249. Effect.gen(function* () {
  250. const listed = yield* request(ExperimentalPaths.worktree, tmp.directory)
  251. expect(listed.status).toBe(200)
  252. expect(yield* json(listed)).toContain(info.directory)
  253. const reset = yield* request(ExperimentalPaths.worktreeReset, tmp.directory, {
  254. method: "POST",
  255. headers: { "content-type": "application/json" },
  256. body: JSON.stringify({ directory: info.directory }),
  257. })
  258. expect(reset.status).toBe(200)
  259. expect(yield* json(reset)).toBe(true)
  260. }),
  261. )
  262. const afterRemove = yield* request(ExperimentalPaths.worktree, tmp.directory)
  263. expect(afterRemove.status).toBe(200)
  264. expect(yield* json(afterRemove)).toEqual([])
  265. }),
  266. { git: true, config: { formatter: false, lsp: false } },
  267. )
  268. })