Просмотр исходного кода

test(server): migrate HTTP API SDK test to Effect runner (#27208)

Kit Langton 3 месяцев назад
Родитель
Сommit
d1356f509e
1 измененных файлов с 116 добавлено и 71 удалено
  1. 116 71
      packages/opencode/test/server/httpapi-sdk.test.ts

+ 116 - 71
packages/opencode/test/server/httpapi-sdk.test.ts

@@ -2,11 +2,14 @@ import { afterEach, describe, expect } from "bun:test"
 import { ConfigProvider, Effect, Layer } from "effect"
 import type * as Scope from "effect/Scope"
 import { HttpRouter } from "effect/unstable/http"
+import { ChildProcessSpawner } from "effect/unstable/process"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import { validateSession } from "../../src/cli/cmd/tui/validate-session"
-import { Instance } from "../../src/project/instance"
-import { WithInstance } from "../../src/project/with-instance"
+import { InstanceBootstrap } from "../../src/project/bootstrap-service"
+import { InstanceStore } from "../../src/project/instance-store"
 import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
 import { Server } from "../../src/server/server"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -18,8 +21,17 @@ import { errorMessage } from "../../src/util/error"
 import { TestLLMServer } from "../lib/llm-server"
 import path from "path"
 import { resetDatabase } from "../fixture/db"
-import { disposeAllInstances, tmpdir } from "../fixture/fixture"
-import { it } from "../lib/effect"
+import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
+const it = testEffect(
+  Layer.mergeAll(
+    AppFileSystem.defaultLayer,
+    CrossSpawnSpawner.defaultLayer,
+    InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
+  ),
+)
 
 const original = {
   OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
@@ -32,6 +44,8 @@ type SdkResult = { response: Response; data?: unknown; error?: unknown }
 type Captured = { status: number; data?: unknown; error?: unknown }
 type ProjectFixture = { sdk: Sdk; directory: string }
 type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
+type TestServices = AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service
+type TestScope = Scope.Scope | TestServices
 
 function app(serverPath: ServerPath, input?: { password?: string; username?: string }) {
   Flag.OPENCODE_SERVER_PASSWORD = input?.password
@@ -149,12 +163,26 @@ function expectStatus(request: () => Promise<{ response: Response }>, status: nu
   )
 }
 
-function firstEvent(open: () => Promise<{ stream: AsyncIterator<unknown> }>) {
-  return Effect.acquireRelease(call(open), (events) =>
-    call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
+function firstEvent(open: (signal: AbortSignal) => Promise<{ stream: AsyncIterator<unknown> }>) {
+  return Effect.acquireRelease(
+    Effect.sync(() => new AbortController()),
+    (controller) => Effect.sync(() => controller.abort()),
   ).pipe(
-    Effect.flatMap((events) => call(() => events.stream.next())),
-    Effect.map((result) => result.value),
+    Effect.flatMap((controller) =>
+      Effect.acquireRelease(call(() => open(controller.signal)), (events) =>
+        call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
+      ).pipe(
+        Effect.flatMap((events) =>
+          call(() => events.stream.next()).pipe(
+            Effect.timeoutOrElse({
+              duration: "1 second",
+              orElse: () => Effect.fail(new Error("timed out waiting for SDK event")),
+            }),
+          ),
+        ),
+        Effect.map((result) => result.value),
+      ),
+    ),
   )
 }
 
@@ -188,11 +216,32 @@ function resetState() {
   })
 }
 
-function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, Scope.Scope>) {
+function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, TestScope>) {
   it.live(name, effect)
 }
 
-function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, Scope.Scope>) {
+function httpapiInstance<A, E>(
+  name: string,
+  options: {
+    serverPath: ServerPath
+    git?: boolean
+    config?: Partial<Config.Info>
+    setup?: (dir: string) => Effect.Effect<void, E, TestServices>
+  },
+  run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
+) {
+  it.instance(
+    name,
+    Effect.gen(function* () {
+      const instance = yield* TestInstance
+      yield* (options.setup?.(instance.directory) ?? Effect.void)
+      return yield* run({ sdk: client(options.serverPath, instance.directory), directory: instance.directory })
+    }),
+    { git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } },
+  )
+}
+
+function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, TestScope>) {
   it.live(
     name,
     Effect.gen(function* () {
@@ -204,35 +253,33 @@ function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath)
   )
 }
 
-function withProject<A, E, R>(
+function withProject<A, E, E2 = never>(
   serverPath: ServerPath,
-  options: { git?: boolean; config?: Partial<Config.Info>; setup?: (dir: string) => Effect.Effect<void> },
-  run: (input: ProjectFixture) => Effect.Effect<A, E, R>,
+  options: { git?: boolean; config?: Partial<Config.Info>; setup?: (dir: string) => Effect.Effect<void, E2, TestServices> },
+  run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
 ) {
-  return Effect.acquireRelease(
-    call(() => tmpdir({ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } })),
-    (tmp) => call(() => tmp[Symbol.asyncDispose]()).pipe(Effect.ignore),
-  ).pipe(
-    Effect.tap((tmp) => options.setup?.(tmp.path) ?? Effect.void),
-    Effect.flatMap((tmp) => run({ sdk: client(serverPath, tmp.path), directory: tmp.path })),
-  )
+  return Effect.gen(function* () {
+    const directory = yield* tmpdirScoped({ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } })
+    yield* (options.setup?.(directory) ?? Effect.void)
+    return yield* run({ sdk: client(serverPath, directory), directory })
+  })
 }
 
-function withStandardProject<A, E, R>(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect<A, E, R>) {
+function withStandardProject<A, E>(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>) {
   return withProject(serverPath, { setup: writeStandardFiles }, run)
 }
 
-function withFakeLlm<A, E, R>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>) {
+function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) {
   return Effect.gen(function* () {
     const llm = yield* TestLLMServer
     return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
   }).pipe(Effect.provide(TestLLMServer.layer))
 }
 
-function withFakeLlmProject<A, E, R>(
+function withFakeLlmProject<A, E>(
   serverPath: ServerPath,
-  options: { setup?: (dir: string) => Effect.Effect<void> },
-  run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>,
+  options: { setup?: (dir: string) => Effect.Effect<void, E, TestServices> },
+  run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>,
 ) {
   return Effect.gen(function* () {
     const llm = yield* TestLLMServer
@@ -248,15 +295,17 @@ function withFakeLlmProject<A, E, R>(
 }
 
 function writeStandardFiles(dir: string) {
-  return Effect.all([
-    call(() => Bun.write(path.join(dir, "hello.txt"), "hello")),
-    call(() => Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n")),
-  ]).pipe(Effect.asVoid)
+  return AppFileSystem.Service.use((fs) =>
+    Effect.all([
+      fs.writeWithDirs(path.join(dir, "hello.txt"), "hello"),
+      fs.writeWithDirs(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n"),
+    ]).pipe(Effect.asVoid),
+  )
 }
 
 function writeProjectSkill(dir: string) {
-  return call(() =>
-    Bun.write(
+  return AppFileSystem.Service.use((fs) =>
+    fs.writeWithDirs(
       path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"),
       `---
 name: project-rest-skill
@@ -266,40 +315,36 @@ description: A project skill visible to REST API prompts.
 # Project REST Skill
 `,
     ),
-  ).pipe(Effect.asVoid)
+  )
 }
 
 function seedMessage(directory: string, sessionID: string) {
   const id = SessionID.make(sessionID)
-  return call(
-    async () =>
-      await WithInstance.provide({
-        directory,
-        fn: () =>
-          Effect.runPromise(
-            SessionNs.Service.use((svc) =>
-              Effect.gen(function* () {
-                const message = yield* svc.updateMessage({
-                  id: MessageID.ascending(),
-                  sessionID: id,
-                  role: "user",
-                  time: { created: Date.now() },
-                  agent: "test",
-                  model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
-                  tools: {},
-                } satisfies MessageV2.User)
-                const part = yield* svc.updatePart({
-                  id: PartID.ascending(),
-                  sessionID: id,
-                  messageID: message.id,
-                  type: "text",
-                  text: "seeded message",
-                })
-                return { message, part }
-              }),
-            ).pipe(Effect.provide(SessionNs.defaultLayer)),
-          ),
-      }),
+  return InstanceStore.Service.use((store) =>
+    store.provide(
+      { directory },
+      SessionNs.Service.use((svc) =>
+        Effect.gen(function* () {
+          const message = yield* svc.updateMessage({
+            id: MessageID.ascending(),
+            sessionID: id,
+            role: "user",
+            time: { created: Date.now() },
+            agent: "test",
+            model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
+            tools: {},
+          } satisfies MessageV2.User)
+          const part = yield* svc.updatePart({
+            id: PartID.ascending(),
+            sessionID: id,
+            messageID: message.id,
+            type: "text",
+            text: "seeded message",
+          })
+          return { message, part }
+        }),
+      ).pipe(Effect.provide(SessionNs.defaultLayer)),
+    ),
   )
 }
 
@@ -320,7 +365,7 @@ describe("HttpApi SDK", () => {
 
       expect(health.response.status).toBe(200)
       expect(health.data).toMatchObject({ healthy: true })
-      expect(yield* firstEvent(() => sdk.global.event({ signal: AbortSignal.timeout(1_000) }))).toMatchObject({
+      expect(yield* firstEvent((signal) => sdk.global.event({ signal }))).toMatchObject({
         payload: { type: "server.connected" },
       })
       expect(log.response.status).toBe(200)
@@ -329,9 +374,10 @@ describe("HttpApi SDK", () => {
     }),
   )
 
-  httpapi(
+  httpapiInstance(
     "uses the generated SDK for safe instance routes",
-    withProject("raw", { git: false, setup: writeStandardFiles }, ({ sdk }) =>
+    { serverPath: "raw", git: false, setup: writeStandardFiles },
+    ({ sdk }) =>
       Effect.gen(function* () {
         const file = yield* call(() => sdk.file.read({ path: "hello.txt" }))
         const session = yield* call(() => sdk.session.create({ title: "sdk" }))
@@ -351,7 +397,6 @@ describe("HttpApi SDK", () => {
           expectStatus(() => sdk.find.files({ query: "hello", limit: 10 }), 200),
         ])
       }),
-    ),
   )
 
   serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
@@ -370,14 +415,14 @@ describe("HttpApi SDK", () => {
   )
 
   serverPathParity("matches generated SDK global event stream", (serverPath) =>
-    firstEvent(() => client(serverPath).global.event({ signal: AbortSignal.timeout(1_000) })).pipe(
+    firstEvent((signal) => client(serverPath).global.event({ signal })).pipe(
       Effect.map((event) => ({ type: record(record(event).payload).type })),
     ),
   )
 
   serverPathParity("matches generated SDK instance event stream", (serverPath) =>
     withStandardProject(serverPath, ({ sdk }) =>
-      firstEvent(() => sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })).pipe(
+      firstEvent((signal) => sdk.event.subscribe(undefined, { signal })).pipe(
         Effect.map((event) => ({ type: record(record(event).payload).type })),
       ),
     ),
@@ -431,9 +476,10 @@ describe("HttpApi SDK", () => {
     ),
   )
 
-  httpapi(
+  httpapiInstance(
     "uses generated SDK basic auth behavior",
-    withStandardProject("raw", ({ directory }) =>
+    { serverPath: "raw", setup: writeStandardFiles },
+    ({ directory }) =>
       Effect.gen(function* () {
         const missing = yield* capture(() =>
           client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }),
@@ -456,7 +502,6 @@ describe("HttpApi SDK", () => {
           content: record(good.data).content,
         }
       }),
-    ),
   )
 
   serverPathParity("matches generated SDK instance read routes", (serverPath) =>