session-review.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. import { waitSessionIdle, withSession } from "../actions"
  2. import { test, expect } from "../fixtures"
  3. import { createSdk } from "../utils"
  4. const count = 14
  5. function body(mark: string) {
  6. return [
  7. `title ${mark}`,
  8. `mark ${mark}`,
  9. ...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
  10. ]
  11. }
  12. function files(tag: string) {
  13. return Array.from({ length: count }, (_, i) => {
  14. const id = String(i).padStart(2, "0")
  15. return {
  16. file: `review-scroll-${id}.txt`,
  17. mark: `${tag}-${id}`,
  18. }
  19. })
  20. }
  21. function seed(list: ReturnType<typeof files>) {
  22. const out = ["*** Begin Patch"]
  23. for (const item of list) {
  24. out.push(`*** Add File: ${item.file}`)
  25. for (const line of body(item.mark)) out.push(`+${line}`)
  26. }
  27. out.push("*** End Patch")
  28. return out.join("\n")
  29. }
  30. function edit(file: string, prev: string, next: string) {
  31. return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
  32. "\n",
  33. )
  34. }
  35. async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
  36. await sdk.session.promptAsync({
  37. sessionID,
  38. agent: "build",
  39. system: [
  40. "You are seeding deterministic e2e UI state.",
  41. "Your only valid response is one apply_patch tool call.",
  42. `Use this JSON input: ${JSON.stringify({ patchText })}`,
  43. "Do not call any other tools.",
  44. "Do not output plain text.",
  45. ].join("\n"),
  46. parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
  47. })
  48. await waitSessionIdle(sdk, sessionID, 120_000)
  49. }
  50. async function show(page: Parameters<typeof test>[0]["page"]) {
  51. const btn = page.getByRole("button", { name: "Toggle review" }).first()
  52. await expect(btn).toBeVisible()
  53. if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
  54. await expect(btn).toHaveAttribute("aria-expanded", "true")
  55. }
  56. async function expand(page: Parameters<typeof test>[0]["page"]) {
  57. const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
  58. const open = await close
  59. .isVisible()
  60. .then((value) => value)
  61. .catch(() => false)
  62. const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
  63. if (open) {
  64. await close.click()
  65. await expect(btn).toBeVisible()
  66. }
  67. await expect(btn).toBeVisible()
  68. await btn.click()
  69. await expect(close).toBeVisible()
  70. }
  71. async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
  72. await page.waitForFunction(
  73. ({ file, mark }) => {
  74. const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
  75. if (!(view instanceof HTMLElement)) return false
  76. const head = Array.from(view.querySelectorAll("h3")).find(
  77. (node) => node instanceof HTMLElement && node.textContent?.includes(file),
  78. )
  79. if (!(head instanceof HTMLElement)) return false
  80. return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
  81. if (!(host instanceof HTMLElement)) return false
  82. const root = host.shadowRoot
  83. return root?.textContent?.includes(`mark ${mark}`) ?? false
  84. })
  85. },
  86. { file, mark },
  87. { timeout: 60_000 },
  88. )
  89. }
  90. async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
  91. return page.evaluate((file) => {
  92. const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
  93. if (!(view instanceof HTMLElement)) return null
  94. const row = Array.from(view.querySelectorAll("h3")).find(
  95. (node) => node instanceof HTMLElement && node.textContent?.includes(file),
  96. )
  97. if (!(row instanceof HTMLElement)) return null
  98. const a = row.getBoundingClientRect()
  99. const b = view.getBoundingClientRect()
  100. return {
  101. top: a.top - b.top,
  102. y: view.scrollTop,
  103. }
  104. }, file)
  105. }
  106. async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
  107. const row = page.locator(`[data-file="${file}"]`).first()
  108. await expect(row).toBeVisible()
  109. const line = row.locator('diffs-container [data-line="2"]').first()
  110. await expect(line).toBeVisible()
  111. await line.hover()
  112. const add = row.getByRole("button", { name: /^Comment$/ }).first()
  113. await expect(add).toBeVisible()
  114. await add.click()
  115. const area = row.locator('[data-slot="line-comment-textarea"]').first()
  116. await expect(area).toBeVisible()
  117. await area.fill(note)
  118. const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
  119. await expect(submit).toBeEnabled()
  120. await submit.click()
  121. await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
  122. await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
  123. }
  124. async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
  125. const row = page.locator(`[data-file="${file}"]`).first()
  126. const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
  127. const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
  128. const tools = row.locator('[data-slot="line-comment-tools"]').first()
  129. const [width, viewBox, popBox, toolsBox] = await Promise.all([
  130. view.evaluate((el) => el.scrollWidth - el.clientWidth),
  131. view.boundingBox(),
  132. pop.boundingBox(),
  133. tools.boundingBox(),
  134. ])
  135. if (!viewBox || !popBox || !toolsBox) return null
  136. return {
  137. width,
  138. pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
  139. tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
  140. }
  141. }
  142. async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
  143. const row = page.locator(`[data-file="${file}"]`).first()
  144. await expect(row).toBeVisible()
  145. await row.hover()
  146. const open = row.getByRole("button", { name: /^Open file$/i }).first()
  147. await expect(open).toBeVisible()
  148. await open.click()
  149. const tab = page.getByRole("tab", { name: file }).first()
  150. await expect(tab).toBeVisible()
  151. await tab.click()
  152. const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
  153. await expect(viewer).toBeVisible()
  154. return viewer
  155. }
  156. async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
  157. const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
  158. await expect(viewer).toBeVisible()
  159. const line = viewer.locator('diffs-container [data-line="2"]').first()
  160. await expect(line).toBeVisible()
  161. await line.hover()
  162. const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
  163. await expect(add).toBeVisible()
  164. await add.click()
  165. const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
  166. await expect(area).toBeVisible()
  167. await area.fill(note)
  168. const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
  169. await expect(submit).toBeEnabled()
  170. await submit.click()
  171. await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
  172. await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
  173. }
  174. async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
  175. const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
  176. const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
  177. const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
  178. const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
  179. const [width, viewBox, popBox, toolsBox] = await Promise.all([
  180. view.evaluate((el) => el.scrollWidth - el.clientWidth),
  181. view.boundingBox(),
  182. pop.boundingBox(),
  183. tools.boundingBox(),
  184. ])
  185. if (!viewBox || !popBox || !toolsBox) return null
  186. return {
  187. width,
  188. pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
  189. tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
  190. }
  191. }
  192. test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
  193. test.setTimeout(180_000)
  194. const tag = `review-comment-${Date.now()}`
  195. const file = `review-comment-${tag}.txt`
  196. const note = `comment ${tag}`
  197. await page.setViewportSize({ width: 1280, height: 900 })
  198. await withProject(async (project) => {
  199. const sdk = createSdk(project.directory)
  200. await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
  201. await patch(sdk, session.id, seed([{ file, mark: tag }]))
  202. await expect
  203. .poll(
  204. async () => {
  205. const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
  206. return diff.length
  207. },
  208. { timeout: 60_000 },
  209. )
  210. .toBe(1)
  211. await project.gotoSession(session.id)
  212. await show(page)
  213. const tab = page.getByRole("tab", { name: /Review/i }).first()
  214. await expect(tab).toBeVisible()
  215. await tab.click()
  216. await expand(page)
  217. await waitMark(page, file, tag)
  218. await comment(page, file, note)
  219. await expect
  220. .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
  221. .toBeLessThanOrEqual(1)
  222. await expect
  223. .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
  224. .toBeLessThanOrEqual(1)
  225. await expect
  226. .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
  227. .toBeLessThanOrEqual(1)
  228. })
  229. })
  230. })
  231. test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
  232. test.setTimeout(180_000)
  233. const tag = `review-file-comment-${Date.now()}`
  234. const file = `review-file-comment-${tag}.txt`
  235. const note = `comment ${tag}`
  236. await page.setViewportSize({ width: 1280, height: 900 })
  237. await withProject(async (project) => {
  238. const sdk = createSdk(project.directory)
  239. await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
  240. await patch(sdk, session.id, seed([{ file, mark: tag }]))
  241. await expect
  242. .poll(
  243. async () => {
  244. const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
  245. return diff.length
  246. },
  247. { timeout: 60_000 },
  248. )
  249. .toBe(1)
  250. await project.gotoSession(session.id)
  251. await show(page)
  252. const tab = page.getByRole("tab", { name: /Review/i }).first()
  253. await expect(tab).toBeVisible()
  254. await tab.click()
  255. await expand(page)
  256. await waitMark(page, file, tag)
  257. await openReviewFile(page, file)
  258. await fileComment(page, note)
  259. await expect
  260. .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
  261. .toBeLessThanOrEqual(1)
  262. await expect
  263. .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
  264. .toBeLessThanOrEqual(1)
  265. await expect
  266. .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
  267. .toBeLessThanOrEqual(1)
  268. })
  269. })
  270. })
  271. test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
  272. test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
  273. test.setTimeout(180_000)
  274. const tag = `review-${Date.now()}`
  275. const list = files(tag)
  276. const hit = list[list.length - 4]!
  277. const next = `${tag}-live`
  278. await page.setViewportSize({ width: 1600, height: 1000 })
  279. await withProject(async (project) => {
  280. const sdk = createSdk(project.directory)
  281. await withSession(sdk, `e2e review ${tag}`, async (session) => {
  282. await patch(sdk, session.id, seed(list))
  283. await expect
  284. .poll(
  285. async () => {
  286. const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
  287. return info?.summary?.files ?? 0
  288. },
  289. { timeout: 60_000 },
  290. )
  291. .toBe(list.length)
  292. await expect
  293. .poll(
  294. async () => {
  295. const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
  296. return diff.length
  297. },
  298. { timeout: 60_000 },
  299. )
  300. .toBe(list.length)
  301. await project.gotoSession(session.id)
  302. await show(page)
  303. const tab = page.getByRole("tab", { name: /Review/i }).first()
  304. await expect(tab).toBeVisible()
  305. await tab.click()
  306. const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
  307. await expect(view).toBeVisible()
  308. const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
  309. await expect(heads).toHaveCount(list.length, {
  310. timeout: 60_000,
  311. })
  312. await expand(page)
  313. await waitMark(page, hit.file, hit.mark)
  314. const row = page
  315. .getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
  316. .first()
  317. await expect(row).toBeVisible()
  318. await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
  319. await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
  320. const prev = await spot(page, hit.file)
  321. if (!prev) throw new Error(`missing review row for ${hit.file}`)
  322. await patch(sdk, session.id, edit(hit.file, hit.mark, next))
  323. await expect
  324. .poll(
  325. async () => {
  326. const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
  327. const item = diff.find((item) => item.file === hit.file)
  328. return typeof item?.after === "string" ? item.after : ""
  329. },
  330. { timeout: 60_000 },
  331. )
  332. .toContain(`mark ${next}`)
  333. await waitMark(page, hit.file, next)
  334. await expect
  335. .poll(
  336. async () => {
  337. const next = await spot(page, hit.file)
  338. if (!next) return Number.POSITIVE_INFINITY
  339. return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
  340. },
  341. { timeout: 60_000 },
  342. )
  343. .toBeLessThanOrEqual(32)
  344. })
  345. })
  346. })