session-review.spec.ts 14 KB

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