session-review.spec.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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. test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
  107. test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
  108. test.setTimeout(180_000)
  109. const tag = `review-${Date.now()}`
  110. const list = files(tag)
  111. const hit = list[list.length - 4]!
  112. const next = `${tag}-live`
  113. await page.setViewportSize({ width: 1600, height: 1000 })
  114. await withProject(async (project) => {
  115. const sdk = createSdk(project.directory)
  116. await withSession(sdk, `e2e review ${tag}`, async (session) => {
  117. await patch(sdk, session.id, seed(list))
  118. await expect
  119. .poll(
  120. async () => {
  121. const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
  122. return info?.summary?.files ?? 0
  123. },
  124. { timeout: 60_000 },
  125. )
  126. .toBe(list.length)
  127. await expect
  128. .poll(
  129. async () => {
  130. const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
  131. return diff.length
  132. },
  133. { timeout: 60_000 },
  134. )
  135. .toBe(list.length)
  136. await project.gotoSession(session.id)
  137. await show(page)
  138. const tab = page.getByRole("tab", { name: /Review/i }).first()
  139. await expect(tab).toBeVisible()
  140. await tab.click()
  141. const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
  142. await expect(view).toBeVisible()
  143. const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
  144. await expect(heads).toHaveCount(list.length, {
  145. timeout: 60_000,
  146. })
  147. await expand(page)
  148. await waitMark(page, hit.file, hit.mark)
  149. const row = page
  150. .getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
  151. .first()
  152. await expect(row).toBeVisible()
  153. await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
  154. await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
  155. const prev = await spot(page, hit.file)
  156. if (!prev) throw new Error(`missing review row for ${hit.file}`)
  157. await patch(sdk, session.id, edit(hit.file, hit.mark, next))
  158. await expect
  159. .poll(
  160. async () => {
  161. const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
  162. const item = diff.find((item) => item.file === hit.file)
  163. return typeof item?.after === "string" ? item.after : ""
  164. },
  165. { timeout: 60_000 },
  166. )
  167. .toContain(`mark ${next}`)
  168. await waitMark(page, hit.file, next)
  169. await expect
  170. .poll(
  171. async () => {
  172. const next = await spot(page, hit.file)
  173. if (!next) return Number.POSITIVE_INFINITY
  174. return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
  175. },
  176. { timeout: 60_000 },
  177. )
  178. .toBeLessThanOrEqual(32)
  179. })
  180. })
  181. })