worktree-endpoint-repro.test.ts 10 KB

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