tab-navigate-mousedown.spec.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  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 server = "http://127.0.0.1:4096"
  5. const sessionA = session("ses_tab_a", "Tab A session")
  6. const sessionB = session("ses_tab_b", "Tab B session")
  7. const sessionC = session("ses_tab_c", "Tab C session")
  8. const unresolvedSessionID = "ses_tab_unresolved"
  9. test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
  10. await mockServer(page)
  11. await page.addInitScript(
  12. ({ server, sessionA, sessionB }) => {
  13. localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
  14. localStorage.setItem(
  15. "opencode.window.browser.dat:tabs",
  16. JSON.stringify([
  17. { type: "session", server, sessionId: sessionA },
  18. { type: "session", server, sessionId: sessionB },
  19. ]),
  20. )
  21. },
  22. { server, sessionA: sessionA.id, sessionB: sessionB.id },
  23. )
  24. const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
  25. const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
  26. await page.goto(hrefA)
  27. await expect(page.getByText(sessionA.title).first()).toBeVisible()
  28. const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`)
  29. await expect(linkB).toBeVisible()
  30. const box = await linkB.boundingBox()
  31. if (!box) throw new Error("tab link has no bounding box")
  32. await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
  33. await page.mouse.down()
  34. // Navigation must happen on mousedown, before the button is released.
  35. await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
  36. await page.mouse.up()
  37. await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
  38. })
  39. test("keyboard navigation follows the visible tab order", async ({ page }) => {
  40. await mockServer(page)
  41. await page.addInitScript(
  42. ({ server, sessionA, unresolved, sessionC }) => {
  43. localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
  44. localStorage.setItem(
  45. "opencode.window.browser.dat:tabs",
  46. JSON.stringify([
  47. { type: "session", server, sessionId: sessionA },
  48. { type: "session", server, sessionId: unresolved },
  49. { type: "session", server, sessionId: sessionC },
  50. ]),
  51. )
  52. },
  53. { server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
  54. )
  55. const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
  56. const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
  57. await page.goto(hrefA)
  58. await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
  59. await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
  60. await page.keyboard.press("Control+Alt+ArrowRight")
  61. await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
  62. })
  63. function session(id: string, title: string) {
  64. return {
  65. id,
  66. slug: id,
  67. projectID: "project-tabs",
  68. directory: "C:/tab-project",
  69. title,
  70. version: "dev",
  71. time: { created: 1, updated: 1 },
  72. }
  73. }
  74. async function mockServer(page: Page) {
  75. const sessions = [sessionA, sessionB, sessionC]
  76. await page.route("**/*", async (route) => {
  77. const url = new URL(route.request().url())
  78. if (url.origin !== server) return route.fallback()
  79. if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
  80. return new Promise(() => {})
  81. if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
  82. return sse(route)
  83. if (url.pathname === "/global/health") return json(route, { healthy: true })
  84. if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
  85. if (url.pathname === "/api/session/active") return json(route, { data: {} })
  86. const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
  87. if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
  88. if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
  89. return json(route, { data: [], cursor: {} })
  90. const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
  91. if (byId) return json(route, byId)
  92. if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
  93. if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
  94. if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
  95. if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
  96. return json(route, [])
  97. if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
  98. if (url.pathname === "/provider")
  99. return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
  100. if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
  101. if (url.pathname === "/project" || url.pathname === "/project/current") {
  102. const project = {
  103. id: sessionA.projectID,
  104. worktree: sessionA.directory,
  105. vcs: "git",
  106. time: { created: 1, updated: 1 },
  107. sandboxes: [],
  108. }
  109. return json(route, url.pathname === "/project" ? [project] : project)
  110. }
  111. if (url.pathname === "/path")
  112. return json(route, {
  113. state: sessionA.directory,
  114. config: sessionA.directory,
  115. worktree: sessionA.directory,
  116. directory: sessionA.directory,
  117. home: sessionA.directory,
  118. })
  119. if (url.pathname === "/api/path")
  120. return json(route, {
  121. state: sessionA.directory,
  122. config: sessionA.directory,
  123. worktree: sessionA.directory,
  124. directory: sessionA.directory,
  125. home: sessionA.directory,
  126. })
  127. if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
  128. if (url.pathname === "/api/vcs")
  129. return json(route, {
  130. location: { directory: sessionA.directory },
  131. data: { branch: "main", defaultBranch: "main" },
  132. })
  133. return json(route, {})
  134. })
  135. }
  136. function json(route: Route, body: unknown, status = 200) {
  137. return route.fulfill({
  138. status,
  139. contentType: "application/json",
  140. headers: { "access-control-allow-origin": "*" },
  141. body: JSON.stringify(body),
  142. })
  143. }
  144. function sse(route: Route) {
  145. return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
  146. }