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 { ConfigProvider, Effect, Layer } from "effect"
|
||||||
import type * as Scope from "effect/Scope"
|
import type * as Scope from "effect/Scope"
|
||||||
import { HttpRouter } from "effect/unstable/http"
|
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 { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { validateSession } from "../../src/cli/cmd/tui/validate-session"
|
import { validateSession } from "../../src/cli/cmd/tui/validate-session"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||||
import { WithInstance } from "../../src/project/with-instance"
|
import { InstanceStore } from "../../src/project/instance-store"
|
||||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
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 { TestLLMServer } from "../lib/llm-server"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
import { it } from "../lib/effect"
|
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 = {
|
const original = {
|
||||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
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 Captured = { status: number; data?: unknown; error?: unknown }
|
||||||
type ProjectFixture = { sdk: Sdk; directory: string }
|
type ProjectFixture = { sdk: Sdk; directory: string }
|
||||||
type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
|
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 }) {
|
function app(serverPath: ServerPath, input?: { password?: string; username?: string }) {
|
||||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
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> }>) {
|
function firstEvent(open: (signal: AbortSignal) => Promise<{ stream: AsyncIterator<unknown> }>) {
|
||||||
return Effect.acquireRelease(call(open), (events) =>
|
return Effect.acquireRelease(
|
||||||
|
Effect.sync(() => new AbortController()),
|
||||||
|
(controller) => Effect.sync(() => controller.abort()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((controller) =>
|
||||||
|
Effect.acquireRelease(call(() => open(controller.signal)), (events) =>
|
||||||
call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
|
call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.flatMap((events) => call(() => events.stream.next())),
|
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),
|
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)
|
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(
|
it.live(
|
||||||
name,
|
name,
|
||||||
Effect.gen(function* () {
|
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,
|
serverPath: ServerPath,
|
||||||
options: { git?: boolean; config?: Partial<Config.Info>; setup?: (dir: string) => Effect.Effect<void> },
|
options: { git?: boolean; config?: Partial<Config.Info>; setup?: (dir: string) => Effect.Effect<void, E2, TestServices> },
|
||||||
run: (input: ProjectFixture) => Effect.Effect<A, E, R>,
|
run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
|
||||||
) {
|
) {
|
||||||
return Effect.acquireRelease(
|
return Effect.gen(function* () {
|
||||||
call(() => tmpdir({ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } })),
|
const directory = yield* tmpdirScoped({ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } })
|
||||||
(tmp) => call(() => tmp[Symbol.asyncDispose]()).pipe(Effect.ignore),
|
yield* (options.setup?.(directory) ?? Effect.void)
|
||||||
).pipe(
|
return yield* run({ sdk: client(serverPath, directory), directory })
|
||||||
Effect.tap((tmp) => options.setup?.(tmp.path) ?? Effect.void),
|
})
|
||||||
Effect.flatMap((tmp) => run({ sdk: client(serverPath, tmp.path), directory: tmp.path })),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
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* () {
|
return Effect.gen(function* () {
|
||||||
const llm = yield* TestLLMServer
|
const llm = yield* TestLLMServer
|
||||||
return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
|
return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
|
||||||
}).pipe(Effect.provide(TestLLMServer.layer))
|
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||||
}
|
}
|
||||||
|
|
||||||
function withFakeLlmProject<A, E, R>(
|
function withFakeLlmProject<A, E>(
|
||||||
serverPath: ServerPath,
|
serverPath: ServerPath,
|
||||||
options: { setup?: (dir: string) => Effect.Effect<void> },
|
options: { setup?: (dir: string) => Effect.Effect<void, E, TestServices> },
|
||||||
run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>,
|
run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>,
|
||||||
) {
|
) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const llm = yield* TestLLMServer
|
const llm = yield* TestLLMServer
|
||||||
@@ -248,15 +295,17 @@ function withFakeLlmProject<A, E, R>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeStandardFiles(dir: string) {
|
function writeStandardFiles(dir: string) {
|
||||||
return Effect.all([
|
return AppFileSystem.Service.use((fs) =>
|
||||||
call(() => Bun.write(path.join(dir, "hello.txt"), "hello")),
|
Effect.all([
|
||||||
call(() => Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n")),
|
fs.writeWithDirs(path.join(dir, "hello.txt"), "hello"),
|
||||||
]).pipe(Effect.asVoid)
|
fs.writeWithDirs(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n"),
|
||||||
|
]).pipe(Effect.asVoid),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeProjectSkill(dir: string) {
|
function writeProjectSkill(dir: string) {
|
||||||
return call(() =>
|
return AppFileSystem.Service.use((fs) =>
|
||||||
Bun.write(
|
fs.writeWithDirs(
|
||||||
path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"),
|
path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"),
|
||||||
`---
|
`---
|
||||||
name: project-rest-skill
|
name: project-rest-skill
|
||||||
@@ -266,17 +315,14 @@ description: A project skill visible to REST API prompts.
|
|||||||
# Project REST Skill
|
# Project REST Skill
|
||||||
`,
|
`,
|
||||||
),
|
),
|
||||||
).pipe(Effect.asVoid)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedMessage(directory: string, sessionID: string) {
|
function seedMessage(directory: string, sessionID: string) {
|
||||||
const id = SessionID.make(sessionID)
|
const id = SessionID.make(sessionID)
|
||||||
return call(
|
return InstanceStore.Service.use((store) =>
|
||||||
async () =>
|
store.provide(
|
||||||
await WithInstance.provide({
|
{ directory },
|
||||||
directory,
|
|
||||||
fn: () =>
|
|
||||||
Effect.runPromise(
|
|
||||||
SessionNs.Service.use((svc) =>
|
SessionNs.Service.use((svc) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const message = yield* svc.updateMessage({
|
const message = yield* svc.updateMessage({
|
||||||
@@ -299,7 +345,6 @@ function seedMessage(directory: string, sessionID: string) {
|
|||||||
}),
|
}),
|
||||||
).pipe(Effect.provide(SessionNs.defaultLayer)),
|
).pipe(Effect.provide(SessionNs.defaultLayer)),
|
||||||
),
|
),
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +365,7 @@ describe("HttpApi SDK", () => {
|
|||||||
|
|
||||||
expect(health.response.status).toBe(200)
|
expect(health.response.status).toBe(200)
|
||||||
expect(health.data).toMatchObject({ healthy: true })
|
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" },
|
payload: { type: "server.connected" },
|
||||||
})
|
})
|
||||||
expect(log.response.status).toBe(200)
|
expect(log.response.status).toBe(200)
|
||||||
@@ -329,9 +374,10 @@ describe("HttpApi SDK", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
httpapi(
|
httpapiInstance(
|
||||||
"uses the generated SDK for safe instance routes",
|
"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* () {
|
Effect.gen(function* () {
|
||||||
const file = yield* call(() => sdk.file.read({ path: "hello.txt" }))
|
const file = yield* call(() => sdk.file.read({ path: "hello.txt" }))
|
||||||
const session = yield* call(() => sdk.session.create({ title: "sdk" }))
|
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),
|
expectStatus(() => sdk.find.files({ query: "hello", limit: 10 }), 200),
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
|
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
|
||||||
@@ -370,14 +415,14 @@ describe("HttpApi SDK", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
serverPathParity("matches generated SDK global event stream", (serverPath) =>
|
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 })),
|
Effect.map((event) => ({ type: record(record(event).payload).type })),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
serverPathParity("matches generated SDK instance event stream", (serverPath) =>
|
serverPathParity("matches generated SDK instance event stream", (serverPath) =>
|
||||||
withStandardProject(serverPath, ({ sdk }) =>
|
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 })),
|
Effect.map((event) => ({ type: record(record(event).payload).type })),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -431,9 +476,10 @@ describe("HttpApi SDK", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
httpapi(
|
httpapiInstance(
|
||||||
"uses generated SDK basic auth behavior",
|
"uses generated SDK basic auth behavior",
|
||||||
withStandardProject("raw", ({ directory }) =>
|
{ serverPath: "raw", setup: writeStandardFiles },
|
||||||
|
({ directory }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const missing = yield* capture(() =>
|
const missing = yield* capture(() =>
|
||||||
client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }),
|
client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }),
|
||||||
@@ -456,7 +502,6 @@ describe("HttpApi SDK", () => {
|
|||||||
content: record(good.data).content,
|
content: record(good.data).content,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
|
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user