cross-server-tab-close.spec.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. import { expect, test, type Page, type Route } from "@playwright/test"
  2. import { base64Encode } from "@opencode-ai/core/util/encode"
  3. import { currentSession } from "../utils/mock-server"
  4. const serverA = "http://127.0.0.1:4096"
  5. const serverB = "http://127.0.0.1:4097"
  6. const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
  7. const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
  8. test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
  9. const requests: string[] = []
  10. await mockServers(page, requests)
  11. await page.addInitScript(
  12. ({ serverB, sessionA, sessionB }) => {
  13. localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
  14. localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
  15. localStorage.setItem(
  16. "opencode.window.browser.dat:tabs",
  17. JSON.stringify([
  18. { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
  19. { type: "session", server: serverB, sessionId: sessionB },
  20. ]),
  21. )
  22. },
  23. { serverB, sessionA: sessionA.id, sessionB: sessionB.id },
  24. )
  25. const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
  26. const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
  27. await page.goto(hrefA)
  28. await expect(page.getByText(sessionA.title).first()).toBeVisible()
  29. const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
  30. await tabA.locator('[data-slot="tab-close"] button').click()
  31. await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
  32. await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
  33. await expect(page.getByText(sessionB.title).first()).toBeVisible()
  34. const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
  35. expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
  36. expect(
  37. requests.some((request) => {
  38. const url = new URL(request)
  39. return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
  40. }),
  41. ).toBe(true)
  42. })
  43. test("legacy session routes preserve an existing tab's server", async ({ page }) => {
  44. await mockServers(page, [])
  45. await page.addInitScript(
  46. ({ serverB, sessionB }) => {
  47. localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
  48. localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
  49. localStorage.setItem(
  50. "opencode.window.browser.dat:tabs",
  51. JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
  52. )
  53. },
  54. { serverB, sessionB: sessionB.id },
  55. )
  56. const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
  57. await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
  58. await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
  59. })
  60. function session(id: string, directory: string, title: string) {
  61. return {
  62. id,
  63. slug: id,
  64. projectID: `project-${id}`,
  65. directory,
  66. title,
  67. version: "dev",
  68. time: { created: 1, updated: 1 },
  69. }
  70. }
  71. async function mockServers(page: Page, requests: string[]) {
  72. await page.route("**/*", async (route) => {
  73. const url = new URL(route.request().url())
  74. if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
  75. requests.push(url.toString())
  76. const current = url.origin === serverA ? sessionA : sessionB
  77. const directory = url.searchParams.get("directory")
  78. if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
  79. if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
  80. return sse(route)
  81. if (url.pathname === "/global/health") return json(route, {}, 404)
  82. if (url.pathname === "/api/health") return json(route, { pid: 1 })
  83. if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
  84. if (url.pathname === "/api/session/active") return json(route, { data: {} })
  85. if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
  86. if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
  87. if (url.pathname === `/session/${current.id}`) return json(route, current)
  88. if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
  89. if (url.pathname === `/session/${current.id}/message`) return json(route, [])
  90. if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
  91. if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
  92. return json(route, [])
  93. if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
  94. if (url.pathname === "/provider")
  95. return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
  96. if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
  97. if (url.pathname === "/project" || url.pathname === "/project/current") {
  98. const project = {
  99. id: current.projectID,
  100. worktree: current.directory,
  101. vcs: "git",
  102. time: { created: 1, updated: 1 },
  103. sandboxes: [],
  104. }
  105. return json(route, url.pathname === "/project" ? [project] : project)
  106. }
  107. if (url.pathname === "/path")
  108. return json(route, {
  109. state: current.directory,
  110. config: current.directory,
  111. worktree: current.directory,
  112. directory: current.directory,
  113. home: current.directory,
  114. })
  115. if (url.pathname === "/api/path")
  116. return json(route, {
  117. state: current.directory,
  118. config: current.directory,
  119. worktree: current.directory,
  120. directory: current.directory,
  121. home: current.directory,
  122. })
  123. if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
  124. if (url.pathname === "/api/vcs")
  125. return json(route, {
  126. location: { directory: current.directory },
  127. data: { branch: "main", defaultBranch: "main" },
  128. })
  129. return json(route, {})
  130. })
  131. }
  132. function json(route: Route, body: unknown, status = 200) {
  133. return route.fulfill({
  134. status,
  135. contentType: "application/json",
  136. headers: { "access-control-allow-origin": "*" },
  137. body: JSON.stringify(body),
  138. })
  139. }
  140. function sse(route: Route) {
  141. return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
  142. }