| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- import type { Page, Route } from "@playwright/test"
- const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
- const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
- export interface MockServerConfig {
- protocol?: "v1" | "v2"
- provider: unknown | (() => unknown)
- integrationMethods?: Record<string, unknown[]>
- onConnectKey?: (input: { integrationID: string; body: unknown }) => void
- onInstanceDispose?: () => void
- directory: string
- project: unknown
- sessions: ({ id: string } & Record<string, unknown>)[]
- pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
- vcsDiff?: unknown[]
- messageDelay?: number
- beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
- onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
- message?: (sessionID: string, messageID: string) => unknown
- onMessage?: (input: { sessionID: string; messageID: string }) => void
- events?: () => unknown[]
- eventRetry?: number
- todos?: (sessionID: string) => unknown[]
- permissions?: unknown[] | (() => unknown[])
- questions?: unknown[] | (() => unknown[])
- fileList?: (path: string) => unknown | Promise<unknown>
- fileContent?: (path: string) => unknown | Promise<unknown>
- findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
- sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
- }
- export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
- const cursors = new Map<string, string>()
- let nextCursor = 0
- const staticRoutes: Record<string, unknown> = {
- "/path": {
- state: config.directory,
- config: config.directory,
- worktree: config.directory,
- directory: config.directory,
- home: "C:/OpenCode",
- },
- "/project": [config.project],
- "/project/current": config.project,
- "/agent": [{ name: "build", mode: "primary" }],
- "/vcs": { branch: "main", default_branch: "main" },
- "/session": config.sessions,
- }
- 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.port !== targetPort && url.port !== appPort) return route.fallback()
- const path = url.pathname
- if (path === "/global/event" || path === "/event" || path === "/api/event") {
- const events = config.events?.()
- return sse(
- route,
- path === "/api/event"
- ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
- : [
- ...(path === "/global/event"
- ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
- : []),
- ...(events ?? []),
- ],
- config.eventRetry,
- )
- }
- if (path === "/global/health")
- return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
- if (path === "/api/health" && config.protocol === "v2")
- return json(route, { healthy: true, version: "2.0.0", pid: 1 })
- if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
- if (path === "/provider")
- return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
- if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
- const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
- if (legacyAuth && route.request().method() === "PUT") {
- config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
- return json(route, true)
- }
- if (path === "/instance/dispose" && route.request().method() === "POST") {
- config.onInstanceDispose?.()
- return json(route, true)
- }
- if (path === "/permission")
- return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
- if (path === "/question")
- return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
- if (path === "/session/status")
- return json(
- route,
- typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
- )
- if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
- if (path === "/file" && config.fileList)
- return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
- if (path === "/file/content" && config.fileContent)
- return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
- if (path === "/find/file" && config.findFiles)
- return json(
- route,
- await config.findFiles({
- query: url.searchParams.get("query") ?? "",
- dirs: url.searchParams.get("dirs") ?? undefined,
- limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
- }),
- )
- if (path === "/api/reference")
- return json(route, {
- location: {
- directory: config.directory,
- project: { id: (config.project as { id?: string }).id, directory: config.directory },
- },
- 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/command") 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: [{ 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 (path === "/api/project") return json(route, [config.project])
- if (path === "/api/project/current")
- return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
- if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
- if (path === "/api/path")
- return json(route, {
- state: config.directory,
- config: config.directory,
- worktree: config.directory,
- directory: config.directory,
- home: "C:/OpenCode",
- })
- 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/question/request")
- return json(route, {
- location: location(config),
- data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
- })
- if (path === "/api/vcs")
- return json(route, { location: location(config), data: { branch: "main", defaultBranch: "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/pty/shells") 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 (emptyObject.has(path)) return json(route, {})
- if (emptyList.has(path)) return json(route, [])
- if (path === "/api/session") {
- 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) => !directory || session.directory === directory)
- .filter((session) => parentID !== "null" || session.parentID === undefined)
- .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.toReversed() : sessions
- 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 = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
- return json(route, {
- data: Object.fromEntries(
- Object.entries(statuses).flatMap(([id, status]) =>
- status.type === "idle" ? [] : [[id, { type: "running" }]],
- ),
- ),
- })
- }
- if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
- return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
- }
- if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
- return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
- }
- if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
- return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
- }
- if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
- return json(route, true)
- }
- if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
- return json(route, true)
- }
- if (
- /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
- route.request().method() === "POST"
- ) {
- return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
- }
- if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
- return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
- }
- if (path in staticRoutes) return json(route, staticRoutes[path])
- 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 sessionMatch = path.match(/^\/session\/([^/]+)$/)
- if (sessionMatch) {
- const session = config.sessions.find((s) => s.id === sessionMatch[1])
- return json(route, session ?? {})
- }
- const projectMatch = path.match(/^\/project\/([^/]+)$/)
- if (projectMatch) return json(route, config.project)
- const messageMatch = path.match(/^\/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]!)
- if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
- return json(route, message)
- }
- const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
- if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
- if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
- const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
- if (currentMessagesMatch) {
- 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: currentMessagesMatch[1], before, phase: "start" })
- await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
- if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
- const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
- config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
- const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
- if (cursor) cursors.set(cursor, pageData.cursor!)
- return json(route, {
- data: pageData.items.map(currentMessage).reverse(),
- cursor: { next: cursor },
- })
- }
- const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
- if (messagesMatch) {
- const token = url.searchParams.get("before") ?? 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 limit = Number(url.searchParams.get("limit") ?? 80)
- const pageData = config.pageMessages(messagesMatch[1], limit, before)
- config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
- if (!pageData.cursor) return json(route, pageData.items)
- const cursor = `cursor_${++nextCursor}`
- cursors.set(cursor, pageData.cursor)
- return json(route, pageData.items, { "x-next-cursor": cursor })
- }
- if (url.port === targetPort && targetPort !== appPort) return json(route, {})
- return route.fallback()
- })
- }
- function location(config: MockServerConfig) {
- return {
- directory: config.directory,
- project: { id: (config.project as { id?: string }).id, directory: config.directory },
- }
- }
- function currentPermission(value: unknown) {
- const permission = value as Record<string, unknown>
- if (permission.action) return permission
- const tool = permission.tool as { messageID?: string; callID?: string } | undefined
- return {
- id: permission.id,
- sessionID: permission.sessionID,
- action: permission.permission,
- resources: permission.patterns ?? [],
- save: permission.always,
- metadata: permission.metadata,
- source:
- tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
- }
- }
- export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
- const time = session.time && typeof session.time === "object" ? session.time : {}
- return {
- id: session.id,
- parentID: session.parentID,
- projectID: session.projectID ?? "project",
- agent: session.agent ?? "build",
- model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
- cost: session.cost ?? 0,
- tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
- time: {
- created: "created" in time && typeof time.created === "number" ? time.created : 0,
- updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
- ...(session.time && typeof session.time === "object" && "archived" in session.time
- ? { archived: session.time.archived }
- : {}),
- },
- title: session.title ?? session.id,
- location: {
- directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
- ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
- },
- subpath: session.path,
- revert: session.revert,
- }
- }
- function currentMessage(value: unknown) {
- const item = value as {
- info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
- parts: Array<Record<string, unknown> & { type: string }>
- }
- if (item.info.role === "user") {
- return {
- id: item.info.id,
- type: "user",
- time: item.info.time,
- text: item.parts
- .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
- .join("\n"),
- }
- }
- return {
- id: item.info.id,
- type: "assistant",
- time: item.info.time,
- agent: item.info.agent ?? "build",
- model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
- cost: item.info.cost,
- tokens: item.info.tokens,
- error: item.info.error,
- content: item.parts.flatMap<unknown>((part) => {
- if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
- if (part.type !== "tool") return []
- const state = part.state as Record<string, unknown>
- return [
- {
- type: "tool",
- id: part.id,
- name: part.tool,
- time: state.time ?? { created: item.info.time.created },
- state:
- state.status === "pending"
- ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
- : state.status === "completed"
- ? {
- status: "completed",
- input: state.input ?? {},
- structured: state.metadata ?? {},
- content: [{ type: "text", text: state.output ?? "" }],
- }
- : state.status === "error"
- ? {
- status: "error",
- input: state.input ?? {},
- structured: state.metadata ?? {},
- content: [],
- error: { type: "ToolError", message: state.error ?? "Tool failed" },
- }
- : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
- },
- ]
- }),
- }
- }
- function json(route: Route, body: unknown, headers?: Record<string, string>, 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"}`,
- })
- }
- function currentEvent(input: unknown) {
- if (!input || typeof input !== "object" || !("payload" in input)) return input
- const envelope = input as { directory?: string; payload?: unknown }
- if (!envelope.payload || typeof envelope.payload !== "object") return input
- const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
- if (!payload.type) return input
- return {
- id: payload.id ?? `evt_mock_${Date.now()}`,
- created: Date.now(),
- type: payload.type,
- data: payload.properties ?? {},
- location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
- }
- }
|