fix: 修正 logo 中 N 和 G 字母造型
N 添加对角线笔画(█▄ █),G 添加内横杠(█ ▀█), 避免与 O 字母造型雷同。同步更新 ui.ts 中的硬编码 wordmark。
This commit is contained in:
15
packages/opencode/test/server/AGENTS.md
Normal file
15
packages/opencode/test/server/AGENTS.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Server Test Guide
|
||||
|
||||
Use these patterns for server and HttpApi middleware tests in this directory.
|
||||
|
||||
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
|
||||
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
|
||||
- Use tiny `HttpApiBuilder` probe groups that declare the typed middleware under test and expose context such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
|
||||
- Declare middleware in the same order as production when testing interactions, for example `InstanceContextMiddleware` followed by `WorkspaceRoutingMiddleware`.
|
||||
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
|
||||
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
|
||||
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
|
||||
- Use scoped test layers for flags, database reset, and other global mutable state. Restore flags and reset state in finalizers.
|
||||
- Use `tmpdirScoped({ git: true })` plus `Project.use.fromDirectory(dir)` for project-backed requests.
|
||||
- If a test needs persisted state without matching runtime state, keep direct database setup inside a narrowly named helper that explains that state.
|
||||
- Add comments for non-obvious test topology, especially tests involving both the local test server and a fake upstream server.
|
||||
59
packages/opencode/test/server/auth.test.ts
Normal file
59
packages/opencode/test/server/auth.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Option, Redacted } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
|
||||
const original = {
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
})
|
||||
|
||||
describe("ServerAuth", () => {
|
||||
test("does not emit auth headers without a password", () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = undefined
|
||||
Flag.OPENCODE_SERVER_USERNAME = "alice"
|
||||
|
||||
expect(ServerAuth.header()).toBeUndefined()
|
||||
expect(ServerAuth.headers()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("defaults to the opencode username", () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
Flag.OPENCODE_SERVER_USERNAME = undefined
|
||||
|
||||
expect(ServerAuth.headers()).toEqual({
|
||||
Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the configured username", () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
Flag.OPENCODE_SERVER_USERNAME = "alice"
|
||||
|
||||
expect(ServerAuth.headers()).toEqual({
|
||||
Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`,
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers explicit credentials", () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
Flag.OPENCODE_SERVER_USERNAME = "alice"
|
||||
|
||||
expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({
|
||||
Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`,
|
||||
})
|
||||
})
|
||||
|
||||
test("validates decoded credentials against effect config", () => {
|
||||
const config = { password: Option.some("secret"), username: "alice" }
|
||||
|
||||
expect(ServerAuth.required(config)).toBe(true)
|
||||
expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true)
|
||||
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false)
|
||||
})
|
||||
})
|
||||
31
packages/opencode/test/server/global-bus.ts
Normal file
31
packages/opencode/test/server/global-bus.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Cause, Effect } from "effect"
|
||||
|
||||
export function waitGlobalBusEvent(input: {
|
||||
timeout?: number
|
||||
message?: string
|
||||
predicate: (event: GlobalEvent) => boolean
|
||||
}) {
|
||||
return Effect.callback<GlobalEvent, unknown>((resume) => {
|
||||
const cleanup = () => GlobalBus.off("event", handler)
|
||||
|
||||
const handler = (event: GlobalEvent) => {
|
||||
try {
|
||||
if (!input.predicate(event)) return
|
||||
cleanup()
|
||||
resume(Effect.succeed(event))
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
resume(Effect.fail(error))
|
||||
}
|
||||
}
|
||||
|
||||
GlobalBus.on("event", handler)
|
||||
return Effect.sync(cleanup)
|
||||
}).pipe(
|
||||
Effect.timeout(input.timeout ?? 10_000),
|
||||
Effect.mapError((error) =>
|
||||
Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error,
|
||||
),
|
||||
)
|
||||
}
|
||||
104
packages/opencode/test/server/global-session-list.test.ts
Normal file
104
packages/opencode/test/server/global-session-list.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { Project } from "@/project/project"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Project.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const withSession = (input?: Parameters<SessionNs.Interface["create"]>[0]) =>
|
||||
Effect.acquireRelease(SessionNs.use.create(input), (created) =>
|
||||
SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)),
|
||||
)
|
||||
|
||||
describe("session.listGlobal", () => {
|
||||
it.instance(
|
||||
"lists sessions across projects with project metadata",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* TestInstance
|
||||
const second = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const firstSession = yield* withSession({ title: "first-session" })
|
||||
const secondSession = yield* withSession({ title: "second-session" }).pipe(provideInstance(second))
|
||||
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 }))
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(firstSession.id)
|
||||
expect(ids).toContain(secondSession.id)
|
||||
|
||||
const firstProject = yield* Project.use.get(firstSession.projectID)
|
||||
const secondProject = yield* Project.use.get(secondSession.projectID)
|
||||
|
||||
const firstItem = sessions.find((session) => session.id === firstSession.id)
|
||||
const secondItem = sessions.find((session) => session.id === secondSession.id)
|
||||
|
||||
expect(firstItem?.project?.id).toBe(firstProject?.id)
|
||||
expect(firstItem?.project?.worktree).toBe(firstProject?.worktree)
|
||||
expect(secondItem?.project?.id).toBe(secondProject?.id)
|
||||
expect(secondItem?.project?.worktree).toBe(secondProject?.worktree)
|
||||
expect(first.directory).not.toBe(second)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"excludes archived sessions by default",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const archived = yield* withSession({ title: "archived-session" })
|
||||
|
||||
yield* SessionNs.Service.use((session) => session.setArchived({ sessionID: archived.id, time: Date.now() }))
|
||||
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 }))
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).not.toContain(archived.id)
|
||||
|
||||
const allSessions = yield* SessionNs.Service.use((session) =>
|
||||
session.listGlobal({ limit: 200, archived: true }),
|
||||
)
|
||||
const allIds = allSessions.map((session) => session.id)
|
||||
|
||||
expect(allIds).toContain(archived.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"supports cursor pagination",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
|
||||
const first = yield* withSession({ title: "page-one" })
|
||||
const ready = yield* Deferred.make<void>()
|
||||
yield* Deferred.succeed(ready, undefined).pipe(Effect.delay("5 millis"), Effect.forkScoped)
|
||||
yield* Deferred.await(ready).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "1 second",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting between session creates")),
|
||||
}),
|
||||
)
|
||||
const second = yield* withSession({ title: "page-two" })
|
||||
|
||||
const page = yield* SessionNs.Service.use((session) =>
|
||||
session.listGlobal({ directory: test.directory, limit: 1 }),
|
||||
)
|
||||
expect(page.length).toBe(1)
|
||||
expect(page[0].id).toBe(second.id)
|
||||
|
||||
const next = yield* SessionNs.Service.use((session) =>
|
||||
session.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }),
|
||||
)
|
||||
const ids = next.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(first.id)
|
||||
expect(ids).not.toContain(second.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
174
packages/opencode/test/server/httpapi-authorization.test.ts
Normal file
174
packages/opencode/test/server/httpapi-authorization.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import {
|
||||
Authorization,
|
||||
authorizationLayer,
|
||||
ServerAuthorization,
|
||||
serverAuthorizationLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const Api = HttpApi.make("test-authorization").add(
|
||||
HttpApiGroup.make("test")
|
||||
.add(
|
||||
HttpApiEndpoint.get("probe", "/probe", {
|
||||
success: Schema.String,
|
||||
}),
|
||||
HttpApiEndpoint.get("missing", "/missing", {
|
||||
success: Schema.String,
|
||||
error: HttpApiError.NotFound,
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
|
||||
const ServerApi = HttpApi.make("test-server-authorization").add(
|
||||
HttpApiGroup.make("test.v2")
|
||||
.add(
|
||||
HttpApiEndpoint.get("probe", "/api/probe", {
|
||||
success: Schema.String,
|
||||
}),
|
||||
)
|
||||
.middleware(ServerAuthorization),
|
||||
)
|
||||
|
||||
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
|
||||
handlers
|
||||
.handle("probe", () => Effect.succeed("ok"))
|
||||
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
|
||||
)
|
||||
|
||||
const serverHandlers = HttpApiBuilder.group(ServerApi, "test.v2", (handlers) =>
|
||||
handlers.handle("probe", () => Effect.succeed("ok")),
|
||||
)
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
||||
|
||||
const v2ApiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(ServerApi).pipe(Layer.provide(serverHandlers), Layer.provide(serverAuthorizationLayer)),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
||||
|
||||
const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })
|
||||
const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" })
|
||||
const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" })
|
||||
|
||||
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
|
||||
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
|
||||
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
|
||||
const itV2Secret = testEffect(v2ApiLayer.pipe(Layer.provide(secretLayer)))
|
||||
|
||||
const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? ""
|
||||
|
||||
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
|
||||
|
||||
const getProbe = (headers?: Record<string, string>) =>
|
||||
HttpClientRequest.get("/probe").pipe(
|
||||
headers ? HttpClientRequest.setHeaders(headers) : (request) => request,
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
describe("HttpApi authorization middleware", () => {
|
||||
it.live("allows requests when server password is not configured", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* getProbe()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toBe("ok")
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("requires configured password for basic auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const [missing, badPassword, good] = yield* Effect.all(
|
||||
[
|
||||
getProbe(),
|
||||
getProbe({ authorization: basic("opencode", "wrong") }),
|
||||
getProbe({ authorization: basic("opencode", "secret") }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(missing.status).toBe(401)
|
||||
expect(missing.headers["www-authenticate"] ?? "").toContain("Basic")
|
||||
expect(badPassword.status).toBe(401)
|
||||
expect(badPassword.headers["www-authenticate"] ?? "").toContain("Basic")
|
||||
expect(good.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
itKitSecret.live("respects configured basic auth username", () =>
|
||||
Effect.gen(function* () {
|
||||
const [defaultUser, configuredUser] = yield* Effect.all(
|
||||
[getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(defaultUser.status).toBe(401)
|
||||
expect(configuredUser.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("accepts auth token query credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("prefers auth token query credentials over basic auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.get(
|
||||
`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`,
|
||||
).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("preserves handler errors when basic auth succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.get("/missing").pipe(
|
||||
HttpClientRequest.setHeader("authorization", basic("opencode", "secret")),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("preserves handler errors when auth token query succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("rejects malformed auth token query credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
}),
|
||||
)
|
||||
|
||||
itV2Secret.live("returns bodyful v2 unauthorized errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get("/api/probe")
|
||||
const body = yield* response.json
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers["www-authenticate"] ?? "").toContain("Basic")
|
||||
expect(body).toEqual({ _tag: "UnauthorizedError", message: "Authentication required" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
151
packages/opencode/test/server/httpapi-compression.test.ts
Normal file
151
packages/opencode/test/server/httpapi-compression.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { gunzipSync, inflateSync } from "node:zlib"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
// /config echoes the config back. Padding the config pushes the response body
|
||||
// well past the 1024 B threshold so we can observe compression behavior.
|
||||
function fatConfig() {
|
||||
const instructions: string[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
instructions.push(`padding-instruction-${i}-${"x".repeat(40)}`)
|
||||
}
|
||||
return {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
username: "compression-test-user",
|
||||
instructions,
|
||||
}
|
||||
}
|
||||
|
||||
describe("HttpApi compression", () => {
|
||||
describe("encodes responses", () => {
|
||||
test("gzips JSON when Accept-Encoding includes gzip and body exceeds threshold", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-encoding")).toBe("gzip")
|
||||
const compressed = new Uint8Array(await response.arrayBuffer())
|
||||
const decompressed = gunzipSync(compressed)
|
||||
const json = JSON.parse(new TextDecoder().decode(decompressed))
|
||||
expect(json).toMatchObject({ username: "compression-test-user" })
|
||||
expect(compressed.byteLength).toBeLessThan(decompressed.byteLength)
|
||||
})
|
||||
|
||||
test("uses deflate when only deflate is acceptable", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "deflate" },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-encoding")).toBe("deflate")
|
||||
const compressed = new Uint8Array(await response.arrayBuffer())
|
||||
const decompressed = inflateSync(compressed)
|
||||
const json = JSON.parse(new TextDecoder().decode(decompressed))
|
||||
expect(json).toMatchObject({ username: "compression-test-user" })
|
||||
})
|
||||
|
||||
test("prefers gzip when both gzip and deflate are acceptable", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip, deflate" },
|
||||
})
|
||||
expect(response.headers.get("content-encoding")).toBe("gzip")
|
||||
})
|
||||
|
||||
test("does not include the original Content-Length when compressed", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
|
||||
})
|
||||
const compressed = new Uint8Array(await response.arrayBuffer())
|
||||
const declared = response.headers.get("content-length")
|
||||
// Either absent (transfer-encoding chunked) or matches the compressed length.
|
||||
if (declared !== null) expect(Number(declared)).toBe(compressed.byteLength)
|
||||
})
|
||||
})
|
||||
|
||||
describe("skips", () => {
|
||||
test("when no Accept-Encoding header is present", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
})
|
||||
|
||||
test("when Accept-Encoding only allows unsupported encodings", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "br" },
|
||||
})
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
})
|
||||
|
||||
test("when the response body is below the 1024-byte threshold", async () => {
|
||||
// A bare config produces a tiny response (~few hundred bytes).
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const response = await app().request("/config", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const body = new Uint8Array(await response.arrayBuffer())
|
||||
expect(body.byteLength).toBeLessThan(1024)
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
})
|
||||
|
||||
test("HEAD requests", async () => {
|
||||
await using tmp = await tmpdir({ config: fatConfig() })
|
||||
const response = await app().request("/config", {
|
||||
method: "HEAD",
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
|
||||
})
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("streaming exclusions", () => {
|
||||
test("/event SSE is not compressed", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const controller = new AbortController()
|
||||
const response = await app().request("/event", {
|
||||
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
|
||||
signal: controller.signal,
|
||||
})
|
||||
try {
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
} finally {
|
||||
controller.abort()
|
||||
await response.body?.cancel().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
test("/global/event SSE is not compressed", async () => {
|
||||
const controller = new AbortController()
|
||||
const response = await app().request("/global/event", {
|
||||
headers: { "accept-encoding": "gzip" },
|
||||
signal: controller.signal,
|
||||
})
|
||||
try {
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
} finally {
|
||||
controller.abort()
|
||||
await response.body?.cancel().catch(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
110
packages/opencode/test/server/httpapi-config.test.ts
Normal file
110
packages/opencode/test/server/httpapi-config.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
import { waitGlobalBusEvent } from "./global-bus"
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function waitDisposed(directory: string) {
|
||||
return waitGlobalBusEvent({
|
||||
message: "timed out waiting for instance disposal",
|
||||
predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
|
||||
})
|
||||
}
|
||||
|
||||
const tmpdirEffect = (options: Parameters<typeof tmpdir>[0]) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir(options)),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("config HttpApi", () => {
|
||||
it.live(
|
||||
"serves config update through the default server app",
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirEffect({ config: { formatter: false, lsp: false } })
|
||||
const disposed = yield* waitDisposed(tmp.path).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request("/config", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
body: JSON.stringify({ username: "patched-user", formatter: false, lsp: false }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({
|
||||
username: "patched-user",
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
})
|
||||
yield* Fiber.join(disposed)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "config.json")).json())).toMatchObject({
|
||||
username: "patched-user",
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"serves config with active provider model status",
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirEffect({
|
||||
config: {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
provider: {
|
||||
omniroute: {
|
||||
models: {
|
||||
"gpt-4o": {
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request("/config", {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({
|
||||
provider: {
|
||||
omniroute: {
|
||||
models: {
|
||||
"gpt-4o": {
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
63
packages/opencode/test/server/httpapi-control-plane.test.ts
Normal file
63
packages/opencode/test/server/httpapi-control-plane.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Option, Ref } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Installation } from "../../src/installation"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
|
||||
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
|
||||
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
|
||||
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
|
||||
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const input = MoveSession.Input.make({
|
||||
sessionID: SessionV2.ID.make("ses_move"),
|
||||
destination: { directory: AbsolutePath.make("/destination") },
|
||||
moveChanges: true,
|
||||
})
|
||||
const called = Ref.makeUnsafe<MoveSession.Input | undefined>(undefined)
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
|
||||
Layer.provide([authorizationLayer, schemaErrorLayer]),
|
||||
// Raw HttpApi routes expose an opaque handler context at the request boundary.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
|
||||
),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provide(Layer.mock(Auth.Service)({})),
|
||||
Layer.provide(Layer.mock(Config.Service)({})),
|
||||
Layer.provide(Layer.mock(Installation.Service)({})),
|
||||
Layer.provide(
|
||||
Layer.mock(MoveSession.Service)({
|
||||
moveSession: (value) => Ref.set(called, value),
|
||||
}),
|
||||
),
|
||||
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
|
||||
)
|
||||
const it = testEffect(apiLayer)
|
||||
|
||||
describe("control-plane HttpApi", () => {
|
||||
it.live("moves a session through the root control-plane route", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.post("/experimental/control-plane/move-session").pipe(
|
||||
HttpClientRequest.setBody(HttpBody.jsonUnsafe(input)),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(yield* Ref.get(called)).toEqual(input)
|
||||
}),
|
||||
)
|
||||
})
|
||||
63
packages/opencode/test/server/httpapi-cors-vary.test.ts
Normal file
63
packages/opencode/test/server/httpapi-cors-vary.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
const PREFLIGHT_HEADERS = {
|
||||
origin: "http://localhost:3000",
|
||||
"access-control-request-method": "POST",
|
||||
"access-control-request-headers": "content-type, x-opencode-directory",
|
||||
}
|
||||
|
||||
// effect-smol's HttpMiddleware.cors overwrites `Vary: Origin` with
|
||||
// `Vary: Access-Control-Request-Headers` on OPTIONS preflight responses
|
||||
// (the two share the same record key during the spread). With dynamic
|
||||
// origin echoing, missing Vary: Origin lets shared caches serve a preflight
|
||||
// cached for one origin against a different origin. corsVaryFixLayer
|
||||
// restores the merged form.
|
||||
describe("CORS preflight Vary header", () => {
|
||||
test("HTTP API backend preflight Vary contains Origin", async () => {
|
||||
const response = await app().request("/global/config", {
|
||||
method: "OPTIONS",
|
||||
headers: PREFLIGHT_HEADERS,
|
||||
})
|
||||
|
||||
expect([200, 204]).toContain(response.status)
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
expect((response.headers.get("vary") ?? "").toLowerCase()).toContain("origin")
|
||||
})
|
||||
|
||||
test("HTTP API backend preflight Vary still preserves Access-Control-Request-Headers", async () => {
|
||||
const response = await app().request("/global/config", {
|
||||
method: "OPTIONS",
|
||||
headers: PREFLIGHT_HEADERS,
|
||||
})
|
||||
|
||||
const vary = (response.headers.get("vary") ?? "").toLowerCase()
|
||||
expect(vary).toContain("origin")
|
||||
expect(vary).toContain("access-control-request-headers")
|
||||
})
|
||||
|
||||
test("HTTP API backend does not duplicate Origin in Vary", async () => {
|
||||
const response = await app().request("/global/config", {
|
||||
method: "OPTIONS",
|
||||
headers: PREFLIGHT_HEADERS,
|
||||
})
|
||||
|
||||
const vary = response.headers.get("vary") ?? ""
|
||||
const originCount = vary
|
||||
.split(",")
|
||||
.map((s: string) => s.trim().toLowerCase())
|
||||
.filter((s: string) => s === "origin").length
|
||||
expect(originCount).toBe(1)
|
||||
})
|
||||
})
|
||||
122
packages/opencode/test/server/httpapi-cors.test.ts
Normal file
122
packages/opencode/test/server/httpapi-cors.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config, ConfigProvider, Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { HttpApiApp } 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_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
}
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.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")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("adds CORS headers to unauthorized responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
HttpApiApp.createRoutes().pipe(
|
||||
Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: "secret" }))),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(new URL("/global/config", "http://localhost"), {
|
||||
headers: { origin: "https://app.opencode.ai" },
|
||||
}),
|
||||
HttpApiApp.context,
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe("https://app.opencode.ai")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses custom CORS origins passed to the server", () =>
|
||||
Effect.gen(function* () {
|
||||
const listener = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] })),
|
||||
(listener) => Effect.promise(() => listener.stop(true)),
|
||||
)
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL(InstancePaths.path, listener.url), {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "https://custom.example",
|
||||
"access-control-request-method": "GET",
|
||||
"access-control-request-headers": "authorization",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe("https://custom.example")
|
||||
expect(response.headers.get("access-control-allow-headers")).toBe("authorization")
|
||||
|
||||
const rejected = yield* Effect.promise(() =>
|
||||
fetch(new URL(InstancePaths.path, listener.url), {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "https://evil.example",
|
||||
"access-control-request-method": "GET",
|
||||
"access-control-request-headers": "authorization",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(rejected.status).toBe(204)
|
||||
expect(rejected.headers.get("access-control-allow-origin")).not.toBe("https://evil.example")
|
||||
}),
|
||||
)
|
||||
})
|
||||
101
packages/opencode/test/server/httpapi-error-middleware.test.ts
Normal file
101
packages/opencode/test/server/httpapi-error-middleware.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { errorLayer } from "../../src/server/routes/instance/httpapi/middleware/error"
|
||||
import { NotFoundError } from "../../src/storage/storage"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))
|
||||
|
||||
function expectUnknownErrorBody(body: unknown) {
|
||||
expect(body).toMatchObject({
|
||||
name: "UnknownError",
|
||||
data: { message: "Unexpected server error. Check server logs for details." },
|
||||
})
|
||||
expect((body as { data?: { ref?: unknown } }).data?.ref).toMatch(/^err_[0-9a-f-]{8}$/)
|
||||
}
|
||||
|
||||
describe("HttpApi error middleware", () => {
|
||||
it.live("returns a safe body for unknown 500 defects", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* HttpRouter.add("GET", "/boom", Effect.die(new Error("secret stack marker"))).pipe(
|
||||
Layer.provide(errorLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
const response = yield* HttpClientRequest.get("/boom").pipe(HttpClient.execute)
|
||||
const body = yield* response.json
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expectUnknownErrorBody(body)
|
||||
expect(JSON.stringify(body)).not.toContain("secret stack marker")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns a safe body for named defects", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* HttpRouter.add(
|
||||
"GET",
|
||||
"/named",
|
||||
Effect.die(new NamedError.Unknown({ message: "secret named marker" })),
|
||||
).pipe(Layer.provide(errorLayer), HttpRouter.serve, Layer.build)
|
||||
|
||||
const response = yield* HttpClientRequest.get("/named").pipe(HttpClient.execute)
|
||||
const body = yield* response.json
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expectUnknownErrorBody(body)
|
||||
expect(JSON.stringify(body)).not.toContain("secret named marker")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns invalid config defects as structured client errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const configError = new ConfigErrorV1.InvalidError({
|
||||
path: "/tmp/opencode.json",
|
||||
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
|
||||
})
|
||||
|
||||
yield* HttpRouter.add("GET", "/config-error", Effect.die(configError)).pipe(
|
||||
Layer.provide(errorLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
const response = yield* HttpClientRequest.get("/config-error").pipe(HttpClient.execute)
|
||||
const body = yield* response.json
|
||||
const serialized = JSON.stringify(body)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toMatchObject({
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "/tmp/opencode.json",
|
||||
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
|
||||
},
|
||||
})
|
||||
expect(serialized).toContain("/tmp/opencode.json")
|
||||
expect(serialized).toContain("anthropic")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not map storage not-found defects to 404", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* HttpRouter.add(
|
||||
"GET",
|
||||
"/missing",
|
||||
Effect.die(new NotFoundError({ message: "Resource not found: secret" })),
|
||||
).pipe(Layer.provide(errorLayer), HttpRouter.serve, Layer.build)
|
||||
|
||||
const response = yield* HttpClientRequest.get("/missing").pipe(HttpClient.execute)
|
||||
const body = yield* response.json
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expectUnknownErrorBody(body)
|
||||
}),
|
||||
)
|
||||
})
|
||||
94
packages/opencode/test/server/httpapi-event.test.ts
Normal file
94
packages/opencode/test/server/httpapi-event.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Queue, Schema, Stream } from "effect"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const EventData = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
type: Schema.String,
|
||||
properties: Schema.Record(Schema.String, Schema.Any),
|
||||
})
|
||||
|
||||
const readEvent = (reader: Queue.Dequeue<Uint8Array>) =>
|
||||
Effect.gen(function* () {
|
||||
const value = yield* Queue.take(reader).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for event")),
|
||||
}),
|
||||
)
|
||||
return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(value).replace(/^data: /, "")))
|
||||
})
|
||||
|
||||
const openEventStream = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* requestInDirectory(EventPaths.event, directory)
|
||||
const reader = yield* Queue.unbounded<Uint8Array>()
|
||||
yield* response.stream.pipe(
|
||||
Stream.runForEach((value) => Queue.offer(reader, value)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return { response, reader }
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const it = testEffect(httpApiLayer)
|
||||
|
||||
describe("event HttpApi", () => {
|
||||
it.instance(
|
||||
"serves event stream",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const { response, reader } = yield* openEventStream(directory)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers["content-type"]).toContain("text/event-stream")
|
||||
expect(response.headers["cache-control"]).toBe("no-cache, no-transform")
|
||||
expect(response.headers["x-accel-buffering"]).toBe("no")
|
||||
expect(response.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"keeps the event stream open after the initial event",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const { reader } = yield* openEventStream(directory)
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
|
||||
// If no second event arrives within 250ms, the stream is still open.
|
||||
const status = yield* Queue.take(reader).pipe(
|
||||
Effect.as("event" as const),
|
||||
Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed("open" as const) }),
|
||||
)
|
||||
expect(status).toBe("open")
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"delivers instance events after the initial event",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const { reader } = yield* openEventStream(directory)
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
|
||||
const created = yield* requestInDirectory("/session", directory, { method: "POST" })
|
||||
expect(created.status).toBe(200)
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "session.created" })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
64
packages/opencode/test/server/httpapi-exercise/assertions.ts
Normal file
64
packages/opencode/test/server/httpapi-exercise/assertions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { CallResult, JsonObject } from "./types"
|
||||
|
||||
export function parse(text: string): unknown {
|
||||
if (!text) return undefined
|
||||
try {
|
||||
return JSON.parse(text) as unknown
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
export function looksJson(result: CallResult) {
|
||||
return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[")
|
||||
}
|
||||
|
||||
export function stable(value: unknown): string {
|
||||
return JSON.stringify(sort(value))
|
||||
}
|
||||
|
||||
function sort(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sort)
|
||||
if (!value || typeof value !== "object") return value
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, sort(item)]),
|
||||
)
|
||||
}
|
||||
|
||||
export function array(value: unknown): asserts value is unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error("expected array")
|
||||
}
|
||||
|
||||
export function object(value: unknown): asserts value is JsonObject {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object")
|
||||
}
|
||||
|
||||
export function boolean(value: unknown): asserts value is boolean {
|
||||
if (typeof value !== "boolean") throw new Error("expected boolean")
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is JsonObject {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function check(value: boolean, message: string): asserts value {
|
||||
if (!value) throw new Error(message)
|
||||
}
|
||||
|
||||
export function message(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function pad(value: string, size: number) {
|
||||
return value.length >= size ? value : value + " ".repeat(size - value.length)
|
||||
}
|
||||
|
||||
export function indent(value: string) {
|
||||
return value
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n")
|
||||
}
|
||||
144
packages/opencode/test/server/httpapi-exercise/backend.ts
Normal file
144
packages/opencode/test/server/httpapi-exercise/backend.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { ConfigProvider, Effect, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { parse } from "./assertions"
|
||||
import { runtime, type Runtime } from "./runtime"
|
||||
import type { ActiveScenario, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
|
||||
|
||||
type CallOptions = {
|
||||
auth?: {
|
||||
password?: string
|
||||
username?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function call(scenario: ActiveScenario, ctx: SeededContext<unknown>, options: CallOptions = {}) {
|
||||
return Effect.promise(async () =>
|
||||
capture(await app(await runtime(), options).request(toRequest(scenario, ctx)), scenario.capture),
|
||||
)
|
||||
}
|
||||
|
||||
export function callAuthProbe(scenario: ActiveScenario, credentials: "missing" | "valid" = "missing") {
|
||||
return Effect.promise(async () => {
|
||||
const controller = new AbortController()
|
||||
return Promise.race([
|
||||
Promise.resolve(
|
||||
app(await runtime(), { auth: { password: "secret" } }).request(
|
||||
toAuthProbeRequest(scenario, credentials, controller.signal),
|
||||
),
|
||||
).then((response) => capture(response, scenario.capture)),
|
||||
Bun.sleep(1_000).then(() => {
|
||||
controller.abort("auth probe timed out")
|
||||
return {
|
||||
status: 0,
|
||||
contentType: "",
|
||||
text: "auth probe timed out",
|
||||
body: undefined,
|
||||
timedOut: true,
|
||||
}
|
||||
}),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
type CachedApp = BackendApp & { readonly dispose: () => Promise<void> }
|
||||
|
||||
const appCache: Partial<Record<string, CachedApp>> = {}
|
||||
|
||||
export async function disposeApps() {
|
||||
const apps = Object.values(appCache)
|
||||
for (const key of Object.keys(appCache)) delete appCache[key]
|
||||
await Promise.all(apps.flatMap((app) => (app === undefined ? [] : [app.dispose()])))
|
||||
}
|
||||
|
||||
function app(modules: Runtime, options: CallOptions) {
|
||||
const username = options.auth?.username
|
||||
const password = options.auth?.password
|
||||
const cacheKey = `${username ?? ""}:${password ?? ""}`
|
||||
if (appCache[cacheKey]) return appCache[cacheKey]
|
||||
|
||||
const web = HttpRouter.toWebHandler(
|
||||
modules.HttpApiApp.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password, OPENCODE_SERVER_USERNAME: username }),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true, memoMap: modules.memoMap },
|
||||
)
|
||||
return (appCache[cacheKey] = {
|
||||
dispose: web.dispose,
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return web.handler(
|
||||
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
|
||||
modules.HttpApiApp.context,
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function toRequest(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
|
||||
const spec = scenario.request(ctx, ctx.state)
|
||||
return new Request(new URL(spec.path, "http://localhost"), {
|
||||
method: scenario.method,
|
||||
headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers },
|
||||
body: spec.body === undefined ? undefined : JSON.stringify(spec.body),
|
||||
})
|
||||
}
|
||||
|
||||
function toAuthProbeRequest(scenario: ActiveScenario, credentials: "missing" | "valid", signal: AbortSignal) {
|
||||
const spec = scenario.authProbe ?? {
|
||||
path: authProbePath(scenario.path),
|
||||
body: scenario.method === "GET" ? undefined : {},
|
||||
}
|
||||
const headers = {
|
||||
...(spec.body === undefined ? {} : { "content-type": "application/json" }),
|
||||
...spec.headers,
|
||||
...(credentials === "valid" ? { authorization: basic("opencode", "secret") } : {}),
|
||||
}
|
||||
return new Request(new URL(spec.path, "http://localhost"), {
|
||||
method: scenario.method,
|
||||
headers,
|
||||
body: spec.body === undefined ? undefined : JSON.stringify(spec.body),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
function basic(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function authProbePath(path: string) {
|
||||
return path
|
||||
.replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`)
|
||||
.replace(/:([^/]+)/g, (_match, key: string) => `auth_${key}`)
|
||||
}
|
||||
|
||||
async function capture(response: Response, mode: CaptureMode): Promise<CallResult> {
|
||||
const text = mode === "stream" ? await captureStream(response) : await response.text()
|
||||
return {
|
||||
status: response.status,
|
||||
contentType: response.headers.get("content-type") ?? "",
|
||||
text,
|
||||
body: parse(text),
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function captureStream(response: Response) {
|
||||
if (!response.body) return ""
|
||||
const reader = response.body.getReader()
|
||||
const read = reader.read().then(
|
||||
(result) => ({ result }),
|
||||
(error: unknown) => ({ error }),
|
||||
)
|
||||
const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))])
|
||||
if ("timeout" in winner) {
|
||||
await reader.cancel("timed out waiting for stream chunk").catch(() => undefined)
|
||||
throw new Error("timed out waiting for stream chunk")
|
||||
}
|
||||
if ("error" in winner) throw winner.error
|
||||
await reader.cancel().catch(() => undefined)
|
||||
if (winner.result.done) return ""
|
||||
return new TextDecoder().decode(winner.result.value)
|
||||
}
|
||||
210
packages/opencode/test/server/httpapi-exercise/dsl.ts
Normal file
210
packages/opencode/test/server/httpapi-exercise/dsl.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Effect } from "effect"
|
||||
import { looksJson } from "./assertions"
|
||||
import type {
|
||||
ActiveScenario,
|
||||
AuthPolicy,
|
||||
BuilderState,
|
||||
CallResult,
|
||||
Comparison,
|
||||
Method,
|
||||
ProjectOptions,
|
||||
RequestSpec,
|
||||
ScenarioContext,
|
||||
SeededContext,
|
||||
TodoScenario,
|
||||
} from "./types"
|
||||
|
||||
class ScenarioBuilder<S = undefined> {
|
||||
private readonly state: BuilderState<S>
|
||||
|
||||
constructor(method: Method, path: string, name: string, auth: AuthPolicy) {
|
||||
this.state = {
|
||||
method,
|
||||
path,
|
||||
name,
|
||||
project: { git: true },
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it.
|
||||
seed: () => Effect.succeed(undefined as S),
|
||||
request: (ctx) => ({ path, headers: ctx.headers() }),
|
||||
authProbe: undefined,
|
||||
capture: "full",
|
||||
mutates: false,
|
||||
reset: true,
|
||||
auth,
|
||||
}
|
||||
}
|
||||
|
||||
global() {
|
||||
return this.clone({ project: undefined, request: () => ({ path: this.state.path }) })
|
||||
}
|
||||
|
||||
inProject(project: ProjectOptions = { git: true }) {
|
||||
return this.clone({ project })
|
||||
}
|
||||
|
||||
withLlm() {
|
||||
return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } })
|
||||
}
|
||||
|
||||
at(request: BuilderState<S>["request"]) {
|
||||
return this.clone({ request })
|
||||
}
|
||||
|
||||
probe(authProbe: RequestSpec) {
|
||||
return this.clone({ authProbe })
|
||||
}
|
||||
|
||||
mutating() {
|
||||
return this.clone({ mutates: true })
|
||||
}
|
||||
|
||||
preserveDatabase() {
|
||||
return this.clone({ reset: false })
|
||||
}
|
||||
|
||||
stream() {
|
||||
return this.clone({ capture: "stream" })
|
||||
}
|
||||
|
||||
protected() {
|
||||
return this.auth("protected")
|
||||
}
|
||||
|
||||
public() {
|
||||
return this.auth("public")
|
||||
}
|
||||
|
||||
publicBypass() {
|
||||
return this.auth("public-bypass")
|
||||
}
|
||||
|
||||
ticketBypass() {
|
||||
return this.auth("ticket-bypass")
|
||||
}
|
||||
|
||||
private auth(auth: AuthPolicy) {
|
||||
return this.clone({ auth })
|
||||
}
|
||||
|
||||
/** Assert a non-JSON or shape-only response. */
|
||||
ok(status = 200, compare: Comparison = "status") {
|
||||
return this.done(compare, (_ctx, result) =>
|
||||
Effect.sync(() => {
|
||||
if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
status(
|
||||
status = 200,
|
||||
inspect?: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>,
|
||||
compare: Comparison = "status",
|
||||
) {
|
||||
return this.done(compare, (ctx, result) =>
|
||||
Effect.gen(function* () {
|
||||
if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
|
||||
if (inspect) yield* inspect(ctx, result)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Assert JSON status/content-type plus an optional synchronous body check. */
|
||||
json(status = 200, inspect?: (body: unknown, ctx: SeededContext<S>) => void, compare: Comparison = "json") {
|
||||
return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare)
|
||||
}
|
||||
|
||||
/** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */
|
||||
jsonEffect(
|
||||
status = 200,
|
||||
inspect?: (body: unknown, ctx: SeededContext<S>) => Effect.Effect<void>,
|
||||
compare: Comparison = "json",
|
||||
) {
|
||||
return this.done(compare, (ctx, result) =>
|
||||
Effect.gen(function* () {
|
||||
if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
|
||||
if (!looksJson(result))
|
||||
throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`)
|
||||
if (inspect) yield* inspect(result.body, ctx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private clone(next: Partial<BuilderState<S>>) {
|
||||
const builder = new ScenarioBuilder<S>(this.state.method, this.state.path, this.state.name, this.state.auth)
|
||||
Object.assign(builder.state, this.state, next)
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed typed state before the HTTP request. The returned value becomes `ctx.state`
|
||||
* for `.at(...)` and assertions, giving stateful route tests type-safe setup.
|
||||
*/
|
||||
seeded<Next>(seed: (ctx: ScenarioContext) => Effect.Effect<Next>) {
|
||||
const builder = new ScenarioBuilder<Next>(this.state.method, this.state.path, this.state.name, this.state.auth)
|
||||
Object.assign(builder.state, this.state, { seed })
|
||||
return builder
|
||||
}
|
||||
|
||||
private done(
|
||||
compare: Comparison,
|
||||
expect: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>,
|
||||
): ActiveScenario {
|
||||
const state = this.state
|
||||
return {
|
||||
kind: "active",
|
||||
method: state.method,
|
||||
path: state.path,
|
||||
name: state.name,
|
||||
project: state.project,
|
||||
seed: state.seed,
|
||||
authProbe: state.authProbe,
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder.
|
||||
request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }),
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder.
|
||||
expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result),
|
||||
compare,
|
||||
capture: state.capture,
|
||||
mutates: state.mutates,
|
||||
reset: state.reset,
|
||||
auth: state.auth,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const routes = (auth: AuthPolicy) => ({
|
||||
get: (path: string, name: string) => new ScenarioBuilder("GET", path, name, auth),
|
||||
post: (path: string, name: string) => new ScenarioBuilder("POST", path, name, auth),
|
||||
put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name, auth),
|
||||
patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name, auth),
|
||||
delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name, auth),
|
||||
})
|
||||
|
||||
export const http = {
|
||||
protected: routes("protected"),
|
||||
public: routes("public"),
|
||||
publicBypass: routes("public-bypass"),
|
||||
ticketBypass: routes("ticket-bypass"),
|
||||
}
|
||||
|
||||
export const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({
|
||||
kind: "todo",
|
||||
method,
|
||||
path,
|
||||
name,
|
||||
reason,
|
||||
})
|
||||
|
||||
export function route(template: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce(
|
||||
(next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value),
|
||||
template,
|
||||
)
|
||||
}
|
||||
|
||||
export function controlledPtyInput(title: string | undefined) {
|
||||
return {
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "sleep 30"],
|
||||
...(title ? { title } : {}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
|
||||
const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL
|
||||
export const exerciseGlobalRoot =
|
||||
process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ??
|
||||
path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`)
|
||||
process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data")
|
||||
process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config")
|
||||
process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state")
|
||||
process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache")
|
||||
process.env.OPENCODE_DISABLE_SHARE = "true"
|
||||
export const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode")
|
||||
export const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode")
|
||||
|
||||
const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB
|
||||
export const exerciseDatabasePath =
|
||||
process.env.OPENCODE_HTTPAPI_EXERCISE_DB ??
|
||||
path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`)
|
||||
process.env.OPENCODE_DB = exerciseDatabasePath
|
||||
Flag.OPENCODE_DB = exerciseDatabasePath
|
||||
|
||||
export const original = {
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
export const cleanupExercisePaths = Effect.promise(async () => {
|
||||
const fs = await import("fs/promises")
|
||||
if (!preserveExerciseDatabase) {
|
||||
await Promise.all(
|
||||
[exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) =>
|
||||
fs.rm(file, { force: true }).catch(() => undefined),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!preserveExerciseGlobalRoot)
|
||||
await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined)
|
||||
})
|
||||
1617
packages/opencode/test/server/httpapi-exercise/index.ts
Normal file
1617
packages/opencode/test/server/httpapi-exercise/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
66
packages/opencode/test/server/httpapi-exercise/report.ts
Normal file
66
packages/opencode/test/server/httpapi-exercise/report.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Duration } from "effect"
|
||||
import { indent, pad } from "./assertions"
|
||||
import type { Options, Result, Scenario } from "./types"
|
||||
|
||||
export const color = {
|
||||
dim: "\x1b[2m",
|
||||
green: "\x1b[32m",
|
||||
red: "\x1b[31m",
|
||||
yellow: "\x1b[33m",
|
||||
cyan: "\x1b[36m",
|
||||
reset: "\x1b[0m",
|
||||
}
|
||||
|
||||
export function printHeader(
|
||||
options: Options,
|
||||
effectRoutes: string[],
|
||||
selected: Scenario[],
|
||||
missing: string[],
|
||||
extra: Scenario[],
|
||||
paths: { database: string; global: string },
|
||||
) {
|
||||
console.log(`${color.cyan}HttpApi exerciser${color.reset}`)
|
||||
console.log(`${color.dim}db=${paths.database}${color.reset}`)
|
||||
console.log(`${color.dim}global=${paths.global}${color.reset}`)
|
||||
console.log(
|
||||
`${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length}${color.reset}`,
|
||||
)
|
||||
console.log("")
|
||||
}
|
||||
|
||||
export function printResults(results: Result[], missing: string[], extra: Scenario[]) {
|
||||
for (const result of results) {
|
||||
if (result.status === "pass") {
|
||||
console.log(
|
||||
`${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (result.status === "skip") {
|
||||
console.log(
|
||||
`${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
console.log(
|
||||
`${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`,
|
||||
)
|
||||
console.log(`${color.red}${indent(result.message)}${color.reset}`)
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.log("\nMissing scenarios")
|
||||
for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`)
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
console.log("\nExtra scenarios")
|
||||
for (const scenario of extra)
|
||||
console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`)
|
||||
}
|
||||
console.log(
|
||||
`\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`,
|
||||
)
|
||||
}
|
||||
|
||||
function routeKey(scenario: Scenario) {
|
||||
return `${scenario.method} ${scenario.path}`
|
||||
}
|
||||
96
packages/opencode/test/server/httpapi-exercise/routing.ts
Normal file
96
packages/opencode/test/server/httpapi-exercise/routing.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Duration } from "effect"
|
||||
import { OpenApiMethods, type OpenApiSpec, type Options, type Result, type Scenario } from "./types"
|
||||
|
||||
type ScenarioTimeout = `${number} ${Duration.Unit}`
|
||||
|
||||
const durationUnits = new Set<string>([
|
||||
"nano",
|
||||
"nanos",
|
||||
"micro",
|
||||
"micros",
|
||||
"milli",
|
||||
"millis",
|
||||
"second",
|
||||
"seconds",
|
||||
"minute",
|
||||
"minutes",
|
||||
"hour",
|
||||
"hours",
|
||||
"day",
|
||||
"days",
|
||||
"week",
|
||||
"weeks",
|
||||
])
|
||||
|
||||
export function routeKeys(spec: OpenApiSpec) {
|
||||
return Object.entries(spec.paths ?? {})
|
||||
.flatMap(([path, item]) =>
|
||||
OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
|
||||
)
|
||||
.sort()
|
||||
}
|
||||
|
||||
export function routeKey(scenario: Scenario) {
|
||||
return `${scenario.method} ${scenario.path}`
|
||||
}
|
||||
|
||||
export function coverageResult(scenario: Scenario): Result {
|
||||
if (scenario.kind === "todo") return { status: "skip", scenario }
|
||||
return { status: "pass", scenario }
|
||||
}
|
||||
|
||||
export function parseOptions(args: string[]): Options {
|
||||
const mode = option(args, "--mode") ?? "effect"
|
||||
if (mode !== "effect" && mode !== "coverage" && mode !== "auth") throw new Error(`invalid --mode ${mode}`)
|
||||
return {
|
||||
mode,
|
||||
include: option(args, "--include"),
|
||||
startAt: option(args, "--start-at"),
|
||||
stopAt: option(args, "--stop-at"),
|
||||
failOnMissing: args.includes("--fail-on-missing"),
|
||||
failOnSkip: args.includes("--fail-on-skip"),
|
||||
scenarioTimeout: parseScenarioTimeout(option(args, "--scenario-timeout") ?? "30 seconds"),
|
||||
progress: args.includes("--progress"),
|
||||
trace: args.includes("--trace"),
|
||||
}
|
||||
}
|
||||
|
||||
export function matches(options: Options, scenario: Scenario) {
|
||||
if (!options.include) return true
|
||||
return (
|
||||
scenario.name.includes(options.include) ||
|
||||
scenario.path.includes(options.include) ||
|
||||
scenario.method.includes(options.include.toUpperCase())
|
||||
)
|
||||
}
|
||||
|
||||
export function selectedScenarios(options: Options, scenarios: Scenario[]) {
|
||||
const included = scenarios.filter((scenario) => matches(options, scenario))
|
||||
const start = options.startAt ? included.findIndex((scenario) => matchesName(options.startAt!, scenario)) : 0
|
||||
const end = options.stopAt
|
||||
? included.findIndex((scenario) => matchesName(options.stopAt!, scenario))
|
||||
: included.length - 1
|
||||
if (start === -1) throw new Error(`--start-at matched no scenario: ${options.startAt}`)
|
||||
if (end === -1) throw new Error(`--stop-at matched no scenario: ${options.stopAt}`)
|
||||
return included.slice(start, end + 1)
|
||||
}
|
||||
|
||||
function matchesName(value: string, scenario: Scenario) {
|
||||
return scenario.name.includes(value) || scenario.path.includes(value) || scenario.method.includes(value.toUpperCase())
|
||||
}
|
||||
|
||||
function option(args: string[], name: string) {
|
||||
const index = args.indexOf(name)
|
||||
if (index === -1) return undefined
|
||||
return args[index + 1]
|
||||
}
|
||||
|
||||
function parseScenarioTimeout(input: string) {
|
||||
if (!isScenarioTimeout(input)) throw new Error(`invalid --scenario-timeout ${input}`)
|
||||
return Duration.fromInputUnsafe(input)
|
||||
}
|
||||
|
||||
function isScenarioTimeout(input: string): input is ScenarioTimeout {
|
||||
const [amount, unit, extra] = input.trim().split(/\s+/)
|
||||
return extra === undefined && amount !== undefined && Number.isFinite(Number(amount)) && durationUnits.has(unit ?? "")
|
||||
}
|
||||
267
packages/opencode/test/server/httpapi-exercise/runner.ts
Normal file
267
packages/opencode/test/server/httpapi-exercise/runner.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Cause, Duration, Effect, Layer, Scope } from "effect"
|
||||
import { TestLLMServer } from "../../lib/llm-server"
|
||||
import type { Config } from "../../../src/config/config"
|
||||
|
||||
import type { MessageV2 } from "../../../src/session/message-v2"
|
||||
import { MessageID, PartID } from "../../../src/session/schema"
|
||||
import { call, callAuthProbe, disposeApps } from "./backend"
|
||||
import { original } from "./environment"
|
||||
import { runtime } from "./runtime"
|
||||
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
export function runScenario(options: Options) {
|
||||
return (scenario: Scenario) => {
|
||||
if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result)
|
||||
return runActive(options, scenario).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: options.scenarioTimeout,
|
||||
orElse: () => Effect.die(new Error(`scenario timed out after ${Duration.format(options.scenarioTimeout)}`)),
|
||||
}),
|
||||
Effect.as({ status: "pass", scenario } as Result),
|
||||
Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })),
|
||||
Effect.scoped,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function runActive(options: Options, scenario: ActiveScenario) {
|
||||
if (options.mode === "auth") return runAuth(scenario)
|
||||
|
||||
return withContext(options, scenario, "shared", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trace(options, scenario, "request start")
|
||||
const result = yield* call(scenario, ctx)
|
||||
yield* trace(options, scenario, `response ${result.status}`)
|
||||
yield* trace(options, scenario, "expect start")
|
||||
yield* scenario.expect(ctx, ctx.state, result)
|
||||
yield* trace(options, scenario, "expect done")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function runAuth(scenario: ActiveScenario) {
|
||||
return Effect.gen(function* () {
|
||||
const result = yield* callAuthProbe(scenario, "missing")
|
||||
if (scenario.auth === "protected") {
|
||||
if (result.status !== 401) throw new Error(`auth expected 401, got ${result.status}`)
|
||||
const authed = yield* callAuthProbe(scenario, "valid")
|
||||
if (authed.status === 401) throw new Error("auth rejected valid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 401) throw new Error("auth expected public access, got 401")
|
||||
if (result.timedOut) throw new Error("auth expected public access, probe timed out")
|
||||
})
|
||||
}
|
||||
|
||||
function withContext<A, E>(
|
||||
options: Options,
|
||||
scenario: ActiveScenario,
|
||||
label: string,
|
||||
use: (ctx: SeededContext<unknown>) => Effect.Effect<A, E>,
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
yield* trace(options, scenario, `${label} context acquire start`)
|
||||
const llm = scenario.project?.llm ? yield* TestLLMServer : undefined
|
||||
const project = scenario.project
|
||||
const dir = project
|
||||
? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url)))
|
||||
: undefined
|
||||
yield* trace(options, scenario, `${label} context acquire done`)
|
||||
return { dir, llm }
|
||||
}),
|
||||
(ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trace(options, scenario, `${label} tmpdir cleanup start`)
|
||||
yield* Effect.promise(async () => {
|
||||
await ctx.dir?.[Symbol.asyncDispose]()
|
||||
}).pipe(Effect.ignore)
|
||||
yield* trace(options, scenario, `${label} tmpdir cleanup done`)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trace(options, scenario, `${label} runtime start`)
|
||||
const modules = yield* Effect.promise(() => runtime())
|
||||
const scope = yield* Scope.Scope
|
||||
const app = yield* Layer.buildWithMemoMap(modules.AppLayer, modules.memoMap, scope)
|
||||
yield* trace(options, scenario, `${label} runtime done`)
|
||||
const path = context.dir?.path
|
||||
const instance = path
|
||||
? yield* trace(options, scenario, `${label} instance load start`).pipe(
|
||||
Effect.andThen(
|
||||
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
|
||||
Effect.provide(app),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sleep("100 millis").pipe(
|
||||
Effect.andThen(
|
||||
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
|
||||
Effect.provide(app),
|
||||
),
|
||||
),
|
||||
Effect.catchCause(() => Effect.failCause(cause)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.tap(() => trace(options, scenario, `${label} instance load done`)),
|
||||
)
|
||||
: undefined
|
||||
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(app))
|
||||
const directory = () => {
|
||||
if (!context.dir?.path) throw new Error("scenario needs a project directory")
|
||||
return context.dir.path
|
||||
}
|
||||
const llm = () => {
|
||||
if (!context.llm) throw new Error("scenario needs fake LLM")
|
||||
return context.llm
|
||||
}
|
||||
const base: ScenarioContext = {
|
||||
directory: context.dir?.path,
|
||||
headers: (extra) => ({
|
||||
...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}),
|
||||
...extra,
|
||||
}),
|
||||
file: (name, content) =>
|
||||
Effect.promise(() => {
|
||||
return Bun.write(`${directory()}/${name}`, content)
|
||||
}).pipe(Effect.asVoid),
|
||||
session: (input) =>
|
||||
run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))),
|
||||
sessionGet: (sessionID) =>
|
||||
run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe(
|
||||
Effect.catchCause(() => Effect.succeed(undefined)),
|
||||
),
|
||||
project: () =>
|
||||
Effect.sync(() => {
|
||||
if (!instance) throw new Error("scenario needs a project directory")
|
||||
return instance.project
|
||||
}),
|
||||
message: (sessionID, input) =>
|
||||
Effect.gen(function* () {
|
||||
const info: SessionV1.User = {
|
||||
id: MessageID.ascending(),
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
},
|
||||
}
|
||||
const part: SessionV1.TextPart = {
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: info.id,
|
||||
type: "text",
|
||||
text: input?.text ?? "hello",
|
||||
}
|
||||
yield* run(
|
||||
modules.Session.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
yield* svc.updateMessage(info)
|
||||
yield* svc.updatePart(part)
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { info, part }
|
||||
}),
|
||||
messages: (sessionID) =>
|
||||
run(modules.Session.Service.use((svc) => svc.messages({ sessionID }).pipe(Effect.orDie))),
|
||||
todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))),
|
||||
worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input).pipe(Effect.orDie))),
|
||||
worktreeRemove: (directory) =>
|
||||
run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)),
|
||||
llmText: (value) => Effect.suspend(() => llm().text(value)),
|
||||
llmWait: (count) => Effect.suspend(() => llm().wait(count)),
|
||||
tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)),
|
||||
}
|
||||
yield* trace(options, scenario, `${label} seed start`)
|
||||
const state = yield* scenario.seed(base)
|
||||
yield* trace(options, scenario, `${label} seed done`)
|
||||
yield* trace(options, scenario, `${label} use start`)
|
||||
const result = yield* use({ ...base, state })
|
||||
yield* trace(options, scenario, `${label} use done`)
|
||||
return result
|
||||
}).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)),
|
||||
),
|
||||
Effect.ensuring(scenario.reset ? resetState : Effect.void),
|
||||
)
|
||||
}
|
||||
|
||||
function trace(options: Options, scenario: ActiveScenario, phase: string) {
|
||||
return Effect.sync(() => {
|
||||
if (!options.trace) return
|
||||
console.log(`[trace] ${scenario.name}: ${phase}`)
|
||||
})
|
||||
}
|
||||
|
||||
function projectOptions(
|
||||
project: ProjectOptions,
|
||||
llmUrl: string | undefined,
|
||||
): { git?: boolean; config?: Partial<ConfigV1.Info> } {
|
||||
if (!project.llm || !llmUrl) return { git: project.git, config: project.config }
|
||||
const fake = fakeLlmConfig(llmUrl)
|
||||
return {
|
||||
git: project.git,
|
||||
config: {
|
||||
...fake,
|
||||
...project.config,
|
||||
provider: {
|
||||
...fake.provider,
|
||||
...project.config?.provider,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fakeLlmConfig(url: string): Partial<ConfigV1.Info> {
|
||||
return {
|
||||
model: "test/test-model",
|
||||
small_model: "test/test-model",
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100000, output: 10000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const resetState = Effect.promise(async () => {
|
||||
const modules = await runtime()
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
await disposeApps()
|
||||
await modules.disposeAllInstances()
|
||||
await modules.resetDatabase()
|
||||
await Bun.sleep(25)
|
||||
})
|
||||
52
packages/opencode/test/server/httpapi-exercise/runtime.ts
Normal file
52
packages/opencode/test/server/httpapi-exercise/runtime.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type Runtime = {
|
||||
PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"]
|
||||
HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"]
|
||||
AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"]
|
||||
memoMap: import("effect").Layer.MemoMap
|
||||
InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"]
|
||||
InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"]
|
||||
Session: (typeof import("../../../src/session/session"))["Session"]
|
||||
Todo: (typeof import("../../../src/session/todo"))["Todo"]
|
||||
Worktree: (typeof import("../../../src/worktree"))["Worktree"]
|
||||
Project: (typeof import("../../../src/project/project"))["Project"]
|
||||
Tui: typeof import("../../../src/server/shared/tui-control")
|
||||
disposeAllInstances: (typeof import("../../fixture/fixture"))["disposeAllInstances"]
|
||||
tmpdir: (typeof import("../../fixture/fixture"))["tmpdir"]
|
||||
resetDatabase: (typeof import("../../fixture/db"))["resetDatabase"]
|
||||
}
|
||||
|
||||
let runtimePromise: Promise<Runtime> | undefined
|
||||
|
||||
export function runtime() {
|
||||
return (runtimePromise ??= (async () => {
|
||||
const publicApi = await import("../../../src/server/routes/instance/httpapi/public")
|
||||
const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server")
|
||||
const appRuntime = await import("../../../src/effect/app-runtime")
|
||||
const { Layer } = await import("effect")
|
||||
const instanceRef = await import("../../../src/effect/instance-ref")
|
||||
const instanceStore = await import("../../../src/project/instance-store")
|
||||
const session = await import("../../../src/session/session")
|
||||
const todo = await import("../../../src/session/todo")
|
||||
const worktree = await import("../../../src/worktree")
|
||||
const project = await import("../../../src/project/project")
|
||||
const tui = await import("../../../src/server/shared/tui-control")
|
||||
const fixture = await import("../../fixture/fixture")
|
||||
const db = await import("../../fixture/db")
|
||||
return {
|
||||
PublicApi: publicApi.PublicApi,
|
||||
HttpApiApp: httpApiServer.HttpApiApp,
|
||||
AppLayer: appRuntime.AppLayer,
|
||||
memoMap: Layer.makeMemoMapUnsafe(),
|
||||
InstanceRef: instanceRef.InstanceRef,
|
||||
InstanceStore: instanceStore.InstanceStore,
|
||||
Session: session.Session,
|
||||
Todo: todo.Todo,
|
||||
Worktree: worktree.Worktree,
|
||||
Project: project.Project,
|
||||
Tui: tui,
|
||||
disposeAllInstances: fixture.disposeAllInstances,
|
||||
tmpdir: fixture.tmpdir,
|
||||
resetDatabase: db.resetDatabase,
|
||||
}
|
||||
})())
|
||||
}
|
||||
123
packages/opencode/test/server/httpapi-exercise/types.ts
Normal file
123
packages/opencode/test/server/httpapi-exercise/types.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Duration, Effect } from "effect"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import type { Config } from "../../../src/config/config"
|
||||
import type { Project } from "../../../src/project/project"
|
||||
import type { Worktree } from "../../../src/worktree"
|
||||
import type { MessageV2 } from "../../../src/session/message-v2"
|
||||
import type { SessionID } from "../../../src/session/schema"
|
||||
|
||||
export const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const
|
||||
export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
|
||||
|
||||
export type Method = (typeof Methods)[number]
|
||||
export type OpenApiMethod = (typeof OpenApiMethods)[number]
|
||||
export type Mode = "effect" | "coverage" | "auth"
|
||||
export type Comparison = "none" | "status" | "json"
|
||||
export type CaptureMode = "full" | "stream"
|
||||
export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass"
|
||||
export type ProjectOptions = { git?: boolean; config?: Partial<ConfigV1.Info>; llm?: boolean }
|
||||
export type OpenApiSpec = { paths?: Record<string, Partial<Record<OpenApiMethod, unknown>>> }
|
||||
export type JsonObject = Record<string, unknown>
|
||||
|
||||
export type Options = {
|
||||
mode: Mode
|
||||
include: string | undefined
|
||||
startAt: string | undefined
|
||||
stopAt: string | undefined
|
||||
failOnMissing: boolean
|
||||
failOnSkip: boolean
|
||||
scenarioTimeout: Duration.Duration
|
||||
progress: boolean
|
||||
trace: boolean
|
||||
}
|
||||
|
||||
export type RequestSpec = {
|
||||
path: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export type CallResult = {
|
||||
status: number
|
||||
contentType: string
|
||||
body: unknown
|
||||
text: string
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
export type BackendApp = {
|
||||
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
|
||||
}
|
||||
|
||||
/** Effect-native helpers available while setting up and asserting a scenario. */
|
||||
export type ScenarioContext = {
|
||||
directory: string | undefined
|
||||
headers: (extra?: Record<string, string>) => Record<string, string>
|
||||
file: (name: string, content: string) => Effect.Effect<void>
|
||||
session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect<SessionInfo>
|
||||
sessionGet: (sessionID: SessionID) => Effect.Effect<SessionInfo | undefined>
|
||||
project: () => Effect.Effect<Project.Info>
|
||||
message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect<MessageSeed>
|
||||
messages: (sessionID: SessionID) => Effect.Effect<SessionV1.WithParts[]>
|
||||
todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect<void>
|
||||
worktree: (input?: { name?: string }) => Effect.Effect<Worktree.Info>
|
||||
worktreeRemove: (directory: string) => Effect.Effect<void>
|
||||
llmText: (value: string) => Effect.Effect<void>
|
||||
llmWait: (count: number) => Effect.Effect<void>
|
||||
tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */
|
||||
export type SeededContext<S> = ScenarioContext & {
|
||||
state: S
|
||||
}
|
||||
|
||||
export type Scenario = ActiveScenario | TodoScenario
|
||||
export type ActiveScenario = {
|
||||
kind: "active"
|
||||
method: Method
|
||||
path: string
|
||||
name: string
|
||||
project: ProjectOptions | undefined
|
||||
seed: (ctx: ScenarioContext) => Effect.Effect<unknown>
|
||||
request: (ctx: ScenarioContext, state: unknown) => RequestSpec
|
||||
authProbe: RequestSpec | undefined
|
||||
expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect<void>
|
||||
compare: Comparison
|
||||
capture: CaptureMode
|
||||
mutates: boolean
|
||||
reset: boolean
|
||||
auth: AuthPolicy
|
||||
}
|
||||
|
||||
export type BuilderState<S> = {
|
||||
method: Method
|
||||
path: string
|
||||
name: string
|
||||
project: ProjectOptions | undefined
|
||||
seed: (ctx: ScenarioContext) => Effect.Effect<S>
|
||||
request: (ctx: SeededContext<S>) => RequestSpec
|
||||
authProbe: RequestSpec | undefined
|
||||
capture: CaptureMode
|
||||
mutates: boolean
|
||||
reset: boolean
|
||||
auth: AuthPolicy
|
||||
}
|
||||
|
||||
export type TodoScenario = {
|
||||
kind: "todo"
|
||||
method: Method
|
||||
path: string
|
||||
name: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type Result =
|
||||
| { status: "pass"; scenario: ActiveScenario }
|
||||
| { status: "fail"; scenario: ActiveScenario; message: string }
|
||||
| { status: "skip"; scenario: TodoScenario }
|
||||
|
||||
export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID }
|
||||
export type TodoInfo = { content: string; status: string; priority: string }
|
||||
export type MessageSeed = { info: SessionV1.User; part: SessionV1.TextPart }
|
||||
297
packages/opencode/test/server/httpapi-experimental.test.ts
Normal file
297
packages/opencode/test/server/httpapi-experimental.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AccountV2 } from "@opencode-ai/core/account"
|
||||
import { AccountTable } from "@opencode-ai/core/account/sql"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance
|
||||
|
||||
function request(path: string, directory: string, init: RequestInit = {}) {
|
||||
return requestInDirectory(path, directory, init)
|
||||
}
|
||||
|
||||
function createSession(input?: Session.CreateInput) {
|
||||
return Session.use.create(input)
|
||||
}
|
||||
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json.pipe(Effect.map((value) => value as T))
|
||||
}
|
||||
|
||||
function waitReady(input: { directory?: string; name?: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const ready = yield* Deferred.make<void>()
|
||||
const on = (event: GlobalEvent) => {
|
||||
if (event.payload.type !== Worktree.Event.Ready.type) return
|
||||
if (input.directory && event.directory !== input.directory) return
|
||||
if (input.name && event.payload.properties.name !== input.name) return
|
||||
Deferred.doneUnsafe(ready, Effect.void)
|
||||
}
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||
|
||||
return yield* Deferred.await(ready).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "10 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function insertAccount() {
|
||||
return Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(AccountTable)
|
||||
.values({
|
||||
id: AccountV2.ID.make("account-test"),
|
||||
email: "test@example.com",
|
||||
url: "https://console.example.com",
|
||||
access_token: AccountV2.AccessToken.make("access"),
|
||||
refresh_token: AccountV2.RefreshToken.make("refresh"),
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return "account-test"
|
||||
}),
|
||||
(id) =>
|
||||
Database.Service.use(({ db }) =>
|
||||
db
|
||||
.delete(AccountTable)
|
||||
.where(eq(AccountTable.id, AccountV2.ID.make(id)))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function setSessionUpdated(session: Session.Info, updated: number) {
|
||||
return Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: updated })
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
}
|
||||
|
||||
function withCreatedWorktree(
|
||||
directory: string,
|
||||
use: (info: Worktree.Info) => Effect.Effect<void, unknown, HttpClient.HttpClient>,
|
||||
) {
|
||||
const name = "api-test"
|
||||
const headers = { "content-type": "application/json" }
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.gen(function* () {
|
||||
const ready = yield* waitReady({ name }).pipe(Effect.forkScoped)
|
||||
const created = yield* request(ExperimentalPaths.worktree, directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
const info = yield* json<Worktree.Info>(created)
|
||||
expect(info).toMatchObject({ name, branch: "opencode/api-test" })
|
||||
yield* Fiber.join(ready)
|
||||
return info
|
||||
}),
|
||||
use,
|
||||
(info) =>
|
||||
Effect.gen(function* () {
|
||||
const removed = yield* request(ExperimentalPaths.worktree, directory, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
body: JSON.stringify({ directory: info.directory }),
|
||||
})
|
||||
if (removed.status !== 200) return yield* Effect.fail(new Error(`failed to remove worktree: ${removed.status}`))
|
||||
const ok = yield* json<boolean>(removed)
|
||||
if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${info.directory}`))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("experimental HttpApi", () => {
|
||||
it.instance(
|
||||
"serves read-only experimental endpoints through the default server app",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const directory = tmp.directory
|
||||
const [consoleState, consoleOrgs, toolList, toolIDs, worktrees, resources] = yield* Effect.all(
|
||||
[
|
||||
request(ExperimentalPaths.console, directory),
|
||||
request(ExperimentalPaths.consoleOrgs, directory),
|
||||
request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, directory),
|
||||
request(ExperimentalPaths.toolIDs, directory),
|
||||
request(ExperimentalPaths.worktree, directory),
|
||||
request(ExperimentalPaths.resource, directory),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(consoleState.status).toBe(200)
|
||||
expect(yield* json(consoleState)).toEqual({
|
||||
consoleManagedProviders: [],
|
||||
switchableOrgCount: 0,
|
||||
})
|
||||
|
||||
expect(consoleOrgs.status).toBe(200)
|
||||
expect(yield* json(consoleOrgs)).toEqual({ orgs: [] })
|
||||
|
||||
expect(toolList.status).toBe(200)
|
||||
expect(yield* json<unknown[]>(toolList)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "bash",
|
||||
description: expect.any(String),
|
||||
parameters: expect.any(Object),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(toolIDs.status).toBe(200)
|
||||
expect(yield* json(toolIDs)).toContain("bash")
|
||||
|
||||
expect(worktrees.status).toBe(200)
|
||||
expect(yield* json(worktrees)).toEqual([])
|
||||
|
||||
expect(resources.status).toBe(200)
|
||||
expect(yield* json(resources)).toEqual({})
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance("returns declared worktree errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const response = yield* request(ExperimentalPaths.worktree, tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(yield* json(response)).toEqual({
|
||||
name: "WorktreeNotGitError",
|
||||
data: { message: "Worktrees are only supported for git projects" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"serves Console org switch through the default server app",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const accountID = yield* insertAccount()
|
||||
const switched = yield* request(ExperimentalPaths.consoleSwitch, tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ accountID, orgID: "org-test" }),
|
||||
})
|
||||
|
||||
expect(switched.status).toBe(200)
|
||||
expect(yield* json(switched)).toBe(true)
|
||||
}),
|
||||
{ config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"serves global session list through the default server app",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const first = yield* createSession({ title: "page-one" })
|
||||
const second = yield* createSession({ title: "page-two" })
|
||||
yield* setSessionUpdated(first, 1)
|
||||
yield* setSessionUpdated(second, 2)
|
||||
|
||||
const page = yield* request(
|
||||
`${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.directory, limit: "1" })}`,
|
||||
tmp.directory,
|
||||
)
|
||||
expect(page.status).toBe(200)
|
||||
expect(page.headers["x-next-cursor"]).toBeTruthy()
|
||||
|
||||
const body = yield* json<Session.GlobalInfo[]>(page)
|
||||
expect(body.map((session) => session.id)).toEqual([second.id])
|
||||
expect(body[0].project?.id).toBe(second.projectID)
|
||||
|
||||
const next = yield* request(
|
||||
`${ExperimentalPaths.session}?${new URLSearchParams({
|
||||
directory: tmp.directory,
|
||||
limit: "10",
|
||||
cursor: body[0].time.updated.toString(),
|
||||
})}`,
|
||||
tmp.directory,
|
||||
)
|
||||
expect(next.status).toBe(200)
|
||||
expect((yield* json<Session.GlobalInfo[]>(next)).map((session) => session.id)).toContain(first.id)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
testWorktreeMutations(
|
||||
"serves worktree mutations through the default server app",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
yield* withCreatedWorktree(tmp.directory, (info) =>
|
||||
Effect.gen(function* () {
|
||||
const listed = yield* request(ExperimentalPaths.worktree, tmp.directory)
|
||||
expect(listed.status).toBe(200)
|
||||
expect(yield* json(listed)).toContain(info.directory)
|
||||
|
||||
const reset = yield* request(ExperimentalPaths.worktreeReset, tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ directory: info.directory }),
|
||||
})
|
||||
|
||||
expect(reset.status).toBe(200)
|
||||
expect(yield* json(reset)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
const afterRemove = yield* request(ExperimentalPaths.worktree, tmp.directory)
|
||||
expect(afterRemove.status).toBe(200)
|
||||
expect(yield* json(afterRemove)).toEqual([])
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
73
packages/opencode/test/server/httpapi-file.test.ts
Normal file
73
packages/opencode/test/server/httpapi-file.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Context } from "effect"
|
||||
import path from "path"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
function request(route: string, directory: string, query?: Record<string, string>) {
|
||||
const url = new URL(`http://localhost${route}`)
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
return HttpApiApp.webHandler().handler(
|
||||
new Request(url, {
|
||||
headers: {
|
||||
"x-opencode-directory": directory,
|
||||
},
|
||||
}),
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("file HttpApi", () => {
|
||||
test("serves read endpoints", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(path.join(tmp.path, "hello.txt"), "hello")
|
||||
|
||||
const [list, content, status] = await Promise.all([
|
||||
request(FilePaths.list, tmp.path, { path: "." }),
|
||||
request(FilePaths.content, tmp.path, { path: "hello.txt" }),
|
||||
request(FilePaths.status, tmp.path),
|
||||
])
|
||||
|
||||
expect(list.status).toBe(200)
|
||||
expect(await list.json()).toContainEqual(
|
||||
expect.objectContaining({ name: "hello.txt", path: "hello.txt", type: "file" }),
|
||||
)
|
||||
|
||||
expect(content.status).toBe(200)
|
||||
expect(await content.json()).toMatchObject({ type: "text", content: "hello" })
|
||||
|
||||
expect(status.status).toBe(200)
|
||||
expect(await status.json()).toEqual([])
|
||||
})
|
||||
|
||||
test("serves search endpoints", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(path.join(tmp.path, "hello.txt"), "needle")
|
||||
|
||||
const [text, files, symbols] = await Promise.all([
|
||||
request(FilePaths.findText, tmp.path, { pattern: "needle" }),
|
||||
request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }),
|
||||
request(FilePaths.findSymbol, tmp.path, { query: "hello" }),
|
||||
])
|
||||
|
||||
expect(text.status).toBe(200)
|
||||
expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 }))
|
||||
|
||||
expect(files.status).toBe(200)
|
||||
expect(await files.json()).toContain("hello.txt")
|
||||
|
||||
expect(symbols.status).toBe(200)
|
||||
expect(await symbols.json()).toEqual([])
|
||||
})
|
||||
})
|
||||
66
packages/opencode/test/server/httpapi-global.test.ts
Normal file
66
packages/opencode/test/server/httpapi-global.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Installation } from "../../src/installation"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
|
||||
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
|
||||
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
|
||||
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
|
||||
Layer.provide([authorizationLayer, schemaErrorLayer]),
|
||||
// Raw HttpApi routes expose an opaque handler context at the request boundary.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
|
||||
),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provide(Layer.mock(Auth.Service)({})),
|
||||
Layer.provide(Layer.mock(Config.Service)({})),
|
||||
Layer.provide(Layer.mock(MoveSession.Service)({})),
|
||||
Layer.provide(
|
||||
Layer.mock(Installation.Service)({
|
||||
method: () => Effect.succeed("npm"),
|
||||
latest: () => Effect.succeed("9.9.9"),
|
||||
upgrade: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
|
||||
)
|
||||
const it = testEffect(apiLayer)
|
||||
|
||||
describe("global HttpApi", () => {
|
||||
it.live("upgrades to latest when the request body is omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.post(GlobalPaths.upgrade)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({ success: true, version: "9.9.9" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects malformed upgrade payloads", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe(
|
||||
HttpClientRequest.setBody(HttpBody.text("{", "application/json")),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(yield* response.json).toEqual({ success: false, error: "Invalid request body" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
348
packages/opencode/test/server/httpapi-instance-context.test.ts
Normal file
348
packages/opencode/test/server/httpapi-instance-context.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
||||
import {
|
||||
InstanceContextMiddleware,
|
||||
instanceContextLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { withFixedWorkspaceID } from "../fixture/flag"
|
||||
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
|
||||
import { waitGlobalBusEvent } from "./global-bus"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true })
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
testStateLayer,
|
||||
NodeHttpServer.layerTest,
|
||||
NodeServices.layer,
|
||||
InstanceLayer.layer,
|
||||
Project.defaultLayer,
|
||||
workspaceLayer,
|
||||
).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||
)
|
||||
|
||||
const instanceContextTestLayer = Layer.mergeAll(
|
||||
instanceContextLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
)
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure: (info) => ({ ...info, name: "local-test", directory }),
|
||||
create: async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target: () => ({ type: "local" as const, directory }),
|
||||
})
|
||||
|
||||
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
registerAdapter(input.projectID, input.type, localAdapter(input.directory))
|
||||
const workspace = yield* Workspace.Service
|
||||
return yield* workspace.create({
|
||||
type: input.type,
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: input.projectID,
|
||||
})
|
||||
}),
|
||||
(info) => Workspace.use.remove(info.id).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const probeInstanceContext = Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return {
|
||||
directory: instance?.directory,
|
||||
worktree: instance?.worktree,
|
||||
projectID: instance?.project.id,
|
||||
workspaceID,
|
||||
}
|
||||
})
|
||||
|
||||
const ProbeResult = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
worktree: Schema.optional(Schema.String),
|
||||
projectID: Schema.optional(Schema.String),
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("instance-context-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.post("dispose", "/dispose-probe", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.Boolean,
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("get", () => probeInstanceContext)
|
||||
.handle("session", () => probeInstanceContext)
|
||||
.handle(
|
||||
"dispose",
|
||||
Effect.fn("InstanceContextProbe.dispose")(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
if (!instance) return false
|
||||
yield* markInstanceForDisposal(instance)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
)
|
||||
|
||||
const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build)
|
||||
|
||||
const waitDisposedEvent = waitGlobalBusEvent({
|
||||
message: "timed out waiting for instance disposal",
|
||||
predicate: (event) => event.payload.type === "server.instance.disposed",
|
||||
}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
|
||||
|
||||
const serveDisposeProbe = () =>
|
||||
HttpRouter.serve(probeRoutes, { 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* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({
|
||||
directory: dir,
|
||||
worktree: dir,
|
||||
projectID: project.project.id,
|
||||
workspaceID: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("falls back to the raw directory when URI decoding fails", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A")
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({
|
||||
directory: path.join(process.cwd(), "%E0%A4%A"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("provides selected workspace id on control-plane routes", () =>
|
||||
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-workspace-ref",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({
|
||||
directory: dir,
|
||||
workspaceID: workspace.id,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses workspace routing output instead of raw directory hints", () =>
|
||||
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-routing-output",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({
|
||||
directory: workspaceDir,
|
||||
workspaceID: workspace.id,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses configured workspace id instead of routing to the requested workspace", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
|
||||
yield* withFixedWorkspaceID(fixedWorkspaceID)
|
||||
|
||||
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-fixed-workspace-ref",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({
|
||||
directory: dir,
|
||||
workspaceID: fixedWorkspaceID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("falls through to local instead of MissingWorkspace when configured workspace id is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
|
||||
yield* withFixedWorkspaceID(fixedWorkspaceID)
|
||||
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
yield* Project.use.fromDirectory(dir)
|
||||
yield* serveProbe()
|
||||
|
||||
// Reference a workspace id that is not registered locally. Without the
|
||||
// configured env override, this would short-circuit to a 500
|
||||
// MissingWorkspace response. With the env set, planRequest must skip the
|
||||
// MissingWorkspace branch and fall through to Local with the configured
|
||||
// workspace id.
|
||||
const unknownWorkspaceID = WorkspaceV2.ID.ascending()
|
||||
const response = yield* HttpClientRequest.get(`/probe?workspace=${unknownWorkspaceID}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({
|
||||
directory: dir,
|
||||
workspaceID: fixedWorkspaceID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps configured workspace id on control-plane routes without remote routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
|
||||
yield* withFixedWorkspaceID(fixedWorkspaceID)
|
||||
|
||||
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-fixed-workspace-control-plane",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
// /session is matched by isLocalWorkspaceRoute, so shouldStayOnControlPlane
|
||||
// is true. Combined with the env override, the route must stay Local with
|
||||
// the configured workspace id (not divert to the requested workspace's
|
||||
// local directory).
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({
|
||||
directory: dir,
|
||||
workspaceID: fixedWorkspaceID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
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({ startImmediately: true }))
|
||||
|
||||
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 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
function app(input: { password?: string; username?: string }) {
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
HttpApiApp.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input.password,
|
||||
OPENCODE_SERVER_USERNAME: input.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, HttpApiApp.context),
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function basic(username: string, password: string) {
|
||||
return ServerAuth.header({ username, password }) ?? ""
|
||||
}
|
||||
|
||||
async function cancelBody(response: Response) {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi instance route authorization", () => {
|
||||
test("requires configured auth before opening the instance event stream", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const server = app({ password: "secret" })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
|
||||
const missing = await server.request(EventPaths.event, { headers })
|
||||
await cancelBody(missing)
|
||||
expect(missing.status).toBe(401)
|
||||
|
||||
const authed = await server.request(EventPaths.event, {
|
||||
headers: { ...headers, authorization: basic("opencode", "secret") },
|
||||
})
|
||||
await cancelBody(authed)
|
||||
expect(authed.status).toBe(200)
|
||||
})
|
||||
|
||||
test("requires configured auth before resolving the PTY websocket route", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const server = app({ password: "secret" })
|
||||
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
|
||||
const missing = await server.request(route, { headers })
|
||||
await cancelBody(missing)
|
||||
expect(missing.status).toBe(401)
|
||||
|
||||
const authed = await server.request(route, {
|
||||
headers: { ...headers, authorization: basic("opencode", "secret") },
|
||||
})
|
||||
await cancelBody(authed)
|
||||
expect(authed.status).toBe(404)
|
||||
})
|
||||
})
|
||||
265
packages/opencode/test/server/httpapi-instance.test.ts
Normal file
265
packages/opencode/test/server/httpapi-instance.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config, Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { QuestionID } from "../../src/question/schema"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// Flip the experimental workspaces flag so EventV2.run actually writes to
|
||||
// EventSequenceTable (the source of truth the fence middleware reads). Reset
|
||||
// the database around the test so per-instance state does not leak between
|
||||
// runs. resetDatabase() already calls disposeAllInstances(), so we don't
|
||||
// repeat it.
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
// 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 directly through the same route layer production uses.
|
||||
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.routes,
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
)
|
||||
|
||||
const httpApiServerLayer = servedRoutes.pipe(
|
||||
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer))
|
||||
const handlerContext = Context.empty() as Context.Context<unknown>
|
||||
|
||||
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
|
||||
|
||||
describe("instance HttpApi", () => {
|
||||
it.live("serves the OpenAPI document", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get("/doc")
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers["content-type"]).toContain("application/json")
|
||||
expect(yield* response.json).toMatchObject({
|
||||
openapi: expect.any(String),
|
||||
info: expect.any(Object),
|
||||
paths: expect.objectContaining({
|
||||
"/global/health": expect.any(Object),
|
||||
"/session": expect.any(Object),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("emits a sync fence header for fixed-workspace mutations", () =>
|
||||
Effect.gen(function* () {
|
||||
const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
|
||||
Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
|
||||
}),
|
||||
)
|
||||
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const response = yield* HttpClientRequest.post(SessionPaths.create).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClientRequest.bodyJson({ title: "fenced" }),
|
||||
Effect.flatMap(HttpClient.execute),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(JSON.parse(response.headers[FenceHeader] ?? "{}")).not.toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not emit sync fence headers for fixed-workspace reads or no-op mutations", () =>
|
||||
Effect.gen(function* () {
|
||||
const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
|
||||
Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
|
||||
}),
|
||||
)
|
||||
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const read = yield* HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute)
|
||||
const log = yield* HttpClientRequest.post(ControlPaths.log).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClientRequest.bodyJson({ service: "fence-test", level: "info", message: "noop" }),
|
||||
Effect.flatMap(HttpClient.execute),
|
||||
)
|
||||
|
||||
expect(read.status).toBe(200)
|
||||
expect(read.headers[FenceHeader]).toBeUndefined()
|
||||
expect(log.status).toBe(200)
|
||||
expect(log.headers[FenceHeader]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects malformed permission and question request ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const request = (path: string, init?: RequestInit) =>
|
||||
Effect.promise(() =>
|
||||
HttpApiApp.webHandler().handler(
|
||||
new Request(`http://localhost${path}`, {
|
||||
...init,
|
||||
headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers },
|
||||
}),
|
||||
handlerContext,
|
||||
),
|
||||
)
|
||||
const [permission, questionReply, questionReject] = yield* Effect.all(
|
||||
[
|
||||
request("/permission/invalid-permission-id/reply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reply: "once" }),
|
||||
}),
|
||||
request("/question/invalid-question-id/reply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ answers: [["Yes"]] }),
|
||||
}),
|
||||
request("/question/invalid-question-id/reject", { method: "POST" }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(permission.status).toBe(400)
|
||||
expect(questionReply.status).toBe(400)
|
||||
expect(questionReject.status).toBe(400)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns typed not found bodies for missing permission and question requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const request = (path: string, init?: RequestInit) =>
|
||||
Effect.promise(() =>
|
||||
HttpApiApp.webHandler().handler(
|
||||
new Request(`http://localhost${path}`, {
|
||||
...init,
|
||||
headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers },
|
||||
}),
|
||||
handlerContext,
|
||||
),
|
||||
)
|
||||
const permissionID = PermissionV1.ID.ascending()
|
||||
const questionReplyID = QuestionID.ascending()
|
||||
const questionRejectID = QuestionID.ascending()
|
||||
const [permission, questionReply, questionReject] = yield* Effect.all(
|
||||
[
|
||||
request(`/permission/${permissionID}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reply: "once" }),
|
||||
}),
|
||||
request(`/question/${questionReplyID}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ answers: [["Yes"]] }),
|
||||
}),
|
||||
request(`/question/${questionRejectID}/reject`, { method: "POST" }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(permission.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => permission.json())).toEqual({
|
||||
_tag: "PermissionNotFoundError",
|
||||
requestID: permissionID,
|
||||
message: `Permission request not found: ${permissionID}`,
|
||||
})
|
||||
expect(questionReply.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => questionReply.json())).toEqual({
|
||||
_tag: "QuestionNotFoundError",
|
||||
requestID: questionReplyID,
|
||||
message: `Question request not found: ${questionReplyID}`,
|
||||
})
|
||||
expect(questionReject.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => questionReject.json())).toEqual({
|
||||
_tag: "QuestionNotFoundError",
|
||||
requestID: questionRejectID,
|
||||
message: `Question request not found: ${questionRejectID}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns typed not found bodies for missing projects", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const projectID = ProjectV2.ID.make("project_missing")
|
||||
const response = yield* Effect.promise(() =>
|
||||
HttpApiApp.webHandler().handler(
|
||||
new Request(`http://localhost/project/${projectID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "x-opencode-directory": dir, "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "Missing" }),
|
||||
}),
|
||||
handlerContext,
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
_tag: "ProjectNotFoundError",
|
||||
projectID,
|
||||
message: `Project not found: ${projectID}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
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 [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" },
|
||||
)
|
||||
|
||||
expect(paths.status).toBe(200)
|
||||
expect(yield* paths.json).toMatchObject({ directory: dir, worktree: dir })
|
||||
|
||||
expect(vcs.status).toBe(200)
|
||||
expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) })
|
||||
|
||||
expect(diff.status).toBe(200)
|
||||
expect(yield* diff.json).toContainEqual(
|
||||
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
33
packages/opencode/test/server/httpapi-layer.ts
Normal file
33
packages/opencode/test/server/httpapi-layer.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Config, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
|
||||
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.routes,
|
||||
{
|
||||
disableListenLog: true,
|
||||
disableLogger: true,
|
||||
},
|
||||
)
|
||||
|
||||
export const httpApiLayer = servedRoutes.pipe(
|
||||
Layer.provide(layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
)
|
||||
|
||||
export function request(path: string, init?: RequestInit) {
|
||||
const url = new URL(path, "http://localhost")
|
||||
return HttpClientRequest.fromWeb(new Request(url, init)).pipe(
|
||||
HttpClientRequest.setUrl(url.pathname),
|
||||
HttpClient.execute,
|
||||
)
|
||||
}
|
||||
|
||||
export function requestInDirectory(path: string, directory: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return request(path, { ...init, headers })
|
||||
}
|
||||
412
packages/opencode/test/server/httpapi-listen.test.ts
Normal file
412
packages/opencode/test/server/httpapi-listen.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import net from "node:net"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { withTimeout } from "../../src/util/timeout"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const original = {
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
||||
envUsername: process.env.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
const auth = { username: "opencode", password: "listen-secret" }
|
||||
const testPty = process.platform === "win32" ? test.skip : test
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword
|
||||
if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME
|
||||
else process.env.OPENCODE_SERVER_USERNAME = original.envUsername
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
async function startListener() {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = auth.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = auth.username
|
||||
process.env.OPENCODE_SERVER_PASSWORD = auth.password
|
||||
process.env.OPENCODE_SERVER_USERNAME = auth.username
|
||||
return Server.listen({ hostname: "127.0.0.1", port: 0 })
|
||||
}
|
||||
|
||||
async function startNoAuthListener() {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = undefined
|
||||
Flag.OPENCODE_SERVER_USERNAME = auth.username
|
||||
delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
process.env.OPENCODE_SERVER_USERNAME = auth.username
|
||||
return Server.listen({ hostname: "127.0.0.1", port: 0 })
|
||||
}
|
||||
|
||||
function authorization() {
|
||||
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
|
||||
}
|
||||
|
||||
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string, ticket?: string) {
|
||||
const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url)
|
||||
url.protocol = "ws:"
|
||||
url.searchParams.set("directory", dir)
|
||||
url.searchParams.set("cursor", "-1")
|
||||
if (ticket) url.searchParams.set("ticket", ticket)
|
||||
return url
|
||||
}
|
||||
|
||||
async function requestTicket(
|
||||
listener: Awaited<ReturnType<typeof startListener>>,
|
||||
id: string,
|
||||
dir: string,
|
||||
options?: { ticketHeader?: boolean; origin?: string },
|
||||
) {
|
||||
const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: authorization(),
|
||||
"x-opencode-directory": dir,
|
||||
...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }),
|
||||
...(options?.origin ? { origin: options.origin } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async function connectTicket(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
|
||||
const response = await requestTicket(listener, id, dir)
|
||||
expect(response.status).toBe(200)
|
||||
return (await response.json()) as { ticket: string; expires_in: number }
|
||||
}
|
||||
|
||||
async function createCat(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
|
||||
const response = await fetch(new URL(PtyPaths.create, listener.url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: authorization(),
|
||||
"x-opencode-directory": dir,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
return (await response.json()) as { id: string }
|
||||
}
|
||||
|
||||
async function openSocket(url: URL) {
|
||||
const ws = new WebSocket(url)
|
||||
ws.binaryType = "arraybuffer"
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener("open", () => resolve(), { once: true })
|
||||
ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true })
|
||||
}),
|
||||
5_000,
|
||||
"timed out waiting for websocket open",
|
||||
)
|
||||
return ws
|
||||
}
|
||||
|
||||
async function expectSocketRejected(url: URL, init?: { headers?: Record<string, string> }) {
|
||||
// Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that.
|
||||
const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record<string, string> }) => WebSocket
|
||||
const ws = new Ctor(url, init)
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener(
|
||||
"open",
|
||||
() => {
|
||||
ws.close(1000)
|
||||
reject(new Error("websocket opened"))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
ws.addEventListener("error", () => resolve(), { once: true })
|
||||
ws.addEventListener("close", () => resolve(), { once: true })
|
||||
}),
|
||||
5_000,
|
||||
"timed out waiting for websocket rejection",
|
||||
)
|
||||
}
|
||||
|
||||
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) {
|
||||
return withTimeout(listener.stop(true), 10_000, label)
|
||||
}
|
||||
|
||||
function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) {
|
||||
const decoder = new TextDecoder()
|
||||
let onMessage: ((event: MessageEvent) => void) | undefined
|
||||
return withTimeout(
|
||||
new Promise<string>((resolve) => {
|
||||
onMessage = (event: MessageEvent) => {
|
||||
const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer)
|
||||
if (!predicate(message)) return
|
||||
resolve(message)
|
||||
}
|
||||
ws.addEventListener("message", onMessage)
|
||||
}),
|
||||
5_000,
|
||||
"timed out waiting for websocket message",
|
||||
).finally(() => {
|
||||
if (onMessage) ws.removeEventListener("message", onMessage)
|
||||
})
|
||||
}
|
||||
|
||||
async function openPtySocket(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
|
||||
const info = await createCat(listener, dir)
|
||||
const ticket = await connectTicket(listener, info.id, dir)
|
||||
const ws = await openSocket(socketURL(listener, info.id, dir, ticket.ticket))
|
||||
return {
|
||||
ws,
|
||||
closed: new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true })),
|
||||
}
|
||||
}
|
||||
|
||||
describe("HttpApi Server.listen", () => {
|
||||
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
let stopped = false
|
||||
try {
|
||||
const response = await fetch(new URL(PtyPaths.shells, listener.url), {
|
||||
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: expect.any(String),
|
||||
name: expect.any(String),
|
||||
acceptable: expect.any(Boolean),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
const info = await createCat(listener, tmp.path)
|
||||
const ticket = await connectTicket(listener, info.id, tmp.path)
|
||||
expect(ticket.expires_in).toBeGreaterThan(0)
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
|
||||
const closed = new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true }))
|
||||
|
||||
const message = waitForMessage(ws, (message) => message.includes("ping-listen"))
|
||||
ws.send("ping-listen\n")
|
||||
expect(await message).toContain("ping-listen")
|
||||
|
||||
await stop(listener, "timed out waiting for listener.stop(true)")
|
||||
stopped = true
|
||||
await withTimeout(closed, 5_000, "timed out waiting for websocket close")
|
||||
expect(ws.readyState).toBe(WebSocket.CLOSED)
|
||||
|
||||
const restarted = await startListener()
|
||||
try {
|
||||
const nextInfo = await createCat(restarted, tmp.path)
|
||||
const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path)
|
||||
const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket))
|
||||
const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted"))
|
||||
nextWs.send("ping-restarted\n")
|
||||
expect(await nextMessage).toContain("ping-restarted")
|
||||
nextWs.close(1000)
|
||||
} finally {
|
||||
await stop(restarted, "timed out waiting for restarted listener.stop(true)")
|
||||
}
|
||||
} finally {
|
||||
if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
testPty("stop(true) is safe when called concurrently and repeatedly", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
let stopped = false
|
||||
try {
|
||||
const socket = await openPtySocket(listener, tmp.path)
|
||||
|
||||
await withTimeout(
|
||||
Promise.all([listener.stop(true), listener.stop(true)]).then(() => undefined),
|
||||
10_000,
|
||||
"timed out waiting for concurrent listener.stop(true)",
|
||||
)
|
||||
await withTimeout(socket.closed, 5_000, "timed out waiting for websocket close after concurrent stop")
|
||||
await withTimeout(listener.stop(true), 5_000, "timed out waiting for repeated listener.stop(true)")
|
||||
stopped = true
|
||||
} finally {
|
||||
if (!stopped) await stop(listener, "timed out cleaning up concurrent stop listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
testPty("stop(true) can force a graceful stop already in progress", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
let stopped = false
|
||||
try {
|
||||
const socket = await openPtySocket(listener, tmp.path)
|
||||
|
||||
const graceful = listener.stop()
|
||||
const forced = listener.stop(true)
|
||||
await withTimeout(
|
||||
Promise.all([graceful, forced]).then(() => undefined),
|
||||
10_000,
|
||||
"timed out waiting for forced listener stop",
|
||||
)
|
||||
await withTimeout(socket.closed, 5_000, "timed out waiting for websocket close after forced stop")
|
||||
stopped = true
|
||||
} finally {
|
||||
if (!stopped) await stop(listener, "timed out cleaning up forced stop listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
testPty("graceful stop waits for an overlapping forced stop", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
let stopped = false
|
||||
try {
|
||||
const socket = await openPtySocket(listener, tmp.path)
|
||||
const forced = listener.stop(true)
|
||||
await withTimeout(listener.stop(), 10_000, "timed out waiting for graceful stop after forced stop")
|
||||
stopped = true
|
||||
await withTimeout(forced, 5_000, "timed out waiting for overlapping forced stop")
|
||||
await withTimeout(socket.closed, 5_000, "timed out waiting for websocket close before graceful stop resolved")
|
||||
} finally {
|
||||
if (!stopped) await stop(listener, "timed out cleaning up overlapping stop listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test("stop() gracefully closes an idle listener and is repeat-safe", async () => {
|
||||
const listener = await startListener()
|
||||
await withTimeout(listener.stop(), 10_000, "timed out waiting for graceful listener.stop()")
|
||||
await withTimeout(listener.stop(), 5_000, "timed out waiting for repeated graceful listener.stop()")
|
||||
await expect(
|
||||
fetch(new URL(PtyPaths.shells, listener.url), { headers: { authorization: authorization() } }),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("default in-process handler does not emit Effect HTTP response logs", async () => {
|
||||
let output = ""
|
||||
// oxlint-disable-next-line typescript-eslint/unbound-method -- restored in finally after temporarily capturing stderr.
|
||||
const original = process.stderr.write
|
||||
process.stderr.write = ((chunk) => {
|
||||
output += String(chunk)
|
||||
return true
|
||||
}) as typeof process.stderr.write
|
||||
try {
|
||||
const response = await Server.Default().app.request("/status")
|
||||
expect(response.status).toBe(200)
|
||||
} finally {
|
||||
process.stderr.write = original
|
||||
}
|
||||
|
||||
expect(output).not.toContain("Sent HTTP response")
|
||||
})
|
||||
|
||||
test("port 0 prefers 4096 when free", async () => {
|
||||
if (!(await isPortFree(4096))) return
|
||||
const listener = await startListener()
|
||||
try {
|
||||
expect(listener.port).toBe(4096)
|
||||
} finally {
|
||||
await stop(listener, "timed out cleaning up port-0 prefers-4096 listener")
|
||||
}
|
||||
})
|
||||
|
||||
test("port 0 falls back when 4096 is taken", async () => {
|
||||
const blocker = await occupyPort(4096)
|
||||
if (!blocker) return
|
||||
try {
|
||||
const listener = await startListener()
|
||||
try {
|
||||
expect(listener.port).not.toBe(4096)
|
||||
expect(listener.port).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await stop(listener, "timed out cleaning up port-0 fallback listener")
|
||||
}
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
try {
|
||||
const info = await createCat(listener, tmp.path)
|
||||
|
||||
expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403)
|
||||
expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403)
|
||||
|
||||
// Regression for #25698: minting without a directory uses the server cwd
|
||||
// and cannot find a PTY registered in a project directory.
|
||||
const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), {
|
||||
method: "POST",
|
||||
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
|
||||
})
|
||||
expect(ambiguous.status).toBe(404)
|
||||
|
||||
const directoryScoped = await fetch(
|
||||
new URL(
|
||||
`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`,
|
||||
listener.url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
|
||||
},
|
||||
)
|
||||
expect(directoryScoped.status).toBe(200)
|
||||
const mint = (await directoryScoped.json()) as { ticket: string }
|
||||
const scopedWs = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket))
|
||||
scopedWs.close(1000)
|
||||
|
||||
await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket"))
|
||||
|
||||
const reusable = await connectTicket(listener, info.id, tmp.path)
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket))
|
||||
await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket))
|
||||
ws.close(1000)
|
||||
|
||||
const other = await createCat(listener, tmp.path)
|
||||
const scoped = await connectTicket(listener, info.id, tmp.path)
|
||||
await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket))
|
||||
|
||||
const crossOrigin = await connectTicket(listener, info.id, tmp.path)
|
||||
await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), {
|
||||
headers: { origin: "https://evil.example" },
|
||||
})
|
||||
} finally {
|
||||
await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const listener = await startNoAuthListener()
|
||||
try {
|
||||
const info = await createCat(listener, tmp.path)
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
|
||||
const message = waitForMessage(ws, (message) => message.includes("ping-no-auth"))
|
||||
ws.send("ping-no-auth\n")
|
||||
expect(await message).toContain("ping-no-auth")
|
||||
ws.close(1000)
|
||||
} finally {
|
||||
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function isPortFree(port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const probe = net.createServer()
|
||||
probe.once("error", () => resolve(false))
|
||||
probe.once("listening", () => probe.close(() => resolve(true)))
|
||||
probe.listen(port, "127.0.0.1")
|
||||
})
|
||||
}
|
||||
|
||||
function occupyPort(port: number) {
|
||||
return new Promise<net.Server | undefined>((resolve) => {
|
||||
const server = net.createServer()
|
||||
server.once("error", () => resolve(undefined))
|
||||
server.listen(port, "127.0.0.1", () => resolve(server))
|
||||
})
|
||||
}
|
||||
73
packages/opencode/test/server/httpapi-mcp-oauth.test.ts
Normal file
73
packages/opencode/test/server/httpapi-mcp-oauth.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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((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",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
223
packages/opencode/test/server/httpapi-mcp.test.ts
Normal file
223
packages/opencode/test/server/httpapi-mcp.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()).pipe(Effect.ignore))
|
||||
}),
|
||||
)
|
||||
const it = testEffect(testStateLayer)
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
type TestApp = ReturnType<typeof app>
|
||||
type TestHandler = ReturnType<typeof HttpApiApp.webHandler>
|
||||
|
||||
const request = Effect.fnUntraced(function* (
|
||||
handler: TestHandler,
|
||||
route: string,
|
||||
directory: string,
|
||||
init?: RequestInit,
|
||||
) {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
handler.handler(
|
||||
new Request(`http://localhost${route}`, {
|
||||
...init,
|
||||
headers,
|
||||
}),
|
||||
context,
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const json = <A>(response: Response) => Effect.promise(() => response.json() as Promise<A>)
|
||||
|
||||
const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: string; headers: HeadersInit }) {
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(input.app.request(input.path, { method: "POST", headers: input.headers })),
|
||||
)
|
||||
return {
|
||||
status: response.status,
|
||||
body: yield* Effect.promise(() => response.text()),
|
||||
}
|
||||
})
|
||||
|
||||
describe("mcp HttpApi", () => {
|
||||
it.instance(
|
||||
"serves status endpoint",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const handler = HttpApiApp.webHandler()
|
||||
const response = yield* request(handler, McpPaths.status, tmp.directory)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* json(response)).toEqual({ demo: { status: "disabled" } })
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"serves add, connect, and disconnect endpoints",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const handler = HttpApiApp.webHandler()
|
||||
const added = yield* request(handler, McpPaths.status, tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "added",
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["echo", "added"],
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(added.status).toBe(200)
|
||||
expect(yield* json(added)).toMatchObject({ added: { status: "disabled" } })
|
||||
|
||||
const addedDisconnected = yield* request(handler, "/mcp/added/disconnect", tmp.directory, { method: "POST" })
|
||||
expect(addedDisconnected.status).toBe(200)
|
||||
expect(yield* json(addedDisconnected)).toBe(true)
|
||||
|
||||
const connected = yield* request(handler, "/mcp/demo/connect", tmp.directory, { method: "POST" })
|
||||
expect(connected.status).toBe(200)
|
||||
expect(yield* json(connected)).toBe(true)
|
||||
|
||||
const disconnected = yield* request(handler, "/mcp/demo/disconnect", tmp.directory, { method: "POST" })
|
||||
expect(disconnected.status).toBe(200)
|
||||
expect(yield* json(disconnected)).toBe(true)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"serves deterministic OAuth endpoints",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const handler = HttpApiApp.webHandler()
|
||||
const start = yield* request(handler, "/mcp/demo/auth", tmp.directory, { method: "POST" })
|
||||
expect(start.status).toBe(400)
|
||||
|
||||
const authenticate = yield* request(handler, "/mcp/demo/auth/authenticate", tmp.directory, { method: "POST" })
|
||||
expect(authenticate.status).toBe(400)
|
||||
|
||||
const removed = yield* request(handler, "/mcp/demo/auth", tmp.directory, { method: "DELETE" })
|
||||
expect(removed.status).toBe(200)
|
||||
expect(yield* json(removed)).toEqual({ success: true })
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns unsupported OAuth error responses",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const dir = tmp.directory
|
||||
const headers = { "x-opencode-directory": dir }
|
||||
|
||||
yield* Effect.forEach(["/mcp/demo/auth", "/mcp/demo/auth/authenticate"], (path) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* readResponse({ app: app(), path, headers })
|
||||
|
||||
expect(response).toEqual({
|
||||
status: 400,
|
||||
body: JSON.stringify({ error: "MCP server demo does not support OAuth" }),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns typed not found errors for missing MCP servers",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const handler = HttpApiApp.webHandler()
|
||||
|
||||
for (const input of [
|
||||
{ method: "POST", route: "/mcp/missing/auth" },
|
||||
{ method: "POST", route: "/mcp/missing/auth/authenticate" },
|
||||
{ method: "POST", route: "/mcp/missing/auth/callback", body: JSON.stringify({ code: "code" }) },
|
||||
{ method: "DELETE", route: "/mcp/missing/auth" },
|
||||
{ method: "POST", route: "/mcp/missing/connect" },
|
||||
{ method: "POST", route: "/mcp/missing/disconnect" },
|
||||
]) {
|
||||
const response = yield* request(handler, input.route, tmp.directory, {
|
||||
method: input.method,
|
||||
headers: input.body ? { "content-type": "application/json" } : undefined,
|
||||
body: input.body,
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* json(response)).toEqual({
|
||||
_tag: "McpServerNotFoundError",
|
||||
name: "missing",
|
||||
message: "MCP server not found: missing",
|
||||
})
|
||||
}
|
||||
}),
|
||||
{ config: { mcp: {} } },
|
||||
)
|
||||
})
|
||||
79
packages/opencode/test/server/httpapi-mdns.test.ts
Normal file
79
packages/opencode/test/server/httpapi-mdns.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { withTimeout } from "../../src/util/timeout"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances } from "../fixture/fixture"
|
||||
|
||||
type Event = { kind: "publish"; port: number; name: string } | { kind: "unpublishAll" } | { kind: "destroy" }
|
||||
const events: Event[] = []
|
||||
|
||||
void mock.module("bonjour-service", () => ({
|
||||
Bonjour: class {
|
||||
publish(opts: { port: number; name: string }) {
|
||||
events.push({ kind: "publish", port: opts.port, name: opts.name })
|
||||
return { on: () => {} }
|
||||
}
|
||||
unpublishAll() {
|
||||
events.push({ kind: "unpublishAll" })
|
||||
}
|
||||
destroy() {
|
||||
events.push({ kind: "destroy" })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Import Server AFTER the mock so the MDNS module picks up the stub.
|
||||
const { Server } = await import("../../src/server/server")
|
||||
|
||||
const original = {
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
events.length = 0
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi Server.listen mDNS", () => {
|
||||
test("skips publish for loopback hostnames", async () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
|
||||
Flag.OPENCODE_SERVER_USERNAME = "opencode"
|
||||
const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, mdns: true })
|
||||
try {
|
||||
expect(events.filter((e) => e.kind === "publish")).toEqual([])
|
||||
} finally {
|
||||
await withTimeout(listener.stop(true), 10_000, "timed out stopping loopback mdns listener")
|
||||
}
|
||||
expect(events.filter((e) => e.kind === "publish")).toEqual([])
|
||||
})
|
||||
|
||||
test("publishes for non-loopback hostnames and unpublishes on stop", async () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
|
||||
Flag.OPENCODE_SERVER_USERNAME = "opencode"
|
||||
const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true })
|
||||
try {
|
||||
const published = events.filter((e) => e.kind === "publish")
|
||||
expect(published.length).toBe(1)
|
||||
expect(published[0]!.port).toBe(listener.port)
|
||||
expect(published[0]!.name).toBe(`opencode-${listener.port}`)
|
||||
} finally {
|
||||
await withTimeout(listener.stop(true), 10_000, "timed out stopping mdns listener")
|
||||
}
|
||||
expect(events.some((e) => e.kind === "unpublishAll")).toBe(true)
|
||||
expect(events.some((e) => e.kind === "destroy")).toBe(true)
|
||||
})
|
||||
|
||||
test("scope finalizer unpublishes even if stop() is not called for force-close", async () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
|
||||
Flag.OPENCODE_SERVER_USERNAME = "opencode"
|
||||
const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true })
|
||||
expect(events.filter((e) => e.kind === "publish").length).toBe(1)
|
||||
// Plain (graceful) stop without close=true should still unpublish.
|
||||
await withTimeout(listener.stop(), 10_000, "timed out stopping graceful mdns listener")
|
||||
expect(events.some((e) => e.kind === "unpublishAll")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
// Regression coverage for issue #26526's claim that promptAsync's
|
||||
// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not —
|
||||
// forkIn preserves Context.Reference values via standard fiber inheritance.
|
||||
//
|
||||
// The companion claim that the streaming prompt handler "captures and
|
||||
// provides" those services is true and load-bearing: Stream.fromEffect's
|
||||
// body runs detached from the request fiber's context, so the explicit
|
||||
// Effect.provideService calls there are required, not defensive duplication.
|
||||
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Schema, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import {
|
||||
InstanceContextMiddleware,
|
||||
instanceContextLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true })
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
testStateLayer,
|
||||
NodeHttpServer.layerTest,
|
||||
NodeServices.layer,
|
||||
InstanceLayer.layer,
|
||||
Project.defaultLayer,
|
||||
workspaceLayer,
|
||||
).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||
)
|
||||
|
||||
const instanceContextTestLayer = Layer.mergeAll(
|
||||
instanceContextLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
)
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure: (info) => ({ ...info, name: "local-test", directory }),
|
||||
create: async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target: () => ({ type: "local" as const, directory }),
|
||||
})
|
||||
|
||||
const setupWorkspace = (kind: string) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
yield* Project.use.fromDirectory(dir)
|
||||
const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)))
|
||||
registerAdapter(projectID, kind, localAdapter(dir))
|
||||
const workspace = yield* Workspace.Service.use((svc) =>
|
||||
svc.create({ type: kind, branch: null, extra: null, projectID }),
|
||||
)
|
||||
return { dir, workspace }
|
||||
})
|
||||
|
||||
type Capture = { directory?: string; workspaceID?: string }
|
||||
|
||||
const captureInstance = Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return { directory: instance?.directory, workspaceID } satisfies Capture
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("handler-context-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
|
||||
HttpApiEndpoint.post("streamWithout", "/stream-probe-without", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
|
||||
}),
|
||||
HttpApiEndpoint.post("streamWith", "/stream-probe-with", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const serveProbes = (input: {
|
||||
fork?: Effect.Effect<boolean, never, Scope.Scope>
|
||||
streamWithout?: Effect.Effect<HttpServerResponse.HttpServerResponse>
|
||||
streamWith?: Effect.Effect<HttpServerResponse.HttpServerResponse>
|
||||
}) =>
|
||||
HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(
|
||||
HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("fork", () => input.fork ?? Effect.succeed(false))
|
||||
.handleRaw(
|
||||
"streamWithout",
|
||||
() => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })),
|
||||
)
|
||||
.handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
|
||||
),
|
||||
),
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi handler context inheritance", () => {
|
||||
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
|
||||
// the request's Context — including InstanceRef and WorkspaceRef provided
|
||||
// by InstanceContextMiddleware — without any explicit re-provide.
|
||||
it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () =>
|
||||
Effect.gen(function* () {
|
||||
const { dir, workspace } = yield* setupWorkspace("local-fork")
|
||||
const capture = yield* Deferred.make<Capture>()
|
||||
|
||||
yield* serveProbes({
|
||||
fork: Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Deferred.succeed(capture, yield* captureInstance)
|
||||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
return true
|
||||
}),
|
||||
})
|
||||
|
||||
const response = yield* HttpClient.post(
|
||||
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
|
||||
expect(observed.directory).toBe(dir)
|
||||
expect(observed.workspaceID).toBe(workspace.id)
|
||||
}),
|
||||
)
|
||||
|
||||
// Mirrors handlers/session.ts:255 prompt — the streaming handler reads
|
||||
// InstanceRef/WorkspaceRef in the request fiber and re-provides them to
|
||||
// the Stream.fromEffect body. This test locks in why the explicit
|
||||
// provides are required: without them the stream body sees undefined.
|
||||
it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () =>
|
||||
Effect.gen(function* () {
|
||||
const { dir, workspace } = yield* setupWorkspace("local-stream")
|
||||
const withoutCapture = yield* Deferred.make<Capture>()
|
||||
const withCapture = yield* Deferred.make<Capture>()
|
||||
|
||||
yield* serveProbes({
|
||||
streamWithout: Effect.gen(function* () {
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(withoutCapture, yield* captureInstance)
|
||||
return ""
|
||||
}),
|
||||
).pipe(Stream.encodeText),
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
}),
|
||||
streamWith: Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(withCapture, yield* captureInstance)
|
||||
return ""
|
||||
}).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)),
|
||||
).pipe(Stream.encodeText),
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
|
||||
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
|
||||
yield* responseWithout.text
|
||||
const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`)
|
||||
yield* responseWith.text
|
||||
|
||||
const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds"))
|
||||
expect(without.directory).toBeUndefined()
|
||||
expect(without.workspaceID).toBeUndefined()
|
||||
|
||||
const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds"))
|
||||
expect(withProvide.directory).toBe(dir)
|
||||
expect(withProvide.workspaceID).toBe(workspace.id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
400
packages/opencode/test/server/httpapi-provider.test.ts
Normal file
400
packages/opencode/test/server/httpapi-provider.test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { markPluginDependenciesReady } from "../fixture/plugin"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, request } from "./httpapi-layer"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => resetDatabase()),
|
||||
() => Effect.promise(() => resetDatabase()),
|
||||
),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, httpApiLayer))
|
||||
const projectOptions = { config: { formatter: false, lsp: false } }
|
||||
const providerID = "test-oauth-parity"
|
||||
const oauthURL = "https://example.com/oauth"
|
||||
const oauthInstructions = "Finish OAuth"
|
||||
|
||||
function providerListHasFetch(list: unknown) {
|
||||
if (!Array.isArray(list)) return false
|
||||
return list.some((item: unknown) => {
|
||||
if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false
|
||||
if (item.id !== "google") return false
|
||||
if (typeof item.options !== "object" || item.options === null) return false
|
||||
return "fetch" in item.options
|
||||
})
|
||||
}
|
||||
|
||||
function hasProviderWithFetch(input: unknown, key: "all" | "providers") {
|
||||
if (typeof input !== "object" || input === null) return false
|
||||
if (key === "all") return "all" in input && providerListHasFetch(input.all)
|
||||
return "providers" in input && providerListHasFetch(input.providers)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function providerList(input: unknown, key: "all" | "providers") {
|
||||
if (!isRecord(input)) return []
|
||||
if (!Array.isArray(input[key])) return []
|
||||
return input[key]
|
||||
}
|
||||
|
||||
function providerByID(input: unknown, key: "all" | "providers", id: string) {
|
||||
return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id)
|
||||
}
|
||||
|
||||
function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) {
|
||||
const provider = providerByID(input, key, id)
|
||||
if (!isRecord(provider) || !isRecord(provider.models)) return false
|
||||
return Object.values(provider.models).some((model) => {
|
||||
if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false
|
||||
return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some(
|
||||
(cost) => typeof cost === "number" && cost > 0,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) {
|
||||
const provider = providerByID(input, key, id)
|
||||
if (!isRecord(provider)) return false
|
||||
if (provider.name === "mutated-provider") return true
|
||||
return isRecord(provider.options) && provider.options.mutatedByPlugin === true
|
||||
}
|
||||
|
||||
function requestAuthorize(input: {
|
||||
providerID: string
|
||||
method: number
|
||||
headers: HeadersInit
|
||||
inputs?: Record<string, string>
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
const response = yield* request(`/provider/${input.providerID}/oauth/authorize`, {
|
||||
method: "POST",
|
||||
headers: input.headers,
|
||||
body: JSON.stringify({ method: input.method, ...(input.inputs ? { inputs: input.inputs } : {}) }),
|
||||
})
|
||||
return {
|
||||
status: response.status,
|
||||
body: yield* response.text,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function requestCallback(input: { providerID: string; method: number; headers: HeadersInit; code?: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const response = yield* request(`/provider/${input.providerID}/oauth/callback`, {
|
||||
method: "POST",
|
||||
headers: input.headers,
|
||||
body: JSON.stringify({ method: input.method, ...(input.code ? { code: input.code } : {}) }),
|
||||
})
|
||||
return {
|
||||
status: response.status,
|
||||
body: yield* response.text,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function writeProviderAuthPlugin(dir: string) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||
|
||||
yield* fs.writeWithDirs(
|
||||
path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"),
|
||||
[
|
||||
"export default {",
|
||||
' id: "test.provider-oauth-parity",',
|
||||
" server: async () => ({",
|
||||
" auth: {",
|
||||
` provider: "${providerID}",`,
|
||||
" methods: [",
|
||||
' { type: "api", label: "API key" },',
|
||||
" {",
|
||||
' type: "oauth",',
|
||||
' label: "OAuth",',
|
||||
" authorize: async () => ({",
|
||||
` url: "${oauthURL}",`,
|
||||
' method: "code",',
|
||||
` instructions: "${oauthInstructions}",`,
|
||||
" callback: async () => ({ type: 'success', key: 'token' }),",
|
||||
" }),",
|
||||
" },",
|
||||
" ],",
|
||||
" },",
|
||||
" }),",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function writeProviderAuthValidationPlugin(dir: string) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||
|
||||
yield* fs.writeWithDirs(
|
||||
path.join(dir, ".opencode", "plugin", "provider-oauth-validation.ts"),
|
||||
[
|
||||
"export default {",
|
||||
' id: "test.provider-oauth-validation",',
|
||||
" server: async () => ({",
|
||||
" auth: {",
|
||||
' provider: "test-oauth-validation",',
|
||||
" methods: [",
|
||||
" {",
|
||||
' type: "oauth",',
|
||||
' label: "OAuth",',
|
||||
" prompts: [",
|
||||
" {",
|
||||
' type: "text",',
|
||||
' key: "token",',
|
||||
' message: "Token",',
|
||||
" validate: (value) => value === 'ok' ? undefined : 'Token must be ok',",
|
||||
" },",
|
||||
" ],",
|
||||
" authorize: async () => ({",
|
||||
` url: "${oauthURL}",`,
|
||||
' method: "code",',
|
||||
` instructions: "${oauthInstructions}",`,
|
||||
" callback: async () => ({ type: 'success', key: 'token' }),",
|
||||
" }),",
|
||||
" },",
|
||||
" ],",
|
||||
" },",
|
||||
" }),",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function writeFunctionOptionsPlugin(dir: string) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||
|
||||
yield* fs.writeWithDirs(
|
||||
path.join(dir, ".opencode", "plugin", "provider-function-options.ts"),
|
||||
[
|
||||
"export default {",
|
||||
' id: "test.provider-function-options",',
|
||||
" server: async () => ({",
|
||||
" auth: {",
|
||||
' provider: "google",',
|
||||
" loader: async (_getAuth, provider) => {",
|
||||
" for (const model of Object.values(provider.models ?? {})) {",
|
||||
" model.cost = { input: 0, output: 0 }",
|
||||
" }",
|
||||
" return {",
|
||||
' apiKey: "",',
|
||||
" fetch: async (input, init) => fetch(input, init),",
|
||||
" }",
|
||||
" },",
|
||||
" methods: [{ type: 'api', label: 'API key' }],",
|
||||
" },",
|
||||
" }),",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function writeProviderModelsMutationPlugin(dir: string) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
|
||||
|
||||
yield* fs.writeWithDirs(
|
||||
path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"),
|
||||
[
|
||||
"export default {",
|
||||
' id: "test.provider-models-mutation",',
|
||||
" server: async () => ({",
|
||||
" provider: {",
|
||||
' id: "google",',
|
||||
" models: async (provider) => {",
|
||||
" const models = Object.fromEntries(",
|
||||
" Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),",
|
||||
" )",
|
||||
' provider.name = "mutated-provider"',
|
||||
" provider.options = { ...provider.options, mutatedByPlugin: true }",
|
||||
" for (const model of Object.values(provider.models ?? {})) {",
|
||||
" model.cost = { input: 0, output: 0 }",
|
||||
" }",
|
||||
" return models",
|
||||
" },",
|
||||
" },",
|
||||
" }),",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function setEnvScoped(key: string, value: string) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env[key]
|
||||
process.env[key] = value
|
||||
return previous
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env[key]
|
||||
else process.env[key] = previous
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("provider HttpApi", () => {
|
||||
it.instance.skip(
|
||||
"returns public v2 provider not found errors",
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* TestInstance).directory
|
||||
const response = yield* request("/api/provider/missing", {
|
||||
headers: { "x-opencode-directory": directory },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* response.json).toEqual({
|
||||
_tag: "ProviderNotFoundError",
|
||||
providerID: "missing",
|
||||
message: "Provider not found: missing",
|
||||
})
|
||||
}),
|
||||
projectOptions,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"serves OAuth authorize response shapes",
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* TestInstance).directory
|
||||
const headers = { "x-opencode-directory": directory, "content-type": "application/json" }
|
||||
const api = yield* requestAuthorize({
|
||||
providerID,
|
||||
method: 0,
|
||||
headers,
|
||||
})
|
||||
// method 0 (api-key style) — authorize() resolves with no further
|
||||
// redirect; #26474 changed the wire format to JSON `null` so clients
|
||||
// can `.json()` parse uniformly instead of getting an empty body
|
||||
// that throws.
|
||||
expect(api).toEqual({ status: 200, body: "null" })
|
||||
|
||||
const oauth = yield* requestAuthorize({
|
||||
providerID,
|
||||
method: 1,
|
||||
headers,
|
||||
})
|
||||
expect(JSON.parse(oauth.body)).toEqual({
|
||||
url: oauthURL,
|
||||
method: "code",
|
||||
instructions: oauthInstructions,
|
||||
})
|
||||
}),
|
||||
{ ...projectOptions, init: writeProviderAuthPlugin },
|
||||
30000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns declared provider auth validation errors",
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* TestInstance).directory
|
||||
const response = yield* requestAuthorize({
|
||||
providerID: "test-oauth-validation",
|
||||
method: 0,
|
||||
inputs: { token: "nope" },
|
||||
headers: { "x-opencode-directory": directory, "content-type": "application/json" },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(JSON.parse(response.body)).toEqual({
|
||||
name: "ProviderAuthValidationFailed",
|
||||
data: { field: "token", message: "Token must be ok" },
|
||||
})
|
||||
}),
|
||||
{ ...projectOptions, init: writeProviderAuthValidationPlugin },
|
||||
30000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns declared provider auth callback errors",
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* TestInstance).directory
|
||||
const response = yield* requestCallback({
|
||||
providerID,
|
||||
method: 0,
|
||||
headers: { "x-opencode-directory": directory, "content-type": "application/json" },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(JSON.parse(response.body)).toEqual({
|
||||
name: "ProviderAuthOauthMissing",
|
||||
data: { providerID },
|
||||
})
|
||||
}),
|
||||
projectOptions,
|
||||
30000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"serves provider lists when auth loaders add runtime fetch options",
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* TestInstance).directory
|
||||
yield* setEnvScoped(
|
||||
"OPENCODE_AUTH_CONTENT",
|
||||
JSON.stringify({
|
||||
google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 },
|
||||
}),
|
||||
)
|
||||
const headers = { "x-opencode-directory": directory }
|
||||
const providerResponse = yield* request("/provider", { headers })
|
||||
const configResponse = yield* request("/config/providers", { headers })
|
||||
|
||||
expect(providerResponse.status).toBe(200)
|
||||
expect(configResponse.status).toBe(200)
|
||||
|
||||
const providerBody = yield* providerResponse.json
|
||||
const configBody = yield* configResponse.json
|
||||
expect(hasProviderWithFetch(providerBody, "all")).toBe(false)
|
||||
expect(hasProviderWithFetch(configBody, "providers")).toBe(false)
|
||||
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
|
||||
expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true)
|
||||
}),
|
||||
{ ...projectOptions, init: writeFunctionOptionsPlugin },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"keeps provider.models hook input mutations out of provider state",
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* TestInstance).directory
|
||||
|
||||
const headers = { "x-opencode-directory": directory }
|
||||
const providerResponse = yield* request("/provider", { headers })
|
||||
const configResponse = yield* request("/config/providers", { headers })
|
||||
|
||||
expect(providerResponse.status).toBe(200)
|
||||
expect(configResponse.status).toBe(200)
|
||||
|
||||
const providerBody = yield* providerResponse.json
|
||||
const configBody = yield* configResponse.json
|
||||
expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false)
|
||||
expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false)
|
||||
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
|
||||
}),
|
||||
{ ...projectOptions, init: writeProviderModelsMutationPlugin },
|
||||
)
|
||||
})
|
||||
272
packages/opencode/test/server/httpapi-pty.test.ts
Normal file
272
packages/opencode/test/server/httpapi-pty.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture"
|
||||
import { Config, Effect, Layer, Queue, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testPty = process.platform === "win32" ? test.skip : test
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.routes,
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
)
|
||||
|
||||
const effectIt = testEffect(
|
||||
Layer.mergeAll(
|
||||
testStateLayer,
|
||||
Socket.layerWebSocketConstructorGlobal,
|
||||
servedRoutes.pipe(
|
||||
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function serverUrl() {
|
||||
return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
|
||||
}
|
||||
|
||||
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("pty HttpApi bridge", () => {
|
||||
test("serves available shell list through experimental Effect routes", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(PtyPaths.shells, { headers: { "x-opencode-directory": tmp.path } })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: expect.any(String),
|
||||
name: expect.any(String),
|
||||
acceptable: expect.any(Boolean),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
testPty("serves PTY JSON routes through experimental Effect routes", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const list = await app().request(PtyPaths.list, { headers })
|
||||
expect(list.status).toBe(200)
|
||||
expect(await list.json()).toEqual([])
|
||||
|
||||
const created = await app().request(PtyPaths.create, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"], title: "demo" }),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
const info = await created.json()
|
||||
|
||||
try {
|
||||
expect(info).toMatchObject({ title: "demo", command: "/usr/bin/env", status: "running" })
|
||||
|
||||
const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
|
||||
expect(found.status).toBe(200)
|
||||
expect(await found.json()).toMatchObject({ id: info.id, title: "demo" })
|
||||
|
||||
const updated = await app().request(PtyPaths.update.replace(":ptyID", info.id), {
|
||||
method: "PUT",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: "renamed", size: { cols: 80, rows: 24 } }),
|
||||
})
|
||||
expect(updated.status).toBe(200)
|
||||
expect(await updated.json()).toMatchObject({ id: info.id, title: "renamed" })
|
||||
} finally {
|
||||
await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers })
|
||||
}
|
||||
|
||||
const missing = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
|
||||
expect(missing.status).toBe(404)
|
||||
expect(await missing.json()).toEqual({
|
||||
_tag: "PtyNotFoundError",
|
||||
ptyID: info.id,
|
||||
message: `PTY session not found: ${info.id}`,
|
||||
})
|
||||
|
||||
const missingUpdate = await app().request(PtyPaths.update.replace(":ptyID", info.id), {
|
||||
method: "PUT",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: "missing" }),
|
||||
})
|
||||
expect(missingUpdate.status).toBe(404)
|
||||
expect(await missingUpdate.json()).toEqual({
|
||||
_tag: "PtyNotFoundError",
|
||||
ptyID: info.id,
|
||||
message: `PTY session not found: ${info.id}`,
|
||||
})
|
||||
|
||||
const missingRemove = await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers })
|
||||
expect(missingRemove.status).toBe(404)
|
||||
expect(await missingRemove.json()).toEqual({
|
||||
_tag: "PtyNotFoundError",
|
||||
ptyID: info.id,
|
||||
message: `PTY session not found: ${info.id}`,
|
||||
})
|
||||
})
|
||||
|
||||
testPty("disposes PTY sessions with their legacy instance", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const created = await app().request(PtyPaths.create, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"] }),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
|
||||
await disposeAllInstances()
|
||||
|
||||
const list = await app().request(PtyPaths.list, { headers })
|
||||
expect(list.status).toBe(200)
|
||||
expect(await list.json()).toEqual([])
|
||||
})
|
||||
|
||||
test("returns 404 for missing PTY websocket before upgrade", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns 404 for missing PTY websocket before decoding cursor query", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(`${PtyPaths.connect.replace(":ptyID", PtyID.ascending())}?cursor=a&cursor=b`, {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns typed not found errors for missing PTY HTTP resources", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const missingID = String(PtyID.ascending())
|
||||
const expected = {
|
||||
_tag: "PtyNotFoundError",
|
||||
ptyID: missingID,
|
||||
message: `PTY session not found: ${missingID}`,
|
||||
}
|
||||
|
||||
const found = await app().request(PtyPaths.get.replace(":ptyID", missingID), { headers })
|
||||
expect(found.status).toBe(404)
|
||||
expect(await found.json()).toEqual(expected)
|
||||
|
||||
const updated = await app().request(PtyPaths.update.replace(":ptyID", missingID), {
|
||||
method: "PUT",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: "missing" }),
|
||||
})
|
||||
expect(updated.status).toBe(404)
|
||||
expect(await updated.json()).toEqual(expected)
|
||||
|
||||
const removed = await app().request(PtyPaths.remove.replace(":ptyID", missingID), { method: "DELETE", headers })
|
||||
expect(removed.status).toBe(404)
|
||||
expect(await removed.json()).toEqual(expected)
|
||||
})
|
||||
|
||||
test("returns typed errors for PTY connect token failures", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const missingID = String(PtyID.ascending())
|
||||
|
||||
const forbidden = await app().request(PtyPaths.connectToken.replace(":ptyID", missingID), {
|
||||
method: "POST",
|
||||
headers,
|
||||
})
|
||||
expect(forbidden.status).toBe(403)
|
||||
expect(await forbidden.json()).toEqual({
|
||||
_tag: "PtyForbiddenError",
|
||||
message: "Invalid PTY connect token request",
|
||||
})
|
||||
|
||||
const missing = await app().request(PtyPaths.connectToken.replace(":ptyID", missingID), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"x-opencode-ticket": "1",
|
||||
},
|
||||
})
|
||||
expect(missing.status).toBe(404)
|
||||
expect(await missing.json()).toEqual({
|
||||
_tag: "PtyNotFoundError",
|
||||
ptyID: missingID,
|
||||
message: `PTY session not found: ${missingID}`,
|
||||
})
|
||||
})
|
||||
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
|
||||
"serves PTY websocket output and input through Effect routes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
|
||||
const created = yield* HttpClientRequest.post(PtyPaths.create).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }),
|
||||
Effect.flatMap(HttpClient.execute),
|
||||
)
|
||||
expect(created.status).toBe(200)
|
||||
const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json)
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`,
|
||||
{ closeCodeIsError: () => false },
|
||||
)
|
||||
const messages = yield* Queue.unbounded<string>()
|
||||
yield* socket
|
||||
.runRaw((message) =>
|
||||
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
|
||||
)
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(Effect.forkScoped)
|
||||
const write = yield* socket.writer
|
||||
|
||||
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
|
||||
if (next.includes(expected)) return next
|
||||
return yield* takeUntil(expected, next)
|
||||
})
|
||||
|
||||
yield* write("ping-route\n")
|
||||
expect(yield* takeUntil("ping-route")).toContain("ping-route")
|
||||
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
expect(removed.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
})
|
||||
317
packages/opencode/test/server/httpapi-public-openapi.test.ts
Normal file
317
packages/opencode/test/server/httpapi-public-openapi.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
|
||||
type Method = "get" | "post" | "put" | "delete" | "patch"
|
||||
type OpenApiSchema = {
|
||||
readonly $ref?: string
|
||||
readonly anyOf?: ReadonlyArray<OpenApiSchema>
|
||||
readonly type?: string
|
||||
readonly enum?: readonly unknown[]
|
||||
readonly properties?: Record<string, OpenApiSchema>
|
||||
readonly required?: readonly string[]
|
||||
}
|
||||
type OpenApiResponse = {
|
||||
readonly description?: string
|
||||
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
|
||||
}
|
||||
type OpenApiOperation = {
|
||||
readonly parameters?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly in: string
|
||||
readonly required?: boolean
|
||||
readonly schema?: { readonly type?: string }
|
||||
}>
|
||||
readonly responses?: Record<string, OpenApiResponse>
|
||||
readonly requestBody?: { readonly required?: boolean }
|
||||
readonly security?: unknown
|
||||
}
|
||||
type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
|
||||
type OpenApiSpec = {
|
||||
readonly paths: Record<string, OpenApiPathItem>
|
||||
readonly components: { readonly schemas: Record<string, OpenApiSchema> }
|
||||
}
|
||||
|
||||
const methods = ["get", "post", "put", "delete", "patch"] as const
|
||||
|
||||
const allowedV2BuiltInEndpointErrors: string[] = []
|
||||
|
||||
function v2Operations(spec: OpenApiSpec) {
|
||||
return Object.entries(spec.paths).flatMap(([path, item]) =>
|
||||
path.startsWith("/api/")
|
||||
? methods.flatMap((method) => {
|
||||
const operation = item[method]
|
||||
return operation ? [{ method, path, operation }] : []
|
||||
})
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function responseRef(response: OpenApiResponse | undefined) {
|
||||
return response?.content?.["application/json"]?.schema?.$ref
|
||||
}
|
||||
|
||||
function componentName(ref: string) {
|
||||
return ref.replace("#/components/schemas/", "")
|
||||
}
|
||||
|
||||
function componentNames(response: OpenApiResponse | undefined) {
|
||||
const schema = response?.content?.["application/json"]?.schema
|
||||
if (!schema) return []
|
||||
return [
|
||||
...new Set([schema, ...(schema.anyOf ?? [])].flatMap((item) => (item.$ref ? [componentName(item.$ref)] : []))),
|
||||
]
|
||||
}
|
||||
|
||||
function isBuiltInEndpointError(name: string) {
|
||||
return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_")
|
||||
}
|
||||
|
||||
describe("PublicApi OpenAPI v2 errors", () => {
|
||||
test("documents nested legacy global sync events", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
const schema = spec.components.schemas.SyncEventSessionCreated
|
||||
|
||||
expect(schema?.required).toEqual(["type", "id", "syncEvent"])
|
||||
expect(schema?.properties?.type?.enum).toEqual(["sync"])
|
||||
expect(schema?.properties?.syncEvent).toMatchObject({
|
||||
required: ["type", "id", "seq", "aggregateID", "data"],
|
||||
properties: {
|
||||
type: { enum: ["session.created.1"] },
|
||||
id: { type: "string" },
|
||||
seq: { type: "number" },
|
||||
aggregateID: { type: "string" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves /api auth responses", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of v2Operations(spec)) {
|
||||
expect(route.operation.responses?.["401"], `${route.method.toUpperCase()} ${route.path}`).toBeDefined()
|
||||
expect(route.operation.security, `${route.method.toUpperCase()} ${route.path}`).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
test("documents references separately from filesystem routes", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const path of ["/api/fs/read/*", "/api/fs/list"]) {
|
||||
expect(spec.paths[path]?.get?.parameters, path).not.toContainEqual(expect.objectContaining({ name: "reference" }))
|
||||
}
|
||||
expect(spec.paths["/api/reference"]?.get).toBeDefined()
|
||||
})
|
||||
|
||||
test("preserves required request bodies for v2 mutations", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const path of [
|
||||
"/api/session/{sessionID}/prompt",
|
||||
"/api/session/{sessionID}/permission/{requestID}/reply",
|
||||
"/api/session/{sessionID}/question/{requestID}/reply",
|
||||
]) {
|
||||
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("documents connector discovery and connection routes", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const [method, path] of [
|
||||
["get", "/api/connector"],
|
||||
["get", "/api/connector/{connectorID}"],
|
||||
["post", "/api/connector/{connectorID}/connect/key"],
|
||||
["post", "/api/connector/{connectorID}/connect/oauth"],
|
||||
["get", "/api/connector/oauth/{attemptID}"],
|
||||
["post", "/api/connector/oauth/{attemptID}/complete"],
|
||||
["delete", "/api/connector/oauth/{attemptID}"],
|
||||
] as const) {
|
||||
expect(spec.paths[path]?.[method], `${method.toUpperCase()} ${path}`).toBeDefined()
|
||||
}
|
||||
|
||||
for (const path of [
|
||||
"/api/connector/{connectorID}/connect/key",
|
||||
"/api/connector/{connectorID}/connect/oauth",
|
||||
"/api/connector/oauth/{attemptID}/complete",
|
||||
]) {
|
||||
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("does not rewrite /api endpoint errors to legacy error components", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
const refs = v2Operations(spec)
|
||||
.flatMap((route) =>
|
||||
Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
|
||||
const ref = responseRef(response)
|
||||
return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : []
|
||||
}),
|
||||
)
|
||||
.filter((entry) => entry.endsWith(" BadRequestError") || entry.endsWith(" NotFoundError"))
|
||||
|
||||
expect(refs).toEqual([])
|
||||
})
|
||||
|
||||
test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
const builtInEndpointErrors = v2Operations(spec)
|
||||
.flatMap((route) =>
|
||||
Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
|
||||
if (status === "401") return []
|
||||
const ref = responseRef(response)
|
||||
if (!ref) return []
|
||||
const name = componentName(ref)
|
||||
return isBuiltInEndpointError(name) ? [`${route.method.toUpperCase()} ${route.path} ${status} ${name}`] : []
|
||||
}),
|
||||
)
|
||||
.sort()
|
||||
|
||||
expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors)
|
||||
})
|
||||
|
||||
test("documents v2 provider and model catalog errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
expect(componentName(responseRef(spec.paths["/api/provider"]?.get?.responses?.["503"]) ?? "")).toBe(
|
||||
"ServiceUnavailableError",
|
||||
)
|
||||
expect(componentName(responseRef(spec.paths["/api/model"]?.get?.responses?.["503"]) ?? "")).toBe(
|
||||
"ServiceUnavailableError",
|
||||
)
|
||||
expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["404"]) ?? "")).toBe(
|
||||
"ProviderNotFoundError",
|
||||
)
|
||||
expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["503"]) ?? "")).toBe(
|
||||
"ServiceUnavailableError",
|
||||
)
|
||||
})
|
||||
|
||||
test("documents v2 session not-found errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of [
|
||||
["post", "/api/session/{sessionID}/prompt"],
|
||||
["post", "/api/session/{sessionID}/compact"],
|
||||
["post", "/api/session/{sessionID}/wait"],
|
||||
["get", "/api/session/{sessionID}/context"],
|
||||
["get", "/api/session/{sessionID}/message"],
|
||||
] as const) {
|
||||
expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toContain("SessionNotFoundError")
|
||||
}
|
||||
})
|
||||
|
||||
test("documents v2 unfinished session mutation errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of [
|
||||
["post", "/api/session/{sessionID}/compact"],
|
||||
["post", "/api/session/{sessionID}/wait"],
|
||||
] as const) {
|
||||
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["503"]) ?? "")).toBe(
|
||||
"ServiceUnavailableError",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("documents v2 session read data errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of [
|
||||
["get", "/api/session/{sessionID}/context"],
|
||||
["get", "/api/session/{sessionID}/message"],
|
||||
] as const) {
|
||||
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["500"]) ?? "")).toMatch(
|
||||
/^UnknownError\d*$/,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("documents session busy errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of [
|
||||
["post", "/session/{sessionID}/shell"],
|
||||
["post", "/session/{sessionID}/revert"],
|
||||
["post", "/session/{sessionID}/unrevert"],
|
||||
["delete", "/session/{sessionID}/message/{messageID}"],
|
||||
] as const) {
|
||||
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe(
|
||||
"SessionBusyError",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("documents permission and question not-found errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
expect(
|
||||
componentName(responseRef(spec.paths["/permission/{requestID}/reply"]?.post?.responses?.["404"]) ?? ""),
|
||||
).toBe("PermissionNotFoundError")
|
||||
for (const route of [
|
||||
["post", "/question/{requestID}/reply"],
|
||||
["post", "/question/{requestID}/reject"],
|
||||
] as const) {
|
||||
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
|
||||
"QuestionNotFoundError",
|
||||
)
|
||||
}
|
||||
for (const route of [
|
||||
["post", "/api/session/{sessionID}/question/{requestID}/reply"],
|
||||
["post", "/api/session/{sessionID}/question/{requestID}/reject"],
|
||||
] as const) {
|
||||
expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toEqual([
|
||||
"QuestionNotFoundError",
|
||||
"SessionNotFoundError",
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test("documents MCP server not-found errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of [
|
||||
["post", "/mcp/{name}/auth"],
|
||||
["post", "/mcp/{name}/auth/authenticate"],
|
||||
["post", "/mcp/{name}/auth/callback"],
|
||||
["delete", "/mcp/{name}/auth"],
|
||||
["post", "/mcp/{name}/connect"],
|
||||
["post", "/mcp/{name}/disconnect"],
|
||||
] as const) {
|
||||
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
|
||||
"McpServerNotFoundError",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("documents PTY resource and ticket errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const route of [
|
||||
["get", "/pty/{ptyID}"],
|
||||
["put", "/pty/{ptyID}"],
|
||||
["delete", "/pty/{ptyID}"],
|
||||
["post", "/pty/{ptyID}/connect-token"],
|
||||
] as const) {
|
||||
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
|
||||
"PtyNotFoundError",
|
||||
)
|
||||
}
|
||||
expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
|
||||
"PtyForbiddenError",
|
||||
)
|
||||
expect(
|
||||
spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
|
||||
?.filter((parameter) => parameter.in === "query")
|
||||
.map((parameter) => parameter.name),
|
||||
).toEqual(["directory", "workspace", "cursor", "ticket"])
|
||||
})
|
||||
|
||||
test("documents project not-found errors", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
expect(componentName(responseRef(spec.paths["/project/{projectID}"]?.patch?.responses?.["404"]) ?? "")).toBe(
|
||||
"ProjectNotFoundError",
|
||||
)
|
||||
})
|
||||
})
|
||||
330
packages/opencode/test/server/httpapi-query-schema-drift.test.ts
Normal file
330
packages/opencode/test/server/httpapi-query-schema-drift.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
import {
|
||||
FilePaths,
|
||||
FileQuery,
|
||||
FindFileQuery,
|
||||
FindTextQuery,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import {
|
||||
ExperimentalPaths,
|
||||
SessionListQuery as ExperimentalSessionListQuery,
|
||||
ToolListQuery,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import {
|
||||
ListQuery as SessionListQuery,
|
||||
MessagesQuery,
|
||||
SessionPaths,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { SessionMessagesQuery } from "@opencode-ai/server/groups/message"
|
||||
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
type Method = "get" | "post" | "put" | "delete" | "patch"
|
||||
type QuerySchema = { readonly fields: Record<string, unknown> }
|
||||
type OpenApiSchema = {
|
||||
readonly anyOf?: readonly OpenApiSchema[]
|
||||
readonly enum?: readonly string[]
|
||||
readonly maximum?: number
|
||||
readonly minimum?: number
|
||||
readonly pattern?: string
|
||||
readonly type?: string
|
||||
}
|
||||
type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema }
|
||||
type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
|
||||
|
||||
const openApiDriftRoutes = [
|
||||
{ method: "get", path: SessionPaths.list, query: SessionListQuery },
|
||||
{ method: "get", path: SessionPaths.messages, query: MessagesQuery },
|
||||
{ method: "get", path: FilePaths.findFile, query: FindFileQuery },
|
||||
{ method: "get", path: FilePaths.findText, query: FindTextQuery },
|
||||
{ method: "get", path: FilePaths.list, query: FileQuery },
|
||||
{ method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
|
||||
{ method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
|
||||
{ method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
|
||||
{ method: "get", path: "/api/session/:sessionID/message", query: SessionMessagesQuery },
|
||||
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
|
||||
|
||||
const numericSdkQueryParams = [
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } },
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } },
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } },
|
||||
{ method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
|
||||
{ method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } },
|
||||
{ method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } },
|
||||
{
|
||||
method: "get",
|
||||
path: SessionPaths.messages,
|
||||
name: "limit",
|
||||
schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||
},
|
||||
{ method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
|
||||
] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
|
||||
|
||||
const booleanSdkQueryParams = [
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "roots" },
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "archived" },
|
||||
{ method: "get", path: SessionPaths.list, name: "roots" },
|
||||
] satisfies Array<{ method: Method; path: string; name: string }>
|
||||
|
||||
const queryParamPatterns = [
|
||||
{ method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" },
|
||||
] satisfies Array<{ method: Method; path: string; name: string; pattern: string }>
|
||||
|
||||
const pathParamPatterns = [
|
||||
{ method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" },
|
||||
{ method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" },
|
||||
{ method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" },
|
||||
{ method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" },
|
||||
{ method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" },
|
||||
{ method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" },
|
||||
{ method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" },
|
||||
{ method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk" },
|
||||
] satisfies Array<{ method: Method; path: string; name: string; pattern: string }>
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function request(url: string, init?: RequestInit) {
|
||||
return Effect.promise(async () => app().request(url, init))
|
||||
}
|
||||
|
||||
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(fn))
|
||||
}
|
||||
|
||||
function openApiPath(path: string) {
|
||||
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
|
||||
}
|
||||
|
||||
function queryParameters(operation: OpenApiOperation | undefined) {
|
||||
return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name)
|
||||
}
|
||||
|
||||
function queryParameter(operation: OpenApiOperation | undefined, name: string) {
|
||||
return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name)
|
||||
}
|
||||
|
||||
function pathParameter(operation: OpenApiOperation | undefined, name: string) {
|
||||
return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name)
|
||||
}
|
||||
|
||||
function assertAdvertisedQueryParamsAreRuntimeFields(input: {
|
||||
readonly method: Method
|
||||
readonly operation: OpenApiOperation | undefined
|
||||
readonly path: string
|
||||
readonly query: QuerySchema
|
||||
}) {
|
||||
const runtimeFields = new Set(Object.keys(input.query.fields))
|
||||
const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name))
|
||||
|
||||
expect(
|
||||
advertisedOnly,
|
||||
`${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`,
|
||||
).toEqual([])
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
// Regression for the "OpenAPI advertises ?directory&workspace, runtime
|
||||
// rejects them" drift class. Each affected route must accept both params
|
||||
// without 400.
|
||||
describe("httpapi query schema drift", () => {
|
||||
const routingParams = (dir: string) =>
|
||||
`directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}`
|
||||
|
||||
const expectNotSchemaRejection = (status: number, url: string) => {
|
||||
expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
|
||||
}
|
||||
|
||||
it.effect(
|
||||
"boolean query schema accepts only true and false strings",
|
||||
Effect.sync(() => {
|
||||
const decode = Schema.decodeUnknownSync(QueryBoolean)
|
||||
const encode = Schema.encodeUnknownSync(QueryBoolean)
|
||||
|
||||
expect(decode("true")).toBe(true)
|
||||
expect(decode("false")).toBe(false)
|
||||
expect(encode(true)).toBe("true")
|
||||
expect(encode(false)).toBe("false")
|
||||
|
||||
for (const input of ["1", "yes", "True", "", true, false]) {
|
||||
expect(() => decode(input)).toThrow()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"OpenAPI query params are declared by runtime query schemas",
|
||||
Effect.sync(() => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
for (const route of openApiDriftRoutes) {
|
||||
assertAdvertisedQueryParamsAreRuntimeFields({
|
||||
...route,
|
||||
operation: spec.paths[openApiPath(route.path)]?.[route.method],
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"OpenAPI query and path schemas preserve compatibility metadata",
|
||||
Effect.sync(() => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
for (const expected of numericSdkQueryParams) {
|
||||
expect(
|
||||
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
|
||||
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
|
||||
).toEqual(expected.schema)
|
||||
}
|
||||
for (const expected of booleanSdkQueryParams) {
|
||||
expect(
|
||||
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
|
||||
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
|
||||
).toEqual(QueryBooleanOpenApi)
|
||||
}
|
||||
for (const expected of queryParamPatterns) {
|
||||
expect(
|
||||
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
|
||||
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
|
||||
).toEqual({ type: "string", pattern: expected.pattern })
|
||||
}
|
||||
for (const expected of pathParamPatterns) {
|
||||
expect(
|
||||
pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
|
||||
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
|
||||
).toEqual({ type: "string", pattern: expected.pattern })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"drift assertion catches spec-only workspace query params",
|
||||
Effect.sync(() => {
|
||||
expect(() =>
|
||||
assertAdvertisedQueryParamsAreRuntimeFields({
|
||||
method: "get",
|
||||
operation: {
|
||||
parameters: [
|
||||
{ name: "directory", in: "query" },
|
||||
{ name: "workspace", in: "query" },
|
||||
],
|
||||
},
|
||||
path: "/fixture",
|
||||
query: Schema.Struct({}),
|
||||
}),
|
||||
).toThrow("advertises query params not accepted by runtime schema")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"session list accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/session?${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"session messages accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"file find/file accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/find/file?query=foo&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"file find/text accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/find?pattern=foo&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"file read accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/file?path=foo&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"experimental session list accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/experimental/session?${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"experimental tool list accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"vcs diff accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
62
packages/opencode/test/server/httpapi-reference.test.ts
Normal file
62
packages/opencode/test/server/httpapi-reference.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("reference HttpApi", () => {
|
||||
test("lists usable references resolved in the server workspace", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
references: {
|
||||
docs: "./docs",
|
||||
effect: { repository: "Effect-TS/effect", branch: "main" },
|
||||
bad: "not-a-repo",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const response = await Server.Default().app.request("/api/reference", {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
expect(body).toMatchObject({ location: { directory: tmp.path } })
|
||||
expect(body.data).toEqual([
|
||||
{
|
||||
name: "docs",
|
||||
path: path.join(tmp.path, "docs"),
|
||||
description: null,
|
||||
hidden: null,
|
||||
source: {
|
||||
type: "local",
|
||||
path: path.join(tmp.path, "docs"),
|
||||
description: null,
|
||||
hidden: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "effect",
|
||||
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"),
|
||||
description: null,
|
||||
hidden: null,
|
||||
source: {
|
||||
type: "git",
|
||||
repository: "Effect-TS/effect",
|
||||
branch: "main",
|
||||
description: null,
|
||||
hidden: null,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
165
packages/opencode/test/server/httpapi-schema-error-body.test.ts
Normal file
165
packages/opencode/test/server/httpapi-schema-error-body.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { PartTable } from "@opencode-ai/core/session/sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
|
||||
const text = (response: HttpClientResponse.HttpClientResponse) => response.text
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const seedCorruptStepFinishPart = Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
// Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
|
||||
// that broke the user's session in the OMO/Windows bug.
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never, // drizzle's .set() can't narrow the discriminated union
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return info.id
|
||||
})
|
||||
|
||||
describe("schema-rejection wire shape", () => {
|
||||
it.instance(
|
||||
"Payload schema rejection returns NamedError-shaped JSON, not empty",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
})
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.headers["content-type"] ?? "").toContain("application/json")
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({
|
||||
name: "BadRequest",
|
||||
data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
|
||||
})
|
||||
expect(parsed.data.message).toEqual(expect.any(String))
|
||||
expect(parsed.data.message.length).toBeGreaterThan(0)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Query schema rejection returns NamedError-shaped JSON",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
// /find/file?limit=999999 violates the limit constraint check.
|
||||
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* requestInDirectory(url, test.directory)
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"v2 query schema rejection returns InvalidRequestError JSON",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const res = yield* requestInDirectory("/api/session?limit=0", test.directory)
|
||||
const parsed = JSON.parse(yield* text(res))
|
||||
expect(res.status).toBe(400)
|
||||
expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" })
|
||||
expect(parsed.message).toEqual(expect.any(String))
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"rejected request body never echoes back unbounded — message is capped",
|
||||
// Defense against DoS-amplification + secret-echo: Effect's Issue formatter
|
||||
// dumps the rejected `actual` verbatim. A multi-MB invalid array would
|
||||
// become a multi-MB 400 response and log line. Cap kicks in around 1KB.
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const huge = "X".repeat(50_000)
|
||||
const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: huge }),
|
||||
})
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
// 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB.
|
||||
expect(body.length).toBeLessThan(2 * 1024)
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed.data.message).not.toContain(huge)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const sessionID = yield* seedCorruptStepFinishPart
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* requestInDirectory(url, test.directory)
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.headers["content-type"] ?? "").toContain("application/json")
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } })
|
||||
// Field path in data.message — what made this PR worth shipping.
|
||||
expect(parsed.data.message).toMatch(/output/)
|
||||
}),
|
||||
{ config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
909
packages/opencode/test/server/httpapi-sdk.test.ts
Normal file
909
packages/opencode/test/server/httpapi-sdk.test.ts
Normal file
@@ -0,0 +1,909 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
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/tui/validate-session"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
import type { Config } from "@/config/config"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { errorMessage } from "../../src/util/error"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
import path from "path"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { httpApiLayer } from "./httpapi-layer"
|
||||
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
|
||||
Database.defaultLayer,
|
||||
httpApiLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const original = {
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
type ServerPath = "default" | "raw"
|
||||
type Sdk = ReturnType<typeof createOpencodeClient>
|
||||
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 =
|
||||
| FSUtil.Service
|
||||
| ChildProcessSpawner.ChildProcessSpawner
|
||||
| InstanceStore.Service
|
||||
| HttpServer.HttpServer
|
||||
type TestScope = Scope.Scope | TestServices
|
||||
|
||||
function client(
|
||||
serverPath: ServerPath,
|
||||
directory?: string,
|
||||
input?: {
|
||||
password?: string
|
||||
username?: string
|
||||
headers?: Record<string, string>
|
||||
workspaceID?: string
|
||||
onRequest?: (request: Request) => void
|
||||
},
|
||||
) {
|
||||
return serverFetch(serverPath, input).pipe(
|
||||
Effect.map((fetch) =>
|
||||
createOpencodeClient({
|
||||
baseUrl: "http://localhost",
|
||||
directory,
|
||||
experimental_workspaceID: input?.workspaceID,
|
||||
headers: input?.headers,
|
||||
fetch,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function serverFetch(
|
||||
serverPath: ServerPath,
|
||||
input?: { password?: string; username?: string; onRequest?: (request: Request) => void },
|
||||
) {
|
||||
return HttpServer.HttpServer.use((server) =>
|
||||
Effect.sync(() => {
|
||||
void serverPath
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
const baseUrl = HttpServer.formatAddress(server.address)
|
||||
return Object.assign(
|
||||
async (request: RequestInfo | URL, init?: RequestInit) => {
|
||||
const source = request instanceof Request ? request : new Request(request, init)
|
||||
input?.onRequest?.(source)
|
||||
const url = new URL(source.url)
|
||||
return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source))
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function authorization(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function call<T>(request: () => Promise<T>) {
|
||||
return Effect.promise(request)
|
||||
}
|
||||
|
||||
function capture(request: () => Promise<SdkResult>) {
|
||||
return call(request).pipe(
|
||||
Effect.map((result) => ({
|
||||
status: result.response.status,
|
||||
data: result.data,
|
||||
error: result.error,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function captureThrown(request: () => Promise<unknown>) {
|
||||
return call(async () => {
|
||||
try {
|
||||
await request()
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function expectStatus(request: () => Promise<{ response: Response }>, status: number) {
|
||||
return call(request).pipe(
|
||||
Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
|
||||
function firstEvent(open: (signal: AbortSignal) => Promise<{ stream: AsyncIterator<unknown> }>) {
|
||||
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),
|
||||
).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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function record(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
|
||||
}
|
||||
|
||||
function array(value: unknown) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function statuses(input: Record<string, Captured>) {
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status]))
|
||||
}
|
||||
|
||||
function firstPartText(value: unknown) {
|
||||
return record(array(record(value).parts)[0]).text
|
||||
}
|
||||
|
||||
function sessionTitles(value: unknown) {
|
||||
return array(value)
|
||||
.map((item) => record(item).title)
|
||||
.filter((title): title is string => typeof title === "string")
|
||||
.sort()
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
return Effect.promise(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
}
|
||||
|
||||
function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, TestScope>) {
|
||||
it.live(name, effect)
|
||||
}
|
||||
|
||||
function httpapiInstance<A, E>(
|
||||
name: string,
|
||||
options: {
|
||||
serverPath: ServerPath
|
||||
git?: boolean
|
||||
config?: Partial<ConfigV1.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: yield* 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, scenario("raw"))
|
||||
}
|
||||
|
||||
function withProject<A, E, E2 = never>(
|
||||
serverPath: ServerPath,
|
||||
options: {
|
||||
git?: boolean
|
||||
config?: Partial<ConfigV1.Info>
|
||||
setup?: (dir: string) => Effect.Effect<void, E2, TestServices>
|
||||
},
|
||||
run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped({
|
||||
git: options.git ?? false,
|
||||
config: { formatter: false, lsp: false, ...options.config },
|
||||
})
|
||||
yield* options.setup?.(directory) ?? Effect.void
|
||||
return yield* run({ sdk: yield* client(serverPath, directory), directory })
|
||||
})
|
||||
}
|
||||
|
||||
function withStandardProject<A, E>(
|
||||
serverPath: ServerPath,
|
||||
run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
|
||||
) {
|
||||
return withProject(serverPath, { setup: writeStandardFiles }, run)
|
||||
}
|
||||
|
||||
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: testProviderConfig(llm.url) }, (input) => run({ ...input, llm }))
|
||||
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||
}
|
||||
|
||||
function withFakeLlmProject<A, E>(
|
||||
serverPath: ServerPath,
|
||||
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
|
||||
return yield* withProject(
|
||||
serverPath,
|
||||
{
|
||||
config: testProviderConfig(llm.url),
|
||||
setup: options.setup,
|
||||
},
|
||||
(input) => run({ ...input, llm }),
|
||||
)
|
||||
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||
}
|
||||
|
||||
function writeStandardFiles(dir: string) {
|
||||
return FSUtil.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 FSUtil.Service.use((fs) =>
|
||||
fs.writeWithDirs(
|
||||
path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"),
|
||||
`---
|
||||
name: project-rest-skill
|
||||
description: A project skill visible to REST API prompts.
|
||||
---
|
||||
|
||||
# Project REST Skill
|
||||
`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function seedMessage(directory: string, sessionID: string) {
|
||||
const id = SessionID.make(sessionID)
|
||||
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: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
tools: {},
|
||||
} satisfies SessionV1.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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi SDK", () => {
|
||||
httpapi(
|
||||
"uses the generated SDK for global and control routes",
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* client("raw")
|
||||
const health = yield* call(() => sdk.global.health())
|
||||
const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }))
|
||||
|
||||
expect(health.response.status).toBe(200)
|
||||
expect(health.data).toMatchObject({ healthy: true })
|
||||
expect(yield* firstEvent((signal) => sdk.global.event({ signal }))).toMatchObject({
|
||||
payload: { type: "server.connected" },
|
||||
})
|
||||
expect(log.response.status).toBe(200)
|
||||
expect(log.data).toBe(true)
|
||||
yield* expectStatus(() => sdk.auth.set({ providerID: "test" }), 400)
|
||||
}),
|
||||
)
|
||||
|
||||
httpapiInstance(
|
||||
"uses the generated SDK for safe instance routes",
|
||||
{ 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" }))
|
||||
const listed = yield* call(() => sdk.session.list({ roots: true, limit: 10 }))
|
||||
|
||||
expect(file.response.status).toBe(200)
|
||||
expect(file.data).toMatchObject({ content: "hello" })
|
||||
expect(session.response.status).toBe(200)
|
||||
expect(session.data).toMatchObject({ title: "sdk" })
|
||||
expect(listed.response.status).toBe(200)
|
||||
expect(listed.data?.map((item) => item.id)).toContain(session.data?.id)
|
||||
|
||||
yield* Effect.all([
|
||||
expectStatus(() => sdk.project.current(), 200),
|
||||
expectStatus(() => sdk.config.get(), 200),
|
||||
expectStatus(() => sdk.config.providers(), 200),
|
||||
expectStatus(() => sdk.find.files({ query: "hello", limit: 10 }), 200),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
httpapi(
|
||||
"routes configured SDK directory and workspace for v2 location GETs",
|
||||
withProject("raw", { setup: writeStandardFiles }, ({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const workspaceID = "wrk_sdk"
|
||||
let request: Request | undefined
|
||||
const sdk = yield* client("raw", directory, {
|
||||
workspaceID,
|
||||
onRequest: (value) => (request = value),
|
||||
})
|
||||
const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" }))
|
||||
const url = new URL(request!.url)
|
||||
|
||||
expect(found.response.status).toBe(200)
|
||||
expect(found.data).toMatchObject({ data: [{ path: "hello.txt", type: "file" }] })
|
||||
expect(url.searchParams.get("directory")).toBe(directory)
|
||||
expect(url.searchParams.get("workspace")).toBe(workspaceID)
|
||||
expect(url.searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
|
||||
expect(request!.headers.has("x-opencode-directory")).toBe(false)
|
||||
expect(request!.headers.has("x-opencode-workspace")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* client(serverPath)
|
||||
const health = yield* capture(() => sdk.global.health())
|
||||
const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
|
||||
const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" }))
|
||||
|
||||
return {
|
||||
statuses: statuses({ health, log, invalidAuth }),
|
||||
health: record(health.data).healthy,
|
||||
log: log.data,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK global event stream", (serverPath) =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* client(serverPath)
|
||||
const event = yield* firstEvent((signal) => sdk.global.event({ signal }))
|
||||
return { type: record(record(event).payload).type }
|
||||
}),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK instance event stream", (serverPath) =>
|
||||
withStandardProject(serverPath, ({ sdk }) =>
|
||||
firstEvent((signal) => sdk.event.subscribe(undefined, { signal })).pipe(
|
||||
Effect.map((event) => ({ type: record(record(event).payload).type })),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK missing session errors", (serverPath) =>
|
||||
withStandardProject(serverPath, ({ sdk }) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = "ses_missing"
|
||||
const expected = {
|
||||
name: "NotFoundError",
|
||||
data: { message: `Session not found: ${sessionID}` },
|
||||
}
|
||||
const missing = yield* capture(() => sdk.session.get({ sessionID }))
|
||||
const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
|
||||
|
||||
// Result-tuple path: error body is preserved as-is so existing
|
||||
// consumers reading `result.error.name` / `JSON.stringify(error)`
|
||||
// keep working byte-for-byte.
|
||||
expect(missing.error).toEqual(expected)
|
||||
// throwOnError path: SDK wraps the body in a real Error with the
|
||||
// server's message, with the original parsed body preserved under
|
||||
// `.cause.body`.
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
expect((thrown as Error).message).toBe(expected.data.message)
|
||||
expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected)
|
||||
return {
|
||||
status: missing.status,
|
||||
error: missing.error,
|
||||
thrown,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("formats missing session validation errors for -s", (serverPath) =>
|
||||
withStandardProject(serverPath, ({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
|
||||
const fetch = yield* serverFetch(serverPath)
|
||||
const thrown = yield* captureThrown(() =>
|
||||
validateSession({
|
||||
url: "http://localhost",
|
||||
directory,
|
||||
sessionID,
|
||||
fetch,
|
||||
}),
|
||||
)
|
||||
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
|
||||
return errorMessage(thrown)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
httpapiInstance(
|
||||
"uses generated SDK basic auth behavior",
|
||||
{ serverPath: "raw", setup: writeStandardFiles },
|
||||
({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const missingSdk = yield* client("raw", directory, { password: "secret" })
|
||||
const missing = yield* capture(() => missingSdk.file.read({ path: "hello.txt" }))
|
||||
const badSdk = yield* client("raw", directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "wrong") },
|
||||
})
|
||||
const bad = yield* capture(() => badSdk.file.read({ path: "hello.txt" }))
|
||||
const goodSdk = yield* client("raw", directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "secret") },
|
||||
})
|
||||
const good = yield* capture(() => goodSdk.file.read({ path: "hello.txt" }))
|
||||
|
||||
return {
|
||||
statuses: statuses({ missing, bad, good }),
|
||||
content: record(good.data).content,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
|
||||
withProject(serverPath, { git: true, setup: writeStandardFiles }, ({ sdk, directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* capture(() => sdk.project.current())
|
||||
const projects = yield* capture(() => sdk.project.list())
|
||||
const paths = yield* capture(() => sdk.path.get())
|
||||
const config = yield* capture(() => sdk.config.get())
|
||||
const providers = yield* capture(() => sdk.config.providers())
|
||||
const file = yield* capture(() => sdk.file.read({ path: "hello.txt" }))
|
||||
const files = yield* capture(() => sdk.file.list({ path: "." }))
|
||||
const fileStatus = yield* capture(() => sdk.file.status())
|
||||
const findFiles = yield* capture(() => sdk.find.files({ query: "hello", limit: 10 }))
|
||||
const findText = yield* capture(() => sdk.find.text({ pattern: "sdk-parity" }))
|
||||
const agents = yield* capture(() => sdk.app.agents())
|
||||
const skills = yield* capture(() => sdk.app.skills())
|
||||
const tools = yield* capture(() => sdk.tool.ids())
|
||||
const vcs = yield* capture(() => sdk.vcs.get())
|
||||
const formatter = yield* capture(() => sdk.formatter.status())
|
||||
const lsp = yield* capture(() => sdk.lsp.status())
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
project,
|
||||
projects,
|
||||
paths,
|
||||
config,
|
||||
providers,
|
||||
file,
|
||||
files,
|
||||
fileStatus,
|
||||
findFiles,
|
||||
findText,
|
||||
agents,
|
||||
skills,
|
||||
tools,
|
||||
vcs,
|
||||
formatter,
|
||||
lsp,
|
||||
}),
|
||||
project: { worktreeSelected: record(project.data).worktree === 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"),
|
||||
foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
|
||||
listedFile: JSON.stringify(files.data).includes("hello.txt"),
|
||||
vcs: { hasBranch: typeof record(vcs.data).branch === "string" },
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK session lifecycle routes", (serverPath) =>
|
||||
withStandardProject(serverPath, ({ sdk }) =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* capture(() => sdk.session.create({ title: "parent" }))
|
||||
const parentID = String(record(parent.data).id)
|
||||
const child = yield* capture(() => sdk.session.create({ title: "child", parentID }))
|
||||
const childID = String(record(child.data).id)
|
||||
const get = yield* capture(() => sdk.session.get({ sessionID: parentID }))
|
||||
const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "renamed" }))
|
||||
const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 }))
|
||||
const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 }))
|
||||
const children = yield* capture(() => sdk.session.children({ sessionID: parentID }))
|
||||
const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID }))
|
||||
const status = yield* capture(() => sdk.session.status())
|
||||
const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID }))
|
||||
const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" }))
|
||||
const missingMessages = yield* capture(() => sdk.session.messages({ sessionID: "ses_missing", limit: 2 }))
|
||||
const invalidCursor = yield* capture(() =>
|
||||
sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }),
|
||||
)
|
||||
const deleted = yield* capture(() => sdk.session.delete({ sessionID: childID }))
|
||||
const getDeleted = yield* capture(() => sdk.session.get({ sessionID: childID }))
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
parent,
|
||||
child,
|
||||
get,
|
||||
update,
|
||||
roots,
|
||||
all,
|
||||
children,
|
||||
todo,
|
||||
status,
|
||||
messages,
|
||||
missingGet,
|
||||
missingMessages,
|
||||
invalidCursor,
|
||||
deleted,
|
||||
getDeleted,
|
||||
}),
|
||||
getTitle: record(get.data).title,
|
||||
updatedTitle: record(update.data).title,
|
||||
rootTitles: sessionTitles(roots.data),
|
||||
allTitles: sessionTitles(all.data),
|
||||
childCount: array(children.data).length,
|
||||
todoCount: array(todo.data).length,
|
||||
messageCount: array(messages.data).length,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK session message and part routes", (serverPath) =>
|
||||
withStandardProject(serverPath, ({ sdk, directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* capture(() => sdk.session.create({ title: "messages" }))
|
||||
const sessionID = String(record(session.data).id)
|
||||
const seeded = yield* seedMessage(directory, sessionID)
|
||||
const list = yield* capture(() => sdk.session.messages({ sessionID }))
|
||||
const page = yield* capture(() => sdk.session.messages({ sessionID, limit: 1 }))
|
||||
const message = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
const partUpdate = yield* capture(() =>
|
||||
sdk.part.update({
|
||||
sessionID,
|
||||
messageID: seeded.message.id,
|
||||
partID: seeded.part.id,
|
||||
part: { ...seeded.part, text: "updated message" } as NonNullable<
|
||||
Parameters<Sdk["part"]["update"]>[0]["part"]
|
||||
>,
|
||||
}),
|
||||
)
|
||||
const updated = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
const partDelete = yield* capture(() =>
|
||||
sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }),
|
||||
)
|
||||
const withoutPart = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
const deleteMessage = yield* capture(() =>
|
||||
sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }),
|
||||
)
|
||||
const missingMessage = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
session,
|
||||
list,
|
||||
page,
|
||||
message,
|
||||
partUpdate,
|
||||
updated,
|
||||
partDelete,
|
||||
withoutPart,
|
||||
deleteMessage,
|
||||
missingMessage,
|
||||
}),
|
||||
listCount: array(list.data).length,
|
||||
pageCount: array(page.data).length,
|
||||
initialText: firstPartText(message.data),
|
||||
updatedText: firstPartText(updated.data),
|
||||
partCountAfterDelete: array(record(withoutPart.data).parts).length,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Regression: EventV2 must publish on the same ProjectBus the /event handler
|
||||
// subscribes to, AND the /event stream must forward handler ALS/context into the
|
||||
// body-pump fiber. Drives the full SDK → /event → Session.updatePart → sync.run →
|
||||
// bus.publish → SDK subscriber path. Goes red if either the publisher uses a
|
||||
// different bus instance (Bug 2 / pre-#27825) or the stream loses context (Bug 1 /
|
||||
// pre-#27425).
|
||||
serverPathParity("streams sync-backed part updates to /event subscribers", (serverPath) =>
|
||||
withStandardProject(serverPath, ({ sdk, directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* capture(() => sdk.session.create({ title: "sync-backed part event" }))
|
||||
const sessionID = String(record(session.data).id)
|
||||
const seeded = yield* seedMessage(directory, sessionID)
|
||||
|
||||
const controller = new AbortController()
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()))
|
||||
const events = yield* call(() => sdk.event.subscribe(undefined, { signal: controller.signal }))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const ready = yield* Deferred.make<void>()
|
||||
const received = yield* Deferred.make<unknown>()
|
||||
|
||||
yield* call(async () => {
|
||||
for await (const event of events.stream) {
|
||||
const payload = record(event).payload ?? event
|
||||
const type = record(payload).type
|
||||
if (type === "server.connected") {
|
||||
Deferred.doneUnsafe(ready, Effect.void)
|
||||
continue
|
||||
}
|
||||
if (type === MessageV2.Event.PartUpdated.type) {
|
||||
Deferred.doneUnsafe(received, Effect.succeed(payload))
|
||||
return
|
||||
}
|
||||
}
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* awaitWithTimeout(Deferred.await(ready), "timed out waiting for /event server.connected", "2 seconds")
|
||||
|
||||
const updated = yield* capture(() =>
|
||||
sdk.part.update({
|
||||
sessionID,
|
||||
messageID: seeded.message.id,
|
||||
partID: seeded.part.id,
|
||||
part: { ...seeded.part, text: "updated via sync" } as NonNullable<
|
||||
Parameters<Sdk["part"]["update"]>[0]["part"]
|
||||
>,
|
||||
}),
|
||||
)
|
||||
expect(updated.status).toBe(200)
|
||||
|
||||
const event = yield* awaitWithTimeout(
|
||||
Deferred.await(received),
|
||||
"timed out waiting for message.part.updated bus payload over /event",
|
||||
"5 seconds",
|
||||
)
|
||||
const properties = record(record(event).properties)
|
||||
expect(record(properties.part)).toMatchObject({ id: seeded.part.id, type: "text" })
|
||||
return { type: record(event).type, partType: record(properties.part).type }
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) =>
|
||||
withStandardProject(serverPath, ({ sdk }) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* capture(() => sdk.session.create({ title: "prompt" }))
|
||||
const sessionID = String(record(session.data).id)
|
||||
const prompt = yield* capture(() =>
|
||||
sdk.session.prompt({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
)
|
||||
const asyncPrompt = yield* capture(() =>
|
||||
sdk.session.promptAsync({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "async hello" }],
|
||||
}),
|
||||
)
|
||||
const messages = yield* capture(() => sdk.session.messages({ sessionID }))
|
||||
|
||||
return {
|
||||
statuses: statuses({ session, prompt, asyncPrompt, messages }),
|
||||
promptRole: record(record(prompt.data).info).role,
|
||||
messageCount: array(messages.data).length,
|
||||
messageTexts: array(messages.data)
|
||||
.flatMap((item) => array(record(item).parts))
|
||||
.map((part) => record(part).text)
|
||||
.filter((text): text is string => typeof text === "string")
|
||||
.sort(),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) =>
|
||||
withFakeLlm(serverPath, ({ sdk, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.text("fake world", { usage: { input: 11, output: 7 } })
|
||||
const session = yield* capture(() =>
|
||||
sdk.session.create({
|
||||
title: "llm prompt",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
}),
|
||||
)
|
||||
const sessionID = String(record(session.data).id)
|
||||
const prompt = yield* capture(() =>
|
||||
sdk.session.prompt({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "test-model" },
|
||||
parts: [{ type: "text", text: "hello llm" }],
|
||||
}),
|
||||
)
|
||||
const messages = yield* capture(() => sdk.session.messages({ sessionID }))
|
||||
const inputs = yield* llm.inputs
|
||||
|
||||
return {
|
||||
statuses: statuses({ session, prompt, messages }),
|
||||
calls: inputs.length,
|
||||
requestedModel: inputs[0]?.model,
|
||||
responseText: JSON.stringify(prompt.data).includes("fake world"),
|
||||
persistedText: JSON.stringify(messages.data).includes("fake world"),
|
||||
userText: JSON.stringify(messages.data).includes("hello llm"),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
httpapi(
|
||||
"includes project skills in REST API prompt context",
|
||||
withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } })
|
||||
const session = yield* capture(() =>
|
||||
sdk.session.create({
|
||||
title: "project skill prompt",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
}),
|
||||
)
|
||||
const sessionID = String(record(session.data).id)
|
||||
const prompt = yield* capture(() =>
|
||||
sdk.session.prompt({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "test-model" },
|
||||
parts: [{ type: "text", text: "hello skill context" }],
|
||||
}),
|
||||
)
|
||||
const inputs = yield* llm.inputs
|
||||
|
||||
expect(session.status).toBe(200)
|
||||
expect(prompt.status).toBe(200)
|
||||
expect(JSON.stringify(inputs[0])).toContain("project-rest-skill")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK TUI validation and command routes", (serverPath) =>
|
||||
withStandardProject(serverPath, ({ sdk }) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* capture(() => sdk.session.create({ title: "tui" }))
|
||||
const sessionID = String(record(session.data).id)
|
||||
const appendPrompt = yield* capture(() => sdk.tui.appendPrompt({ text: "hello" }))
|
||||
const openHelp = yield* capture(() => sdk.tui.openHelp())
|
||||
const openSessions = yield* capture(() => sdk.tui.openSessions())
|
||||
const openThemes = yield* capture(() => sdk.tui.openThemes())
|
||||
const openModels = yield* capture(() => sdk.tui.openModels())
|
||||
const submitPrompt = yield* capture(() => sdk.tui.submitPrompt())
|
||||
const clearPrompt = yield* capture(() => sdk.tui.clearPrompt())
|
||||
const executeCommand = yield* capture(() => sdk.tui.executeCommand({ command: "session_new" }))
|
||||
const showToast = yield* capture(() => sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" }))
|
||||
const selectSession = yield* capture(() => sdk.tui.selectSession({ sessionID }))
|
||||
const missingSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "ses_missing" }))
|
||||
const invalidSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "invalid_session_id" }))
|
||||
|
||||
return {
|
||||
statuses: statuses({
|
||||
session,
|
||||
appendPrompt,
|
||||
openHelp,
|
||||
openSessions,
|
||||
openThemes,
|
||||
openModels,
|
||||
submitPrompt,
|
||||
clearPrompt,
|
||||
executeCommand,
|
||||
showToast,
|
||||
selectSession,
|
||||
missingSession,
|
||||
invalidSession,
|
||||
}),
|
||||
data: {
|
||||
appendPrompt: appendPrompt.data,
|
||||
openHelp: openHelp.data,
|
||||
openSessions: openSessions.data,
|
||||
openThemes: openThemes.data,
|
||||
openModels: openModels.data,
|
||||
submitPrompt: submitPrompt.data,
|
||||
clearPrompt: clearPrompt.data,
|
||||
executeCommand: executeCommand.data,
|
||||
showToast: showToast.data,
|
||||
selectSession: selectSession.data,
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK project git initialization", (serverPath) =>
|
||||
withProject(serverPath, {}, ({ sdk, directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* capture(() => sdk.project.current())
|
||||
const init = yield* capture(() => sdk.project.initGit())
|
||||
const after = yield* capture(() => sdk.project.current())
|
||||
|
||||
return {
|
||||
statuses: statuses({ before, init, after }),
|
||||
before: {
|
||||
vcs: record(before.data).vcs ?? null,
|
||||
worktree: record(before.data).worktree,
|
||||
},
|
||||
init: {
|
||||
vcs: record(init.data).vcs,
|
||||
worktreeSelected: record(init.data).worktree === directory,
|
||||
},
|
||||
after: {
|
||||
vcs: record(after.data).vcs,
|
||||
worktreeSelected: record(after.data).worktree === directory,
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
1011
packages/opencode/test/server/httpapi-session.test.ts
Normal file
1011
packages/opencode/test/server/httpapi-session.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
148
packages/opencode/test/server/httpapi-sync.test.ts
Normal file
148
packages/opencode/test/server/httpapi-sync.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { afterEach, describe, expect, mock } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Session } from "@/session/session"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("sync HttpApi", () => {
|
||||
it.instance(
|
||||
"serves sync routes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const tmp = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
|
||||
const session = yield* Session.use.create({ title: "sync" })
|
||||
|
||||
const started = yield* requestInDirectory(SyncPaths.start, tmp.directory, { method: "POST", headers })
|
||||
expect(started.status).toBe(200)
|
||||
expect(yield* started.json).toBe(true)
|
||||
|
||||
const history = yield* requestInDirectory(SyncPaths.history, tmp.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(history.status).toBe(200)
|
||||
const rows = (yield* history.json) as Array<{
|
||||
id: string
|
||||
aggregate_id: string
|
||||
seq: number
|
||||
type: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
|
||||
|
||||
const replayed = yield* requestInDirectory(SyncPaths.replay, tmp.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
directory: tmp.directory,
|
||||
events: rows
|
||||
.filter((row) => row.aggregate_id === session.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
aggregateID: row.aggregate_id,
|
||||
seq: row.seq,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
expect(replayed.status).toBe(200)
|
||||
expect(yield* replayed.json).toEqual({ sessionID: session.id })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"validates seq values",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
|
||||
const cases = [
|
||||
{
|
||||
path: SyncPaths.history,
|
||||
body: { aggregate: -1 },
|
||||
},
|
||||
{
|
||||
path: SyncPaths.history,
|
||||
body: { aggregate: 1.5 },
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.directory,
|
||||
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.directory,
|
||||
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.directory,
|
||||
events: [{ id: "event", aggregateID: "session", seq: 0, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const response = yield* requestInDirectory(item.path, tmp.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(item.body),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance.skip(
|
||||
"returns structured validation errors",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const response = yield* Effect.promise(() =>
|
||||
HttpApiApp.webHandler().handler(
|
||||
new Request(`http://localhost${SyncPaths.history}`, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
}),
|
||||
context,
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.headers.get("content-type") ?? "").toContain("application/json")
|
||||
const body = (yield* Effect.promise(() => response.json())) as Record<string, unknown>
|
||||
expect(body.success).toBe(false)
|
||||
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
453
packages/opencode/test/server/httpapi-ui.test.ts
Normal file
453
packages/opencode/test/server/httpapi-ui.test.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ConfigProvider, Effect, Layer } from "effect"
|
||||
import {
|
||||
HttpClient,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
HttpRouter,
|
||||
HttpServer,
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const original = {
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
||||
envUsername: process.env.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
|
||||
restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, RuntimeFlags.layer()))
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
return
|
||||
}
|
||||
process.env[key] = value
|
||||
}
|
||||
|
||||
function app(input?: { password?: string; username?: string }) {
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
HttpApiApp.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
handler(
|
||||
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
|
||||
HttpApiApp.context,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function uiApp(input?: {
|
||||
password?: string
|
||||
username?: string
|
||||
client?: Layer.Layer<HttpClient.HttpClient>
|
||||
disableEmbeddedWebUi?: boolean
|
||||
}) {
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
yield* router.add("*", "/*", (request) =>
|
||||
serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
|
||||
)
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
|
||||
Layer.provide([
|
||||
FSUtil.defaultLayer,
|
||||
input?.client ?? httpClient(new Response("ui")),
|
||||
RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }),
|
||||
HttpServer.layerServices,
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
handler(
|
||||
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
|
||||
HttpApiApp.context,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function routeOrderingApp() {
|
||||
let proxiedUrl: string | undefined
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
yield* router.add("GET", "/session/:sessionID", () =>
|
||||
Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })),
|
||||
)
|
||||
yield* router.add("*", "/*", (request) =>
|
||||
serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
|
||||
)
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide([
|
||||
FSUtil.defaultLayer,
|
||||
RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
|
||||
httpClient(new Response("ui"), (request) => {
|
||||
proxiedUrl = request.url
|
||||
}),
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
proxiedUrl: () => proxiedUrl,
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
handler(
|
||||
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
|
||||
HttpApiApp.context,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) {
|
||||
return Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) => {
|
||||
onRequest?.(request)
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, response))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function responseText(response: Response) {
|
||||
return Effect.promise(() => response.text())
|
||||
}
|
||||
|
||||
describe("HttpApi UI fallback", () => {
|
||||
it.live("serves the web UI through the HTTP API app", () =>
|
||||
Effect.gen(function* () {
|
||||
let proxiedUrl: string | undefined
|
||||
|
||||
const response = yield* uiApp({
|
||||
disableEmbeddedWebUi: true,
|
||||
client: httpClient(
|
||||
new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } }),
|
||||
(request) => {
|
||||
proxiedUrl = request.url
|
||||
},
|
||||
),
|
||||
}).request("/")
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-type")).toContain("text/html")
|
||||
expect(yield* responseText(response)).toBe("<html>opencode</html>")
|
||||
expect(proxiedUrl).toBe("https://app.opencode.ai/")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("strips upstream transfer encoding headers from proxied assets", () =>
|
||||
Effect.gen(function* () {
|
||||
let proxiedUrl: string | undefined
|
||||
|
||||
const response = yield* Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), {
|
||||
fs,
|
||||
client,
|
||||
disableEmbeddedWebUi: flags.disableEmbeddedWebUi,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) => {
|
||||
proxiedUrl = request.url
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response("console.log('ok')", {
|
||||
headers: {
|
||||
"content-encoding": "br",
|
||||
"content-length": "999",
|
||||
"content-type": "text/javascript",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map(HttpServerResponse.toWeb),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js")
|
||||
expect(response.headers.get("content-encoding")).toBeNull()
|
||||
expect(response.headers.get("content-length")).not.toBe("999")
|
||||
expect(response.headers.get("content-type")).toContain("text/javascript")
|
||||
expect(yield* responseText(response)).toBe("console.log('ok')")
|
||||
}),
|
||||
)
|
||||
|
||||
// Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was
|
||||
// forwarded through the proxy while the proxy itself re-frames the body,
|
||||
// causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`.
|
||||
it.live("strips upstream transfer-encoding header from proxied assets", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), {
|
||||
fs,
|
||||
client,
|
||||
disableEmbeddedWebUi: flags.disableEmbeddedWebUi,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response("<html>opencode</html>", {
|
||||
headers: {
|
||||
"transfer-encoding": "chunked",
|
||||
"content-type": "text/html",
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map(HttpServerResponse.toWeb),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("transfer-encoding")).toBeNull()
|
||||
expect(yield* responseText(response)).toBe("<html>opencode</html>")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves embedded UI assets when Bun can read them but access reports missing", () =>
|
||||
Effect.gen(function* () {
|
||||
let readPath: string | undefined
|
||||
|
||||
const fs = yield* FSUtil.Service
|
||||
const response = yield* serveEmbeddedUIEffect(
|
||||
"/assets/app.js",
|
||||
{
|
||||
...fs,
|
||||
existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"),
|
||||
readFile: (path) => {
|
||||
readPath = path
|
||||
return path === "/$bunfs/root/assets/app.js"
|
||||
? Effect.succeed(new TextEncoder().encode("console.log('embedded')"))
|
||||
: Effect.die(`unexpected embedded UI path: ${path}`)
|
||||
},
|
||||
},
|
||||
{ "assets/app.js": "/$bunfs/root/assets/app.js" },
|
||||
).pipe(Effect.map(HttpServerResponse.toWeb))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(readPath).toBe("/$bunfs/root/assets/app.js")
|
||||
expect(response.headers.get("content-type")).toContain("text/javascript")
|
||||
expect(yield* responseText(response)).toBe("console.log('embedded')")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("allows embedded UI terminal wasm and theme preload CSP", () =>
|
||||
Effect.gen(function* () {
|
||||
const script = 'document.documentElement.dataset.theme = "dark"'
|
||||
|
||||
const fs = yield* FSUtil.Service
|
||||
const response = yield* serveEmbeddedUIEffect(
|
||||
"/",
|
||||
{
|
||||
...fs,
|
||||
readFile: (path) => {
|
||||
return path === "/$bunfs/root/index.html"
|
||||
? Effect.succeed(
|
||||
new TextEncoder().encode(
|
||||
`<html><head><script id="oc-theme-preload-script">${script}</script></head></html>`,
|
||||
),
|
||||
)
|
||||
: Effect.die(`unexpected embedded UI path: ${path}`)
|
||||
},
|
||||
},
|
||||
{ "index.html": "/$bunfs/root/index.html" },
|
||||
).pipe(Effect.map(HttpServerResponse.toWeb))
|
||||
|
||||
const csp = response.headers.get("content-security-policy") ?? ""
|
||||
expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'")
|
||||
expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`)
|
||||
expect(csp).toContain("connect-src * data:")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps matched API routes ahead of the UI fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = routeOrderingApp()
|
||||
const response = yield* server.request("/session/ses_nope")
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(server.proxiedUrl()).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("requires server password for the web UI", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* uiApp({
|
||||
password: "secret",
|
||||
username: "opencode",
|
||||
disableEmbeddedWebUi: true,
|
||||
}).request("/")
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("accepts auth token for the web UI", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* uiApp({
|
||||
password: "secret",
|
||||
username: "opencode",
|
||||
disableEmbeddedWebUi: true,
|
||||
client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } })),
|
||||
}).request(`/?auth_token=${btoa("opencode:secret")}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* responseText(response)).toBe("<html>opencode</html>")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("accepts basic auth for the web UI", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* uiApp({
|
||||
password: "secret",
|
||||
username: "opencode",
|
||||
disableEmbeddedWebUi: true,
|
||||
}).request("/", {
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("accepts basic auth passwords containing colons for the web UI", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* uiApp({
|
||||
password: "sec:ret",
|
||||
username: "opencode",
|
||||
disableEmbeddedWebUi: true,
|
||||
}).request("/", {
|
||||
headers: { authorization: `Basic ${btoa("opencode:sec:ret")}` },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
// Regression for #25698 (Ope): the browser fetches the PWA manifest and
|
||||
// its icons via flows that don't carry app-managed credentials (the
|
||||
// `<link rel="manifest">` request is not under page-auth control), so the
|
||||
// server returning 401 breaks PWA install. These specific public assets
|
||||
// should bypass auth.
|
||||
it.live("serves the PWA manifest without auth even when a server password is set", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) {
|
||||
const response = yield* uiApp({
|
||||
password: "secret",
|
||||
username: "opencode",
|
||||
disableEmbeddedWebUi: true,
|
||||
client: httpClient(new Response("ok")),
|
||||
}).request(path)
|
||||
expect(response.status).not.toBe(401)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("allows web UI preflight without auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* app({ password: "secret", username: "opencode" }).request("/", {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "http://localhost:3000",
|
||||
"access-control-request-method": "GET",
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
}),
|
||||
)
|
||||
})
|
||||
82
packages/opencode/test/server/httpapi-v2-location.test.ts
Normal file
82
packages/opencode/test/server/httpapi-v2-location.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Context, Schema } from "effect"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
function request(route: string, directory: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return HttpApiApp.webHandler().handler(
|
||||
new Request(`http://localhost${route}`, {
|
||||
...init,
|
||||
headers,
|
||||
}),
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
const Event = Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.String,
|
||||
location: Schema.Struct({
|
||||
directory: Schema.String,
|
||||
project: Schema.Struct({ id: Schema.String, directory: Schema.String }),
|
||||
}),
|
||||
data: Schema.Unknown,
|
||||
})
|
||||
|
||||
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
const value = await reader.read()
|
||||
if (value.done) throw new Error("event stream closed")
|
||||
return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, "")))
|
||||
}
|
||||
|
||||
async function readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
|
||||
for (let index = 0; index < 20; index++) {
|
||||
const event = await readEvent(reader)
|
||||
if (event.type === type) return event
|
||||
}
|
||||
throw new Error(`timed out waiting for ${type}`)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("v2 location HttpApi", () => {
|
||||
test("returns command and skill snapshots with resolved locations", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
for (const route of ["/api/command", "/api/skill"]) {
|
||||
const response = await request(route, tmp.path)
|
||||
expect(response.status).toBe(200)
|
||||
const body = (await response.json()) as {
|
||||
location: { directory: string; project: { id: string } }
|
||||
data: unknown
|
||||
}
|
||||
expect(body.data).toBeArray()
|
||||
expect(body.location.directory).toBe(tmp.path)
|
||||
expect(body.location.project.id).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
test("streams native EventV2 payloads with resolved locations", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const response = await request("/api/event", tmp.path)
|
||||
const reader = response.body!.getReader()
|
||||
expect((await readEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const created = await request("/session", tmp.path, { method: "POST" })
|
||||
expect(created.status).toBe(200)
|
||||
expect(await readEventType(reader, "session.created")).toMatchObject({
|
||||
type: "session.created",
|
||||
location: { directory: tmp.path, project: { directory: tmp.path } },
|
||||
data: { sessionID: expect.any(String) },
|
||||
})
|
||||
await reader.cancel()
|
||||
})
|
||||
})
|
||||
555
packages/opencode/test/server/httpapi-workspace-routing.test.ts
Normal file
555
packages/opencode/test/server/httpapi-workspace-routing.test.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
HttpClientRequest,
|
||||
HttpRouter,
|
||||
HttpServer,
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import Http from "node:http"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRouteContext,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true })
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
testStateLayer,
|
||||
NodeHttpServer.layerTest,
|
||||
NodeServices.layer,
|
||||
Database.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
workspaceLayer,
|
||||
Socket.layerWebSocketConstructorGlobal,
|
||||
).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||
)
|
||||
|
||||
type ProxiedRequest = {
|
||||
url: string
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
body: string
|
||||
}
|
||||
|
||||
type TestHandler<E, R> = (
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
|
||||
|
||||
const workspaceRoutingTestLayer = workspaceRoutingLayer.pipe(
|
||||
Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]),
|
||||
)
|
||||
|
||||
const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
|
||||
|
||||
const requestURL = (request: { readonly url: string }) => new URL(request.url, "http://localhost")
|
||||
|
||||
const listenAdditionalServer = <E, R>(handler: TestHandler<E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }))
|
||||
const server = Context.get(context, HttpServer.HttpServer)
|
||||
yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler))
|
||||
return HttpServer.formatAddress(server.address)
|
||||
})
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure: (info) => ({ ...info, name: "local-test", directory }),
|
||||
create: async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target: () => ({ type: "local" as const, directory }),
|
||||
})
|
||||
|
||||
const remoteAdapter = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter => ({
|
||||
name: "Remote Test",
|
||||
description: "Create a remote test workspace",
|
||||
configure: (info) => ({ ...info, name: "remote-test", directory }),
|
||||
create: async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target: () => ({ type: "remote" as const, url, headers }),
|
||||
})
|
||||
|
||||
const eventStreamResponse = () =>
|
||||
HttpServerResponse.text('data: {"payload":{"type":"server.connected","properties":{}}}\n\n', {
|
||||
contentType: "text/event-stream",
|
||||
})
|
||||
|
||||
const syncResponse = (request: HttpServerRequest.HttpServerRequest) => {
|
||||
const url = requestURL(request)
|
||||
if (url.pathname === "/base/global/event") return Effect.succeed(eventStreamResponse())
|
||||
if (url.pathname === "/base/sync/history") return HttpServerResponse.json([])
|
||||
return undefined
|
||||
}
|
||||
|
||||
const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adapter: WorkspaceAdapter }) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
registerAdapter(input.projectID, input.type, input.adapter)
|
||||
const workspace = yield* Workspace.Service
|
||||
return yield* workspace.create({
|
||||
type: input.type,
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: input.projectID,
|
||||
})
|
||||
}),
|
||||
(info) => Workspace.use.remove(info.id).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const createRemoteWorkspace = (input: {
|
||||
dir: string
|
||||
projectID: Project.Info["id"]
|
||||
type: string
|
||||
url: string
|
||||
headers?: HeadersInit
|
||||
}) =>
|
||||
// Workspace.create starts the remote sync loop. The test upstream exposes
|
||||
// /global/event and /sync/history so middleware proxying sees the remote
|
||||
// workspace as active, just like production would.
|
||||
createWorkspace({
|
||||
projectID: input.projectID,
|
||||
type: input.type,
|
||||
adapter: remoteAdapter(path.join(input.dir, `.${input.type}`), input.url, input.headers),
|
||||
})
|
||||
|
||||
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
|
||||
createWorkspace({
|
||||
projectID: input.projectID,
|
||||
type: input.type,
|
||||
adapter: localAdapter(input.directory),
|
||||
})
|
||||
|
||||
const insertRemoteWorkspaceWithoutSync = (input: {
|
||||
dir: string
|
||||
projectID: Project.Info["id"]
|
||||
type: string
|
||||
url: string
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const id = WorkspaceV2.ID.ascending()
|
||||
registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url))
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(WorkspaceTable)
|
||||
.values({ id, type: input.type, project_id: input.projectID })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return id
|
||||
})
|
||||
|
||||
const startRemoteWorkspaceHttpServer = <E, R>(
|
||||
handler: (request: ProxiedRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
|
||||
) =>
|
||||
listenAdditionalServer((request) =>
|
||||
Effect.gen(function* () {
|
||||
// Remote workspaces run a sync loop against their target server. These
|
||||
// bootstrap routes make Workspace.isSyncing(...) true for proxy tests;
|
||||
// everything else is the request being proxied by the middleware.
|
||||
const sync = syncResponse(request)
|
||||
if (sync) return yield* sync
|
||||
return yield* handler({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: yield* request.text,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const listenRemoteWebSocket = () =>
|
||||
listenAdditionalServer((request) => {
|
||||
const sync = syncResponse(request)
|
||||
if (sync) return sync
|
||||
if (requestURL(request).pathname !== "/base/probe") return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
|
||||
return echoWebSocket(request)
|
||||
})
|
||||
|
||||
const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
yield* socket
|
||||
.runRaw((message) => write(`echo:${String(message)}`), {
|
||||
onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
return HttpServerResponse.empty()
|
||||
})
|
||||
|
||||
const ProbeResult = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("workspace-routing-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
|
||||
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.get("workspace", WorkspacePaths.list, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: ProbeResult,
|
||||
}),
|
||||
)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const routeContextResponse = Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return { directory: route.directory, workspaceID: route.workspaceID }
|
||||
})
|
||||
|
||||
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("get", () => routeContextResponse)
|
||||
.handle("patch", () => Effect.succeed(false))
|
||||
.handle("session", () => routeContextResponse)
|
||||
.handle("workspace", () => routeContextResponse),
|
||||
)
|
||||
|
||||
const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi workspace routing middleware", () => {
|
||||
it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
let forwarded: ProxiedRequest | undefined
|
||||
|
||||
// This starts a second HTTP server that stands in for the opencode server
|
||||
// backing a remote workspace. The client below still calls the local test
|
||||
// server; only the middleware should call this server.
|
||||
const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => {
|
||||
forwarded = request
|
||||
const url = requestURL(request)
|
||||
return HttpServerResponse.json(
|
||||
{
|
||||
proxied: true,
|
||||
path: url.pathname,
|
||||
keep: url.searchParams.get("keep"),
|
||||
workspace: url.searchParams.get("workspace"),
|
||||
},
|
||||
{ status: 201, headers: { "x-remote": "yes" } },
|
||||
)
|
||||
})
|
||||
// The adapter target tells the middleware where to proxy selected remote
|
||||
// workspace requests. Appending /probe to this base should produce
|
||||
// `${remoteUrl}/base/probe` on the fake remote server above.
|
||||
const workspace = yield* createRemoteWorkspace({
|
||||
dir,
|
||||
projectID: project.project.id,
|
||||
type: "remote-http-target",
|
||||
url: `${remoteUrl}/base`,
|
||||
headers: { "x-target-auth": "secret" },
|
||||
})
|
||||
|
||||
// The local /probe handler should not run. Selecting a remote workspace
|
||||
// should make the middleware call HttpApiProxy.http instead.
|
||||
yield* serveProbe
|
||||
|
||||
const body = '{"title":"Remote workspace request"}'
|
||||
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
|
||||
HttpClientRequest.setHeaders({
|
||||
"x-opencode-directory": "/secret/path",
|
||||
"x-opencode-workspace": "internal",
|
||||
}),
|
||||
HttpClientRequest.bodyStream(
|
||||
Stream.make(new TextEncoder().encode('{"title":"Remote '), new TextEncoder().encode('workspace request"}')),
|
||||
{ contentType: "application/json" },
|
||||
),
|
||||
HttpClient.execute,
|
||||
Effect.timeout("2 seconds"),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(response.headers["x-remote"]).toBe("yes")
|
||||
expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null })
|
||||
const forwardedURL = forwarded ? requestURL(forwarded) : undefined
|
||||
// These assertions are the routing contract: append the original path to
|
||||
// the remote base URL, preserve normal query params, and remove workspace.
|
||||
expect(forwardedURL?.pathname).toBe("/base/probe")
|
||||
expect(forwardedURL?.searchParams.get("keep")).toBe("yes")
|
||||
expect(forwardedURL?.searchParams.get("workspace")).toBeNull()
|
||||
expect(forwarded?.method).toBe("PATCH")
|
||||
expect(forwarded?.body).toBe(body)
|
||||
expect(forwarded?.headers["content-type"]).toBe("application/json")
|
||||
expect(forwarded?.headers["x-target-auth"]).toBe("secret")
|
||||
expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined()
|
||||
expect(forwarded?.headers["x-opencode-workspace"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("waits for sync fence headers from remote workspace HTTP responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
const workspaceID = WorkspaceV2.ID.ascending()
|
||||
const type = "remote-http-fence-target"
|
||||
const waited = yield* Ref.make<{ workspaceID: WorkspaceV2.ID; state: Record<string, number> } | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
const remoteUrl = yield* startRemoteWorkspaceHttpServer(() =>
|
||||
HttpServerResponse.json(
|
||||
{ proxied: true },
|
||||
{ status: 202, headers: { [FenceHeader]: JSON.stringify({ aggregate: 3 }) } },
|
||||
),
|
||||
)
|
||||
registerAdapter(project.project.id, type, remoteAdapter(path.join(dir, `.${type}`), `${remoteUrl}/base`))
|
||||
|
||||
const workspace = Workspace.Service.of({
|
||||
create: () => Effect.die("unused"),
|
||||
sessionWarp: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
syncList: () => Effect.die("unused"),
|
||||
get: (id) =>
|
||||
Effect.succeed(
|
||||
id === workspaceID
|
||||
? {
|
||||
id: workspaceID,
|
||||
type,
|
||||
branch: null,
|
||||
name: "remote-http-fence-target",
|
||||
directory: null,
|
||||
extra: null,
|
||||
projectID: project.project.id,
|
||||
timeUsed: Date.now(),
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
remove: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
isSyncing: () => Effect.succeed(true),
|
||||
waitForSync: (id, state) => Ref.set(waited, { workspaceID: id, state }),
|
||||
startWorkspaceSyncing: () => Effect.die("unused"),
|
||||
})
|
||||
|
||||
yield* HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
Layer.provide(Layer.succeed(Workspace.Service, workspace)),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspaceID}`).pipe(HttpClient.execute)
|
||||
|
||||
expect(response.status).toBe(202)
|
||||
expect(yield* response.json).toEqual({ proxied: true })
|
||||
expect(yield* Ref.get(waited)).toEqual({ workspaceID, state: { aggregate: 3 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns 503 when a remote workspace is not actively syncing", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
const workspaceID = yield* insertRemoteWorkspaceWithoutSync({
|
||||
dir,
|
||||
projectID: project.project.id,
|
||||
type: "remote-not-syncing",
|
||||
url: "http://127.0.0.1:1/base",
|
||||
})
|
||||
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("proxies remote workspace WebSocket requests through the selected workspace target", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
const remoteUrl = yield* listenRemoteWebSocket()
|
||||
const workspace = yield* createRemoteWorkspace({
|
||||
dir,
|
||||
projectID: project.project.id,
|
||||
type: "remote-websocket-target",
|
||||
url: `${remoteUrl}/base`,
|
||||
})
|
||||
|
||||
// The client connects to the local test server. The middleware should
|
||||
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
|
||||
yield* serveProbe
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
|
||||
{
|
||||
closeCodeIsError: () => false,
|
||||
protocols: "chat",
|
||||
},
|
||||
)
|
||||
const messages = yield* Queue.unbounded<string>()
|
||||
yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped)
|
||||
const write = yield* socket.writer
|
||||
|
||||
expect(yield* Queue.take(messages)).toBe("protocol:chat")
|
||||
yield* write("hello")
|
||||
expect(yield* Queue.take(messages)).toBe("echo:hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns a missing workspace response for unknown workspace ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspaceID = WorkspaceV2.ID.ascending("wrk_missing")
|
||||
// If the middleware resolves the workspace first, this handler is never
|
||||
// reached and the response should be the middleware error response.
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(yield* response.text).toBe(`Workspace not found: ${workspaceID}`)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps control-plane 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: "control-plane-target",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
|
||||
// GET /session is a control-plane route: it lists sessions for the main
|
||||
// process and should not be redirected into the selected workspace target.
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id })
|
||||
}),
|
||||
)
|
||||
|
||||
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* serveProbe
|
||||
|
||||
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()
|
||||
const queryDir = path.join(dir, "query-target")
|
||||
const headerDir = path.join(dir, "header-target")
|
||||
yield* serveProbe
|
||||
|
||||
// Without a selected workspace, the middleware falls back to request
|
||||
// directory hints before using the process cwd.
|
||||
const queryResponse = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(queryDir)}`)
|
||||
const headerResponse = yield* HttpClientRequest.get("/probe").pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", headerDir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(queryResponse.status).toBe(200)
|
||||
expect(yield* queryResponse.json).toEqual({ directory: queryDir, workspaceID: null })
|
||||
expect(headerResponse.status).toBe(200)
|
||||
expect(yield* headerResponse.json).toEqual({ directory: headerDir, workspaceID: null })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("routes local workspace requests through WorkspaceRouteContext", () =>
|
||||
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: "local-target",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
|
||||
yield* serveProbe
|
||||
|
||||
// /probe is not a control-plane route, so selecting a local workspace
|
||||
// should swap the route context to the workspace target directory.
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({
|
||||
directory: workspaceDir,
|
||||
workspaceID: workspace.id,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
513
packages/opencode/test/server/httpapi-workspace.test.ts
Normal file
513
packages/opencode/test/server/httpapi-workspace.test.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
import { afterEach, describe, expect, mock } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { Session } from "@/session/session"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Project.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
workspaceLayer,
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)),
|
||||
Database.defaultLayer,
|
||||
httpApiLayer,
|
||||
).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||
)
|
||||
|
||||
function request(path: string, directory: string, init: RequestInit = {}) {
|
||||
return requestInDirectory(path, directory, init)
|
||||
}
|
||||
|
||||
function requestDefault(path: string, directory: string, init: RequestInit = {}) {
|
||||
return requestInDirectory(path, directory, init)
|
||||
}
|
||||
|
||||
function requestServer(path: string, directory: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return Effect.promise(() => Promise.resolve(Server.Default().app.request(path, { ...init, headers })))
|
||||
}
|
||||
|
||||
function localAdapter(directory: string): WorkspaceAdapter {
|
||||
return {
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure(info) {
|
||||
return {
|
||||
...info,
|
||||
name: "local-test",
|
||||
directory,
|
||||
}
|
||||
},
|
||||
async create() {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target() {
|
||||
return {
|
||||
type: "local" as const,
|
||||
directory,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function listedAdapter(directory: string, type: string): WorkspaceAdapter {
|
||||
return {
|
||||
name: "Listed Test",
|
||||
description: "List a local test workspace",
|
||||
configure(info) {
|
||||
return { ...info, name: "unused", directory }
|
||||
},
|
||||
async create() {},
|
||||
async remove() {},
|
||||
list(context) {
|
||||
return [
|
||||
{
|
||||
type,
|
||||
name: "listed-test",
|
||||
branch: "listed/main",
|
||||
directory,
|
||||
extra: { listed: true },
|
||||
projectID: context?.instance?.project.id ?? missingAdapterContext(),
|
||||
},
|
||||
]
|
||||
},
|
||||
target() {
|
||||
return {
|
||||
type: "local" as const,
|
||||
directory,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function missingAdapterContext(): never {
|
||||
throw new Error("missing workspace adapter context")
|
||||
}
|
||||
|
||||
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
|
||||
return {
|
||||
name: "Remote Test",
|
||||
description: "Create a remote test workspace",
|
||||
configure(info) {
|
||||
return {
|
||||
...info,
|
||||
name: "remote-test",
|
||||
directory,
|
||||
}
|
||||
},
|
||||
async create() {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target() {
|
||||
return {
|
||||
type: "remote" as const,
|
||||
url,
|
||||
headers,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ProxiedRequest = {
|
||||
url: string
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
body: string
|
||||
}
|
||||
|
||||
function listenRemoteHttp(handler: (request: ProxiedRequest) => Response | Promise<Response>) {
|
||||
return Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
return handler({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
body: await request.text(),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function eventStreamResponse() {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode('data: {"payload":{"type":"server.connected","properties":{}}}\n\n'),
|
||||
)
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("workspace HttpApi", () => {
|
||||
it.live("serves read endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const [adapters, workspaces, status] = yield* Effect.all([
|
||||
request(WorkspacePaths.adapters, dir),
|
||||
request(WorkspacePaths.list, dir),
|
||||
request(WorkspacePaths.status, dir),
|
||||
])
|
||||
|
||||
expect(adapters.status).toBe(200)
|
||||
expect(yield* adapters.json).toContainEqual({
|
||||
type: "worktree",
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
})
|
||||
|
||||
expect(workspaces.status).toBe(200)
|
||||
expect(yield* workspaces.json).toEqual([])
|
||||
|
||||
expect(status.status).toBe(200)
|
||||
expect(yield* status.json).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves mutation endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
|
||||
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "local-test", branch: null }),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
const workspace = (yield* created.json) as Workspace.Info
|
||||
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
|
||||
|
||||
const session = yield* Session.use.create({}).pipe(provideInstance(dir))
|
||||
const warped = yield* request(WorkspacePaths.warp, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
|
||||
})
|
||||
expect(warped.status).toBe(204)
|
||||
|
||||
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
expect(removed.status).toBe(200)
|
||||
expect(yield* removed.json).toMatchObject({ id: workspace.id })
|
||||
|
||||
const listed = yield* request(WorkspacePaths.list, dir)
|
||||
expect(listed.status).toBe(200)
|
||||
expect(yield* listed.json).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves list sync endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
const type = `listed-${Math.random().toString(36).slice(2)}`
|
||||
registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type))
|
||||
|
||||
const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" })
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
const listed = yield* request(WorkspacePaths.list, dir)
|
||||
expect(yield* listed.json).toMatchObject([
|
||||
{
|
||||
type,
|
||||
name: "listed-test",
|
||||
branch: "listed/main",
|
||||
directory: path.join(dir, ".listed"),
|
||||
extra: { listed: true },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns a declared not found error when warping into a missing workspace", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const session = yield* Session.use.create({}).pipe(provideInstance(dir))
|
||||
const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_warp")
|
||||
|
||||
const response = yield* request(WorkspacePaths.warp, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: workspaceID, sessionID: session.id }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* response.json).toEqual({
|
||||
name: "NotFoundError",
|
||||
data: { message: `Workspace not found: ${workspaceID}` },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates workspace with the TUI payload shape", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
|
||||
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "local-test", branch: null }),
|
||||
})
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
expect((yield* created.json) as Workspace.Info).toMatchObject({
|
||||
type: "local-test",
|
||||
name: "local-test",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates a real git worktree workspace via the builtin adapter", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const created = yield* requestServer(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||
})
|
||||
|
||||
const body = yield* Effect.promise(() => created.text())
|
||||
expect({ status: created.status, body }).toMatchObject({ status: 200 })
|
||||
const workspace = JSON.parse(body) as Workspace.Info
|
||||
expect(workspace).toMatchObject({ type: "worktree" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("routes local workspace requests through the workspace target directory", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const workspaceDir = path.join(dir, ".workspace-local")
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir))
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "local-target", branch: null }),
|
||||
})
|
||||
const workspace = (yield* created.json) as Workspace.Info
|
||||
|
||||
const url = new URL(`http://localhost${InstancePaths.path}`)
|
||||
url.searchParams.set("workspace", workspace.id)
|
||||
|
||||
const response = yield* request(url.toString(), dir)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({ directory: workspaceDir })
|
||||
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("proxies remote workspace HTTP requests with sanitized forwarding", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const proxied: ProxiedRequest[] = []
|
||||
const remote = listenRemoteHttp((request) => {
|
||||
proxied.push(request)
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/base/global/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/sync/history") return Response.json([])
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
proxied: true,
|
||||
path: url.pathname,
|
||||
keep: url.searchParams.get("keep"),
|
||||
workspace: url.searchParams.get("workspace"),
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
statusText: "Created",
|
||||
headers: {
|
||||
"content-length": "999",
|
||||
"content-type": "application/json",
|
||||
"x-remote": "yes",
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(
|
||||
project.project.id,
|
||||
"remote-target",
|
||||
remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
|
||||
"x-target-auth": "secret",
|
||||
}),
|
||||
)
|
||||
const created = yield* requestDefault(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "remote-target", branch: null }),
|
||||
})
|
||||
const workspace = (yield* created.json) as Workspace.Info
|
||||
|
||||
const url = new URL("http://localhost/config")
|
||||
url.searchParams.set("workspace", workspace.id)
|
||||
url.searchParams.set("keep", "yes")
|
||||
|
||||
try {
|
||||
const response = yield* requestDefault(url.toString(), dir, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"accept-encoding": "br",
|
||||
"content-type": "application/json",
|
||||
"x-opencode-workspace": "internal",
|
||||
},
|
||||
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
|
||||
})
|
||||
|
||||
const responseBody = yield* response.text
|
||||
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
|
||||
expect(response.headers["content-length"]).toBeUndefined()
|
||||
expect(response.headers["x-remote"]).toBe("yes")
|
||||
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null })
|
||||
const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config")
|
||||
expect(forwarded).toEqual([
|
||||
{
|
||||
url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`,
|
||||
method: "PATCH",
|
||||
headers: expect.objectContaining({
|
||||
"content-type": "application/json",
|
||||
"x-target-auth": "secret",
|
||||
}),
|
||||
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
|
||||
},
|
||||
])
|
||||
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
|
||||
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
|
||||
|
||||
const eventURL = new URL(`http://localhost${EventPaths.event}`)
|
||||
eventURL.searchParams.set("workspace", workspace.id)
|
||||
const eventResponse = yield* request(eventURL.toString(), dir)
|
||||
expect(eventResponse.status).toBe(200)
|
||||
expect(eventResponse.headers["content-type"]).toContain("text/event-stream")
|
||||
const event = Array.from(yield* eventResponse.stream.pipe(Stream.take(1), Stream.runCollect))[0]
|
||||
expect(new TextDecoder().decode(event)).toContain("server.connected")
|
||||
expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
|
||||
} finally {
|
||||
void remote.stop(true)
|
||||
yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("proxies remote workspace requests selected from session ownership", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const proxied: ProxiedRequest[] = []
|
||||
const remote = listenRemoteHttp((request) => {
|
||||
proxied.push(request)
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/base/global/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/sync/history") return Response.json([])
|
||||
return Response.json({ proxied: true, path: new URL(request.url).pathname })
|
||||
})
|
||||
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(
|
||||
project.project.id,
|
||||
"remote-session-target",
|
||||
remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
|
||||
)
|
||||
const created = yield* requestDefault(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "remote-session-target", branch: null }),
|
||||
})
|
||||
const workspace = (yield* created.json) as Workspace.Info
|
||||
const sessionResponse = yield* requestDefault("/session", dir, { method: "POST" })
|
||||
const session = (yield* sessionResponse.json) as Session.Info
|
||||
const warped = yield* requestDefault(WorkspacePaths.warp, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
|
||||
})
|
||||
expect(warped.status).toBe(204)
|
||||
|
||||
try {
|
||||
const response = yield* requestDefault(`http://localhost/session/${session.id}/message`, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }),
|
||||
})
|
||||
|
||||
const responseBody = yield* response.text
|
||||
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 })
|
||||
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` })
|
||||
expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([
|
||||
expect.objectContaining({
|
||||
url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`,
|
||||
method: "POST",
|
||||
}),
|
||||
])
|
||||
|
||||
const aborted = yield* request(`http://localhost/session/${session.id}/abort`, dir, { method: "POST" })
|
||||
expect(aborted.status).toBe(200)
|
||||
expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/abort`)).toEqual([
|
||||
expect.objectContaining({
|
||||
url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/abort`,
|
||||
method: "POST",
|
||||
body: "",
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
void remote.stop(true)
|
||||
yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
// Regression: a stored step-finish part with a negative token count made the
|
||||
// messages endpoint 400. Some providers reported `outputTokens` excluding
|
||||
// reasoning while also reporting `reasoningTokens` separately, so the
|
||||
// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to
|
||||
// negative. The pre-fix `safe()` clamp only guarded against non-finite. The
|
||||
// strict `NonNegativeInt` schema then made every load of the message list
|
||||
// fail to encode, killing Desktop boot for every user with such a row.
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { PartTable } from "@opencode-ai/core/session/sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
|
||||
function seedNegativeTokenSession() {
|
||||
return Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
// Bypass the schema with a direct SQL update to install the
|
||||
// negative `output` value we want to test loading.
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never,
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
return info.id
|
||||
})
|
||||
}
|
||||
|
||||
describe("messages endpoint tolerates legacy negative token counts", () => {
|
||||
it.instance(
|
||||
"returns 200 even when a step-finish part has tokens.output < 0",
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()))
|
||||
const test = yield* TestInstance
|
||||
const sessionID = yield* seedNegativeTokenSession()
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* requestInDirectory(url, test.directory)
|
||||
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
109
packages/opencode/test/server/project-copy.test.ts
Normal file
109
packages/opencode/test/server/project-copy.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap-service"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(FSUtil.defaultLayer, Database.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer),
|
||||
)
|
||||
|
||||
function request(directory: string, url: string, init: RequestInit = {}) {
|
||||
return requestInDirectory(url, directory, init)
|
||||
}
|
||||
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json.pipe(Effect.map((value) => value as T))
|
||||
}
|
||||
|
||||
describe("project directories and copies endpoints", () => {
|
||||
type ProjectDirectory = { directory: string; type: "main" | "root" | "git_worktree" }
|
||||
|
||||
it.instance(
|
||||
"lists directories and manages git worktree copies",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const current = yield* request(test.directory, "/project/current")
|
||||
const projectID = (yield* json<{ id: string }>(current)).id
|
||||
const base = `/project/${projectID}`
|
||||
const copies = `/experimental/project/${projectID}/copy`
|
||||
const createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
|
||||
const createdDirectory = path.join(createdParent, "copy")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(createdParent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const initial = yield* request(test.directory, `${base}/directories`)
|
||||
expect(initial.status).toBe(200)
|
||||
expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory, type: "main" }])
|
||||
|
||||
const create = yield* request(test.directory, copies, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ strategy: "git_worktree", directory: createdParent, name: "copy" }),
|
||||
})
|
||||
expect(create.status).toBe(200)
|
||||
const created = yield* json<{ directory: string }>(create)
|
||||
expect(created.directory).toBe(createdDirectory)
|
||||
|
||||
const listed = yield* request(test.directory, `${base}/directories`)
|
||||
expect(yield* json<ProjectDirectory[]>(listed)).toContainEqual({
|
||||
directory: created.directory,
|
||||
type: "git_worktree",
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
|
||||
|
||||
const remove = yield* request(test.directory, copies, {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ directory: created.directory, force: false }),
|
||||
})
|
||||
expect(remove.status).toBe(400)
|
||||
expect(yield* json<{ data: { forceRequired?: boolean } }>(remove)).toMatchObject({
|
||||
data: { forceRequired: true },
|
||||
})
|
||||
|
||||
const forced = yield* request(test.directory, copies, {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ directory: created.directory, force: true }),
|
||||
})
|
||||
expect(forced.status).toBe(204)
|
||||
|
||||
const externalDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-refresh")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(externalDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${externalDirectory} HEAD`.cwd(test.directory).quiet())
|
||||
const refresh = yield* request(test.directory, `${copies}/refresh`, {
|
||||
method: "POST",
|
||||
})
|
||||
expect(refresh.status).toBe(204)
|
||||
const refreshed = yield* request(test.directory, `${base}/directories`)
|
||||
expect(yield* json<ProjectDirectory[]>(refreshed)).toEqual([
|
||||
{ directory: externalDirectory, type: "git_worktree" },
|
||||
{ directory: test.directory, type: "main" },
|
||||
])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
114
packages/opencode/test/server/project-init-git.test.ts
Normal file
114
packages/opencode/test/server/project-init-git.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import path from "path"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
|
||||
|
||||
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer))
|
||||
|
||||
function request(directory: string, url: string, init: RequestInit = {}) {
|
||||
return requestInDirectory(url, directory, init)
|
||||
}
|
||||
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json.pipe(Effect.map((value) => value as T))
|
||||
}
|
||||
|
||||
function collectGlobalEvents() {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const seen: GlobalEvent[] = []
|
||||
const on = (event: GlobalEvent) => {
|
||||
seen.push(event)
|
||||
}
|
||||
GlobalBus.on("event", on)
|
||||
return { seen, on }
|
||||
}),
|
||||
({ on }) => Effect.sync(() => GlobalBus.off("event", on)),
|
||||
)
|
||||
}
|
||||
|
||||
const disposedEvents = (seen: GlobalEvent[], dir: string) =>
|
||||
seen.filter((evt) => evt.directory === dir && evt.payload.type === "server.instance.disposed").length
|
||||
|
||||
describe("project.initGit endpoint", () => {
|
||||
it.instance("initializes git and reloads immediately", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const fs = yield* FSUtil.Service
|
||||
const events = yield* collectGlobalEvents()
|
||||
|
||||
const init = yield* request(tmp.directory, "/project/git/init", {
|
||||
method: "POST",
|
||||
})
|
||||
const body = yield* json(init)
|
||||
expect(init.status).toBe(200)
|
||||
expect(body).toMatchObject({
|
||||
id: "global",
|
||||
vcs: "git",
|
||||
worktree: tmp.directory,
|
||||
})
|
||||
// Reload behavior: bus emits exactly one server.instance.disposed for the directory.
|
||||
expect(disposedEvents(events.seen, tmp.directory)).toBe(1)
|
||||
expect(yield* fs.exists(path.join(tmp.directory, ".git", "opencode"))).toBe(false)
|
||||
|
||||
const current = yield* request(tmp.directory, "/project/current")
|
||||
expect(current.status).toBe(200)
|
||||
expect(yield* json(current)).toMatchObject({
|
||||
id: "global",
|
||||
vcs: "git",
|
||||
worktree: tmp.directory,
|
||||
})
|
||||
|
||||
const ctx = yield* InstanceStore.use.reload({ directory: tmp.directory })
|
||||
const tracked = yield* Snapshot.Service.use((snapshot) => snapshot.track()).pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
)
|
||||
expect(tracked).toBeTruthy()
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"does not reload when the project is already git",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const events = yield* collectGlobalEvents()
|
||||
|
||||
const init = yield* request(tmp.directory, "/project/git/init", {
|
||||
method: "POST",
|
||||
})
|
||||
expect(init.status).toBe(200)
|
||||
expect(yield* json(init)).toMatchObject({
|
||||
vcs: "git",
|
||||
worktree: tmp.directory,
|
||||
})
|
||||
expect(disposedEvents(events.seen, tmp.directory)).toBe(0)
|
||||
|
||||
const current = yield* request(tmp.directory, "/project/current")
|
||||
expect(current.status).toBe(200)
|
||||
expect(yield* json(current)).toMatchObject({
|
||||
vcs: "git",
|
||||
worktree: tmp.directory,
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
113
packages/opencode/test/server/proxy-util.test.ts
Normal file
113
packages/opencode/test/server/proxy-util.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ProxyUtil } from "../../src/server/proxy-util"
|
||||
|
||||
describe("ProxyUtil", () => {
|
||||
describe("websocketTargetURL", () => {
|
||||
test("converts http to ws", () => {
|
||||
expect(ProxyUtil.websocketTargetURL("http://example.com/path")).toBe("ws://example.com/path")
|
||||
})
|
||||
|
||||
test("converts https to wss", () => {
|
||||
expect(ProxyUtil.websocketTargetURL("https://example.com/path")).toBe("wss://example.com/path")
|
||||
})
|
||||
|
||||
test("preserves query params", () => {
|
||||
expect(ProxyUtil.websocketTargetURL("http://example.com/path?foo=bar")).toBe("ws://example.com/path?foo=bar")
|
||||
})
|
||||
|
||||
test("accepts URL objects", () => {
|
||||
expect(ProxyUtil.websocketTargetURL(new URL("http://localhost:3000/ws"))).toBe("ws://localhost:3000/ws")
|
||||
})
|
||||
})
|
||||
|
||||
describe("websocketProtocols", () => {
|
||||
test("returns empty array when no header", () => {
|
||||
const req = new Request("http://localhost")
|
||||
expect(ProxyUtil.websocketProtocols(req)).toEqual([])
|
||||
})
|
||||
|
||||
test("parses single protocol", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { "sec-websocket-protocol": "graphql-ws" },
|
||||
})
|
||||
expect(ProxyUtil.websocketProtocols(req)).toEqual(["graphql-ws"])
|
||||
})
|
||||
|
||||
test("parses multiple protocols", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { "sec-websocket-protocol": "graphql-ws, graphql-transport-ws" },
|
||||
})
|
||||
expect(ProxyUtil.websocketProtocols(req)).toEqual(["graphql-ws", "graphql-transport-ws"])
|
||||
})
|
||||
|
||||
test("trims whitespace and filters empty", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { "sec-websocket-protocol": " proto1 , , proto2 " },
|
||||
})
|
||||
expect(ProxyUtil.websocketProtocols(req)).toEqual(["proto1", "proto2"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("headers", () => {
|
||||
test("strips hop-by-hop headers", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: {
|
||||
connection: "keep-alive",
|
||||
"keep-alive": "timeout=5",
|
||||
"transfer-encoding": "chunked",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
})
|
||||
const result = ProxyUtil.headers(req)
|
||||
expect(result.get("connection")).toBeNull()
|
||||
expect(result.get("keep-alive")).toBeNull()
|
||||
expect(result.get("transfer-encoding")).toBeNull()
|
||||
expect(result.get("content-type")).toBe("application/json")
|
||||
})
|
||||
|
||||
test("strips opencode-specific headers", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: {
|
||||
"x-opencode-directory": "/home/user/project",
|
||||
"x-opencode-workspace": "ws_123",
|
||||
"accept-encoding": "gzip",
|
||||
"x-custom": "keep",
|
||||
},
|
||||
})
|
||||
const result = ProxyUtil.headers(req)
|
||||
expect(result.get("x-opencode-directory")).toBeNull()
|
||||
expect(result.get("x-opencode-workspace")).toBeNull()
|
||||
expect(result.get("accept-encoding")).toBeNull()
|
||||
expect(result.get("x-custom")).toBe("keep")
|
||||
})
|
||||
|
||||
test("merges extra headers", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
const result = ProxyUtil.headers(req, { "x-auth": "token", "content-type": "text/plain" })
|
||||
expect(result.get("x-auth")).toBe("token")
|
||||
expect(result.get("content-type")).toBe("text/plain")
|
||||
})
|
||||
|
||||
test("returns original headers when no extra", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { "content-type": "application/json", "x-foo": "bar" },
|
||||
})
|
||||
const result = ProxyUtil.headers(req)
|
||||
expect(result.get("content-type")).toBe("application/json")
|
||||
expect(result.get("x-foo")).toBe("bar")
|
||||
})
|
||||
|
||||
test("accepts plain object (HeadersInit) as input", () => {
|
||||
const result = ProxyUtil.headers(
|
||||
{ "content-type": "application/json", connection: "keep-alive", "x-custom": "val" },
|
||||
{ "x-extra": "added" },
|
||||
)
|
||||
expect(result.get("connection")).toBeNull()
|
||||
expect(result.get("content-type")).toBe("application/json")
|
||||
expect(result.get("x-custom")).toBe("val")
|
||||
expect(result.get("x-extra")).toBe("added")
|
||||
})
|
||||
})
|
||||
})
|
||||
81
packages/opencode/test/server/sdk-error-shape.test.ts
Normal file
81
packages/opencode/test/server/sdk-error-shape.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true`
|
||||
* path used to throw raw values (empty strings or POJOs from JSON-decoded
|
||||
* error bodies). The TUI catches those and `e.message`/`e.stack` are
|
||||
* undefined, so users see `[object Object]` or a blank crash.
|
||||
*
|
||||
* Both cases must throw a real `Error` instance with a non-empty `.message`
|
||||
* extracted from the response body, plus `.status` and `.body` attached.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function client(directory: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: "http://test",
|
||||
directory,
|
||||
fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
|
||||
})
|
||||
}
|
||||
|
||||
describe("v2 SDK error shape", () => {
|
||||
test("404 with NamedError body throws a real Error carrying the server message", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const sdk = client(tmp.path)
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true })
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error)
|
||||
const err = caught as Error
|
||||
const cause = err.cause as { body?: any; status?: number }
|
||||
expect(err.message).toContain("Session not found")
|
||||
expect(cause.status).toBe(404)
|
||||
expect(cause.body).toMatchObject({
|
||||
name: "NotFoundError",
|
||||
data: { message: expect.stringContaining("Session not found") },
|
||||
})
|
||||
})
|
||||
|
||||
test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => {
|
||||
// Canary for the #26631 wire shape. Asserts the contract end-to-end:
|
||||
// server emits {name:"BadRequest", data:{message, kind}}, SDK's
|
||||
// wrapClientError extracts .data.message into Error.message. If either
|
||||
// side regresses (#26457 reverted because both layers were missing),
|
||||
// this test fails before users see (empty response body).
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const sdk = client(tmp.path)
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true })
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error)
|
||||
const err = caught as Error
|
||||
const cause = err.cause as { body?: any; status?: number }
|
||||
expect(cause.status).toBe(400)
|
||||
expect(cause.body).toMatchObject({
|
||||
name: "BadRequest",
|
||||
data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
|
||||
})
|
||||
expect(typeof cause.body.data.message).toBe("string")
|
||||
expect(cause.body.data.message.length).toBeGreaterThan(0)
|
||||
// Whatever the server put in data.message must be what the user sees.
|
||||
expect(err.message).toBe(cause.body.data.message)
|
||||
})
|
||||
})
|
||||
57
packages/opencode/test/server/sdk-v1-smoke.test.ts
Normal file
57
packages/opencode/test/server/sdk-v1-smoke.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Smoke test: v1 SDK (the plugin contract) can actually reach core endpoints
|
||||
// against the current server. v1 generation has been frozen since #5216
|
||||
// (2025-12-07) so types may be stale, but runtime calls should still work
|
||||
// for endpoints the v1 SDK was generated against.
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { tmpdir, disposeAllInstances } from "../fixture/fixture"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function client(directory: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: "http://test",
|
||||
directory,
|
||||
fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
|
||||
})
|
||||
}
|
||||
|
||||
describe("v1 SDK runtime smoke", () => {
|
||||
test("session.list reaches the server and returns 200", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const sdk = client(tmp.path)
|
||||
const result = await sdk.session.list()
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
})
|
||||
|
||||
test("path.get reaches the server and returns 200", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const sdk = client(tmp.path)
|
||||
const result = await sdk.path.get()
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.data).toBeDefined()
|
||||
})
|
||||
|
||||
test("config.get reaches the server and returns 200", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const sdk = client(tmp.path)
|
||||
const result = await sdk.config.get()
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.data).toBeDefined()
|
||||
})
|
||||
|
||||
test("session 404: result-tuple path returns the error body", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const sdk = client(tmp.path)
|
||||
const result = await sdk.session.get({ path: { id: "ses_no_such" } as never })
|
||||
expect(result.error).toBeDefined()
|
||||
// wire body for 404 is NamedError-shaped
|
||||
expect(result.error).toMatchObject({ name: "NotFoundError" })
|
||||
})
|
||||
})
|
||||
109
packages/opencode/test/server/session-actions.test.ts
Normal file
109
packages/opencode/test/server/session-actions.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, mock } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
describe("session action routes", () => {
|
||||
it.instance(
|
||||
"session routes expose metadata on create, update, get, and fork",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const headers = { "Content-Type": "application/json" }
|
||||
|
||||
const created = yield* requestInDirectory("/session", test.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: "meta-session",
|
||||
metadata: { source: "sdk", trace: { id: "abc" } },
|
||||
}),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
|
||||
const session = (yield* created.json) as SessionNs.Info
|
||||
expect(session.metadata).toEqual({ source: "sdk", trace: { id: "abc" } })
|
||||
|
||||
const updated = yield* requestInDirectory(`/session/${session.id}`, test.directory, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }),
|
||||
})
|
||||
expect(updated.status).toBe(200)
|
||||
|
||||
const next = (yield* updated.json) as SessionNs.Info
|
||||
expect(next.metadata).toEqual({ source: "sdk", trace: { id: "def" }, tags: ["one"] })
|
||||
|
||||
const fetched = yield* requestInDirectory(`/session/${session.id}`, test.directory)
|
||||
expect(fetched.status).toBe(200)
|
||||
expect(((yield* fetched.json) as SessionNs.Info).metadata).toEqual(next.metadata)
|
||||
|
||||
const forked = yield* requestInDirectory(`/session/${session.id}/fork`, test.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(forked.status).toBe(200)
|
||||
|
||||
const fork = (yield* forked.json) as SessionNs.Info
|
||||
expect(fork.metadata).toEqual(next.metadata)
|
||||
|
||||
const reset = yield* requestInDirectory(`/session/${session.id}`, test.directory, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata: {} }),
|
||||
})
|
||||
expect(reset.status).toBe(200)
|
||||
expect(((yield* reset.json) as SessionNs.Info).metadata).toEqual({})
|
||||
|
||||
yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore))
|
||||
yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore))
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"abort route returns success",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) =>
|
||||
SessionNs.use.remove(created.id).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const res = yield* requestInDirectory(`/session/${session.id}/abort`, test.directory, { method: "POST" })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(yield* res.json).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"experimental background route is a no-op without synchronous subagents",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) =>
|
||||
SessionNs.use.remove(created.id).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const res = yield* requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, {
|
||||
method: "POST",
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(yield* res.json).toBe(false)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Regression test for the same bug class as #26574 (sibling of #26566 and
|
||||
* #26553). The Desktop app calls GET /session/<id>/diff; before #26574
|
||||
* the response was Schema-encoded against `Snapshot.FileDiff` with
|
||||
* `patch: Schema.String` (required), so any session whose stored
|
||||
* `summary_diffs` had a row without `patch` returned HTTP 400 and the
|
||||
* session never loaded. Legacy session-level diffs are no longer surfaced,
|
||||
* but the endpoint remains compatible and must still return successfully.
|
||||
*
|
||||
* This test inserts a session row with a missing-patch diff entry and
|
||||
* asserts that GET /session/<id>/diff returns 200 with empty data.
|
||||
*/
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { MessageID } from "@/session/schema"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer, httpApiLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function pathFor(template: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template)
|
||||
}
|
||||
|
||||
const withSession = (input?: Parameters<Session.Interface["create"]>[0]) =>
|
||||
Effect.acquireRelease(Session.use.create(input), (created) => Session.use.remove(created.id).pipe(Effect.ignore))
|
||||
|
||||
describe("session diff with missing patch (#26574)", () => {
|
||||
it.instance(
|
||||
"GET /session/<id>/diff ignores legacy session-level diff storage",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const session = yield* withSession({ title: "missing-patch" })
|
||||
|
||||
// Mimic legacy/imported on-disk shape: a diff entry with no
|
||||
// `patch` text. Pre-fix the typed response encoder rejects
|
||||
// this and returns 400.
|
||||
yield* Storage.Service.use((storage) =>
|
||||
storage.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]),
|
||||
)
|
||||
|
||||
const response = yield* requestInDirectory(
|
||||
pathFor(SessionPaths.diff, { sessionID: session.id }),
|
||||
test.directory,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual([])
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"GET /session/<id>/diff returns requested turn diffs",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const session = yield* withSession({ title: "turn-diff" })
|
||||
const messageID = MessageID.ascending()
|
||||
yield* Session.use.updateMessage({
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") },
|
||||
summary: {
|
||||
diffs: [{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }],
|
||||
},
|
||||
} satisfies SessionV1.User)
|
||||
|
||||
const response = yield* requestInDirectory(
|
||||
`${pathFor(SessionPaths.diff, { sessionID: session.id })}?messageID=${messageID}`,
|
||||
test.directory,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual([{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }])
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
312
packages/opencode/test/server/session-list.test.ts
Normal file
312
packages/opencode/test/server/session-list.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
|
||||
import { mkdir } from "fs/promises"
|
||||
import path from "path"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
|
||||
const layer = (experimentalWorkspaces: boolean) =>
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
SessionNs.layer.pipe(
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
),
|
||||
)
|
||||
const it = testEffect(layer(false))
|
||||
const itWorkspaces = testEffect(layer(true))
|
||||
|
||||
const withSession = (input?: Parameters<SessionNs.Interface["create"]>[0]) =>
|
||||
Effect.acquireRelease(SessionNs.use.create(input), (created) =>
|
||||
SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)),
|
||||
)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
describe("session.list", () => {
|
||||
it.instance(
|
||||
"does not filter by directory when directory is omitted",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
|
||||
|
||||
const root = yield* withSession({ title: "root" })
|
||||
const parent = yield* withSession({ title: "parent" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages")),
|
||||
)
|
||||
const current = yield* withSession({ title: "current" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode")),
|
||||
)
|
||||
const sibling = yield* withSession({ title: "sibling" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "app")),
|
||||
)
|
||||
|
||||
const ids = (yield* SessionNs.use.list()).map((session) => session.id)
|
||||
expect(ids).toContain(root.id)
|
||||
expect(ids).toContain(parent.id)
|
||||
expect(ids).toContain(current.id)
|
||||
expect(ids).toContain(sibling.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"filters by directory when directory is provided",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
|
||||
|
||||
const root = yield* withSession({ title: "root" })
|
||||
const parent = yield* withSession({ title: "parent" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages")),
|
||||
)
|
||||
const current = yield* withSession({ title: "current" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode")),
|
||||
)
|
||||
const sibling = yield* withSession({ title: "sibling" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "app")),
|
||||
)
|
||||
|
||||
const ids = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({ directory: path.join(test.directory, "packages", "opencode") }),
|
||||
)).map((session) => session.id)
|
||||
expect(ids).not.toContain(root.id)
|
||||
expect(ids).not.toContain(parent.id)
|
||||
expect(ids).toContain(current.id)
|
||||
expect(ids).not.toContain(sibling.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
itWorkspaces.instance(
|
||||
"filters by directory when experimental workspaces are enabled",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
|
||||
|
||||
const current = yield* withSession({ title: "current" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode")),
|
||||
)
|
||||
const sibling = yield* withSession({ title: "sibling" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "app")),
|
||||
)
|
||||
|
||||
const ids = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({ directory: path.join(test.directory, "packages", "opencode") }),
|
||||
)).map((session) => session.id)
|
||||
expect(ids).toContain(current.id)
|
||||
expect(ids).not.toContain(sibling.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"matches a session regardless of directory separator on Windows",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform !== "win32") return
|
||||
const test = yield* TestInstance
|
||||
const dir = path.join(test.directory, "packages", "opencode")
|
||||
yield* Effect.promise(() => mkdir(dir, { recursive: true }))
|
||||
|
||||
const created = yield* withSession({ title: "separator" }).pipe(provideInstance(dir))
|
||||
|
||||
// A forward-slash query (e.g. from the SDK/HTTP layer) must still find it —
|
||||
// this is the regression: backslash-stored vs forward-slash-queried.
|
||||
const forwardIDs = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({ directory: dir.replaceAll("\\", "/") }),
|
||||
)).map((session) => session.id)
|
||||
expect(forwardIDs).toContain(created.id)
|
||||
|
||||
// The native form must keep matching too.
|
||||
const nativeIDs = (yield* SessionNs.Service.use((session) => session.list({ directory: dir }))).map(
|
||||
(session) => session.id,
|
||||
)
|
||||
expect(nativeIDs).toContain(created.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"filters by path and ignores directory when path is provided",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
mkdir(path.join(test.directory, "packages", "opencode", "src", "deep"), { recursive: true }),
|
||||
)
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
|
||||
|
||||
const parent = yield* withSession({ title: "parent" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode")),
|
||||
)
|
||||
const current = yield* withSession({ title: "current" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode", "src")),
|
||||
)
|
||||
const deeper = yield* withSession({ title: "deeper" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode", "src", "deep")),
|
||||
)
|
||||
const sibling = yield* withSession({ title: "sibling" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "app")),
|
||||
)
|
||||
|
||||
const pathIDs = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({
|
||||
directory: path.join(test.directory, "packages", "app"),
|
||||
path: "packages/opencode/src",
|
||||
}),
|
||||
)).map((session) => session.id)
|
||||
expect(pathIDs).not.toContain(parent.id)
|
||||
expect(pathIDs).toContain(current.id)
|
||||
expect(pathIDs).toContain(deeper.id)
|
||||
expect(pathIDs).not.toContain(sibling.id)
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const windowsPathIDs = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({ path: "packages\\opencode\\src" }),
|
||||
)).map((session) => session.id)
|
||||
expect(windowsPathIDs).toContain(current.id)
|
||||
expect(windowsPathIDs).toContain(deeper.id)
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"falls back to directory when filtering legacy sessions without path",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true }),
|
||||
)
|
||||
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
|
||||
|
||||
const current = yield* withSession({ title: "legacy-current" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "opencode", "src")),
|
||||
)
|
||||
const sibling = yield* withSession({ title: "legacy-sibling" }).pipe(
|
||||
provideInstance(path.join(test.directory, "packages", "app")),
|
||||
)
|
||||
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ path: null })
|
||||
.where(eq(SessionTable.id, current.id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ path: null })
|
||||
.where(eq(SessionTable.id, sibling.id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const pathIDs = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({
|
||||
directory: path.join(test.directory, "packages", "opencode", "src"),
|
||||
path: "packages/opencode/src",
|
||||
}),
|
||||
)).map((session) => session.id)
|
||||
expect(pathIDs).toContain(current.id)
|
||||
expect(pathIDs).not.toContain(sibling.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"filters root sessions",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* withSession({ title: "root-session" })
|
||||
const child = yield* withSession({ title: "child-session", parentID: root.id })
|
||||
|
||||
const sessions = yield* SessionNs.use.list({ roots: true })
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(root.id)
|
||||
expect(ids).not.toContain(child.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"filters by start time",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withSession({ title: "new-session" })
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.list({ start: Date.now() + 86400000 }))
|
||||
expect(sessions.length).toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"filters by search term",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withSession({ title: "unique-search-term-abc" })
|
||||
yield* withSession({ title: "other-session-xyz" })
|
||||
|
||||
const sessions = yield* SessionNs.use.list({ search: "unique-search" })
|
||||
const titles = sessions.map((session) => session.title)
|
||||
|
||||
expect(titles).toContain("unique-search-term-abc")
|
||||
expect(titles).not.toContain("other-session-xyz")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"respects limit parameter",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withSession({ title: "session-1" })
|
||||
yield* withSession({ title: "session-2" })
|
||||
yield* withSession({ title: "session-3" })
|
||||
|
||||
const sessions = yield* SessionNs.use.list({ limit: 2 })
|
||||
expect(sessions.length).toBe(2)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"includes metadata in listed sessions",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const meta = { source: "sdk", trace: { id: "abc" } }
|
||||
const created = yield* withSession({ title: "meta-session", metadata: meta })
|
||||
|
||||
const listed = (yield* SessionNs.Service.use((session) => session.list({ search: "meta-session" }))).find(
|
||||
(item) => item.id === created.id,
|
||||
)
|
||||
|
||||
expect(listed?.metadata).toEqual(meta)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
179
packages/opencode/test/server/session-messages.test.ts
Normal file
179
packages/opencode/test/server/session-messages.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
|
||||
|
||||
const model = {
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const withoutWatcher = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
|
||||
if (process.platform !== "win32") return effect
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||
process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true"
|
||||
return previous
|
||||
}),
|
||||
() => effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||
else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = previous
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const sessionScoped = Effect.acquireRelease(SessionNs.use.create({}), (session) =>
|
||||
SessionNs.use.remove(session.id).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const fill = Effect.fn("SessionMessagesTest.fill")(function* (
|
||||
sessionID: SessionID,
|
||||
count: number,
|
||||
time = (i: number) => Date.now() + i,
|
||||
) {
|
||||
const session = yield* SessionNs.Service
|
||||
return yield* Effect.forEach(
|
||||
Array.from({ length: count }, (_, i) => i),
|
||||
(i) =>
|
||||
Effect.gen(function* () {
|
||||
const id = MessageID.ascending()
|
||||
yield* session.updateMessage({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: time(i) },
|
||||
agent: "test",
|
||||
model,
|
||||
tools: {},
|
||||
} satisfies SessionV1.User)
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: id,
|
||||
type: "text",
|
||||
text: `m${i}`,
|
||||
} satisfies SessionV1.TextPart)
|
||||
return id
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function request(path: string) {
|
||||
return TestInstance.pipe(Effect.flatMap((test) => requestInDirectory(path, test.directory)))
|
||||
}
|
||||
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json.pipe(Effect.map((body) => body as T))
|
||||
}
|
||||
|
||||
describe("session messages endpoint", () => {
|
||||
it.instance(
|
||||
"returns cursor headers for older pages",
|
||||
withoutWatcher(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* sessionScoped
|
||||
const ids = yield* fill(session.id, 5)
|
||||
|
||||
const a = yield* request(`/session/${session.id}/message?limit=2`)
|
||||
expect(a.status).toBe(200)
|
||||
const aBody = yield* json<SessionV1.WithParts[]>(a)
|
||||
expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2))
|
||||
const cursor = a.headers["x-next-cursor"]
|
||||
expect(cursor).toBeTruthy()
|
||||
expect(a.headers["link"]).toContain('rel="next"')
|
||||
|
||||
const b = yield* request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`)
|
||||
expect(b.status).toBe(200)
|
||||
const bBody = yield* json<SessionV1.WithParts[]>(b)
|
||||
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"keeps full-history responses when limit is omitted",
|
||||
withoutWatcher(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* sessionScoped
|
||||
const ids = yield* fill(session.id, 3)
|
||||
|
||||
const res = yield* request(`/session/${session.id}/message`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = yield* json<SessionV1.WithParts[]>(res)
|
||||
expect(body.map((item) => item.info.id)).toEqual(ids)
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"rejects invalid cursors and missing sessions",
|
||||
withoutWatcher(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* sessionScoped
|
||||
|
||||
const bad = yield* request(`/session/${session.id}/message?limit=2&before=bad`)
|
||||
expect(bad.status).toBe(400)
|
||||
|
||||
const miss = yield* request(`/session/ses_missing/message?limit=2`)
|
||||
expect(miss.status).toBe(404)
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"does not truncate large legacy limit requests",
|
||||
withoutWatcher(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* sessionScoped
|
||||
yield* fill(session.id, 520)
|
||||
|
||||
const res = yield* request(`/session/${session.id}/message?limit=510`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = yield* json<SessionV1.WithParts[]>(res)
|
||||
expect(body).toHaveLength(510)
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"accepts directory query used by workspace routing",
|
||||
withoutWatcher(
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const session = yield* sessionScoped
|
||||
yield* fill(session.id, 1)
|
||||
|
||||
const res = yield* request(
|
||||
`/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.directory)}`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = yield* json<unknown[]>(res)
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body).toHaveLength(1)
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
66
packages/opencode/test/server/session-select.test.ts
Normal file
66
packages/opencode/test/server/session-select.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Session } from "@/session/session"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer))
|
||||
|
||||
describe("tui.selectSession endpoint", () => {
|
||||
it.instance(
|
||||
"should return 200 when called with valid session",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const session = yield* Session.use.create({})
|
||||
|
||||
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: session.id }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = yield* response.json
|
||||
expect(body).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"should return 404 when session does not exist",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const nonExistentSessionID = "ses_nonexistent123"
|
||||
|
||||
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: nonExistentSessionID }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"should return 400 when session ID format is invalid",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const invalidSessionID = "invalid_session_id"
|
||||
|
||||
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: invalidSessionID }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
181
packages/opencode/test/server/workspace-proxy.test.ts
Normal file
181
packages/opencode/test/server/workspace-proxy.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import Http from "node:http"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Queue } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
function serverUrl() {
|
||||
return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
|
||||
}
|
||||
|
||||
const testServerLayer = Layer.mergeAll(
|
||||
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
|
||||
NodeServices.layer,
|
||||
FetchHttpClient.layer,
|
||||
Socket.layerWebSocketConstructorGlobal,
|
||||
)
|
||||
const it = testEffect(testServerLayer)
|
||||
|
||||
type TestHandler<E, R> = (
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
|
||||
|
||||
function listenServer<E, R>(handler: TestHandler<E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
yield* HttpServer.serveEffect()(HttpServerRequest.HttpServerRequest.use(handler))
|
||||
return yield* serverUrl()
|
||||
})
|
||||
}
|
||||
|
||||
function listenTestServer<E, R>(handler: TestHandler<E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
// Build into the current test scope so the listener stays alive until the
|
||||
// test finishes. Using Effect.provide here would release it immediately.
|
||||
const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }))
|
||||
const server = Context.get(context, HttpServer.HttpServer)
|
||||
yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler))
|
||||
return HttpServer.formatAddress(server.address)
|
||||
})
|
||||
}
|
||||
|
||||
function echoWebSocket(request: HttpServerRequest.HttpServerRequest) {
|
||||
return Effect.gen(function* () {
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
// The upstream announces the negotiated protocol, then echoes every
|
||||
// received frame. The assertions use those messages to prove proxy flow.
|
||||
yield* socket
|
||||
.runRaw((message) => write(`echo:${String(message)}`), {
|
||||
onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
return HttpServerResponse.empty()
|
||||
})
|
||||
}
|
||||
|
||||
describe("HttpApi workspace proxy", () => {
|
||||
it.live("proxies HTTP request and returns streamed response with status and headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const url = yield* listenServer(
|
||||
Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) {
|
||||
const body = yield* req.text
|
||||
return yield* HttpServerResponse.json(
|
||||
{ path: req.url, method: req.method, body },
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
"content-encoding": "identity",
|
||||
"content-length": "999",
|
||||
"x-remote": "yes",
|
||||
},
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const request = HttpServerRequest.fromWeb(
|
||||
new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }),
|
||||
)
|
||||
const httpClient = yield* HttpClient.HttpClient
|
||||
const response = yield* HttpApiProxy.http(
|
||||
httpClient,
|
||||
`${url}/session/abc?keep=yes`,
|
||||
{ "x-extra": "injected" },
|
||||
request,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
const client = HttpServerResponse.toClientResponse(response)
|
||||
expect(yield* client.json).toEqual({
|
||||
path: "/session/abc?keep=yes",
|
||||
method: "POST",
|
||||
body: "request-body",
|
||||
})
|
||||
expect(response.headers["x-remote"]).toBe("yes")
|
||||
expect(response.headers["content-encoding"]).toBeUndefined()
|
||||
expect(response.headers["content-length"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns 500 when remote is unreachable", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything"))
|
||||
const httpClient = yield* HttpClient.HttpClient
|
||||
const response = yield* HttpApiProxy.http(httpClient, "http://127.0.0.1:1/unreachable", undefined, request)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("proxies bodyless Web mutation requests as an empty body", () =>
|
||||
Effect.gen(function* () {
|
||||
const url = yield* listenServer(
|
||||
Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) {
|
||||
return yield* HttpServerResponse.json({ method: req.method, body: yield* req.text })
|
||||
}),
|
||||
)
|
||||
const request = HttpServerRequest.fromWeb(new Request("http://localhost/session/abc/abort", { method: "POST" }))
|
||||
const httpClient = yield* HttpClient.HttpClient
|
||||
const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc/abort`, undefined, request)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* HttpServerResponse.toClientResponse(response).json).toEqual({ method: "POST", body: "" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("strips opencode-internal headers and merges extra headers", () =>
|
||||
Effect.gen(function* () {
|
||||
let forwarded: Record<string, string> = {}
|
||||
const url = yield* listenServer((req) =>
|
||||
Effect.sync(() => {
|
||||
forwarded = req.headers
|
||||
return HttpServerResponse.empty()
|
||||
}),
|
||||
)
|
||||
|
||||
const request = HttpServerRequest.fromWeb(
|
||||
new Request("http://localhost/test", {
|
||||
headers: {
|
||||
"x-opencode-directory": "/secret/path",
|
||||
"x-opencode-workspace": "ws_123",
|
||||
"x-custom": "preserved",
|
||||
},
|
||||
}),
|
||||
)
|
||||
const httpClient = yield* HttpClient.HttpClient
|
||||
yield* HttpApiProxy.http(httpClient, `${url}/test`, { "x-injected": "extra" }, request)
|
||||
|
||||
expect(forwarded["x-opencode-directory"]).toBeUndefined()
|
||||
expect(forwarded["x-opencode-workspace"]).toBeUndefined()
|
||||
expect(forwarded["x-custom"]).toBe("preserved")
|
||||
expect(forwarded["x-injected"]).toBe("extra")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("proxies websocket messages and protocols", () =>
|
||||
Effect.gen(function* () {
|
||||
const upstreamUrl = yield* listenTestServer(echoWebSocket)
|
||||
|
||||
// Client -> proxy listener -> HttpApiProxy.websocket -> upstream listener.
|
||||
// The client never connects to upstream directly.
|
||||
const proxyUrl = yield* listenServer((request) => HttpApiProxy.websocket(request, `${upstreamUrl}/echo`))
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(`${proxyUrl.replace(/^http/, "ws")}/proxy`, {
|
||||
closeCodeIsError: () => false,
|
||||
protocols: "chat",
|
||||
})
|
||||
const messages = yield* Queue.unbounded<string>()
|
||||
yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped)
|
||||
const write = yield* socket.writer
|
||||
|
||||
expect(yield* Queue.take(messages)).toBe("protocol:chat")
|
||||
yield* write("hello")
|
||||
expect(yield* Queue.take(messages)).toBe("echo:hello")
|
||||
}),
|
||||
)
|
||||
})
|
||||
94
packages/opencode/test/server/workspace-routing.test.ts
Normal file
94
packages/opencode/test/server/workspace-routing.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
isLocalWorkspaceRoute,
|
||||
getWorkspaceRouteSessionID,
|
||||
workspaceProxyURL,
|
||||
} from "../../src/server/shared/workspace-routing"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
|
||||
describe("isLocalWorkspaceRoute", () => {
|
||||
test("GET /session is local", () => {
|
||||
expect(isLocalWorkspaceRoute("GET", "/session")).toBe(true)
|
||||
})
|
||||
|
||||
test("GET /session/ses_abc is local (prefix match)", () => {
|
||||
expect(isLocalWorkspaceRoute("GET", "/session/ses_abc")).toBe(true)
|
||||
})
|
||||
|
||||
test("POST /session is not local (method mismatch)", () => {
|
||||
expect(isLocalWorkspaceRoute("POST", "/session")).toBe(false)
|
||||
})
|
||||
|
||||
test("/session/status is forwarded regardless of method", () => {
|
||||
expect(isLocalWorkspaceRoute("GET", "/session/status")).toBe(false)
|
||||
expect(isLocalWorkspaceRoute("POST", "/session/status")).toBe(false)
|
||||
})
|
||||
|
||||
test("unrecognized paths are not local", () => {
|
||||
expect(isLocalWorkspaceRoute("GET", "/config")).toBe(false)
|
||||
expect(isLocalWorkspaceRoute("POST", "/session/ses_abc/message")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getWorkspaceRouteSessionID", () => {
|
||||
test("extracts session ID from path", () => {
|
||||
const url = new URL("http://localhost/session/ses_abc123/message")
|
||||
expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_abc123"))
|
||||
})
|
||||
|
||||
test("extracts session ID without trailing path", () => {
|
||||
const url = new URL("http://localhost/session/ses_xyz")
|
||||
expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_xyz"))
|
||||
})
|
||||
|
||||
test("extracts session ID from experimental background path", () => {
|
||||
const url = new URL("http://localhost/experimental/session/ses_bg/background")
|
||||
expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_bg"))
|
||||
})
|
||||
|
||||
test("returns null for /session/status", () => {
|
||||
const url = new URL("http://localhost/session/status")
|
||||
expect(getWorkspaceRouteSessionID(url)).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for non-session paths", () => {
|
||||
const url = new URL("http://localhost/config")
|
||||
expect(getWorkspaceRouteSessionID(url)).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for bare /session path", () => {
|
||||
const url = new URL("http://localhost/session")
|
||||
expect(getWorkspaceRouteSessionID(url)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("workspaceProxyURL", () => {
|
||||
test("appends request path to target", () => {
|
||||
const result = workspaceProxyURL("http://remote:8080/base", new URL("http://localhost/config"))
|
||||
expect(result.toString()).toBe("http://remote:8080/base/config")
|
||||
})
|
||||
|
||||
test("strips trailing slash on target before appending", () => {
|
||||
const result = workspaceProxyURL("http://remote:8080/base/", new URL("http://localhost/session/abc"))
|
||||
expect(result.pathname).toBe("/base/session/abc")
|
||||
})
|
||||
|
||||
test("preserves query params from request but removes workspace", () => {
|
||||
const url = new URL("http://localhost/config?workspace=ws_123&keep=yes")
|
||||
const result = workspaceProxyURL("http://remote:8080/base", url)
|
||||
expect(result.searchParams.get("workspace")).toBeNull()
|
||||
expect(result.searchParams.get("keep")).toBe("yes")
|
||||
})
|
||||
|
||||
test("preserves hash from request", () => {
|
||||
const url = new URL("http://localhost/page#section")
|
||||
const result = workspaceProxyURL("http://remote:8080", url)
|
||||
expect(result.hash).toBe("#section")
|
||||
})
|
||||
|
||||
test("works with URL object as target", () => {
|
||||
const target = new URL("http://remote:3000/api")
|
||||
const result = workspaceProxyURL(target, new URL("http://localhost/users"))
|
||||
expect(result.toString()).toBe("http://remote:3000/api/users")
|
||||
})
|
||||
})
|
||||
307
packages/opencode/test/server/worktree-endpoint-repro.test.ts
Normal file
307
packages/opencode/test/server/worktree-endpoint-repro.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const stateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
}
|
||||
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(stateLayer)
|
||||
const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance
|
||||
type TestServer = ReturnType<typeof Server.Default>["app"]
|
||||
type CreatedWorktree = { directory: string }
|
||||
type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect<void, Error> }
|
||||
|
||||
function serverScoped() {
|
||||
return Effect.sync(() => Server.Default().app)
|
||||
}
|
||||
|
||||
function request(server: TestServer, input: string, init?: RequestInit) {
|
||||
return Effect.promise(() => Promise.resolve(server.request(input, init)))
|
||||
}
|
||||
|
||||
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
|
||||
return effect.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: `${ms} millis`,
|
||||
orElse: () => Effect.fail(new Error(`${label} timed out after ${ms}ms`)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function json<T>(response: Response) {
|
||||
return Effect.promise(() => response.json() as Promise<T>)
|
||||
}
|
||||
|
||||
function readyWatcher() {
|
||||
return Effect.gen(function* () {
|
||||
const events = yield* Queue.bounded<GlobalEvent>(1)
|
||||
const on = (event: GlobalEvent) => {
|
||||
if (event.payload.type === Worktree.Event.Ready.type) Queue.offerUnsafe(events, event)
|
||||
}
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||
|
||||
return (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
const event = yield* Queue.take(events)
|
||||
if (event.directory === directory) return
|
||||
}
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "10 seconds",
|
||||
orElse: () => Effect.fail(new Error(`timed out waiting for worktree.ready: ${directory}`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function removeCreatedWorktree(input: {
|
||||
server: TestServer
|
||||
rootDirectory: string
|
||||
worktreeDirectory: string
|
||||
ready: Effect.Effect<void, Error>
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
yield* input.ready.pipe(Effect.timeout("1 second"), Effect.ignore)
|
||||
yield* Effect.promise(() => disposeAllInstances()).pipe(Effect.ignore)
|
||||
|
||||
const removed = yield* request(
|
||||
input.server,
|
||||
`${ExperimentalPaths.worktree}?directory=${encodeURIComponent(input.rootDirectory)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ directory: input.worktreeDirectory }),
|
||||
},
|
||||
)
|
||||
if (removed.status !== 200) {
|
||||
const message = yield* Effect.promise(() => removed.text())
|
||||
throw new Error(`failed to remove worktree: ${removed.status} ${message}`)
|
||||
}
|
||||
const ok = yield* json<boolean>(removed)
|
||||
if (!ok) throw new Error(`failed to remove worktree ${input.worktreeDirectory}`)
|
||||
})
|
||||
}
|
||||
|
||||
function createWorktreeScoped(input: {
|
||||
server: TestServer
|
||||
directory: string
|
||||
path: string
|
||||
init: RequestInit
|
||||
timeoutLabel: string
|
||||
timeoutMs?: number
|
||||
}) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
const waitReady = yield* readyWatcher()
|
||||
const response = yield* withRequestTimeout(
|
||||
request(input.server, input.path, input.init),
|
||||
input.timeoutLabel,
|
||||
input.timeoutMs,
|
||||
)
|
||||
if (response.status !== 200) {
|
||||
const message = yield* Effect.promise(() => response.text())
|
||||
throw new Error(`${input.timeoutLabel} failed: ${response.status} ${message}`)
|
||||
}
|
||||
expect(response.status).toBe(200)
|
||||
const body = yield* json<CreatedWorktree>(response)
|
||||
return { directory: body.directory, body, ready: waitReady(body.directory) } satisfies ScopedWorktree
|
||||
}),
|
||||
(created) =>
|
||||
removeCreatedWorktree({
|
||||
server: input.server,
|
||||
rootDirectory: input.directory,
|
||||
worktreeDirectory: created.directory,
|
||||
ready: created.ready,
|
||||
}).pipe(Effect.orDie),
|
||||
).pipe(Effect.map((created) => created.body))
|
||||
}
|
||||
|
||||
function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
|
||||
expect(current.status).toBe(200)
|
||||
const project = yield* json<{ id: string }>(current)
|
||||
const updated = yield* request(
|
||||
input.server,
|
||||
`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ commands: { start: input.command } }),
|
||||
},
|
||||
)
|
||||
expect(updated.status).toBe(200)
|
||||
})
|
||||
}
|
||||
|
||||
describe("worktree endpoint reproduction", () => {
|
||||
worktreeTest(
|
||||
"direct HttpApi worktree create returns without waiting for boot",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
|
||||
const response = yield* createWorktreeScoped({
|
||||
server,
|
||||
directory: test.directory,
|
||||
path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
timeoutLabel: "direct worktree create",
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ directory: expect.any(String) })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
worktreeTest(
|
||||
"direct HttpApi worktree create accepts missing body",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
|
||||
const response = yield* createWorktreeScoped({
|
||||
server,
|
||||
directory: test.directory,
|
||||
path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
|
||||
init: { method: "POST", headers: { "content-type": "application/json" } },
|
||||
timeoutLabel: "direct worktree create without body",
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ directory: expect.any(String) })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
worktreeTest(
|
||||
"direct HttpApi worktree create accepts missing content type and body",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
|
||||
const response = yield* createWorktreeScoped({
|
||||
server,
|
||||
directory: test.directory,
|
||||
path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
|
||||
init: { method: "POST" },
|
||||
timeoutLabel: "direct worktree create without content type or body",
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ directory: expect.any(String) })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
worktreeTest(
|
||||
"direct HttpApi worktree create rejects explicit null payload",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
|
||||
const response = yield* request(
|
||||
server,
|
||||
`${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "null",
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
worktreeTest(
|
||||
"workspace worktree create does not hang",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
|
||||
const response = yield* createWorktreeScoped({
|
||||
server,
|
||||
directory: test.directory,
|
||||
path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||
},
|
||||
timeoutLabel: "workspace worktree create",
|
||||
timeoutMs: 8_000,
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
type: "worktree",
|
||||
directory: expect.any(String),
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
worktreeTest(
|
||||
"workspace worktree create returns without waiting for project start command",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
yield* setProjectStartCommand({
|
||||
server,
|
||||
directory: test.directory,
|
||||
command: 'bun -e "setTimeout(() => {}, 2000)"',
|
||||
})
|
||||
|
||||
const started = Date.now()
|
||||
yield* createWorktreeScoped({
|
||||
server,
|
||||
directory: test.directory,
|
||||
path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||
},
|
||||
timeoutLabel: "workspace worktree create with project start command",
|
||||
timeoutMs: 6_000,
|
||||
})
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(1_500)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user