session-review.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  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. test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
  143. test.setTimeout(180_000)
  144. const tag = `review-comment-${Date.now()}`
  145. const file = `review-comment-${tag}.txt`
  146. const note = `comment ${tag}`
  147. await page.setViewportSize({ width: 1280, height: 900 })
  148. await withProject(async (project) => {
  149. const sdk = createSdk(project.directory)
  150. await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
  151. await patch(sdk, session.id, seed([{ file, mark: tag }]))
  152. await expect
  153. .poll(
  154. async () => {
  155. const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
  156. return diff.length
  157. },
  158. { timeout: 60_000 },
  159. )
  160. .toBe(1)
  161. await project.gotoSession(session.id)
  162. await show(page)
  163. const tab = page.getByRole("tab", { name: /Review/i }).first()
  164. await expect(tab).toBeVisible()
  165. await tab.click()
  166. await expand(page)
  167. await waitMark(page, file, tag)
  168. await comment(page, file, note)
  169. await expect
  170. .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
  171. .toBeLessThanOrEqual(1)
  172. await expect
  173. .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
  174. .toBeLessThanOrEqual(1)
  175. await expect
  176. .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
  177. .toBeLessThanOrEqual(1)
  178. })
  179. })
  180. })
  181. test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
  182. test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
  183. test.setTimeout(180_000)
  184. const tag = `review-${Date.now()}`
  185. const list = files(tag)
  186. const hit = list[list.length - 4]!
  187. const next = `${tag}-live`
  188. await page.setViewportSize({ width: 1600, height: 1000 })
  189. await withProject(async (project) => {
  190. const sdk = createSdk(project.directory)
  191. await withSession(sdk, `e2e review ${tag}`, async (session) => {
  192. await patch(sdk, session.id, seed(list))
  193. await expect
  194. .poll(
  195. async () => {
  196. const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
  197. return info?.summary?.files ?? 0
  198. },
  199. { timeout: 60_000 },
  200. )
  201. .toBe(list.length)
  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(list.length)
  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. const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
  217. await expect(view).toBeVisible()
  218. const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
  219. await expect(heads).toHaveCount(list.length, {
  220. timeout: 60_000,
  221. })
  222. await expand(page)
  223. await waitMark(page, hit.file, hit.mark)
  224. const row = page
  225. .getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
  226. .first()
  227. await expect(row).toBeVisible()
  228. await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
  229. await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
  230. const prev = await spot(page, hit.file)
  231. if (!prev) throw new Error(`missing review row for ${hit.file}`)
  232. await patch(sdk, session.id, edit(hit.file, hit.mark, next))
  233. await expect
  234. .poll(
  235. async () => {
  236. const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
  237. const item = diff.find((item) => item.file === hit.file)
  238. return typeof item?.after === "string" ? item.after : ""
  239. },
  240. { timeout: 60_000 },
  241. )
  242. .toContain(`mark ${next}`)
  243. await waitMark(page, hit.file, next)
  244. await expect
  245. .poll(
  246. async () => {
  247. const next = await spot(page, hit.file)
  248. if (!next) return Number.POSITIVE_INFINITY
  249. return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
  250. },
  251. { timeout: 60_000 },
  252. )
  253. .toBeLessThanOrEqual(32)
  254. })
  255. })
  256. })