terminal-tab-switch.spec.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import { base64Encode } from "@opencode-ai/core/util/encode"
  2. import { expect, test, type Page } from "@playwright/test"
  3. import { mockOpenCodeServer } from "../utils/mock-server"
  4. import { expectSessionTitle } from "../utils/waits"
  5. const directory = "C:/OpenCode/TerminalTabSwitch"
  6. const projectID = "proj_terminal_tab_switch"
  7. const sessionA = "ses_terminal_tab_a"
  8. const sessionB = "ses_terminal_tab_b"
  9. const titleA = "Alpha session"
  10. const titleB = "Beta session"
  11. const ptyID = "pty_tab_switch"
  12. const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
  13. // Marks the terminal DOM node so a remount (fresh node) is detectable.
  14. const PROBE = "original"
  15. test.use({ viewport: { width: 1440, height: 900 } })
  16. // Terminals are workspace-scoped: switching between session tabs in the same
  17. // workspace must keep the terminal mounted and its PTY connection open instead
  18. // of tearing it down and reconnecting.
  19. test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
  20. const connections = await setup(page)
  21. await page.goto(sessionHref(sessionA))
  22. await expectSessionTitle(page, titleA)
  23. await page.keyboard.press("Control+Backquote")
  24. const terminal = page.locator('[data-component="terminal"]')
  25. await expect(terminal).toBeVisible()
  26. await expect.poll(() => connections.length).toBe(1)
  27. const connection = new URL(connections[0]!)
  28. expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
  29. expect(connection.searchParams.get("location[directory]")).toBe(directory)
  30. expect(connection.searchParams.get("ticket")).toBeNull()
  31. await writeProbe(page)
  32. await switchTab(page, titleB)
  33. await expectSessionTitle(page, titleB)
  34. await expect(terminal).toBeVisible()
  35. expect(await readProbe(page)).toBe(PROBE)
  36. expect(connections.length).toBe(1)
  37. await switchTab(page, titleA)
  38. await expectSessionTitle(page, titleA)
  39. await expect(terminal).toBeVisible()
  40. expect(await readProbe(page)).toBe(PROBE)
  41. expect(connections.length).toBe(1)
  42. })
  43. type Probed = HTMLElement & { __e2eProbe?: string }
  44. async function switchTab(page: Page, title: string) {
  45. await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
  46. }
  47. async function writeProbe(page: Page) {
  48. await page.locator('[data-component="terminal"]').evaluate((el, probe) => {
  49. ;(el as Probed).__e2eProbe = probe
  50. }, PROBE)
  51. }
  52. async function readProbe(page: Page) {
  53. return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe)
  54. }
  55. async function setup(page: Page) {
  56. await mockOpenCodeServer(page, {
  57. protocol: "v2",
  58. directory,
  59. project: {
  60. id: projectID,
  61. worktree: directory,
  62. vcs: "git",
  63. name: "terminal-tab-switch",
  64. time: { created: 1700000000000, updated: 1700000000000 },
  65. sandboxes: [],
  66. },
  67. provider: {
  68. all: [
  69. {
  70. id: "opencode",
  71. name: "OpenCode",
  72. models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
  73. },
  74. ],
  75. connected: ["opencode"],
  76. default: { providerID: "opencode", modelID: "test" },
  77. },
  78. sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
  79. pageMessages: () => ({ items: [] }),
  80. })
  81. await page.route("**/api/pty*", (route) =>
  82. route.fulfill({
  83. status: 200,
  84. contentType: "application/json",
  85. body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
  86. }),
  87. )
  88. await page.route(`**/api/pty/${ptyID}*`, (route) =>
  89. route.fulfill({
  90. status: 200,
  91. contentType: "application/json",
  92. body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
  93. }),
  94. )
  95. await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
  96. expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
  97. const url = new URL(route.request().url())
  98. expect(url.searchParams.get("location[directory]")).toBe(directory)
  99. return route.fulfill({
  100. status: 200,
  101. contentType: "application/json",
  102. headers: { "access-control-allow-origin": "*" },
  103. body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
  104. })
  105. })
  106. const connections: string[] = []
  107. await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
  108. connections.push(ws.url())
  109. })
  110. await page.addInitScript(
  111. ({ directory, server, sessions }) => {
  112. localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
  113. localStorage.setItem(
  114. "opencode.global.dat:server",
  115. JSON.stringify({
  116. projects: { local: [{ worktree: directory, expanded: true }] },
  117. lastProject: { local: directory },
  118. }),
  119. )
  120. localStorage.setItem(
  121. "opencode.window.browser.dat:tabs",
  122. JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
  123. )
  124. },
  125. { directory, server, sessions: [sessionA, sessionB] },
  126. )
  127. return connections
  128. }
  129. function session(id: string, title: string, created: number) {
  130. return {
  131. id,
  132. slug: id,
  133. projectID,
  134. directory,
  135. title,
  136. version: "dev",
  137. time: { created, updated: created },
  138. }
  139. }
  140. function sessionHref(sessionID: string) {
  141. return `/server/${base64Encode(server)}/session/${sessionID}`
  142. }
  143. function ptyLocation() {
  144. return { directory, project: { id: projectID, directory } }
  145. }
  146. function ptyInfo() {
  147. return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
  148. }