scroll-interaction.spec.ts 13 KB

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