mock-server.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  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. provider: unknown
  6. directory: string
  7. project: unknown
  8. sessions: ({ id: string } & Record<string, unknown>)[]
  9. pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
  10. vcsDiff?: unknown[]
  11. messageDelay?: number
  12. beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
  13. onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
  14. message?: (sessionID: string, messageID: string) => unknown
  15. onMessage?: (input: { sessionID: string; messageID: string }) => void
  16. events?: () => unknown[]
  17. eventRetry?: number
  18. todos?: (sessionID: string) => unknown[]
  19. permissions?: unknown[] | (() => unknown[])
  20. questions?: unknown[] | (() => unknown[])
  21. fileList?: (path: string) => unknown | Promise<unknown>
  22. fileContent?: (path: string) => unknown | Promise<unknown>
  23. findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
  24. sessionStatus?: unknown
  25. }
  26. export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
  27. const cursors = new Map<string, string>()
  28. let nextCursor = 0
  29. const staticRoutes: Record<string, unknown> = {
  30. "/provider": config.provider,
  31. "/path": {
  32. state: config.directory,
  33. config: config.directory,
  34. worktree: config.directory,
  35. directory: config.directory,
  36. home: "C:/OpenCode",
  37. },
  38. "/project": [config.project],
  39. "/project/current": config.project,
  40. "/agent": [{ name: "build", mode: "primary" }],
  41. "/vcs": { branch: "main", default_branch: "main" },
  42. "/session": config.sessions,
  43. }
  44. await page.route("**/*", async (route) => {
  45. const url = new URL(route.request().url())
  46. const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
  47. const appPort = new URL(
  48. process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
  49. ).port
  50. if (url.port !== targetPort && url.port !== appPort) return route.fallback()
  51. const path = url.pathname
  52. if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
  53. if (path === "/global/health") return json(route, { healthy: true })
  54. if (path === "/api/session")
  55. return json(route, {
  56. data: config.sessions.map((session) => v2Session(session, config.directory)),
  57. cursor: {},
  58. })
  59. if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
  60. if (path === "/permission")
  61. return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
  62. if (path === "/question")
  63. return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
  64. if (path === "/session/status") return json(route, config.sessionStatus ?? {})
  65. if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
  66. if (path === "/file" && config.fileList)
  67. return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
  68. if (path === "/file/content" && config.fileContent)
  69. return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
  70. if (path === "/find/file" && config.findFiles)
  71. return json(
  72. route,
  73. await config.findFiles({
  74. query: url.searchParams.get("query") ?? "",
  75. dirs: url.searchParams.get("dirs") ?? undefined,
  76. limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
  77. }),
  78. )
  79. if (path === "/api/reference")
  80. return json(route, {
  81. location: {
  82. directory: config.directory,
  83. project: { id: (config.project as { id?: string }).id, directory: config.directory },
  84. },
  85. data: [],
  86. })
  87. if (emptyObject.has(path)) return json(route, {})
  88. if (emptyList.has(path)) return json(route, [])
  89. if (path in staticRoutes) return json(route, staticRoutes[path])
  90. const sessionMatch = path.match(/^\/session\/([^/]+)$/)
  91. if (sessionMatch) {
  92. const session = config.sessions.find((s) => s.id === sessionMatch[1])
  93. return json(route, session ?? {})
  94. }
  95. const projectMatch = path.match(/^\/project\/([^/]+)$/)
  96. if (projectMatch) return json(route, config.project)
  97. const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
  98. if (messageMatch) {
  99. config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
  100. if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
  101. const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
  102. if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
  103. return json(route, message)
  104. }
  105. const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
  106. if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
  107. if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
  108. const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
  109. if (messagesMatch) {
  110. const token = url.searchParams.get("before") ?? undefined
  111. const before = token ? cursors.get(token) : undefined
  112. if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
  113. config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
  114. await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
  115. if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
  116. const limit = Number(url.searchParams.get("limit") ?? 80)
  117. const pageData = config.pageMessages(messagesMatch[1], limit, before)
  118. config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
  119. if (!pageData.cursor) return json(route, pageData.items)
  120. const cursor = `cursor_${++nextCursor}`
  121. cursors.set(cursor, pageData.cursor)
  122. return json(route, pageData.items, { "x-next-cursor": cursor })
  123. }
  124. if (url.port === targetPort && targetPort !== appPort) return json(route, {})
  125. return route.fallback()
  126. })
  127. }
  128. function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
  129. const time = session.time && typeof session.time === "object" ? session.time : {}
  130. return {
  131. id: session.id,
  132. parentID: session.parentID,
  133. projectID: session.projectID ?? "project",
  134. cost: session.cost ?? 0,
  135. tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  136. time: {
  137. created: "created" in time && typeof time.created === "number" ? time.created : 0,
  138. updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
  139. ...(session.time && typeof session.time === "object" && "archived" in session.time
  140. ? { archived: session.time.archived }
  141. : {}),
  142. },
  143. title: session.title ?? session.id,
  144. location: {
  145. directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
  146. ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
  147. },
  148. ...(typeof session.path === "string" ? { subpath: session.path } : {}),
  149. }
  150. }
  151. function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
  152. return route.fulfill({
  153. status,
  154. contentType: "application/json",
  155. headers: {
  156. "access-control-allow-origin": "*",
  157. "access-control-expose-headers": "x-next-cursor",
  158. ...headers,
  159. },
  160. body: JSON.stringify(body ?? null),
  161. })
  162. }
  163. function sse(route: Route, events?: unknown[], retry?: number) {
  164. return route.fulfill({
  165. status: 200,
  166. contentType: "text/event-stream",
  167. body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
  168. })
  169. }