adverse.spec.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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. toolPart,
  16. userMessage,
  17. waitForVisualSettle,
  18. type TimelineMessage,
  19. } from "./fixture"
  20. test.describe("timeline adverse visual stability", () => {
  21. test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => {
  22. const activeShellID = "prt_adverse_01_shell"
  23. const messages = [
  24. ...history(24),
  25. userMessage(),
  26. assistantMessage([shell(activeShellID, "running")], { completed: false }),
  27. ]
  28. const timeline = await setupTimeline(page, {
  29. messages,
  30. settings: { shellToolPartsExpanded: true },
  31. cpuRate: 4,
  32. eventRetry: 30,
  33. })
  34. const scroller = page.locator(".scroll-view__viewport", {
  35. has: page.locator('[data-timeline-row="AssistantPart"]'),
  36. })
  37. await scroller.evaluate((element) => {
  38. element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 }))
  39. element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450)
  40. })
  41. await page.waitForTimeout(150)
  42. await expect
  43. .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
  44. .toBeGreaterThan(100)
  45. const anchor = await scroller.evaluate((element) => {
  46. const view = element.getBoundingClientRect()
  47. return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
  48. const rect = row.getBoundingClientRect()
  49. return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
  50. })?.dataset.timelineKey
  51. })
  52. expect(anchor).toBeTruthy()
  53. await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`])
  54. const regions = defineVisualRegions({
  55. anchor: { selector: `[data-timeline-key="${anchor}"]` },
  56. })
  57. await startVisualProbe(page, regions)
  58. await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180)
  59. await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90)
  60. await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350)
  61. await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500)
  62. const trace = await stopVisualProbe<keyof typeof regions>(page)
  63. await reportVisualStability(
  64. testInfo,
  65. "scrolled-away-shell",
  66. trace,
  67. visualPlan(regions, [
  68. { type: "required", regions: ["anchor"] },
  69. { type: "unique", regions: ["anchor"] },
  70. { type: "stable", regions: ["anchor"] },
  71. { type: "fixed", regions: ["anchor"] },
  72. { type: "opacity", regions: "all" },
  73. { type: "continuity", regions: "all" },
  74. { type: "motion", regions: "all", maxPositionReversals: 0 },
  75. { type: "label-stability", regions: "all" },
  76. ]),
  77. )
  78. })
  79. test("preserves an explicit shell state across virtualization", async ({ page }) => {
  80. const targetID = "prt_virtual_shell"
  81. const messages = [
  82. userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }),
  83. assistantMessage([shell(targetID, "completed", lines(20))], {
  84. id: "msg_0001_virtual_assistant",
  85. parentID: "msg_0000_virtual_user",
  86. created: 1700000001000,
  87. }),
  88. ...history(35, 10),
  89. ]
  90. await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } })
  91. const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  92. await scroller.evaluate((element) => {
  93. element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 }))
  94. element.scrollTop = 0
  95. })
  96. await page.waitForTimeout(300)
  97. const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
  98. await expect(trigger).toBeVisible()
  99. await trigger.click()
  100. await expect(trigger).toHaveAttribute("aria-expanded", "true")
  101. await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
  102. await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
  103. await scroller.evaluate((element) => (element.scrollTop = 0))
  104. await expect(trigger).toBeVisible()
  105. await expect(trigger).toHaveAttribute("aria-expanded", "true")
  106. })
  107. test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => {
  108. const shellID = "prt_narrow_01_shell"
  109. const followingID = "prt_narrow_02_following"
  110. const timeline = await setupTimeline(page, {
  111. messages: [
  112. userMessage(),
  113. assistantMessage(
  114. [shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")],
  115. {
  116. completed: false,
  117. },
  118. ),
  119. ],
  120. settings: { shellToolPartsExpanded: true },
  121. viewport: { width: 430, height: 800 },
  122. cpuRate: 4,
  123. })
  124. await waitForVisualSettle(page, [
  125. `[data-timeline-part-id="${shellID}"]`,
  126. `[data-timeline-part-id="${followingID}"]`,
  127. ])
  128. const regions = defineVisualRegions({
  129. shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  130. following: {
  131. selector: `[data-timeline-part-id="${followingID}"]`,
  132. closest: '[data-timeline-row="AssistantPart"]',
  133. },
  134. })
  135. await startVisualProbe(page, regions)
  136. await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100)
  137. await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300)
  138. await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500)
  139. const trace = await stopVisualProbe<keyof typeof regions>(page)
  140. await reportVisualStability(
  141. testInfo,
  142. "narrow-shell",
  143. trace,
  144. visualPlan(
  145. regions,
  146. [
  147. { type: "required", regions: ["shell", "following"] },
  148. { type: "unique", regions: ["shell", "following"] },
  149. { type: "stable", regions: ["shell", "following"] },
  150. { type: "opacity", regions: "all" },
  151. { type: "continuity", regions: "all" },
  152. { type: "motion", regions: "all", maxPositionReversals: 0 },
  153. { type: "label-stability", regions: "all" },
  154. { type: "preserve-bottom-anchor" },
  155. { type: "flow", regions: ["shell", "following"] },
  156. ],
  157. { perMarker: true },
  158. ),
  159. )
  160. })
  161. test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => {
  162. const shellID = "prt_resize_01_shell"
  163. const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"]
  164. const followingID = "prt_resize_04_following"
  165. await setupTimeline(page, {
  166. messages: [
  167. userMessage(),
  168. assistantMessage([
  169. shell(shellID, "completed", wideLines(15)),
  170. toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
  171. toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
  172. textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
  173. ]),
  174. ],
  175. settings: { shellToolPartsExpanded: true },
  176. cpuRate: 4,
  177. seedHistory: true,
  178. })
  179. const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
  180. const regions = defineVisualRegions({
  181. shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
  182. context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
  183. following: {
  184. selector: `[data-timeline-part-id="${followingID}"]`,
  185. closest: '[data-timeline-row="AssistantPart"]',
  186. },
  187. })
  188. await startVisualProbe(page, regions)
  189. await page.setViewportSize({ width: 430, height: 800 })
  190. await page.waitForTimeout(500)
  191. await page.setViewportSize({ width: 900, height: 800 })
  192. await page.waitForTimeout(500)
  193. await page.setViewportSize({ width: 1400, height: 900 })
  194. await page.waitForTimeout(500)
  195. const trace = await stopVisualProbe<keyof typeof regions>(page)
  196. await reportVisualStability(
  197. testInfo,
  198. "responsive-resize",
  199. trace,
  200. visualPlan(regions, [
  201. { type: "required", regions: ["shell", "context", "following"] },
  202. { type: "unique", regions: ["shell", "context", "following"] },
  203. { type: "stable", regions: ["shell", "context", "following"] },
  204. { type: "opacity", regions: "all" },
  205. { type: "continuity", regions: "all" },
  206. { type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 },
  207. { type: "label-stability", regions: "all" },
  208. { type: "flow", regions: ["shell", "context", "following"] },
  209. ]),
  210. )
  211. })
  212. })
  213. function history(count: number, offset = 0): TimelineMessage[] {
  214. return Array.from({ length: count }, (_, index) => {
  215. const value = index + offset
  216. const prefix = `msg_0${String(value).padStart(3, "0")}_history`
  217. const userID = `${prefix}_a_user`
  218. return [
  219. userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }),
  220. assistantMessage(
  221. [
  222. textPart(
  223. `prt_history_${String(value).padStart(3, "0")}`,
  224. `Historical response ${value}. ${"Stable history content. ".repeat(8)}`,
  225. ),
  226. ],
  227. {
  228. id: `${prefix}_b_assistant`,
  229. parentID: userID,
  230. created: 1699990001000 + value * 10_000,
  231. },
  232. ),
  233. ]
  234. }).flat()
  235. }
  236. function lines(count: number) {
  237. return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
  238. }
  239. function wideLines(count: number) {
  240. return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n")
  241. }