terminal-composer-focus.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  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/TerminalComposerFocus"
  6. const projectID = "proj_terminal_composer_focus"
  7. const sessionID = "ses_terminal_composer_focus"
  8. const ptyID = "pty_terminal_composer_focus"
  9. const newPtyID = "pty_terminal_composer_focus_new"
  10. test.use({ viewport: { width: 1440, height: 900 } })
  11. test.beforeEach(async ({ page }) => {
  12. await mockOpenCodeServer(page, {
  13. directory,
  14. project: {
  15. id: projectID,
  16. worktree: directory,
  17. vcs: "git",
  18. name: "terminal-composer-focus",
  19. time: { created: 1700000000000, updated: 1700000000000 },
  20. sandboxes: [],
  21. },
  22. provider: {
  23. all: [
  24. {
  25. id: "opencode",
  26. name: "OpenCode",
  27. models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
  28. },
  29. ],
  30. connected: ["opencode"],
  31. default: { providerID: "opencode", modelID: "test" },
  32. },
  33. sessions: [
  34. {
  35. id: sessionID,
  36. slug: "terminal-composer-focus",
  37. projectID,
  38. directory,
  39. title: "Terminal composer focus",
  40. version: "dev",
  41. time: { created: 1700000000000, updated: 1700000000000 },
  42. },
  43. ],
  44. pageMessages: () => ({ items: [] }),
  45. })
  46. await page.route("**/pty", (route) =>
  47. route.fulfill({
  48. status: 200,
  49. contentType: "application/json",
  50. body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
  51. }),
  52. )
  53. await page.route(`**/pty/${ptyID}`, (route) =>
  54. route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
  55. )
  56. await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
  57. route.fulfill({
  58. status: 200,
  59. contentType: "application/json",
  60. headers: { "access-control-allow-origin": "*" },
  61. body: JSON.stringify({ ticket: "e2e-ticket" }),
  62. }),
  63. )
  64. await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
  65. await page.addInitScript(() => {
  66. localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
  67. })
  68. })
  69. test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
  70. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  71. await expectSessionTitle(page, "Terminal composer focus")
  72. const composer = page.locator('[data-component="prompt-input"]')
  73. const terminal = page.locator('[data-component="terminal"]')
  74. await page.keyboard.press("Control+Backquote")
  75. await expect(terminal).toBeVisible()
  76. await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
  77. await page.keyboard.type("x")
  78. await expect(composer).toHaveText("")
  79. await page.waitForTimeout(300)
  80. await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
  81. await page.keyboard.type("a")
  82. await expect(composer).toBeFocused()
  83. await expect(composer).toHaveText("a")
  84. })
  85. test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
  86. const ghostty = Promise.withResolvers<void>()
  87. const release = Promise.withResolvers<void>()
  88. const created = { count: 0 }
  89. await page.route("**/pty", (route) => {
  90. created.count += 1
  91. return route.fulfill({
  92. status: 200,
  93. contentType: "application/json",
  94. body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
  95. })
  96. })
  97. await page.route(/ghostty-web/, async (route) => {
  98. ghostty.resolve()
  99. await release.promise
  100. await route.continue()
  101. })
  102. await seedCachedTerminal(page)
  103. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
  104. await expectSessionTitle(page, "Terminal composer focus")
  105. const composer = page.locator('[data-component="prompt-input"]')
  106. const terminal = page.locator('[data-component="terminal"]')
  107. await expect(terminal).toBeVisible()
  108. expect(created.count).toBe(0)
  109. await ghostty.promise
  110. await composer.click()
  111. await expect(composer).toBeFocused()
  112. release.resolve()
  113. await expect(terminal.locator("textarea")).toHaveCount(1)
  114. await page.waitForTimeout(300)
  115. await expect(composer).toBeFocused()
  116. })
  117. test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
  118. const ghostty = Promise.withResolvers<void>()
  119. const release = Promise.withResolvers<void>()
  120. await page.route(/ghostty-web/, async (route) => {
  121. ghostty.resolve()
  122. await release.promise
  123. await route.continue()
  124. })
  125. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  126. await expectSessionTitle(page, "Terminal composer focus")
  127. const composer = page.locator('[data-component="prompt-input"]')
  128. const terminal = page.locator('[data-component="terminal"]')
  129. await page.keyboard.press("Control+Backquote")
  130. await expect(terminal).toBeVisible()
  131. await ghostty.promise
  132. await composer.click()
  133. await expect(composer).toBeFocused()
  134. release.resolve()
  135. await expect(terminal.locator("textarea")).toHaveCount(1)
  136. await page.waitForTimeout(50)
  137. await expect(composer).toBeFocused()
  138. })
  139. test("focuses a terminal created from the new-terminal button", async ({ page }) => {
  140. const created = { count: 0 }
  141. await page.route("**/pty", (route) => {
  142. created.count += 1
  143. const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
  144. return route.fulfill({
  145. status: 200,
  146. contentType: "application/json",
  147. body: JSON.stringify(next),
  148. })
  149. })
  150. await page.route(`**/pty/${newPtyID}`, (route) =>
  151. route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
  152. )
  153. await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
  154. route.fulfill({
  155. status: 200,
  156. contentType: "application/json",
  157. headers: { "access-control-allow-origin": "*" },
  158. body: JSON.stringify({ ticket: "e2e-ticket" }),
  159. }),
  160. )
  161. await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
  162. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  163. await expectSessionTitle(page, "Terminal composer focus")
  164. const composer = page.locator('[data-component="prompt-input"]')
  165. const terminal = page.locator('[data-component="terminal"]')
  166. await page.keyboard.press("Control+Backquote")
  167. await expect(terminal.locator("textarea")).toHaveCount(1)
  168. await composer.click()
  169. await expect(composer).toBeFocused()
  170. await page.getByRole("button", { name: "New terminal" }).click()
  171. await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
  172. await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
  173. })
  174. function seedCachedTerminal(page: Page) {
  175. return page.addInitScript(
  176. ({ terminalKey, ptyID }) => {
  177. localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
  178. localStorage.setItem(
  179. terminalKey,
  180. JSON.stringify({
  181. active: ptyID,
  182. all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
  183. }),
  184. )
  185. },
  186. { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
  187. )
  188. }