mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-06 08:21:50 +00:00
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
import { afterEach, describe, expect } from "bun:test"
|
|
import { Effect } from "effect"
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
|
import { Instance } from "../../src/project/instance"
|
|
import { Server } from "../../src/server/server"
|
|
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
|
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
|
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
|
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
|
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
|
|
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
|
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
|
import { MessageID, PartID } from "../../src/session/schema"
|
|
import { Session } from "@/session/session"
|
|
import * as Log from "@opencode-ai/core/util/log"
|
|
import { resetDatabase } from "../fixture/db"
|
|
import { provideInstance, tmpdir } from "../fixture/fixture"
|
|
import { it } from "../lib/effect"
|
|
|
|
void Log.init({ print: false })
|
|
|
|
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
|
|
|
function app(experimental: boolean) {
|
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
|
return experimental ? Server.Default().app : Server.Legacy().app
|
|
}
|
|
type TestApp = ReturnType<typeof app>
|
|
|
|
function pathFor(path: string, params: Record<string, string>) {
|
|
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
|
|
}
|
|
|
|
const seedSessions = Effect.gen(function* () {
|
|
const svc = yield* Session.Service
|
|
const parent = yield* svc.create({ title: "parent" })
|
|
yield* svc.create({ title: "child", parentID: parent.id })
|
|
const message = yield* svc.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
sessionID: parent.id,
|
|
agent: "build",
|
|
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
|
time: { created: Date.now() },
|
|
})
|
|
yield* svc.updatePart({
|
|
id: PartID.ascending(),
|
|
sessionID: parent.id,
|
|
messageID: message.id,
|
|
type: "text",
|
|
text: "hello",
|
|
})
|
|
return { parent, message }
|
|
})
|
|
|
|
function withTmp<A, E, R>(
|
|
options: Parameters<typeof tmpdir>[0],
|
|
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
|
|
) {
|
|
return Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir(options)),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path))))
|
|
}
|
|
|
|
function readJson(label: string, serverApp: TestApp, path: string, headers: HeadersInit) {
|
|
return Effect.promise(async () => {
|
|
const response = await serverApp.request(path, { headers })
|
|
if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`)
|
|
return await response.json()
|
|
})
|
|
}
|
|
|
|
function expectJsonParity(input: {
|
|
label: string
|
|
legacy: TestApp
|
|
httpapi: TestApp
|
|
path: string
|
|
headers: HeadersInit
|
|
}) {
|
|
return Effect.gen(function* () {
|
|
const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers)
|
|
const httpapi = yield* readJson(input.label, input.httpapi, input.path, input.headers)
|
|
expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy })
|
|
return httpapi
|
|
})
|
|
}
|
|
|
|
afterEach(async () => {
|
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
|
await Instance.disposeAll()
|
|
await resetDatabase()
|
|
})
|
|
|
|
describe("HttpApi JSON parity", () => {
|
|
it.live(
|
|
"matches legacy JSON shape for safe GET endpoints",
|
|
withTmp(
|
|
{
|
|
git: true,
|
|
config: {
|
|
formatter: false,
|
|
lsp: false,
|
|
mcp: {
|
|
demo: {
|
|
type: "local",
|
|
command: ["echo", "demo"],
|
|
enabled: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n"))
|
|
|
|
const headers = { "x-opencode-directory": tmp.path }
|
|
const legacy = app(false)
|
|
const httpapi = app(true)
|
|
|
|
yield* Effect.forEach(
|
|
[
|
|
{ label: "global.health", path: GlobalPaths.health, headers: {} },
|
|
{ label: "global.config", path: GlobalPaths.config, headers: {} },
|
|
{ label: "instance.path", path: InstancePaths.path, headers },
|
|
{ label: "instance.vcs", path: InstancePaths.vcs, headers },
|
|
{ label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers },
|
|
{ label: "instance.command", path: InstancePaths.command, headers },
|
|
{ label: "instance.agent", path: InstancePaths.agent, headers },
|
|
{ label: "instance.skill", path: InstancePaths.skill, headers },
|
|
{ label: "instance.lsp", path: InstancePaths.lsp, headers },
|
|
{ label: "instance.formatter", path: InstancePaths.formatter, headers },
|
|
{ label: "config.get", path: "/config", headers },
|
|
{ label: "config.providers", path: "/config/providers", headers },
|
|
{ label: "project.list", path: "/project", headers },
|
|
{ label: "project.current", path: "/project/current", headers },
|
|
{ label: "provider.list", path: "/provider", headers },
|
|
{ label: "provider.auth", path: "/provider/auth", headers },
|
|
{ label: "permission.list", path: "/permission", headers },
|
|
{ label: "question.list", path: "/question", headers },
|
|
{ label: "mcp.status", path: McpPaths.status, headers },
|
|
{ label: "pty.shells", path: PtyPaths.shells, headers },
|
|
{ label: "pty.list", path: PtyPaths.list, headers },
|
|
{ label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers },
|
|
{
|
|
label: "file.content",
|
|
path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`,
|
|
headers,
|
|
},
|
|
{ label: "file.status", path: FilePaths.status, headers },
|
|
{
|
|
label: "find.file",
|
|
path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`,
|
|
headers,
|
|
},
|
|
{
|
|
label: "find.text",
|
|
path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`,
|
|
headers,
|
|
},
|
|
{
|
|
label: "find.symbol",
|
|
path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`,
|
|
headers,
|
|
},
|
|
{ label: "experimental.console", path: ExperimentalPaths.console, headers },
|
|
{ label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers },
|
|
{ label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers },
|
|
{ label: "experimental.worktree", path: ExperimentalPaths.worktree, headers },
|
|
{ label: "experimental.resource", path: ExperimentalPaths.resource, headers },
|
|
],
|
|
(input) => expectJsonParity({ ...input, legacy, httpapi }),
|
|
{ concurrency: 1 },
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"matches legacy JSON shape for session read endpoints",
|
|
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
|
Effect.gen(function* () {
|
|
const headers = { "x-opencode-directory": tmp.path }
|
|
const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer))
|
|
const legacy = app(false)
|
|
const httpapi = app(true)
|
|
|
|
const rootsFalse = yield* expectJsonParity({
|
|
label: "session.list roots false",
|
|
legacy,
|
|
httpapi,
|
|
path: `${SessionPaths.list}?roots=false`,
|
|
headers,
|
|
})
|
|
expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id)
|
|
expect((rootsFalse as Session.Info[]).length).toBe(2)
|
|
|
|
const experimentalRootsFalse = yield* expectJsonParity({
|
|
label: "experimental.session roots false",
|
|
legacy,
|
|
httpapi,
|
|
path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`,
|
|
headers,
|
|
})
|
|
expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2)
|
|
|
|
const experimentalArchivedFalse = yield* expectJsonParity({
|
|
label: "experimental.session archived false",
|
|
legacy,
|
|
httpapi,
|
|
path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", archived: "false" })}`,
|
|
headers,
|
|
})
|
|
expect((experimentalArchivedFalse as Session.GlobalInfo[]).length).toBe(2)
|
|
|
|
yield* Effect.forEach(
|
|
[
|
|
{ label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers },
|
|
{ label: "session.list all", path: SessionPaths.list, headers },
|
|
{ label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers },
|
|
{
|
|
label: "session.children",
|
|
path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }),
|
|
headers,
|
|
},
|
|
{
|
|
label: "session.messages",
|
|
path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }),
|
|
headers,
|
|
},
|
|
{
|
|
label: "session.messages empty before",
|
|
path: `${pathFor(SessionPaths.messages, { sessionID: seeded.parent.id })}?before=`,
|
|
headers,
|
|
},
|
|
{
|
|
label: "session.message",
|
|
path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }),
|
|
headers,
|
|
},
|
|
{
|
|
label: "experimental.session",
|
|
path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`,
|
|
headers,
|
|
},
|
|
],
|
|
(input) => expectJsonParity({ ...input, legacy, httpapi }),
|
|
{ concurrency: 1 },
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
})
|