review-terminal-stacked.spec.ts 10 KB

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