review-terminal-stacked.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import { expect, test, type Page } from "@playwright/test"
  2. import { mockOpenCodeServer } from "../utils/mock-server"
  3. import { expectSessionTitle } from "../utils/waits"
  4. const directory = "C:/OpenCode/ReviewTerminalStacked"
  5. const projectID = "proj_review_terminal_stacked"
  6. const sessionID = "ses_review_terminal_stacked"
  7. const title = "Review terminal stacked"
  8. const branchDiffs = [
  9. fileDiff(".github/actions/setup-bun/action.yml", 7),
  10. ...Array.from({ length: 2_739 }, (_, index) =>
  11. fileDiff(
  12. `src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
  13. 100,
  14. false,
  15. ),
  16. ),
  17. ]
  18. test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
  19. test.setTimeout(120_000)
  20. const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
  21. const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
  22. let detailVersion = 1
  23. let detailFailures = 1
  24. await page.setViewportSize({ width: 1400, height: 900 })
  25. await mockOpenCodeServer(page, {
  26. protocol: "v1",
  27. directory,
  28. project: {
  29. id: projectID,
  30. worktree: directory,
  31. vcs: "git",
  32. name: "review-terminal-stacked",
  33. time: { created: 1700000000000, updated: 1700000000000 },
  34. sandboxes: [],
  35. },
  36. provider: {
  37. all: [
  38. {
  39. id: "opencode",
  40. name: "OpenCode",
  41. models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
  42. },
  43. ],
  44. connected: ["opencode"],
  45. default: { providerID: "opencode", modelID: "test" },
  46. },
  47. sessions: [
  48. {
  49. id: sessionID,
  50. slug: "review-terminal-stacked",
  51. projectID,
  52. directory,
  53. title,
  54. version: "dev",
  55. time: { created: 1700000000000, updated: 1700000000000 },
  56. },
  57. ],
  58. sessionStatus: () => sessionStatus,
  59. pageMessages: () => ({ items: [] }),
  60. events: () => events.splice(0, 1),
  61. eventRetry: 16,
  62. })
  63. await page.route(/\/vcs(?:\?.*)?$/, (route) =>
  64. route.fulfill({
  65. status: 200,
  66. contentType: "application/json",
  67. body: JSON.stringify({
  68. branch: "review-pane-performance",
  69. default_branch: "dev",
  70. }),
  71. }),
  72. )
  73. await page.route("**/vcs/diff**", (route) => {
  74. const url = new URL(route.request().url())
  75. const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
  76. const detail = scope?.endsWith("/src/branch/d00027")
  77. if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
  78. return route.fulfill({
  79. status: 200,
  80. contentType: "application/json",
  81. body: JSON.stringify(
  82. url.searchParams.get("mode") === "branch"
  83. ? detail
  84. ? branchDiffs
  85. .filter((diff) => diff.file.startsWith("src/branch/d00027/"))
  86. .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
  87. : branchDiffs
  88. : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
  89. ),
  90. })
  91. })
  92. await page.route("**/pty*", (route) =>
  93. route.fulfill({
  94. status: 200,
  95. contentType: "application/json",
  96. body: JSON.stringify({
  97. location: { directory, project: { id: projectID, directory } },
  98. data: {
  99. id: "pty_review_terminal",
  100. title: "Terminal 1",
  101. command: "cmd.exe",
  102. args: [],
  103. cwd: directory,
  104. status: "running",
  105. pid: 1,
  106. },
  107. }),
  108. }),
  109. )
  110. await page.route("**/pty/pty_review_terminal*", (route) =>
  111. route.fulfill({
  112. status: 200,
  113. contentType: "application/json",
  114. body: JSON.stringify({
  115. location: { directory, project: { id: projectID, directory } },
  116. data: {
  117. id: "pty_review_terminal",
  118. title: "Terminal 1",
  119. command: "cmd.exe",
  120. args: [],
  121. cwd: directory,
  122. status: "running",
  123. pid: 1,
  124. },
  125. }),
  126. }),
  127. )
  128. await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
  129. route.fulfill({
  130. status: 200,
  131. contentType: "application/json",
  132. body: JSON.stringify({
  133. location: { directory, project: { id: projectID, directory } },
  134. data: { ticket: "e2e-ticket", expires_in: 60 },
  135. }),
  136. }),
  137. )
  138. await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
  139. await page.addInitScript(() => {
  140. localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
  141. localStorage.setItem(
  142. "opencode.global.dat:layout",
  143. JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
  144. )
  145. })
  146. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  147. await expectSessionTitle(page, title)
  148. await expect(page.locator("#review-panel")).toBeVisible()
  149. await expectTree(page, 8, "git-0.ts")
  150. await selectMode(page, "Git changes", "Branch changes")
  151. await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
  152. await page.keyboard.press("Control+Backquote")
  153. await expect(page.locator("#terminal-panel")).toBeVisible()
  154. await expectTree(page, 2_773, "action.yml")
  155. await expectStackGeometry(page)
  156. const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
  157. await treeViewport.hover()
  158. await page.mouse.wheel(0, 100_000)
  159. await expect
  160. .poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
  161. .toBeLessThanOrEqual(1)
  162. const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
  163. await expect(lastFile).toBeVisible()
  164. const bottomGap = await lastFile.evaluate((element) => {
  165. const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
  166. return viewport.bottom - element.getBoundingClientRect().bottom
  167. })
  168. expect(bottomGap).toBeGreaterThanOrEqual(0)
  169. expect(bottomGap).toBeLessThanOrEqual(16)
  170. const lazyDiff = page.waitForRequest((request) => {
  171. const url = new URL(request.url())
  172. return (
  173. url.pathname === "/vcs/diff" &&
  174. url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
  175. )
  176. })
  177. await lastFile.click()
  178. await lazyDiff
  179. const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
  180. await expect(preview).toContainText("after-1")
  181. detailVersion = 2
  182. sessionStatus[sessionID] = { type: "busy" }
  183. events.push(statusEvent("busy"))
  184. await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
  185. const refreshedDiff = page.waitForRequest((request) => {
  186. const url = new URL(request.url())
  187. return (
  188. url.pathname === "/vcs/diff" &&
  189. url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
  190. )
  191. })
  192. sessionStatus[sessionID] = { type: "idle" }
  193. events.push(statusEvent("idle"))
  194. await refreshedDiff
  195. await expect(preview).toContainText("after-2")
  196. await selectMode(page, "Branch changes", "Git changes")
  197. await expectTree(page, 8, "git-0.ts")
  198. await page.getByRole("button", { name: "git-0.ts" }).click()
  199. await selectMode(page, "Git changes", "Branch changes")
  200. await expectTree(page, 2_773, "action.yml")
  201. const filter = page.getByRole("searchbox", { name: "Filter files" })
  202. await filter.fill("generated-2738")
  203. await expectTree(page, 1, "generated-2738.ts")
  204. await filter.fill("")
  205. await expectTree(page, 2_773, "action.yml")
  206. await page.getByRole("button", { name: "Toggle file tree" }).click()
  207. await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
  208. await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
  209. await page.getByRole("button", { name: "Toggle file tree" }).click()
  210. await expectTree(page, 2_773, "action.yml")
  211. await page.keyboard.press("Control+Backquote")
  212. await expect(page.locator("#terminal-panel")).toHaveCount(0)
  213. await expectTree(page, 2_773, "action.yml")
  214. await page.keyboard.press("Control+Backquote")
  215. await expect(page.locator("#terminal-panel")).toBeVisible()
  216. await expectTree(page, 2_773, "action.yml")
  217. await page.getByRole("button", { name: "Toggle review" }).click()
  218. await expect(page.locator("#review-panel")).toHaveCount(0)
  219. await page.getByRole("button", { name: "Toggle review" }).click()
  220. await expectTree(page, 2_773, "action.yml")
  221. await page.setViewportSize({ width: 1_000, height: 700 })
  222. await expectTree(page, 2_773, "action.yml")
  223. await expectStackGeometry(page)
  224. await page.setViewportSize({ width: 1_000, height: 120 })
  225. await page.setViewportSize({ width: 1_400, height: 900 })
  226. await expectTree(page, 2_773, "action.yml")
  227. await expectStackGeometry(page)
  228. })
  229. async function selectMode(page: Page, current: string, next: string) {
  230. await page.getByRole("button", { name: current }).click()
  231. const option = page.getByRole("option", { name: next })
  232. await expect(option).toBeVisible()
  233. await option.click()
  234. }
  235. async function expectTree(page: Page, total: number, file: string) {
  236. await expectMountedTree(page, total)
  237. await expect(page.getByRole("button", { name: file })).toBeVisible()
  238. }
  239. async function expectMountedTree(page: Page, total: number) {
  240. const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
  241. await expect(tree).toHaveAttribute("data-total-rows", String(total))
  242. await expect
  243. .poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
  244. .toBeGreaterThan(0)
  245. const state = await tree.evaluate((element) => ({
  246. root: element.getBoundingClientRect().height,
  247. viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
  248. rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
  249. }))
  250. expect(state.viewport).toBeGreaterThan(0)
  251. expect(state.root).toBeGreaterThan(0)
  252. expect(state.rows).toBeGreaterThan(0)
  253. expect(state.rows).toBeLessThanOrEqual(60)
  254. }
  255. async function expectStackGeometry(page: Page) {
  256. const geometry = await page.evaluate(() => {
  257. const review = document.querySelector<HTMLElement>("#review-panel")!
  258. const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
  259. const reviewParent = review.parentElement!.getBoundingClientRect()
  260. const terminalParent = terminal.parentElement!.getBoundingClientRect()
  261. return {
  262. review: review.getBoundingClientRect().height,
  263. reviewParent: reviewParent.height,
  264. terminal: terminal.getBoundingClientRect().height,
  265. terminalParent: terminalParent.height,
  266. }
  267. })
  268. expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
  269. expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
  270. }
  271. function base64Encode(value: string) {
  272. return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
  273. }
  274. function statusEvent(type: "busy" | "idle") {
  275. return {
  276. directory,
  277. payload: { type: "session.status", properties: { sessionID, status: { type } } },
  278. }
  279. }
  280. function fileDiff(file: string, additions: number, loaded = true, version = 1) {
  281. return {
  282. file,
  283. additions,
  284. deletions: 0,
  285. status: "modified",
  286. patch: loaded
  287. ? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
  288. : `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
  289. }
  290. }