| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- import { PermissionV1 } from "@kirincode-ai/core/v1/permission"
- import { NodeHttpServer, NodeServices } from "@effect/platform-node"
- import { Flag } from "@kirincode-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 "@kirincode-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 "@kirincode-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.KIRINCODE_EXPERIMENTAL_WORKSPACES
- Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = true
- yield* Effect.promise(() => resetDatabase())
- yield* Effect.addFinalizer(() =>
- Effect.promise(async () => {
- Flag.KIRINCODE_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.KIRINCODE_WORKSPACE_ID
- Flag.KIRINCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
- yield* Effect.addFinalizer(() =>
- Effect.sync(() => {
- Flag.KIRINCODE_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.KIRINCODE_WORKSPACE_ID
- Flag.KIRINCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
- yield* Effect.addFinalizer(() =>
- Effect.sync(() => {
- Flag.KIRINCODE_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" }),
- )
- }),
- )
- })
|