worktree-endpoint-repro.test.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer, Queue } from "effect"
  3. import { Flag } from "@kirincode-ai/core/flag/flag"
  4. import { GlobalBus, type GlobalEvent } from "@/bus/global"
  5. import { Worktree } from "@/worktree"
  6. import { Server } from "../../src/server/server"
  7. import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
  8. import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
  9. import { resetDatabase } from "../fixture/db"
  10. import { disposeAllInstances, TestInstance } from "../fixture/fixture"
  11. import { testEffect } from "../lib/effect"
  12. const stateLayer = Layer.effectDiscard(
  13. Effect.gen(function* () {
  14. const original = {
  15. KIRINCODE_EXPERIMENTAL_WORKSPACES: Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES,
  16. }
  17. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
  18. yield* Effect.addFinalizer(() =>
  19. Effect.promise(async () => {
  20. Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = original.KIRINCODE_EXPERIMENTAL_WORKSPACES
  21. await resetDatabase()
  22. }),
  23. )
  24. }),
  25. )
  26. const it = testEffect(stateLayer)
  27. const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance
  28. type TestServer = ReturnType<typeof Server.Default>["app"]
  29. type CreatedWorktree = { directory: string }
  30. type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect<void, Error> }
  31. function serverScoped() {
  32. return Effect.sync(() => Server.Default().app)
  33. }
  34. function request(server: TestServer, input: string, init?: RequestInit) {
  35. return Effect.promise(() => Promise.resolve(server.request(input, init)))
  36. }
  37. function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
  38. return effect.pipe(
  39. Effect.timeoutOrElse({
  40. duration: `${ms} millis`,
  41. orElse: () => Effect.fail(new Error(`${label} timed out after ${ms}ms`)),
  42. }),
  43. )
  44. }
  45. function json<T>(response: Response) {
  46. return Effect.promise(() => response.json() as Promise<T>)
  47. }
  48. function readyWatcher() {
  49. return Effect.gen(function* () {
  50. const events = yield* Queue.bounded<GlobalEvent>(1)
  51. const on = (event: GlobalEvent) => {
  52. if (event.payload.type === Worktree.Event.Ready.type) Queue.offerUnsafe(events, event)
  53. }
  54. GlobalBus.on("event", on)
  55. yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
  56. return (directory: string) =>
  57. Effect.gen(function* () {
  58. while (true) {
  59. const event = yield* Queue.take(events)
  60. if (event.directory === directory) return
  61. }
  62. }).pipe(
  63. Effect.timeoutOrElse({
  64. duration: "10 seconds",
  65. orElse: () => Effect.fail(new Error(`timed out waiting for worktree.ready: ${directory}`)),
  66. }),
  67. )
  68. })
  69. }
  70. function removeCreatedWorktree(input: {
  71. server: TestServer
  72. rootDirectory: string
  73. worktreeDirectory: string
  74. ready: Effect.Effect<void, Error>
  75. }) {
  76. return Effect.gen(function* () {
  77. yield* input.ready.pipe(Effect.timeout("1 second"), Effect.ignore)
  78. yield* Effect.promise(() => disposeAllInstances()).pipe(Effect.ignore)
  79. const removed = yield* request(
  80. input.server,
  81. `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(input.rootDirectory)}`,
  82. {
  83. method: "DELETE",
  84. headers: { "content-type": "application/json" },
  85. body: JSON.stringify({ directory: input.worktreeDirectory }),
  86. },
  87. )
  88. if (removed.status !== 200) {
  89. const message = yield* Effect.promise(() => removed.text())
  90. throw new Error(`failed to remove worktree: ${removed.status} ${message}`)
  91. }
  92. const ok = yield* json<boolean>(removed)
  93. if (!ok) throw new Error(`failed to remove worktree ${input.worktreeDirectory}`)
  94. })
  95. }
  96. function createWorktreeScoped(input: {
  97. server: TestServer
  98. directory: string
  99. path: string
  100. init: RequestInit
  101. timeoutLabel: string
  102. timeoutMs?: number
  103. }) {
  104. return Effect.acquireRelease(
  105. Effect.gen(function* () {
  106. const waitReady = yield* readyWatcher()
  107. const response = yield* withRequestTimeout(
  108. request(input.server, input.path, input.init),
  109. input.timeoutLabel,
  110. input.timeoutMs,
  111. )
  112. if (response.status !== 200) {
  113. const message = yield* Effect.promise(() => response.text())
  114. throw new Error(`${input.timeoutLabel} failed: ${response.status} ${message}`)
  115. }
  116. expect(response.status).toBe(200)
  117. const body = yield* json<CreatedWorktree>(response)
  118. return { directory: body.directory, body, ready: waitReady(body.directory) } satisfies ScopedWorktree
  119. }),
  120. (created) =>
  121. removeCreatedWorktree({
  122. server: input.server,
  123. rootDirectory: input.directory,
  124. worktreeDirectory: created.directory,
  125. ready: created.ready,
  126. }).pipe(Effect.orDie),
  127. ).pipe(Effect.map((created) => created.body))
  128. }
  129. function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
  130. return Effect.gen(function* () {
  131. const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
  132. expect(current.status).toBe(200)
  133. const project = yield* json<{ id: string }>(current)
  134. const updated = yield* request(
  135. input.server,
  136. `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
  137. {
  138. method: "PATCH",
  139. headers: { "content-type": "application/json" },
  140. body: JSON.stringify({ commands: { start: input.command } }),
  141. },
  142. )
  143. expect(updated.status).toBe(200)
  144. })
  145. }
  146. describe("worktree endpoint reproduction", () => {
  147. worktreeTest(
  148. "direct HttpApi worktree create returns without waiting for boot",
  149. () =>
  150. Effect.gen(function* () {
  151. const test = yield* TestInstance
  152. const server = yield* serverScoped()
  153. const response = yield* createWorktreeScoped({
  154. server,
  155. directory: test.directory,
  156. path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
  157. init: {
  158. method: "POST",
  159. headers: { "content-type": "application/json" },
  160. body: JSON.stringify({}),
  161. },
  162. timeoutLabel: "direct worktree create",
  163. })
  164. expect(response).toMatchObject({ directory: expect.any(String) })
  165. }),
  166. { git: true },
  167. )
  168. worktreeTest(
  169. "direct HttpApi worktree create accepts missing body",
  170. () =>
  171. Effect.gen(function* () {
  172. const test = yield* TestInstance
  173. const server = yield* serverScoped()
  174. const response = yield* createWorktreeScoped({
  175. server,
  176. directory: test.directory,
  177. path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
  178. init: { method: "POST", headers: { "content-type": "application/json" } },
  179. timeoutLabel: "direct worktree create without body",
  180. })
  181. expect(response).toMatchObject({ directory: expect.any(String) })
  182. }),
  183. { git: true },
  184. )
  185. worktreeTest(
  186. "direct HttpApi worktree create accepts missing content type and body",
  187. () =>
  188. Effect.gen(function* () {
  189. const test = yield* TestInstance
  190. const server = yield* serverScoped()
  191. const response = yield* createWorktreeScoped({
  192. server,
  193. directory: test.directory,
  194. path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
  195. init: { method: "POST" },
  196. timeoutLabel: "direct worktree create without content type or body",
  197. })
  198. expect(response).toMatchObject({ directory: expect.any(String) })
  199. }),
  200. { git: true },
  201. )
  202. worktreeTest(
  203. "direct HttpApi worktree create rejects explicit null payload",
  204. () =>
  205. Effect.gen(function* () {
  206. const test = yield* TestInstance
  207. const server = yield* serverScoped()
  208. const response = yield* request(
  209. server,
  210. `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
  211. {
  212. method: "POST",
  213. headers: { "content-type": "application/json" },
  214. body: "null",
  215. },
  216. )
  217. expect(response.status).toBe(400)
  218. }),
  219. { git: true },
  220. )
  221. worktreeTest(
  222. "workspace worktree create does not hang",
  223. () =>
  224. Effect.gen(function* () {
  225. const test = yield* TestInstance
  226. const server = yield* serverScoped()
  227. const response = yield* createWorktreeScoped({
  228. server,
  229. directory: test.directory,
  230. path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`,
  231. init: {
  232. method: "POST",
  233. headers: { "content-type": "application/json" },
  234. body: JSON.stringify({ type: "worktree", branch: null }),
  235. },
  236. timeoutLabel: "workspace worktree create",
  237. timeoutMs: 8_000,
  238. })
  239. expect(response).toMatchObject({
  240. type: "worktree",
  241. directory: expect.any(String),
  242. })
  243. }),
  244. { git: true },
  245. )
  246. worktreeTest(
  247. "workspace worktree create returns without waiting for project start command",
  248. () =>
  249. Effect.gen(function* () {
  250. const test = yield* TestInstance
  251. const server = yield* serverScoped()
  252. yield* setProjectStartCommand({
  253. server,
  254. directory: test.directory,
  255. command: 'bun -e "setTimeout(() => {}, 2000)"',
  256. })
  257. const started = Date.now()
  258. yield* createWorktreeScoped({
  259. server,
  260. directory: test.directory,
  261. path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`,
  262. init: {
  263. method: "POST",
  264. headers: { "content-type": "application/json" },
  265. body: JSON.stringify({ type: "worktree", branch: null }),
  266. },
  267. timeoutLabel: "workspace worktree create with project start command",
  268. timeoutMs: 6_000,
  269. })
  270. expect(Date.now() - started).toBeLessThan(1_500)
  271. }),
  272. { git: true },
  273. )
  274. })