scroll-interaction.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import { expect, test } from "@playwright/test"
  2. import {
  3. defineVisualRegions,
  4. reportVisualStability,
  5. startVisualProbe,
  6. stopVisualProbe,
  7. visualPlan,
  8. } from "../../utils/visual-stability"
  9. import {
  10. assistantMessage,
  11. partUpdated,
  12. setupTimeline,
  13. shell,
  14. textPart,
  15. userMessage,
  16. type TimelineMessage,
  17. } from "./fixture"
  18. test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
  19. const shellID = "prt_wheel_01_shell"
  20. const followingID = "prt_wheel_02_following"
  21. const timeline = await setupTimeline(page, {
  22. messages: [
  23. ...history(12),
  24. userMessage(),
  25. assistantMessage([shell(shellID, "running"), textPart(followingID, "Following wheel interaction")], {
  26. completed: false,
  27. }),
  28. ],
  29. settings: { shellToolPartsExpanded: true },
  30. cpuRate: 4,
  31. reducedMotion: true,
  32. seedHistory: true,
  33. })
  34. const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  35. const regions = defineVisualRegions({
  36. shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  37. following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  38. })
  39. await startVisualProbe(page, regions)
  40. await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
  41. await scroller.evaluate((element) =>
  42. element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -180 })),
  43. )
  44. await scroller.evaluate((element) => (element.scrollTop -= 180))
  45. await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 250)
  46. const trace = await stopVisualProbe<keyof typeof regions>(page)
  47. await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
  48. })
  49. test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
  50. await setupTimeline(page, {
  51. messages: history(80),
  52. viewport: { width: 1400, height: 700 },
  53. reducedMotion: true,
  54. })
  55. const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  56. const text = page.getByText("History 79.", { exact: false })
  57. await expect(text).toBeVisible()
  58. await scroller.evaluate((element) => {
  59. element.dataset.selectionLength = "0"
  60. document.addEventListener("selectionchange", () => {
  61. element.dataset.selectionLength = String(
  62. Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
  63. )
  64. })
  65. })
  66. const textBox = await text.boundingBox()
  67. const scrollBox = await scroller.boundingBox()
  68. expect(textBox).not.toBeNull()
  69. expect(scrollBox).not.toBeNull()
  70. if (!textBox || !scrollBox) return
  71. await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
  72. await page.mouse.down()
  73. await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
  74. await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
  75. await expect
  76. .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
  77. .toBeGreaterThan(500)
  78. await page.mouse.up()
  79. })
  80. test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
  81. const shellID = "prt_keyboard_01_shell"
  82. const followingID = "prt_keyboard_02_following"
  83. const timeline = await setupTimeline(page, {
  84. messages: [
  85. ...history(12),
  86. userMessage(),
  87. assistantMessage([shell(shellID, "running"), textPart(followingID, "Following keyboard interaction")], {
  88. completed: false,
  89. }),
  90. ],
  91. settings: { shellToolPartsExpanded: true },
  92. cpuRate: 4,
  93. })
  94. const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  95. await scroller.focus()
  96. for (let index = 0; index < 3; index++) {
  97. await scroller.press("PageUp")
  98. await page.waitForTimeout(250)
  99. }
  100. await expect
  101. .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), {
  102. timeout: 20_000,
  103. })
  104. .toBeGreaterThan(80)
  105. await page.waitForFunction(() => {
  106. const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  107. element.querySelector("[data-timeline-row]"),
  108. )
  109. if (!root) return false
  110. return new Promise<boolean>((resolve) => {
  111. const top = root.scrollTop
  112. requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
  113. })
  114. })
  115. const anchor = await scroller.evaluate((element) => {
  116. const view = element.getBoundingClientRect()
  117. return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
  118. const rect = row.getBoundingClientRect()
  119. return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
  120. })?.dataset.timelineKey
  121. })
  122. expect(anchor).toBeTruthy()
  123. const regions = defineVisualRegions({
  124. anchor: { selector: `[data-timeline-key="${anchor}"]` },
  125. })
  126. await startVisualProbe(page, regions)
  127. await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 400)
  128. const trace = await stopVisualProbe<keyof typeof regions>(page)
  129. await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
  130. })
  131. test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
  132. const shellID = "prt_descendant_keyboard_01_shell"
  133. const timeline = await setupTimeline(page, {
  134. messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
  135. settings: { shellToolPartsExpanded: false },
  136. cpuRate: 4,
  137. reducedMotion: true,
  138. })
  139. const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  140. const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
  141. const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
  142. await row.evaluate((element) => element.setAttribute("tabindex", "0"))
  143. await row.focus()
  144. for (let index = 0; index < 3; index++) {
  145. await row.press("PageUp")
  146. await page.waitForTimeout(250)
  147. }
  148. await expect
  149. .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
  150. .toBeGreaterThan(5)
  151. const anchor = await scroller.evaluate((element) => {
  152. const view = element.getBoundingClientRect()
  153. return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
  154. const rect = row.getBoundingClientRect()
  155. return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
  156. })?.dataset.timelineKey
  157. })
  158. expect(anchor).toBeTruthy()
  159. const regions = defineVisualRegions({
  160. anchor: { selector: `[data-timeline-key="${anchor}"]` },
  161. })
  162. await startVisualProbe(page, regions)
  163. await trigger.click()
  164. await page.waitForTimeout(300)
  165. const trace = await stopVisualProbe<keyof typeof regions>(page)
  166. await reportVisualStability(testInfo, "descendant-keyboard-resize", trace, anchorPlan(regions))
  167. })
  168. test("does not claim keyboard scrolling owned by a nested scrollable", async ({ page }) => {
  169. const shellID = "prt_nested_keyboard_shell"
  170. await setupTimeline(page, {
  171. messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(50))])],
  172. settings: { shellToolPartsExpanded: true },
  173. cpuRate: 4,
  174. reducedMotion: true,
  175. seedHistory: true,
  176. })
  177. const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  178. const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
  179. await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
  180. await nested.focus()
  181. await page.waitForFunction(() => {
  182. const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  183. element.querySelector("[data-timeline-row]"),
  184. )
  185. if (!root) return false
  186. return new Promise<boolean>((resolve) => {
  187. const top = root.scrollTop
  188. requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
  189. })
  190. })
  191. const before = await scroller.evaluate((element) => element.scrollTop)
  192. const nestedBefore = await nested.evaluate((element) => element.scrollTop)
  193. await nested.press("PageUp")
  194. await page.waitForTimeout(300)
  195. expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
  196. expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
  197. await nested.evaluate((element) => (element.scrollTop = 0))
  198. await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
  199. const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
  200. expect(boundaryBefore).toBeGreaterThan(0)
  201. await nested.press("PageUp")
  202. await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
  203. const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
  204. await nonOverflowing.evaluate((element) => {
  205. element.setAttribute("data-scrollable", "")
  206. element.setAttribute("tabindex", "0")
  207. })
  208. await nonOverflowing.focus()
  209. const nonOverflowBefore = await scroller.evaluate((element) => element.scrollTop)
  210. await nonOverflowing.press("PageUp")
  211. await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(nonOverflowBefore)
  212. })
  213. test("jump to latest lands on stable final rows after offscreen growth", async ({ page }, testInfo) => {
  214. const shellID = "prt_jump_01_shell"
  215. const followingID = "prt_jump_02_following"
  216. const timeline = await setupTimeline(page, {
  217. messages: [
  218. ...history(20),
  219. userMessage(),
  220. assistantMessage([shell(shellID, "running"), textPart(followingID, "Latest visible row")], { completed: false }),
  221. ],
  222. settings: { shellToolPartsExpanded: true },
  223. cpuRate: 4,
  224. })
  225. const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  226. await scroller.evaluate(
  227. (element) => (element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 600)),
  228. )
  229. await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
  230. const regions = defineVisualRegions({
  231. shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  232. following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  233. })
  234. await startVisualProbe(page, regions)
  235. await page.getByRole("button", { name: /Jump to latest/i }).click()
  236. await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
  237. await page.waitForTimeout(600)
  238. const trace = await stopVisualProbe<keyof typeof regions>(page)
  239. await reportVisualStability(
  240. testInfo,
  241. "jump-latest",
  242. trace,
  243. visualPlan(regions, [
  244. { type: "required", regions: ["shell", "following"] },
  245. { type: "unique", regions: ["shell", "following"] },
  246. { type: "opacity", regions: "all" },
  247. { type: "continuity", regions: "all" },
  248. { type: "motion", regions: "all", maxPositionReversals: 1 },
  249. { type: "label-stability", regions: "all" },
  250. { type: "acquire-bottom-anchor" },
  251. { type: "flow", regions: ["shell", "following"] },
  252. ]),
  253. )
  254. })
  255. test("handles a single row taller than the viewport", async ({ page }, testInfo) => {
  256. const shellID = "prt_tall_01_shell"
  257. const followingID = "prt_tall_02_following"
  258. const timeline = await setupTimeline(page, {
  259. messages: [
  260. userMessage(),
  261. assistantMessage([shell(shellID, "running"), textPart(followingID, "After tall row")], { completed: false }),
  262. ],
  263. settings: { shellToolPartsExpanded: true },
  264. viewport: { width: 900, height: 360 },
  265. cpuRate: 4,
  266. seedHistory: true,
  267. })
  268. const regions = defineVisualRegions({
  269. shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  270. following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  271. })
  272. await startVisualProbe(page, regions)
  273. await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
  274. const trace = await stopVisualProbe<keyof typeof regions>(page)
  275. await reportVisualStability(
  276. testInfo,
  277. "taller-than-viewport",
  278. trace,
  279. visualPlan(regions, [
  280. { type: "required", regions: ["shell", "following"] },
  281. { type: "unique", regions: ["shell", "following"] },
  282. { type: "stable", regions: ["shell", "following"] },
  283. { type: "opacity", regions: "all" },
  284. { type: "continuity", regions: "all" },
  285. { type: "motion", regions: "all", maxPositionReversals: 0 },
  286. { type: "label-stability", regions: "all" },
  287. { type: "preserve-bottom-anchor" },
  288. { type: "flow", regions: ["shell", "following"] },
  289. ]),
  290. )
  291. })
  292. function history(count: number): TimelineMessage[] {
  293. return Array.from({ length: count }, (_, index) => {
  294. const prefix = `msg_${String(index).padStart(4, "0")}_scroll`
  295. const userID = `${prefix}_a_user`
  296. return [
  297. userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }),
  298. assistantMessage(
  299. [textPart(`prt_${String(index).padStart(4, "0")}_scroll`, `History ${index}. ${"content ".repeat(30)}`)],
  300. {
  301. id: `${prefix}_b_assistant`,
  302. parentID: userID,
  303. created: 1690000001000 + index * 10_000,
  304. },
  305. ),
  306. ]
  307. }).flat()
  308. }
  309. function lines(count: number) {
  310. return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
  311. }
  312. function rowPairPlan(
  313. regions: Record<"shell" | "following", { selector: string; closest?: string }>,
  314. maxPositionReversals: number,
  315. ) {
  316. return visualPlan(regions, [
  317. { type: "required", regions: ["shell", "following"] },
  318. { type: "unique", regions: ["shell", "following"] },
  319. { type: "stable", regions: ["shell", "following"] },
  320. { type: "opacity", regions: "all" },
  321. { type: "continuity", regions: "all" },
  322. { type: "motion", regions: "all", maxPositionReversals },
  323. { type: "label-stability", regions: "all" },
  324. { type: "flow", regions: ["shell", "following"] },
  325. ])
  326. }
  327. function anchorPlan(regions: Record<"anchor", { selector: string; closest?: string }>) {
  328. return visualPlan(regions, [
  329. { type: "required", regions: ["anchor"] },
  330. { type: "unique", regions: ["anchor"] },
  331. { type: "stable", regions: ["anchor"] },
  332. { type: "fixed", regions: ["anchor"] },
  333. { type: "opacity", regions: "all" },
  334. { type: "continuity", regions: "all" },
  335. { type: "motion", regions: "all", maxPositionReversals: 0 },
  336. { type: "label-stability", regions: "all" },
  337. ])
  338. }