mock-server.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  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"])
  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. onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
  13. events?: () => unknown[]
  14. eventRetry?: number
  15. todos?: (sessionID: string) => unknown[]
  16. permissions?: unknown[] | (() => unknown[])
  17. questions?: unknown[] | (() => unknown[])
  18. fileList?: (path: string) => unknown | Promise<unknown>
  19. fileContent?: (path: string) => unknown | Promise<unknown>
  20. sessionStatus?: unknown
  21. }
  22. export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
  23. const cursors = new Map<string, string>()
  24. let nextCursor = 0
  25. const staticRoutes: Record<string, unknown> = {
  26. "/provider": config.provider,
  27. "/path": {
  28. state: config.directory,
  29. config: config.directory,
  30. worktree: config.directory,
  31. directory: config.directory,
  32. home: "C:/OpenCode",
  33. },
  34. "/project": [config.project],
  35. "/project/current": config.project,
  36. "/agent": [{ name: "build", mode: "primary" }],
  37. "/vcs": { branch: "main", default_branch: "main" },
  38. "/session": config.sessions,
  39. }
  40. await page.route("**/*", async (route) => {
  41. const url = new URL(route.request().url())
  42. const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
  43. const appPort = new URL(
  44. process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
  45. ).port
  46. if (url.port !== targetPort && url.port !== appPort) return route.fallback()
  47. const path = url.pathname
  48. if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
  49. if (path === "/global/health") return json(route, { healthy: true })
  50. if (path === "/permission")
  51. return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
  52. if (path === "/question")
  53. return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
  54. if (path === "/session/status") return json(route, config.sessionStatus ?? {})
  55. if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
  56. if (path === "/file" && config.fileList)
  57. return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
  58. if (path === "/file/content" && config.fileContent)
  59. return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
  60. if (emptyObject.has(path)) return json(route, {})
  61. if (emptyList.has(path)) return json(route, [])
  62. if (path in staticRoutes) return json(route, staticRoutes[path])
  63. const sessionMatch = path.match(/^\/session\/([^/]+)$/)
  64. if (sessionMatch) {
  65. const session = config.sessions.find((s) => s.id === sessionMatch[1])
  66. return json(route, session ?? {})
  67. }
  68. const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
  69. if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
  70. if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
  71. const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
  72. if (messagesMatch) {
  73. const token = url.searchParams.get("before") ?? undefined
  74. const before = token ? cursors.get(token) : undefined
  75. if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
  76. config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
  77. if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
  78. const limit = Number(url.searchParams.get("limit") ?? 80)
  79. const pageData = config.pageMessages(messagesMatch[1], limit, before)
  80. config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
  81. if (!pageData.cursor) return json(route, pageData.items)
  82. const cursor = `cursor_${++nextCursor}`
  83. cursors.set(cursor, pageData.cursor)
  84. return json(route, pageData.items, { "x-next-cursor": cursor })
  85. }
  86. if (url.port === targetPort && targetPort !== appPort) return json(route, {})
  87. return route.fallback()
  88. })
  89. }
  90. function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
  91. return route.fulfill({
  92. status,
  93. contentType: "application/json",
  94. headers: {
  95. "access-control-allow-origin": "*",
  96. "access-control-expose-headers": "x-next-cursor",
  97. ...headers,
  98. },
  99. body: JSON.stringify(body ?? null),
  100. })
  101. }
  102. function sse(route: Route, events?: unknown[], retry?: number) {
  103. return route.fulfill({
  104. status: 200,
  105. contentType: "text/event-stream",
  106. body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
  107. })
  108. }