terminal-composer-focus.spec.ts 8.0 KB

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