mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
|
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
|
import { Flag } from "@/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" }),
|
|
)
|
|
}),
|
|
)
|
|
})
|