httpapi-experimental.test.ts 10 KB

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