diff --git a/packages/app/e2e/performance/unit/mock-server.test.ts b/packages/app/e2e/performance/unit/mock-server.test.ts index f1618aafc7..70d7541229 100644 --- a/packages/app/e2e/performance/unit/mock-server.test.ts +++ b/packages/app/e2e/performance/unit/mock-server.test.ts @@ -5,9 +5,11 @@ import { mockOpenCodeServer } from "../../utils/mock-server" test("applies message latency after a list response gate is released", async () => { const events: string[] = [] const gate = Promise.withResolvers() + const started = Promise.withResolvers() let handler: ((route: Route) => Promise) | undefined const page = { addInitScript: () => Promise.resolve(), + on: () => page, route: (_url: string, callback: (route: Route) => Promise) => { handler = callback return Promise.resolve() @@ -21,6 +23,7 @@ test("applies message latency after a list response gate is released", async () messageDelay: 25, beforeMessagesResponse: () => { events.push("before") + started.resolve() return gate.promise }, onMessages: (request) => events.push(request.phase), @@ -31,12 +34,18 @@ test("applies message latency after a list response gate is released", async () }) const response = handler!({ - request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }), + request: () => ({ + url: () => "http://127.0.0.1:4096/api/session/session/message", + method: () => "GET", + headers: () => ({}), + postDataBuffer: () => null, + }), fulfill: () => { events.push("fulfill") return Promise.resolve() }, } as unknown as Route) + await started.promise expect(events).toEqual(["start", "before"]) const released = performance.now() @@ -45,3 +54,42 @@ test("applies message latency after a list response gate is released", async () expect(performance.now() - released).toBeGreaterThanOrEqual(20) expect(events).toEqual(["start", "before", "page", "end", "fulfill"]) }) + +test("routes requests through the HttpApi contract", async () => { + const connected = Promise.withResolvers<{ integrationID: string; body: unknown }>() + let handler: ((route: Route) => Promise) | undefined + const page = { + addInitScript: () => Promise.resolve(), + on: () => page, + route: (_url: string, callback: (route: Route) => Promise) => { + handler = callback + return Promise.resolve() + }, + } as unknown as Page + await mockOpenCodeServer(page, { + provider: {}, + directory: "C:/OpenCode", + project: {}, + sessions: [], + pageMessages: () => ({ items: [] }), + onConnectKey: connected.resolve, + }) + + const body = Buffer.from(JSON.stringify({ key: "secret" })) + let status: number | undefined + await handler!({ + request: () => ({ + url: () => "http://127.0.0.1:4096/api/integration/anthropic/connect/key", + method: () => "POST", + headers: () => ({ "content-type": "application/json" }), + postDataBuffer: () => body, + }), + fulfill: (response: Parameters[0]) => { + status = response?.status + return Promise.resolve() + }, + } as unknown as Route) + + expect(status).toBe(204) + expect(await connected.promise).toEqual({ integrationID: "anthropic", body: { key: "secret" } }) +}) diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts new file mode 100644 index 0000000000..80c928e266 --- /dev/null +++ b/packages/app/e2e/utils/mock-api.ts @@ -0,0 +1,249 @@ +import { Schema, SchemaGetter } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" + +const Json = Schema.Json.pipe( + Schema.decodeTo(Schema.Unknown, { + decode: SchemaGetter.passthrough(), + encode: SchemaGetter.transform(jsonValue), + }), + HttpApiSchema.asJson(), +) +const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson()) +const Query = Schema.Struct({ + directory: Schema.optional(Schema.String), + parentID: Schema.optional(Schema.String), + search: Schema.optional(Schema.String), + order: Schema.optional(Schema.String), + cursor: Schema.optional(Schema.String), + limit: Schema.optional(Schema.NumberFromString), + path: Schema.optional(Schema.String), + query: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), +}) +const SessionParams = { sessionID: Schema.String } +const NoContent = HttpApiSchema.NoContent + +export class MockNotFound extends Schema.TaggedError()("MockNotFound", { + message: Schema.String, +}) {} + +export class MockBadRequest extends Schema.TaggedError()("MockBadRequest", { + message: Schema.String, +}) {} + +const Group = HttpApiGroup.make("mock") + .add(HttpApiEndpoint.get("health", "/api/health", { success: Json })) + .add( + HttpApiEndpoint.get("event", "/api/event", { + success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), + }), + ) + .add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json })) + .add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json })) + .add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json })) + .add(HttpApiEndpoint.get("model", "/api/model", { success: Json })) + .add(HttpApiEndpoint.get("modelDefault", "/api/model/default", { success: Json })) + .add(HttpApiEndpoint.get("integrationList", "/api/integration", { success: Json })) + .add( + HttpApiEndpoint.get("integrationGet", "/api/integration/:integrationID", { + params: { integrationID: Schema.String }, + success: Json, + }), + ) + .add( + HttpApiEndpoint.post("integrationConnect", "/api/integration/:integrationID/connect/key", { + params: { integrationID: Schema.String }, + payload: JsonPayload, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", { + params: { credentialID: Schema.String }, + success: NoContent, + }), + ) + .add(HttpApiEndpoint.get("command", "/api/command", { success: Json })) + .add(HttpApiEndpoint.get("skill", "/api/skill", { success: Json })) + .add(HttpApiEndpoint.get("plugin", "/api/plugin", { success: Json })) + .add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json })) + .add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json })) + .add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json })) + .add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json })) + .add( + HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", { + params: { projectID: Schema.String }, + success: Json, + }), + ) + .add( + HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", { + params: { projectID: Schema.String }, + payload: JsonPayload, + success: Json, + }), + ) + .add( + HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", { + params: { projectID: Schema.String }, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", { + params: { projectID: Schema.String }, + success: NoContent, + }), + ) + .add(HttpApiEndpoint.get("location", "/api/location", { success: Json })) + .add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json })) + .add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json })) + .add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json })) + .add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json })) + .add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json })) + .add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json })) + .add( + HttpApiEndpoint.get("fsRead", "/api/fs/read/*", { + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }), + ) + .add(HttpApiEndpoint.get("fsFind", "/api/fs/find", { query: Query, success: Json })) + .add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json })) + .add( + HttpApiEndpoint.get("ptyConnectToken", "/api/pty/:ptyID/connect-token", { + params: { ptyID: Schema.String }, + success: Json, + }), + ) + .add( + HttpApiEndpoint.get("sessionList", "/api/session", { + query: Query, + success: Json, + error: MockBadRequest.pipe(HttpApiSchema.status(400)), + }), + ) + .add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json })) + .add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json })) + .add( + HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", { + params: SessionParams, + success: Json, + error: MockNotFound.pipe(HttpApiSchema.status(404)), + }), + ) + .add( + HttpApiEndpoint.delete("sessionRemove", "/api/session/:sessionID", { + params: SessionParams, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", { + params: SessionParams, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.get("sessionForm", "/api/session/:sessionID/form", { + params: SessionParams, + success: Json, + }), + ) + .add( + HttpApiEndpoint.post("sessionFormReply", "/api/session/:sessionID/form/:formID/reply", { + params: { ...SessionParams, formID: Schema.String }, + payload: JsonPayload, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", { + params: { ...SessionParams, formID: Schema.String }, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("sessionBackground", "/api/session/:sessionID/background", { + params: SessionParams, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.get("sessionInbox", "/api/session/:sessionID/inbox", { + params: SessionParams, + success: Json, + }), + ) + .add( + HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", { + params: SessionParams, + success: Json, + }), + ) + .add( + HttpApiEndpoint.post("sessionPermissionReply", "/api/session/:sessionID/permission/:permissionID/reply", { + params: { ...SessionParams, permissionID: Schema.String }, + payload: JsonPayload, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("sessionRename", "/api/session/:sessionID/rename", { + params: SessionParams, + payload: JsonPayload, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", { + params: SessionParams, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("sessionRevertStage", "/api/session/:sessionID/revert/stage", { + params: SessionParams, + payload: JsonPayload, + success: Json, + error: MockBadRequest.pipe(HttpApiSchema.status(400)), + }), + ) + .add( + HttpApiEndpoint.post("sessionRevertClear", "/api/session/:sessionID/revert/clear", { + params: SessionParams, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("sessionRevertCommit", "/api/session/:sessionID/revert/commit", { + params: SessionParams, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.get("messageGet", "/api/session/:sessionID/message/:messageID", { + params: { ...SessionParams, messageID: Schema.String }, + success: Json, + error: MockNotFound.pipe(HttpApiSchema.status(404)), + }), + ) + .add( + HttpApiEndpoint.get("messageList", "/api/session/:sessionID/message", { + params: SessionParams, + query: Query, + success: Json, + error: MockBadRequest.pipe(HttpApiSchema.status(400)), + }), + ) + +export const MockApi = HttpApi.make("mock").add(Group) + +function jsonValue(value: unknown): Schema.Json { + if (value === null || typeof value === "string" || typeof value === "boolean") return value + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (Array.isArray(value)) return value.map(jsonValue) + if (!value || typeof value !== "object") return null + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, jsonValue(item)]])), + ) +} diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index cc279d9cdd..c24cf1f7e8 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,5 +1,9 @@ -import type { Page, Route } from "@playwright/test" +import type { Page } from "@playwright/test" import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" +import { Duration, Effect, Layer } from "effect" +import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { MockApi, MockBadRequest, MockNotFound } from "./mock-api" export interface MockServerConfig { provider: unknown | (() => unknown) @@ -39,9 +43,8 @@ type MockStreamWindow = Window & { } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { - const cursors = new Map() + const state = { cursors: new Map(), nextCursor: 0 } const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - let nextCursor = 0 await page.addInitScript( ({ server, retry }) => { @@ -128,316 +131,331 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }, 50) page.on("close", () => clearInterval(timer)) } + const transport = HttpRouter.toWebHandler( + HttpApiBuilder.layer(MockApi).pipe( + Layer.provide(mockHandlers(config, state)), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true }, + ) + page.on("close", () => void transport.dispose()) + await page.route("**/*", async (route) => { const url = new URL(route.request().url()) - const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const appPort = new URL( process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`, ).port if (url.origin !== server && url.port !== appPort) return route.fallback() - - const path = url.pathname - if (path === "/api/event") { - const events = config.events?.() - return sse( - route, - [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])], - config.eventRetry, - ) + if (route.request().method() === "OPTIONS") { + return route.fulfill({ status: 204, headers: corsHeaders }) } - if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 }) - if (path === "/api/reference") - return json(route, { - location: { - directory: config.directory, - project: { + + const body = route.request().postDataBuffer() + const response = await transport.handler( + new Request(url, { + method: route.request().method(), + headers: route.request().headers(), + body: body ? Uint8Array.from(body) : undefined, + }), + ) + if (response.status === 404 && url.origin !== server) return route.fallback() + return route.fulfill({ + status: response.status, + headers: { ...Object.fromEntries(response.headers), ...corsHeaders }, + body: Buffer.from(await response.arrayBuffer()), + }) + }) +} + +const corsHeaders = { + "access-control-allow-origin": "*", + "access-control-allow-headers": "*", + "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS", + "access-control-expose-headers": "x-next-cursor", +} + +function mockHandlers(config: MockServerConfig, state: { cursors: Map; nextCursor: number }) { + const noContent = Effect.succeed(HttpApiSchema.NoContent.make()) + const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay)) + return HttpApiBuilder.group(MockApi, "mock", (handlers) => + handlers + .handleRaw("event", () => { + const events = config.events?.() + const retry = config.eventRetry === undefined ? "" : `retry: ${config.eventRetry}\n\n` + const body = [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])] + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join("") + return Effect.succeed(HttpServerResponse.text(retry + body, { contentType: "text/event-stream" })) + }) + .handleRaw("fsRead", (ctx) => + Effect.gen(function* () { + const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)) + const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path))) + const content = + value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "") + return HttpServerResponse.uint8Array(new TextEncoder().encode(content)) + }), + ) + .handleAll({ + health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }), + reference: () => + Effect.succeed({ + location: { + directory: config.directory, + project: { + id: (config.project as { id?: string }).id, + directory: config.directory, + canonical: config.directory, + }, + }, + data: [], + }), + agent: () => + Effect.succeed({ + location: location(config), + data: [ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + }, + ], + }), + provider: () => Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }), + model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }), + modelDefault: () => + Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }), + integrationList: () => Effect.succeed({ location: location(config), data: [] }), + integrationGet: (ctx) => + Effect.succeed({ + location: location(config), + data: { + id: ctx.params.integrationID, + name: ctx.params.integrationID, + methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }], + connections: [], + }, + }), + integrationConnect: (ctx) => + Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe( + Effect.andThen(noContent), + ), + credentialRemove: () => noContent, + command: () => Effect.succeed({ location: location(config), data: [] }), + skill: () => Effect.succeed({ location: location(config), data: [] }), + plugin: () => Effect.succeed({ location: location(config), data: [] }), + mcp: () => Effect.succeed({ location: location(config), data: [] }), + mcpResource: () => Effect.succeed({ location: location(config), data: { resources: [], templates: [] } }), + projectList: () => { + const project = config.project as typeof config.project & { canonical?: string; worktree?: string } + return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }]) + }, + projectCurrent: () => + Effect.succeed({ id: (config.project as { id?: string }).id, directory: config.directory, canonical: config.directory, - }, + }), + worktreeList: () => + Effect.succeed([ + { directory: config.directory }, + ...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({ + directory, + strategy: "git", + })), + ]), + worktreeCreate: (ctx) => { + const input = record(ctx.payload) ? ctx.payload : {} + return Effect.succeed({ + directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${ + typeof input.name === "string" ? input.name : "copy" + }`, + }) }, - data: [], - }) - if (path === "/api/agent") - return json(route, { - location: location(config), - data: [ - { - id: "build", - name: "Build", - mode: "primary", - hidden: false, - request: { settings: {}, headers: {}, body: {} }, - permissions: [], - }, - ], - }) - if (path === "/api/provider") - return json(route, { - location: location(config), - data: currentProviders(providerConfig(config)), - }) - if (path === "/api/model") - return json(route, { location: location(config), data: currentModels(providerConfig(config)) }) - if (path === "/api/model/default") - return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) }) - if (path === "/api/integration") return json(route, { location: location(config), data: [] }) - if (path === "/api/command") return json(route, { location: location(config), data: [] }) - if (path === "/api/skill") return json(route, { location: location(config), data: [] }) - if (path === "/api/plugin") return json(route, { location: location(config), data: [] }) - if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) - if (path === "/api/mcp/resource") - return json(route, { location: location(config), data: { resources: [], templates: [] } }) - const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] - if (integration && route.request().method() === "GET") - return json(route, { - location: location(config), - data: { - id: integration, - name: integration, - methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }], - connections: [], - }, - }) - const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1] - if (integrationConnect && route.request().method() === "POST") { - config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() }) - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE") - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - if (path === "/api/project") { - const project = config.project as typeof config.project & { canonical?: string; worktree?: string } - return json(route, [ - { - ...project, - canonical: project.canonical ?? project.worktree ?? config.directory, - }, - ]) - } - if (path === "/api/project/current") - return json(route, { - id: (config.project as { id?: string }).id, - directory: config.directory, - canonical: config.directory, - }) - const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1] - if (worktree && route.request().method() === "GET") - return json(route, [ - { directory: config.directory }, - ...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({ - directory, - strategy: "git", - })), - ]) - if (path === "/api/location") return json(route, location(config)) - if (worktree && route.request().method() === "POST") { - const input = route.request().postDataJSON() as { directory: string; name?: string } - return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` }) - } - if (worktree && route.request().method() === "DELETE") - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path)) - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - if (path === "/api/permission/request") - return json(route, { - location: location(config), - data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( - currentPermission, - ), - }) - if (path === "/api/form/request") - return json(route, { - location: location(config), - data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), - }) - if (path === "/api/vcs") - return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } }) - if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) - if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) - if (path === "/api/fs/list" && config.fileList) - return json(route, { - location: location(config), - data: await config.fileList(url.searchParams.get("path") ?? ""), - }) - const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1] - if (fileRead && config.fileContent) { - const value = await config.fileContent(decodeURIComponent(fileRead)) - const content = - value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "") - return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } }) - } - if (path === "/api/fs/find" && config.findFiles) { - const entries = await config.findFiles({ - query: url.searchParams.get("query") ?? "", - dirs: url.searchParams.get("type") ?? undefined, - limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, - }) - return json(route, { - location: location(config), - data: Array.isArray(entries) - ? entries.map((entry) => - typeof entry === "string" - ? { - name: entry.split(/[\\/]/).at(-1) ?? entry, - path: entry, - absolute: `${config.directory}/${entry}`, - type: "directory", - ignored: false, - } - : entry, - ) - : entries, - }) - } - if (path === "/api/shell" && route.request().method() === "GET") - return json(route, { location: location(config), data: [] }) - if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) - return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) - if (path === "/api/session") { - if (route.request().method() === "POST") { - const payload = route.request().postDataJSON() as Record - const created = currentSession( - { - id: "ses_mock_created", - projectID: (config.project as { id?: string }).id, - title: typeof payload.title === "string" ? payload.title : "New session", - parentID: typeof payload.parentID === "string" ? payload.parentID : undefined, - }, - config.directory, - ) - config.sessions.push(created) - return json(route, { data: created }) - } - if (route.request().method() !== "GET") return route.fallback() - const directory = url.searchParams.get("directory") - const parentID = url.searchParams.get("parentID") - const limit = Number(url.searchParams.get("limit") ?? 50) - const offset = Number(url.searchParams.get("cursor") ?? 0) - const sessions = config.sessions - .filter((session) => { - const location = session.location as { directory?: string } | undefined - return !directory || location?.directory === directory || session.directory === directory - }) - .filter((session) => { - if (parentID === null) return true - if (parentID === "null") return session.parentID === undefined - return session.parentID === parentID - }) - .filter((session) => { - const search = url.searchParams.get("search")?.toLowerCase() - return ( - !search || - String(session.title ?? "") - .toLowerCase() - .includes(search) - ) - }) - const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed() - const data = ordered.slice(offset, offset + limit) - const next = offset + limit < ordered.length ? String(offset + limit) : undefined - return json(route, { - data: data.map((session) => currentSession(session, config.directory)), - cursor: { next }, - }) - } - if (path === "/api/session/active") { - const statuses = ( - typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}) - ) as Record - return json(route, { - data: Object.fromEntries( - Object.entries(statuses).flatMap(([id, status]) => - status.type === "idle" ? [] : [[id, { type: "running" }]], + worktreeRemove: () => noContent, + worktreeRefresh: () => noContent, + location: () => Effect.succeed(location(config)), + permissionRequests: () => + Effect.succeed({ + location: location(config), + data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( + currentPermission, + ), + }), + formRequests: () => + Effect.succeed({ + location: location(config), + data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), + }), + vcs: () => + Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }), + vcsStatus: () => Effect.succeed({ location: location(config), data: [] }), + vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }), + fsList: (ctx) => + Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe( + Effect.map((data) => ({ location: location(config), data })), ), - ), - }) - } - if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1] - if (sessionForm && route.request().method() === "GET") { - const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? []) - return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) }) - } - if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST") - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET") - return json(route, { data: [] }) - const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1] - if (sessionPermission && route.request().method() === "GET") { - const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []) - return json(route, { - data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission), - }) - } - if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if ( - /^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && - route.request().method() === "POST" - ) { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - const revertStage = path.match(/^\/api\/session\/([^/]+)\/revert\/stage$/)?.[1] - if (revertStage && route.request().method() === "POST") { - const body = route.request().postDataJSON() - if (!body || typeof body !== "object" || !("messageID" in body) || typeof body.messageID !== "string") { - return json(route, { error: "Invalid revert request" }, undefined, 400) - } - config.onRevertStage?.({ sessionID: revertStage, messageID: body.messageID }) - return json(route, { data: { messageID: body.messageID } }) - } - if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) - if (currentSessionMatch) { - const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) - if (!session) return json(route, { error: "Session not found" }, undefined, 404) - return json(route, { - data: currentSession(session, config.directory), - }) - } - - const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/) - if (messageMatch) { - config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const message = - config.message?.(messageMatch[1]!, messageMatch[2]!) ?? - config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2]) - if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) - return json(route, { data: message }) - } - - const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) - if (messagesMatch) { - const token = url.searchParams.get("cursor") ?? undefined - const before = token ? cursors.get(token) : undefined - if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) - config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) - await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) - config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) - const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined - if (cursor) cursors.set(cursor, pageData.cursor!) - return json(route, { - data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(), - cursor: { next: cursor }, - }) - } - - if (url.port === targetPort && targetPort !== appPort) - return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404) - return route.fallback() - }) + fsFind: (ctx) => + Effect.promise(() => + Promise.resolve( + config.findFiles?.({ query: ctx.query.query ?? "", dirs: ctx.query.type, limit: ctx.query.limit }), + ), + ).pipe( + Effect.map((entries) => ({ + location: location(config), + data: Array.isArray(entries) + ? entries.map((entry) => + typeof entry === "string" + ? { + name: entry.split(/[\\/]/).at(-1) ?? entry, + path: entry, + absolute: `${config.directory}/${entry}`, + type: "directory", + ignored: false, + } + : entry, + ) + : entries, + })), + ), + shell: () => Effect.succeed({ location: location(config), data: [] }), + ptyConnectToken: () => + Effect.succeed({ location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }), + sessionList: (ctx) => { + const sessions = config.sessions + .filter((session) => { + const location = session.location as { directory?: string } | undefined + return ( + !ctx.query.directory || + location?.directory === ctx.query.directory || + session.directory === ctx.query.directory + ) + }) + .filter((session) => { + if (ctx.query.parentID === undefined) return true + if (ctx.query.parentID === "null") return session.parentID === undefined + return session.parentID === ctx.query.parentID + }) + .filter((session) => + ctx.query.search === undefined + ? true + : String(session.title ?? "") + .toLowerCase() + .includes(ctx.query.search.toLowerCase()), + ) + const ordered = ctx.query.order === "asc" ? sessions : sessions.toReversed() + const offset = Number(ctx.query.cursor ?? 0) + const limit = ctx.query.limit ?? 50 + const data = ordered.slice(offset, offset + limit) + return Effect.succeed({ + data: data.map((session) => currentSession(session, config.directory)), + cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined }, + }) + }, + sessionCreate: (ctx) => { + const payload = record(ctx.payload) ? ctx.payload : {} + const created = currentSession( + { + id: "ses_mock_created", + projectID: (config.project as { id?: string }).id, + title: typeof payload.title === "string" ? payload.title : "New session", + parentID: typeof payload.parentID === "string" ? payload.parentID : undefined, + }, + config.directory, + ) + return Effect.sync(() => config.sessions.push(created)).pipe(Effect.as({ data: created })) + }, + sessionActive: () => { + const statuses = ( + typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}) + ) as Record + return Effect.succeed({ + data: Object.fromEntries( + Object.entries(statuses).flatMap(([id, status]) => + status.type === "idle" ? [] : [[id, { type: "running" }]], + ), + ), + }) + }, + sessionGet: (ctx) => { + const session = config.sessions.find((item) => item.id === ctx.params.sessionID) + return session + ? Effect.succeed({ data: currentSession(session, config.directory) }) + : Effect.fail(new MockNotFound({ message: "Session not found" })) + }, + sessionRemove: () => noContent, + sessionShell: () => noContent, + sessionForm: (ctx) => { + const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? []) + return Effect.succeed({ + data: forms.filter((form) => (form as { sessionID?: string }).sessionID === ctx.params.sessionID), + }) + }, + sessionFormReply: () => noContent, + sessionFormCancel: () => noContent, + sessionBackground: () => noContent, + sessionInbox: () => Effect.succeed({ data: [] }), + sessionPermission: (ctx) => { + const permissions = + typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []) + return Effect.succeed({ + data: permissions + .map(currentPermission) + .filter((permission) => permission.sessionID === ctx.params.sessionID), + }) + }, + sessionPermissionReply: () => noContent, + sessionRename: () => noContent, + sessionInterrupt: () => noContent, + sessionRevertStage: (ctx) => { + const payload = record(ctx.payload) ? ctx.payload : {} + const messageID = payload.messageID + if (typeof messageID !== "string") { + return Effect.fail(new MockBadRequest({ message: "Invalid revert request" })) + } + return Effect.sync(() => config.onRevertStage?.({ sessionID: ctx.params.sessionID, messageID })).pipe( + Effect.as({ data: { messageID } }), + ) + }, + sessionRevertClear: () => noContent, + sessionRevertCommit: () => noContent, + messageGet: (ctx) => + Effect.gen(function* () { + config.onMessage?.({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }) + yield* delay + const message = + config.message?.(ctx.params.sessionID, ctx.params.messageID) ?? + config + .pageMessages(ctx.params.sessionID, Number.MAX_SAFE_INTEGER) + .items.find((item) => item.id === ctx.params.messageID) + if (!message) return yield* new MockNotFound({ message: "Message not found" }) + return { data: message } + }), + messageList: (ctx) => { + const token = ctx.query.cursor + const before = token ? state.cursors.get(token) : undefined + if (token && !before) return Effect.fail(new MockBadRequest({ message: "Invalid cursor" })) + return Effect.gen(function* () { + config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "start" }) + if (config.beforeMessagesResponse) { + yield* Effect.promise(() => config.beforeMessagesResponse!({ sessionID: ctx.params.sessionID, before })) + } + yield* delay + const pageData = config.pageMessages(ctx.params.sessionID, ctx.query.limit ?? 50, before) + config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "end" }) + const cursor = pageData.cursor ? `cursor_${++state.nextCursor}` : undefined + if (cursor) state.cursors.set(cursor, pageData.cursor!) + return { + data: ctx.query.order === "asc" ? pageData.items : pageData.items.toReversed(), + cursor: { next: cursor }, + } + }) + }, + }), + ) } function location(config: MockServerConfig) { @@ -595,24 +613,3 @@ function jsonValue(value: unknown): JsonValue | undefined { function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } - -function json(route: Route, body: unknown, headers?: Record, status = 200) { - return route.fulfill({ - status, - contentType: "application/json", - headers: { - "access-control-allow-origin": "*", - "access-control-expose-headers": "x-next-cursor", - ...headers, - }, - body: JSON.stringify(body ?? null), - }) -} - -function sse(route: Route, events?: unknown[], retry?: number) { - return route.fulfill({ - status: 200, - contentType: "text/event-stream", - body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, - }) -} diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index d27fcf4fe8..dd1456446f 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -31,6 +31,11 @@ import { testEffect } from "../lib/effect" const it = testEffect(Layer.empty) const selection = Schema.decodeUnknownSync(ConfigModel.Selection) +function inFixture(root: string, target: string) { + const relative = path.relative(root, target) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + function testLayer( directory: string, globalDirectory = path.join(directory, "global"), @@ -801,7 +806,7 @@ describe("Config", () => { Effect.flatMap((tmp) => Effect.gen(function* () { const config = yield* Config.Service - const entries = yield* config.entries() + const entries = (yield* config.entries()).filter((entry) => !entry.path || inFixture(tmp.path, entry.path)) expect(entries).toEqual([ new Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }), @@ -861,7 +866,7 @@ describe("Config", () => { const watcher = yield* Watcher.Test yield* config.entries() - expect(yield* watcher.subscriptions()).toEqual([ + expect((yield* watcher.subscriptions()).filter((item) => inFixture(tmp.path, item.path))).toEqual([ { type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")), @@ -1506,7 +1511,7 @@ describe("Config", () => { return yield* Effect.gen(function* () { const config = yield* Config.Service - const entries = yield* config.entries() + const entries = (yield* config.entries()).filter((entry) => !entry.path || inFixture(tmp.path, entry.path)) const documents = entries.filter((entry) => entry.type === "document") expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([