mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-16 09:33:24 +00:00
test(server): migrate HTTP API SDK test to Effect runner (#27208)
This commit is contained in:
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user