mock-server.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. import type { Page, Route } from "@playwright/test"
  2. const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
  3. const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
  4. export interface MockServerConfig {
  5. protocol?: "v1" | "v2"
  6. provider: unknown
  7. directory: string
  8. project: unknown
  9. sessions: ({ id: string } & Record<string, unknown>)[]
  10. pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
  11. vcsDiff?: unknown[]
  12. messageDelay?: number
  13. beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
  14. onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
  15. message?: (sessionID: string, messageID: string) => unknown
  16. onMessage?: (input: { sessionID: string; messageID: string }) => void
  17. events?: () => unknown[]
  18. eventRetry?: number
  19. todos?: (sessionID: string) => unknown[]
  20. permissions?: unknown[] | (() => unknown[])
  21. questions?: unknown[] | (() => unknown[])
  22. fileList?: (path: string) => unknown | Promise<unknown>
  23. fileContent?: (path: string) => unknown | Promise<unknown>
  24. findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
  25. sessionStatus?: unknown
  26. }
  27. export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
  28. const cursors = new Map<string, string>()
  29. let nextCursor = 0
  30. const staticRoutes: Record<string, unknown> = {
  31. "/provider": config.provider,
  32. "/path": {
  33. state: config.directory,
  34. config: config.directory,
  35. worktree: config.directory,
  36. directory: config.directory,
  37. home: "C:/OpenCode",
  38. },
  39. "/project": [config.project],
  40. "/project/current": config.project,
  41. "/agent": [{ name: "build", mode: "primary" }],
  42. "/vcs": { branch: "main", default_branch: "main" },
  43. "/session": config.sessions,
  44. }
  45. await page.route("**/*", async (route) => {
  46. const url = new URL(route.request().url())
  47. const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
  48. const appPort = new URL(
  49. process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
  50. ).port
  51. if (url.port !== targetPort && url.port !== appPort) return route.fallback()
  52. const path = url.pathname
  53. if (path === "/global/event" || path === "/event" || path === "/api/event") {
  54. const events = config.events?.()
  55. return sse(
  56. route,
  57. path === "/api/event"
  58. ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
  59. : events,
  60. config.eventRetry,
  61. )
  62. }
  63. if (path === "/global/health")
  64. return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
  65. if (path === "/api/health" && config.protocol === "v2")
  66. return json(route, { healthy: true, version: "2.0.0", pid: 1 })
  67. if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
  68. if (path === "/permission")
  69. return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
  70. if (path === "/question")
  71. return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
  72. if (path === "/session/status") return json(route, config.sessionStatus ?? {})
  73. if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
  74. if (path === "/file" && config.fileList)
  75. return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
  76. if (path === "/file/content" && config.fileContent)
  77. return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
  78. if (path === "/find/file" && config.findFiles)
  79. return json(
  80. route,
  81. await config.findFiles({
  82. query: url.searchParams.get("query") ?? "",
  83. dirs: url.searchParams.get("dirs") ?? undefined,
  84. limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
  85. }),
  86. )
  87. if (path === "/api/reference")
  88. return json(route, {
  89. location: {
  90. directory: config.directory,
  91. project: { id: (config.project as { id?: string }).id, directory: config.directory },
  92. },
  93. data: [],
  94. })
  95. if (path === "/api/agent")
  96. return json(route, {
  97. location: location(config),
  98. data: [
  99. {
  100. id: "build",
  101. name: "Build",
  102. mode: "primary",
  103. hidden: false,
  104. request: { settings: {}, headers: {}, body: {} },
  105. permissions: [],
  106. },
  107. ],
  108. })
  109. if (path === "/api/command") return json(route, { location: location(config), data: [] })
  110. if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
  111. if (path === "/api/mcp/resource")
  112. return json(route, { location: location(config), data: { resources: [], templates: [] } })
  113. const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
  114. if (integration && route.request().method() === "GET")
  115. return json(route, {
  116. location: location(config),
  117. data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
  118. })
  119. if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
  120. return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
  121. if (path === "/api/project") return json(route, [config.project])
  122. if (path === "/api/project/current")
  123. return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
  124. if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
  125. if (path === "/api/path")
  126. return json(route, {
  127. state: config.directory,
  128. config: config.directory,
  129. worktree: config.directory,
  130. directory: config.directory,
  131. home: "C:/OpenCode",
  132. })
  133. if (path === "/api/permission/request")
  134. return json(route, {
  135. location: location(config),
  136. data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
  137. currentPermission,
  138. ),
  139. })
  140. if (path === "/api/question/request")
  141. return json(route, {
  142. location: location(config),
  143. data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
  144. })
  145. if (path === "/api/vcs")
  146. return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
  147. if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
  148. if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
  149. if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
  150. if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
  151. return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
  152. if (emptyObject.has(path)) return json(route, {})
  153. if (emptyList.has(path)) return json(route, [])
  154. if (path === "/api/session") {
  155. const directory = url.searchParams.get("directory")
  156. const parentID = url.searchParams.get("parentID")
  157. const limit = Number(url.searchParams.get("limit") ?? 50)
  158. const offset = Number(url.searchParams.get("cursor") ?? 0)
  159. const sessions = config.sessions
  160. .filter((session) => !directory || session.directory === directory)
  161. .filter((session) => parentID !== "null" || session.parentID === undefined)
  162. .filter((session) => {
  163. const search = url.searchParams.get("search")?.toLowerCase()
  164. return (
  165. !search ||
  166. String(session.title ?? "")
  167. .toLowerCase()
  168. .includes(search)
  169. )
  170. })
  171. const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
  172. const data = ordered.slice(offset, offset + limit)
  173. const next = offset + limit < ordered.length ? String(offset + limit) : undefined
  174. return json(route, {
  175. data: data.map((session) => currentSession(session, config.directory)),
  176. cursor: { next },
  177. })
  178. }
  179. if (path === "/api/session/active") {
  180. const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
  181. return json(route, {
  182. data: Object.fromEntries(
  183. Object.entries(statuses).flatMap(([id, status]) =>
  184. status.type === "idle" ? [] : [[id, { type: "running" }]],
  185. ),
  186. ),
  187. })
  188. }
  189. if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
  190. return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
  191. }
  192. if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
  193. return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
  194. }
  195. if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
  196. return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
  197. }
  198. if (
  199. /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
  200. route.request().method() === "POST"
  201. ) {
  202. return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
  203. }
  204. if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
  205. return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
  206. }
  207. if (path in staticRoutes) return json(route, staticRoutes[path])
  208. const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
  209. if (currentSessionMatch) {
  210. const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
  211. if (!session) return json(route, { error: "Session not found" }, undefined, 404)
  212. return json(route, {
  213. data: currentSession(session, config.directory),
  214. })
  215. }
  216. const sessionMatch = path.match(/^\/session\/([^/]+)$/)
  217. if (sessionMatch) {
  218. const session = config.sessions.find((s) => s.id === sessionMatch[1])
  219. return json(route, session ?? {})
  220. }
  221. const projectMatch = path.match(/^\/project\/([^/]+)$/)
  222. if (projectMatch) return json(route, config.project)
  223. const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
  224. if (messageMatch) {
  225. config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
  226. if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
  227. const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
  228. if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
  229. return json(route, message)
  230. }
  231. const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
  232. if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
  233. if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
  234. const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
  235. if (currentMessagesMatch) {
  236. const token = url.searchParams.get("cursor") ?? undefined
  237. const before = token ? cursors.get(token) : undefined
  238. if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
  239. config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
  240. await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
  241. if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
  242. const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
  243. config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
  244. const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
  245. if (cursor) cursors.set(cursor, pageData.cursor!)
  246. return json(route, {
  247. data: pageData.items.map(currentMessage).reverse(),
  248. cursor: { next: cursor },
  249. })
  250. }
  251. const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
  252. if (messagesMatch) {
  253. const token = url.searchParams.get("before") ?? undefined
  254. const before = token ? cursors.get(token) : undefined
  255. if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
  256. config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
  257. await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
  258. if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
  259. const limit = Number(url.searchParams.get("limit") ?? 80)
  260. const pageData = config.pageMessages(messagesMatch[1], limit, before)
  261. config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
  262. if (!pageData.cursor) return json(route, pageData.items)
  263. const cursor = `cursor_${++nextCursor}`
  264. cursors.set(cursor, pageData.cursor)
  265. return json(route, pageData.items, { "x-next-cursor": cursor })
  266. }
  267. if (url.port === targetPort && targetPort !== appPort) return json(route, {})
  268. return route.fallback()
  269. })
  270. }
  271. function location(config: MockServerConfig) {
  272. return {
  273. directory: config.directory,
  274. project: { id: (config.project as { id?: string }).id, directory: config.directory },
  275. }
  276. }
  277. function currentPermission(value: unknown) {
  278. const permission = value as Record<string, unknown>
  279. if (permission.action) return permission
  280. const tool = permission.tool as { messageID?: string; callID?: string } | undefined
  281. return {
  282. id: permission.id,
  283. sessionID: permission.sessionID,
  284. action: permission.permission,
  285. resources: permission.patterns ?? [],
  286. save: permission.always,
  287. metadata: permission.metadata,
  288. source:
  289. tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
  290. }
  291. }
  292. export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
  293. const time = session.time && typeof session.time === "object" ? session.time : {}
  294. return {
  295. id: session.id,
  296. parentID: session.parentID,
  297. projectID: session.projectID ?? "project",
  298. agent: session.agent ?? "build",
  299. model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
  300. cost: session.cost ?? 0,
  301. tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  302. time: {
  303. created: "created" in time && typeof time.created === "number" ? time.created : 0,
  304. updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
  305. ...(session.time && typeof session.time === "object" && "archived" in session.time
  306. ? { archived: session.time.archived }
  307. : {}),
  308. },
  309. title: session.title ?? session.id,
  310. location: {
  311. directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
  312. ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
  313. },
  314. subpath: session.path,
  315. revert: session.revert,
  316. }
  317. }
  318. function currentMessage(value: unknown) {
  319. const item = value as {
  320. info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
  321. parts: Array<Record<string, unknown> & { type: string }>
  322. }
  323. if (item.info.role === "user") {
  324. return {
  325. id: item.info.id,
  326. type: "user",
  327. time: item.info.time,
  328. text: item.parts
  329. .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
  330. .join("\n"),
  331. }
  332. }
  333. return {
  334. id: item.info.id,
  335. type: "assistant",
  336. time: item.info.time,
  337. agent: item.info.agent ?? "build",
  338. model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
  339. cost: item.info.cost,
  340. tokens: item.info.tokens,
  341. error: item.info.error,
  342. content: item.parts.flatMap<unknown>((part) => {
  343. if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
  344. if (part.type !== "tool") return []
  345. const state = part.state as Record<string, unknown>
  346. return [
  347. {
  348. type: "tool",
  349. id: part.id,
  350. name: part.tool,
  351. time: state.time ?? { created: item.info.time.created },
  352. state:
  353. state.status === "pending"
  354. ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
  355. : state.status === "completed"
  356. ? {
  357. status: "completed",
  358. input: state.input ?? {},
  359. structured: state.metadata ?? {},
  360. content: [{ type: "text", text: state.output ?? "" }],
  361. }
  362. : state.status === "error"
  363. ? {
  364. status: "error",
  365. input: state.input ?? {},
  366. structured: state.metadata ?? {},
  367. content: [],
  368. error: { type: "ToolError", message: state.error ?? "Tool failed" },
  369. }
  370. : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
  371. },
  372. ]
  373. }),
  374. }
  375. }
  376. function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
  377. return route.fulfill({
  378. status,
  379. contentType: "application/json",
  380. headers: {
  381. "access-control-allow-origin": "*",
  382. "access-control-expose-headers": "x-next-cursor",
  383. ...headers,
  384. },
  385. body: JSON.stringify(body ?? null),
  386. })
  387. }
  388. function sse(route: Route, events?: unknown[], retry?: number) {
  389. return route.fulfill({
  390. status: 200,
  391. contentType: "text/event-stream",
  392. body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
  393. })
  394. }
  395. function currentEvent(input: unknown) {
  396. if (!input || typeof input !== "object" || !("payload" in input)) return input
  397. const envelope = input as { directory?: string; payload?: unknown }
  398. if (!envelope.payload || typeof envelope.payload !== "object") return input
  399. const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
  400. if (!payload.type) return input
  401. return {
  402. id: payload.id ?? `evt_mock_${Date.now()}`,
  403. created: Date.now(),
  404. type: payload.type,
  405. data: payload.properties ?? {},
  406. location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
  407. }
  408. }