Selaa lähdekoodia

test(server): migrate worktree endpoint repro to effect runner (#27220)

Kit Langton 3 kuukautta sitten
vanhempi
commit
588b5240d0
1 muutettua tiedostoa jossa 132 lisäystä ja 30 poistoa
  1. 132 30
      packages/opencode/test/server/worktree-endpoint-repro.test.ts

+ 132 - 30
packages/opencode/test/server/worktree-endpoint-repro.test.ts

@@ -1,13 +1,14 @@
 import { describe, expect } from "bun:test"
 import { describe, expect } from "bun:test"
-import { Effect, Layer } from "effect"
+import { Effect, Layer, Queue } from "effect"
 import { HttpRouter } from "effect/unstable/http"
 import { HttpRouter } from "effect/unstable/http"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
+import { GlobalBus, type GlobalEvent } from "@/bus/global"
+import { Worktree } from "@/worktree"
 import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
 import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
 import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
 import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
 import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
 import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
-import { withTimeout } from "../../src/util/timeout"
 import { resetDatabase } from "../fixture/db"
 import { resetDatabase } from "../fixture/db"
-import { TestInstance } from "../fixture/fixture"
+import { disposeAllInstances, TestInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 import { testEffect } from "../lib/effect"
 
 
 const stateLayer = Layer.effectDiscard(
 const stateLayer = Layer.effectDiscard(
@@ -28,7 +29,10 @@ const stateLayer = Layer.effectDiscard(
 )
 )
 
 
 const it = testEffect(stateLayer)
 const it = testEffect(stateLayer)
+const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance
 type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
 type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
+type CreatedWorktree = { directory: string }
+type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect<void, Error> }
 
 
 function serverScoped() {
 function serverScoped() {
   return Effect.acquireRelease(
   return Effect.acquireRelease(
@@ -44,14 +48,106 @@ function request(server: TestServer, input: string, init?: RequestInit) {
 }
 }
 
 
 function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
 function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
-  return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label))
+  return effect.pipe(
+    Effect.timeoutOrElse({
+      duration: `${ms} millis`,
+      orElse: () => Effect.fail(new Error(`${label} timed out after ${ms}ms`)),
+    }),
+  )
+}
+
+function json<T>(response: Response) {
+  return Effect.promise(() => response.json() as Promise<T>)
+}
+
+function readyWatcher() {
+  return Effect.gen(function* () {
+    const events = yield* Queue.bounded<GlobalEvent>(1)
+    const on = (event: GlobalEvent) => {
+      if (event.payload.type === Worktree.Event.Ready.type) Queue.offerUnsafe(events, event)
+    }
+
+    GlobalBus.on("event", on)
+    yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
+
+    return (directory: string) =>
+      Effect.gen(function* () {
+        while (true) {
+          const event = yield* Queue.take(events)
+          if (event.directory === directory) return
+        }
+      }).pipe(
+        Effect.timeoutOrElse({
+          duration: "10 seconds",
+          orElse: () => Effect.fail(new Error(`timed out waiting for worktree.ready: ${directory}`)),
+        }),
+      )
+  })
+}
+
+function removeCreatedWorktree(input: {
+  server: TestServer
+  rootDirectory: string
+  worktreeDirectory: string
+  ready: Effect.Effect<void, Error>
+}) {
+  return Effect.gen(function* () {
+    yield* input.ready.pipe(Effect.timeout("1 second"), Effect.ignore)
+    yield* Effect.promise(() => disposeAllInstances()).pipe(Effect.ignore)
+
+    const removed = yield* request(
+      input.server,
+      `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(input.rootDirectory)}`,
+      {
+        method: "DELETE",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({ directory: input.worktreeDirectory }),
+      },
+    )
+    if (removed.status !== 200) {
+      const message = yield* Effect.promise(() => removed.text())
+      throw new Error(`failed to remove worktree: ${removed.status} ${message}`)
+    }
+    const ok = yield* json<boolean>(removed)
+    if (!ok) throw new Error(`failed to remove worktree ${input.worktreeDirectory}`)
+  })
+}
+
+function createWorktreeScoped(input: {
+  server: TestServer
+  directory: string
+  path: string
+  init: RequestInit
+  timeoutLabel: string
+  timeoutMs?: number
+}) {
+  return Effect.acquireRelease(
+    Effect.gen(function* () {
+      const waitReady = yield* readyWatcher()
+      const response = yield* withRequestTimeout(
+        request(input.server, input.path, input.init),
+        input.timeoutLabel,
+        input.timeoutMs,
+      )
+      expect(response.status).toBe(200)
+      const body = yield* json<CreatedWorktree>(response)
+      return { directory: body.directory, body, ready: waitReady(body.directory) } satisfies ScopedWorktree
+    }),
+    (created) =>
+      removeCreatedWorktree({
+        server: input.server,
+        rootDirectory: input.directory,
+        worktreeDirectory: created.directory,
+        ready: created.ready,
+      }).pipe(Effect.orDie),
+  ).pipe(Effect.map((created) => created.body))
 }
 }
 
 
 function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
 function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
   return Effect.gen(function* () {
   return Effect.gen(function* () {
     const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
     const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
     expect(current.status).toBe(200)
     expect(current.status).toBe(200)
-    const project = (yield* Effect.promise(() => current.json())) as { id: string }
+    const project = yield* json<{ id: string }>(current)
     const updated = yield* request(
     const updated = yield* request(
       input.server,
       input.server,
       `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
       `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
@@ -66,47 +162,51 @@ function setProjectStartCommand(input: { server: TestServer; directory: string;
 }
 }
 
 
 describe("worktree endpoint reproduction", () => {
 describe("worktree endpoint reproduction", () => {
-  it.instance(
+  worktreeTest(
     "direct HttpApi worktree create returns without waiting for boot",
     "direct HttpApi worktree create returns without waiting for boot",
     () =>
     () =>
       Effect.gen(function* () {
       Effect.gen(function* () {
         const test = yield* TestInstance
         const test = yield* TestInstance
         const server = yield* serverScoped()
         const server = yield* serverScoped()
 
 
-        const response = yield* withRequestTimeout(
-          request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, {
+        const response = yield* createWorktreeScoped({
+          server,
+          directory: test.directory,
+          path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
+          init: {
             method: "POST",
             method: "POST",
             headers: { "content-type": "application/json" },
             headers: { "content-type": "application/json" },
             body: JSON.stringify({}),
             body: JSON.stringify({}),
-          }),
-          "direct worktree create",
-        )
+          },
+          timeoutLabel: "direct worktree create",
+        })
 
 
-        expect(response.status).toBe(200)
-        expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) })
+        expect(response).toMatchObject({ directory: expect.any(String) })
       }),
       }),
     { git: true },
     { git: true },
   )
   )
 
 
-  it.instance(
+  worktreeTest(
     "workspace worktree create does not hang",
     "workspace worktree create does not hang",
     () =>
     () =>
       Effect.gen(function* () {
       Effect.gen(function* () {
         const test = yield* TestInstance
         const test = yield* TestInstance
         const server = yield* serverScoped()
         const server = yield* serverScoped()
 
 
-        const response = yield* withRequestTimeout(
-          request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
+        const response = yield* createWorktreeScoped({
+          server,
+          directory: test.directory,
+          path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`,
+          init: {
             method: "POST",
             method: "POST",
             headers: { "content-type": "application/json" },
             headers: { "content-type": "application/json" },
             body: JSON.stringify({ type: "worktree", branch: null }),
             body: JSON.stringify({ type: "worktree", branch: null }),
-          }),
-          "workspace worktree create",
-          8_000,
-        )
+          },
+          timeoutLabel: "workspace worktree create",
+          timeoutMs: 8_000,
+        })
 
 
-        expect(response.status).toBe(200)
-        expect(yield* Effect.promise(() => response.json())).toMatchObject({
+        expect(response).toMatchObject({
           type: "worktree",
           type: "worktree",
           directory: expect.any(String),
           directory: expect.any(String),
         })
         })
@@ -114,7 +214,7 @@ describe("worktree endpoint reproduction", () => {
     { git: true },
     { git: true },
   )
   )
 
 
-  it.instance(
+  worktreeTest(
     "workspace worktree create returns without waiting for project start command",
     "workspace worktree create returns without waiting for project start command",
     () =>
     () =>
       Effect.gen(function* () {
       Effect.gen(function* () {
@@ -127,17 +227,19 @@ describe("worktree endpoint reproduction", () => {
         })
         })
 
 
         const started = Date.now()
         const started = Date.now()
-        const response = yield* withRequestTimeout(
-          request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
+        yield* createWorktreeScoped({
+          server,
+          directory: test.directory,
+          path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`,
+          init: {
             method: "POST",
             method: "POST",
             headers: { "content-type": "application/json" },
             headers: { "content-type": "application/json" },
             body: JSON.stringify({ type: "worktree", branch: null }),
             body: JSON.stringify({ type: "worktree", branch: null }),
-          }),
-          "workspace worktree create with project start command",
-          6_000,
-        )
+          },
+          timeoutLabel: "workspace worktree create with project start command",
+          timeoutMs: 6_000,
+        })
 
 
-        expect(response.status).toBe(200)
         expect(Date.now() - started).toBeLessThan(1_500)
         expect(Date.now() - started).toBeLessThan(1_500)
       }),
       }),
     { git: true },
     { git: true },