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