|
| 1 | +import { NodeHttpServer, NodeServices } from "@effect/platform-node" |
| 2 | +import { Flag } from "@opencode-ai/core/flag/flag" |
| 3 | +import { describe, expect } from "bun:test" |
| 4 | +import { Effect, Layer } from "effect" |
| 5 | +import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" |
| 6 | +import * as Socket from "effect/unstable/socket/Socket" |
| 7 | +import { mkdir } from "node:fs/promises" |
| 8 | +import path from "node:path" |
| 9 | +import { registerAdaptor } from "../../src/control-plane/adaptors" |
| 10 | +import type { WorkspaceAdaptor } from "../../src/control-plane/types" |
| 11 | +import { Workspace } from "../../src/control-plane/workspace" |
| 12 | +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" |
| 13 | +import { Instance } from "../../src/project/instance" |
| 14 | +import { Project } from "../../src/project/project" |
| 15 | +import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" |
| 16 | +import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" |
| 17 | +import { resetDatabase } from "../fixture/db" |
| 18 | +import { tmpdirScoped } from "../fixture/fixture" |
| 19 | +import { testEffect } from "../lib/effect" |
| 20 | + |
| 21 | +const testStateLayer = Layer.effectDiscard( |
| 22 | + Effect.gen(function* () { |
| 23 | + const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES |
| 24 | + yield* Effect.promise(() => resetDatabase()) |
| 25 | + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true |
| 26 | + yield* Effect.addFinalizer(() => |
| 27 | + Effect.promise(async () => { |
| 28 | + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces |
| 29 | + await Instance.disposeAll() |
| 30 | + await resetDatabase() |
| 31 | + }), |
| 32 | + ) |
| 33 | + }), |
| 34 | +) |
| 35 | + |
| 36 | +const it = testEffect( |
| 37 | + Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer), |
| 38 | +) |
| 39 | + |
| 40 | +const instanceContextTestLayer = instanceRouterMiddleware |
| 41 | + .combine(workspaceRouterMiddleware) |
| 42 | + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) |
| 43 | + |
| 44 | +const localAdaptor = (directory: string): WorkspaceAdaptor => ({ |
| 45 | + name: "Local Test", |
| 46 | + description: "Create a local test workspace", |
| 47 | + configure: (info) => ({ ...info, name: "local-test", directory }), |
| 48 | + create: async () => { |
| 49 | + await mkdir(directory, { recursive: true }) |
| 50 | + }, |
| 51 | + async remove() {}, |
| 52 | + target: () => ({ type: "local" as const, directory }), |
| 53 | +}) |
| 54 | + |
| 55 | +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => |
| 56 | + Effect.acquireRelease( |
| 57 | + Effect.promise(async () => { |
| 58 | + registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) |
| 59 | + return Workspace.create({ |
| 60 | + type: input.type, |
| 61 | + branch: null, |
| 62 | + extra: null, |
| 63 | + projectID: input.projectID, |
| 64 | + }) |
| 65 | + }), |
| 66 | + (workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore), |
| 67 | + ) |
| 68 | + |
| 69 | +const probeInstanceContext = Effect.gen(function* () { |
| 70 | + const instance = yield* InstanceRef |
| 71 | + const workspaceID = yield* WorkspaceRef |
| 72 | + return yield* HttpServerResponse.json({ |
| 73 | + directory: instance?.directory, |
| 74 | + worktree: instance?.worktree, |
| 75 | + projectID: instance?.project.id, |
| 76 | + workspaceID, |
| 77 | + }) |
| 78 | +}) |
| 79 | + |
| 80 | +const serveProbe = (probePath: HttpRouter.PathInput = "/probe") => |
| 81 | + HttpRouter.add("GET", probePath, probeInstanceContext).pipe( |
| 82 | + Layer.provide(instanceContextTestLayer), |
| 83 | + HttpRouter.serve, |
| 84 | + Layer.build, |
| 85 | + ) |
| 86 | + |
| 87 | +describe("HttpApi instance context middleware", () => { |
| 88 | + it.live("provides instance context from the routed directory", () => |
| 89 | + Effect.gen(function* () { |
| 90 | + const dir = yield* tmpdirScoped({ git: true }) |
| 91 | + const project = yield* Project.use.fromDirectory(dir) |
| 92 | + yield* serveProbe() |
| 93 | + |
| 94 | + const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`) |
| 95 | + |
| 96 | + expect(response.status).toBe(200) |
| 97 | + expect(yield* response.json).toEqual({ |
| 98 | + directory: dir, |
| 99 | + worktree: dir, |
| 100 | + projectID: project.project.id, |
| 101 | + }) |
| 102 | + }), |
| 103 | + ) |
| 104 | + |
| 105 | + it.live("falls back to the raw directory when URI decoding fails", () => |
| 106 | + Effect.gen(function* () { |
| 107 | + yield* serveProbe() |
| 108 | + |
| 109 | + const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A") |
| 110 | + |
| 111 | + expect(response.status).toBe(200) |
| 112 | + expect(yield* response.json).toMatchObject({ |
| 113 | + directory: path.join(process.cwd(), "%E0%A4%A"), |
| 114 | + }) |
| 115 | + }), |
| 116 | + ) |
| 117 | + |
| 118 | + it.live("provides selected workspace id on control-plane routes", () => |
| 119 | + Effect.gen(function* () { |
| 120 | + const dir = yield* tmpdirScoped({ git: true }) |
| 121 | + const project = yield* Project.use.fromDirectory(dir) |
| 122 | + const workspaceDir = path.join(dir, ".workspace-local") |
| 123 | + const workspace = yield* createLocalWorkspace({ |
| 124 | + projectID: project.project.id, |
| 125 | + type: "instance-context-workspace-ref", |
| 126 | + directory: workspaceDir, |
| 127 | + }) |
| 128 | + yield* serveProbe("/session") |
| 129 | + |
| 130 | + const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe( |
| 131 | + HttpClientRequest.setHeader("x-opencode-directory", dir), |
| 132 | + HttpClient.execute, |
| 133 | + ) |
| 134 | + |
| 135 | + expect(response.status).toBe(200) |
| 136 | + expect(yield* response.json).toMatchObject({ |
| 137 | + directory: dir, |
| 138 | + workspaceID: workspace.id, |
| 139 | + }) |
| 140 | + }), |
| 141 | + ) |
| 142 | + |
| 143 | + it.live("uses workspace routing output instead of raw directory hints", () => |
| 144 | + Effect.gen(function* () { |
| 145 | + const dir = yield* tmpdirScoped({ git: true }) |
| 146 | + const project = yield* Project.use.fromDirectory(dir) |
| 147 | + const workspaceDir = path.join(dir, ".workspace-local") |
| 148 | + const workspace = yield* createLocalWorkspace({ |
| 149 | + projectID: project.project.id, |
| 150 | + type: "instance-context-routing-output", |
| 151 | + directory: workspaceDir, |
| 152 | + }) |
| 153 | + yield* serveProbe() |
| 154 | + |
| 155 | + const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe( |
| 156 | + HttpClientRequest.setHeader("x-opencode-directory", dir), |
| 157 | + HttpClient.execute, |
| 158 | + ) |
| 159 | + |
| 160 | + expect(response.status).toBe(200) |
| 161 | + expect(yield* response.json).toMatchObject({ |
| 162 | + directory: workspaceDir, |
| 163 | + workspaceID: workspace.id, |
| 164 | + }) |
| 165 | + }), |
| 166 | + ) |
| 167 | +}) |
0 commit comments