test: port instance HttpApi path/vcs read coverage to Effect

This commit is contained in:
Kit Langton 2026-04-30 11:07:00 -04:00 committed by GitHub
commit dddfcbf0d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 717 additions and 221 deletions

View file

@ -0,0 +1,64 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Config, Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
}
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_SERVER_PASSWORD = "secret"
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
await resetDatabase()
}),
)
}),
)
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
ExperimentalHttpApiServer.routes,
{ disableListenLog: true, disableLogger: true },
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
),
),
)
describe("HttpApi CORS", () => {
it.live("allows browser preflight requests without credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.options(InstancePaths.path).pipe(
HttpClientRequest.setHeaders({
origin: "http://localhost:3000",
"access-control-request-method": "GET",
"access-control-request-headers": "authorization",
}),
HttpClient.execute,
)
expect(response.status).toBe(204)
expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:3000")
expect(response.headers["access-control-allow-headers"]).toBe("authorization")
}),
)
})

View file

@ -11,9 +11,9 @@ void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
async function readFirstChunk(response: Response) {
@ -45,4 +45,13 @@ describe("event HttpApi bridge", () => {
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n')
})
test("matches legacy first event frame", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const legacy = await app(false).request(EventPaths.event, { headers })
const effect = await app(true).request(EventPaths.event, { headers })
expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy))
})
})

View file

@ -1,7 +1,8 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
@ -12,6 +13,7 @@ import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
@ -84,6 +86,40 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
Layer.build,
)
const waitDisposedEvent = Effect.promise(
() =>
new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed") return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve({ directory: event.directory, workspace: event.workspace })
}
GlobalBus.on("event", onEvent)
}),
)
const serveDisposeProbe = () =>
HttpRouter.serve(
HttpRouter.add(
"POST",
"/dispose-probe",
Effect.gen(function* () {
const instance = yield* InstanceRef
if (!instance) return HttpServerResponse.empty({ status: 500 })
yield* markInstanceForDisposal(instance)
return yield* HttpServerResponse.json(true)
}),
).pipe(Layer.provide(instanceContextTestLayer)),
{ middleware: disposeMiddleware, disableListenLog: true, disableLogger: true },
).pipe(Layer.build)
describe("HttpApi instance context middleware", () => {
it.live("provides instance context from the routed directory", () =>
Effect.gen(function* () {
@ -164,4 +200,25 @@ describe("HttpApi instance context middleware", () => {
})
}),
)
it.live("preserves selected workspace id on instance disposal events", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "instance-context-dispose-event",
directory: workspaceDir,
})
yield* serveDisposeProbe()
const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped)
const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe(HttpClient.execute)
expect(response.status).toBe(200)
expect(yield* response.json).toBe(true)
expect(yield* Fiber.join(disposed)).toEqual({ directory: workspaceDir, workspace: workspace.id })
}),
)
})

View file

@ -0,0 +1,138 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
})
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await resetDatabase()
})
describe("instance HttpApi", () => {
test("serves catalog read endpoints through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const [commands, agents, skills, lsp, formatter] = await Promise.all([
app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }),
])
expect(commands.status).toBe(200)
expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
expect(agents.status).toBe(200)
expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
expect(skills.status).toBe(200)
expect(await skills.json()).toBeArray()
expect(lsp.status).toBe(200)
expect(await lsp.json()).toEqual([])
expect(formatter.status).toBe(200)
expect(await formatter.json()).toEqual([])
})
test("serves project git init through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const disposed = waitDisposed(tmp.path)
const response = await app().request("/project/git/init", {
method: "POST",
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
await disposed
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
expect(current.status).toBe(200)
expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
})
test("serves project update through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
expect(current.status).toBe(200)
const project = (await current.json()) as { id: string }
const response = await app().request(`/project/${project.id}`, {
method: "PATCH",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }),
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
id: project.id,
name: "patched-project",
commands: { start: "bun dev" },
})
const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } })
expect(list.status).toBe(200)
expect(await list.json()).toContainEqual(
expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }),
)
})
test("serves instance dispose through Hono bridge", async () => {
await using tmp = await tmpdir()
const disposed = new Promise<string | undefined>((resolve) => {
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
if (event.payload.type !== "server.instance.disposed") return
GlobalBus.off("event", onEvent)
resolve(event.directory)
}
GlobalBus.on("event", onEvent)
})
const response = await app().request(InstancePaths.dispose, {
method: "POST",
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
expect(await disposed).toBe(tmp.path)
})
})

View file

@ -1,164 +1,83 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { describe, expect } from "bun:test"
import { Config, Effect, FileSystem, Layer, Path } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
// Flip the experimental HttpApi flag so backend selection telemetry on the
// production routes reports the right backend, and reset the database around
// the test so per-instance state does not leak between runs. resetDatabase()
// already calls Instance.disposeAll(), so we don't repeat it.
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
await resetDatabase()
}),
)
}),
)
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
// Mount the production HttpApi route tree on a real Node HTTP server bound to
// 127.0.0.1:0 and a fetch-based HttpClient that prepends the server URL. This
// keeps the test wired through the same route layer production uses, without
// going through Server.Default()/Hono.
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
ExperimentalHttpApiServer.routes,
{ disableListenLog: true, disableLogger: true },
)
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
const httpApiServerLayer = servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
)
async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer))
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
})
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await resetDatabase()
})
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
describe("instance HttpApi", () => {
test("serves path and VCS read endpoints through Hono bridge", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(path.join(tmp.path, "changed.txt"), "hello")
it.live("serves path and VCS read endpoints", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.writeFileString(path.join(dir, "changed.txt"), "hello")
const vcsDiff = new URL(`http://localhost${InstancePaths.vcsDiff}`)
vcsDiff.searchParams.set("mode", "git")
const [paths, vcs, diff] = yield* Effect.all(
[
HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute),
HttpClientRequest.get(InstancePaths.vcs).pipe(directoryHeader(dir), HttpClient.execute),
HttpClientRequest.get(InstancePaths.vcsDiff).pipe(
HttpClientRequest.setUrlParam("mode", "git"),
directoryHeader(dir),
HttpClient.execute,
),
],
{ concurrency: "unbounded" },
)
const [paths, vcs, diff] = await Promise.all([
app().request(InstancePaths.path, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.vcs, { headers: { "x-opencode-directory": tmp.path } }),
app().request(vcsDiff, { headers: { "x-opencode-directory": tmp.path } }),
])
expect(paths.status).toBe(200)
expect(yield* paths.json).toMatchObject({ directory: dir, worktree: dir })
expect(paths.status).toBe(200)
expect(await paths.json()).toMatchObject({ directory: tmp.path, worktree: tmp.path })
expect(vcs.status).toBe(200)
expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) })
expect(vcs.status).toBe(200)
expect(await vcs.json()).toMatchObject({ branch: expect.any(String) })
expect(diff.status).toBe(200)
expect(await diff.json()).toContainEqual(
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
)
})
test("serves catalog read endpoints through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const [commands, agents, skills, lsp, formatter] = await Promise.all([
app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }),
app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }),
])
expect(commands.status).toBe(200)
expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
expect(agents.status).toBe(200)
expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
expect(skills.status).toBe(200)
expect(await skills.json()).toBeArray()
expect(lsp.status).toBe(200)
expect(await lsp.json()).toEqual([])
expect(formatter.status).toBe(200)
expect(await formatter.json()).toEqual([])
})
test("serves project git init through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const disposed = waitDisposed(tmp.path)
const response = await app().request("/project/git/init", {
method: "POST",
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
await disposed
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
expect(current.status).toBe(200)
expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
})
test("serves project update through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
expect(current.status).toBe(200)
const project = (await current.json()) as { id: string }
const response = await app().request(`/project/${project.id}`, {
method: "PATCH",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }),
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
id: project.id,
name: "patched-project",
commands: { start: "bun dev" },
})
const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } })
expect(list.status).toBe(200)
expect(await list.json()).toContainEqual(
expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }),
)
})
test("serves instance dispose through Hono bridge", async () => {
await using tmp = await tmpdir()
const disposed = new Promise<string | undefined>((resolve) => {
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
if (event.payload.type !== "server.instance.disposed") return
GlobalBus.off("event", onEvent)
resolve(event.directory)
}
GlobalBus.on("event", onEvent)
})
const response = await app().request(InstancePaths.dispose, {
method: "POST",
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
expect(await disposed).toBe(tmp.path)
})
expect(diff.status).toBe(200)
expect(yield* diff.json).toContainEqual(
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
)
}),
)
})

View file

@ -0,0 +1,81 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Session } from "@/session/session"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"
import { McpApi, McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { Authorization } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRouteContext,
WorkspaceRoutingMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { testEffect } from "../lib/effect"
const TestHttpApi = HttpApi.make("opencode-instance").addHttpApi(McpApi)
const fakeSession = Layer.mock(Session.Service)({})
const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
Effect.succeed(
handlers
.handle("status", () => Effect.die("unexpected MCP status"))
.handle("add", () => Effect.die("unexpected MCP add"))
.handle("authStart", () =>
Effect.succeed({ authorizationUrl: "https://auth.example/start", oauthState: "state-123" }),
)
.handle("authCallback", () => Effect.die("unexpected MCP authCallback"))
.handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate"))
.handle("authRemove", () => Effect.die("unexpected MCP authRemove"))
.handle("connect", () => Effect.die("unexpected MCP connect"))
.handle("disconnect", () => Effect.die("unexpected MCP disconnect")),
),
)
const passthroughAuthorization = Layer.succeed(
Authorization,
Authorization.of({
basic: (effect) => effect,
authToken: (effect) => effect,
}),
)
const passthroughInstanceContext = Layer.succeed(
InstanceContextMiddleware,
InstanceContextMiddleware.of((effect) => effect),
)
const testWorkspaceRouting = Layer.succeed(
WorkspaceRoutingMiddleware,
WorkspaceRoutingMiddleware.of((effect) =>
effect.pipe(
Effect.provideService(
WorkspaceRouteContext,
WorkspaceRouteContext.of({ directory: process.cwd() }),
),
),
),
)
const it = testEffect(
HttpRouter.serve(
HttpApiBuilder.layer(TestHttpApi).pipe(
Layer.provide(testMcpHandlers),
Layer.provide([passthroughAuthorization, passthroughInstanceContext, testWorkspaceRouting, fakeSession]),
),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest)),
)
describe("mcp HttpApi OAuth", () => {
it.live("preserves oauth state when starting OAuth", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.post(McpPaths.auth.replace(":name", "demo")).pipe(HttpClient.execute)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({
authorizationUrl: "https://auth.example/start",
oauthState: "state-123",
})
}),
)
})

View file

@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { handlePtyInput } from "../../src/pty/input"
describe("pty HttpApi websocket input", () => {
test("does not forward invalid binary frames to the PTY handler", async () => {
const messages: Array<string | ArrayBuffer> = []
const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
await Effect.runPromise(handlePtyInput(handler, "ready"))
await Effect.runPromise(handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd])))
await Effect.runPromise(handlePtyInput(handler, new TextEncoder().encode("hello")))
expect(messages).toEqual(["ready", "hello"])
})
})

View file

@ -404,7 +404,7 @@ describe("HttpApi SDK", () => {
lsp,
}),
project: { worktreeSelected: record(project.data).worktree === directory },
paths: { cwdSelected: record(paths.data).cwd === directory },
paths: { directorySelected: record(paths.data).directory === directory },
file: record(file.data).content,
hasProject: array(projects.data).length > 0,
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),

View file

@ -1,4 +1,6 @@
import { afterEach, describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { PermissionID } from "../../src/permission/schema"
@ -9,7 +11,10 @@ import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/se
import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db"
import { SessionTable } from "@/session/session.sql"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
@ -18,9 +23,9 @@ void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@ -76,6 +81,10 @@ function request(path: string, init?: RequestInit) {
return Effect.promise(async () => app().request(path, init))
}
function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) {
return Effect.promise(async () => app(experimental).request(path, init))
}
function json<T>(response: Response) {
return Effect.promise(async () => {
if (response.status !== 200) throw new Error(await response.text())
@ -217,6 +226,91 @@ describe("session HttpApi", () => {
),
)
it.live(
"matches legacy archived timestamp validation",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const legacy = yield* createSession(tmp.path, { title: "legacy" })
const effect = yield* createSession(tmp.path, { title: "effect" })
const body = JSON.stringify({ time: { archived: -1 } })
const legacyResponse = yield* requestWithBackend(false, pathFor(SessionPaths.update, { sessionID: legacy.id }), {
method: "PATCH",
headers,
body,
})
expect(legacyResponse.status).toBe(200)
expect((yield* json<Session.Info>(legacyResponse)).time.archived).toBe(-1)
const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), {
method: "PATCH",
headers,
body,
})
expect(effectResponse.status).toBe(legacyResponse.status)
expect((yield* json<Session.Info>(effectResponse)).time.archived).toBe(-1)
}),
),
)
it.live(
"matches legacy project-scoped path and directory precedence",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const currentDir = path.join(tmp.path, "packages", "opencode", "src")
yield* Effect.promise(() => mkdir(currentDir, { recursive: true }))
const pathSession = yield* createSession(currentDir)
const pathlessSession = yield* createSession(currentDir)
yield* Effect.sync(() =>
Database.use((db) =>
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, pathlessSession.id)).run(),
),
)
const query = new URLSearchParams({
scope: "project",
path: "packages/opencode/src",
directory: currentDir,
})
const headers = { "x-opencode-directory": tmp.path }
const legacy = (yield* json<Session.Info[]>(
yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }),
)).map((item) => item.id)
const effect = (yield* json<Session.Info[]>(
yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }),
)).map((item) => item.id)
expect(legacy).toContain(pathSession.id)
expect(legacy).not.toContain(pathlessSession.id)
expect(effect).toEqual(legacy)
}),
),
)
it.live(
"matches legacy paginated message link headers",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": tmp.path }
const session = yield* createSession(tmp.path, { title: "messages" })
yield* createTextMessage(tmp.path, session.id, "first")
yield* createTextMessage(tmp.path, session.id, "second")
const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1`
const legacy = yield* requestWithBackend(false, route, { headers })
const effect = yield* requestWithBackend(true, route, { headers })
expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor"))
expect(effect.headers.get("link")).toBe(legacy.headers.get("link"))
expect(effect.headers.get("access-control-expose-headers")).toBe(
legacy.headers.get("access-control-expose-headers"),
)
}),
),
)
it.live(
"serves message mutation routes through Hono bridge",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>

View file

@ -1,6 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { Context } from "hono"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "../../src/bus/global"
import { TuiEvent } from "../../src/cli/cmd/tui/event"
import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance"
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui"
@ -15,9 +17,20 @@ void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
function app(experimental = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function nextCommandExecute() {
return new Promise<unknown>((resolve) => {
const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => {
if (event.payload.type !== TuiEvent.CommandExecute.type) return
GlobalBus.off("event", listener)
resolve(event.payload.properties?.command)
}
GlobalBus.on("event", listener)
})
}
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
@ -72,6 +85,27 @@ describe("tui HttpApi bridge", () => {
expect(missing.status).toBe(404)
})
test("matches legacy unknown execute command behavior", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const body = JSON.stringify({ command: "unknown_command" })
const legacyCommand = nextCommandExecute()
const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body })
expect(legacy.status).toBe(200)
expect(await legacy.json()).toBe(true)
const effectCommand = nextCommandExecute()
const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body })
expect(effect.status).toBe(200)
expect(await effect.json()).toBe(true)
const legacyPublished = await legacyCommand
const effectPublished = await effectCommand
expect(effectPublished).toBe(legacyPublished)
expect(legacyPublished).toBeUndefined()
})
test("serves TUI control queue through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context)

View file

@ -20,6 +20,7 @@ import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { Project } from "../../src/project/project"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
WorkspaceRouteContext,
workspaceRouterMiddleware,
@ -387,6 +388,36 @@ describe("HttpApi workspace routing middleware", () => {
}),
)
it.live("keeps workspace control routes local even when workspace is selected", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "workspace-control-plane-target",
directory: workspaceDir,
})
// Workspace CRUD/status routes manage the control plane itself. Selecting
// a workspace should preserve the selected id for handlers, but must not
// swap the route context to the workspace target directory.
yield* HttpRouter.add(
"GET",
WorkspacePaths.list,
Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id })
}),
)
it.live("uses directory query/header fallback when no workspace is selected", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()