session-timeline.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import { expect, test, type Page } from "@playwright/test"
  2. import { fixture, pageMessages } from "./session-timeline.fixture"
  3. import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
  4. import { mockOpenCodeServer } from "../utils/mock-server"
  5. const forbiddenText = ["Load details", "Show earlier steps"]
  6. type SmokeState = {
  7. ids: string[]
  8. visibleIds: string[]
  9. messageIds: string[]
  10. visibleMessageIds: string[]
  11. topVisibleId?: string
  12. signature: string
  13. scrollTop: number
  14. scrollHeight: number
  15. clientHeight: number
  16. errorToasts: string[]
  17. forbiddenText: string[]
  18. }
  19. type SmokeWindow = Window & {
  20. __timelineSmokeState?: () => SmokeState
  21. __timelineSmokeErrorToasts?: string[]
  22. __timelineSmokeForbiddenText?: string[]
  23. }
  24. test.describe("smoke: session timeline", () => {
  25. test.setTimeout(240_000)
  26. test("renders seeded timeline in order while paging through history", async ({ page }) => {
  27. const errors = trackPageErrors(page)
  28. await mockOpenCodeServer(page, {
  29. sessions: fixture.sessions,
  30. provider: fixture.provider,
  31. directory: fixture.directory,
  32. project: fixture.project,
  33. pageMessages,
  34. })
  35. await configureSmokePage(page)
  36. await openProject(page, "SmokeProject")
  37. await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
  38. await expectSessionReady(page, "smoke-project")
  39. await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
  40. const expectedPartIDs = fixture.expected.targetPartIDs
  41. const expectedMessageIDs = fixture.expected.targetMessageIDs
  42. await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
  43. await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
  44. })
  45. })
  46. async function configureSmokePage(page: Page) {
  47. await page.addInitScript(() => {
  48. localStorage.setItem(
  49. "settings.v3",
  50. JSON.stringify({
  51. general: {
  52. editToolPartsExpanded: true,
  53. shellToolPartsExpanded: true,
  54. showReasoningSummaries: true,
  55. showSessionProgressBar: true,
  56. },
  57. }),
  58. )
  59. const smoke = window as SmokeWindow
  60. smoke.__timelineSmokeErrorToasts = []
  61. smoke.__timelineSmokeForbiddenText = []
  62. const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
  63. const idsOf = (el: HTMLElement) =>
  64. [el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
  65. smoke.__timelineSmokeState = () => {
  66. const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
  67. el.querySelector("[data-timeline-row], [data-session-title]"),
  68. )
  69. if (!scroller) {
  70. return {
  71. ids: [],
  72. visibleIds: [],
  73. messageIds: [],
  74. visibleMessageIds: [],
  75. topVisibleId: undefined,
  76. signature: "",
  77. scrollTop: 0,
  78. scrollHeight: 0,
  79. clientHeight: 0,
  80. errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
  81. forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
  82. }
  83. }
  84. const ids: string[] = []
  85. const visibleIds: string[] = []
  86. const scrollerRect = scroller.getBoundingClientRect()
  87. let topVisibleId: string | undefined
  88. for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
  89. const next = idsOf(el)
  90. ids.push(...next)
  91. const rect = el.getBoundingClientRect()
  92. if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
  93. if (!topVisibleId) topVisibleId = next[0]
  94. visibleIds.push(...next)
  95. }
  96. }
  97. const messageIds: string[] = []
  98. const visibleMessageIds: string[] = []
  99. const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
  100. const rect = el.getBoundingClientRect()
  101. const id = el.dataset.messageId
  102. if (id) {
  103. messageIds.push(id)
  104. if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
  105. }
  106. return {
  107. id,
  108. top: Math.round(rect.top),
  109. bottom: Math.round(rect.bottom),
  110. }
  111. })
  112. const signature = JSON.stringify({
  113. top: Math.round(scroller.scrollTop),
  114. height: Math.round(scroller.scrollHeight),
  115. rows,
  116. ids,
  117. })
  118. return {
  119. ids,
  120. visibleIds,
  121. messageIds,
  122. visibleMessageIds,
  123. topVisibleId,
  124. signature,
  125. scrollTop: Math.round(scroller.scrollTop),
  126. scrollHeight: Math.round(scroller.scrollHeight),
  127. clientHeight: Math.round(scroller.clientHeight),
  128. errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
  129. forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
  130. }
  131. }
  132. let recordFrame: number | undefined
  133. const record = () => {
  134. for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
  135. const text = toast.textContent?.trim()
  136. if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
  137. }
  138. const text = document.body?.textContent ?? ""
  139. for (const value of ["Load details", "Show earlier steps"]) {
  140. if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
  141. smoke.__timelineSmokeForbiddenText!.push(value)
  142. }
  143. }
  144. }
  145. const start = () => {
  146. const root = document.documentElement ?? document.body
  147. if (!root) return
  148. new MutationObserver(() => {
  149. if (recordFrame) return
  150. recordFrame = requestAnimationFrame(() => {
  151. recordFrame = undefined
  152. record()
  153. })
  154. }).observe(root, { childList: true, subtree: true })
  155. record()
  156. }
  157. if (document.documentElement ?? document.body) start()
  158. else document.addEventListener("DOMContentLoaded", start, { once: true })
  159. })
  160. }
  161. async function expectCanScrollToStart(
  162. page: Page,
  163. expectedPartIDs: string[],
  164. expectedMessageIDs: string[],
  165. errors: string[],
  166. ) {
  167. await pointAtTimeline(page)
  168. const seenParts = new Set<string>()
  169. const seenMessages = new Set<string>()
  170. const samples: TraversalSample[] = []
  171. let current = await timelineState(page)
  172. let unchangedAtTop = 0
  173. for (let attempt = 0; attempt < 600; attempt++) {
  174. collectSeen(current, seenParts, seenMessages)
  175. samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
  176. expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
  177. expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
  178. expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
  179. expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
  180. expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
  181. if (
  182. current.scrollTop <= 1 &&
  183. seenParts.size === expectedPartIDs.length &&
  184. seenMessages.size === expectedMessageIDs.length
  185. ) {
  186. expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
  187. return
  188. }
  189. const before = current
  190. const changed = await scrollTimelineUp(page, current)
  191. current = await timelineState(page)
  192. if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
  193. else unchangedAtTop = 0
  194. if (unchangedAtTop >= 2) break
  195. }
  196. collectSeen(current, seenParts, seenMessages)
  197. samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
  198. expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
  199. }
  200. async function timelineState(page: Page) {
  201. return page.evaluate(
  202. () =>
  203. (window as SmokeWindow).__timelineSmokeState?.() ?? {
  204. ids: [],
  205. visibleIds: [],
  206. messageIds: [],
  207. visibleMessageIds: [],
  208. topVisibleId: undefined,
  209. signature: "",
  210. scrollTop: 0,
  211. scrollHeight: 0,
  212. clientHeight: 0,
  213. errorToasts: [],
  214. forbiddenText: [],
  215. },
  216. )
  217. }
  218. function timelineScroller(page: Page) {
  219. return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  220. }
  221. async function pointAtTimeline(page: Page) {
  222. const box = await timelineScroller(page).boundingBox()
  223. if (!box) throw new Error("Timeline scroller is not visible")
  224. await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
  225. }
  226. async function scrollTimelineUp(page: Page, before: SmokeState) {
  227. return page.evaluate(
  228. (prev) =>
  229. new Promise<boolean>((resolve) => {
  230. const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
  231. el.querySelector("[data-timeline-row], [data-session-title]"),
  232. )
  233. if (!scroller) {
  234. resolve(false)
  235. return
  236. }
  237. scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
  238. scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
  239. const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
  240. let frames = 0
  241. let stableFrames = 0
  242. let last = ""
  243. let changed = false
  244. const check = () => {
  245. const current = read()
  246. if (current !== prev) changed = true
  247. if (current === last) stableFrames++
  248. else {
  249. stableFrames = 0
  250. last = current
  251. }
  252. if (changed && stableFrames >= 2) {
  253. resolve(true)
  254. return
  255. }
  256. frames++
  257. if (frames >= 30) {
  258. resolve(changed)
  259. return
  260. }
  261. requestAnimationFrame(check)
  262. }
  263. requestAnimationFrame(check)
  264. }),
  265. before.signature,
  266. )
  267. }
  268. function expectOrderedIDs(expected: string[], actual: string[], label: string) {
  269. expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
  270. const actualSet = new Set(actual)
  271. expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
  272. }
  273. function unique(values: string[]) {
  274. return values.filter((value, index) => values.indexOf(value) === index)
  275. }
  276. function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
  277. for (const id of state.ids) seenParts.add(id)
  278. for (const id of state.visibleIds) seenParts.add(id)
  279. for (const id of state.messageIds) seenMessages.add(id)
  280. for (const id of state.visibleMessageIds) seenMessages.add(id)
  281. }
  282. type TraversalSample = ReturnType<typeof sampleTraversal>
  283. function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
  284. return {
  285. seenParts,
  286. seenMessages,
  287. mounted: state.ids.length,
  288. visible: state.visibleIds.length,
  289. mountedMessages: unique(state.messageIds).length,
  290. visibleMessages: unique(state.visibleMessageIds).length,
  291. top: state.scrollTop,
  292. height: state.scrollHeight,
  293. first: state.ids[0],
  294. last: state.ids.at(-1),
  295. topVisible: state.topVisibleId,
  296. visibleFirst: state.visibleIds[0],
  297. visibleLast: state.visibleIds.at(-1),
  298. }
  299. }
  300. function sampleSummary(samples: TraversalSample[]) {
  301. return samples
  302. .filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
  303. .map(
  304. (sample, index) =>
  305. `${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
  306. )
  307. .join("\n")
  308. }
  309. async function waitForTimelineStable(page: Page) {
  310. await page.waitForFunction(
  311. () =>
  312. new Promise<boolean>((resolve) => {
  313. requestAnimationFrame(() => {
  314. const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
  315. requestAnimationFrame(() => {
  316. const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
  317. requestAnimationFrame(() =>
  318. resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
  319. )
  320. })
  321. })
  322. }),
  323. )
  324. }
  325. async function expectSessionTimelineReady(
  326. page: Page,
  327. expectedPartIDs: string[],
  328. expectedMessageIDs: string[],
  329. errors: string[],
  330. ) {
  331. await waitForTimelineStable(page)
  332. for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
  333. const currentState = await timelineState(page)
  334. expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
  335. expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
  336. expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
  337. expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
  338. expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
  339. }
  340. function expectCompleteScroll(
  341. state: SmokeState,
  342. expectedPartIDs: string[],
  343. expectedMessageIDs: string[],
  344. seenParts: Set<string>,
  345. seenMessages: Set<string>,
  346. samples: TraversalSample[],
  347. ) {
  348. expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
  349. expect(
  350. expectedPartIDs.filter((id) => !seenParts.has(id)),
  351. `missing visible timeline parts\n${sampleSummary(samples)}`,
  352. ).toEqual([])
  353. expect(
  354. expectedMessageIDs.filter((id) => !seenMessages.has(id)),
  355. `missing visible messages\n${sampleSummary(samples)}`,
  356. ).toEqual([])
  357. expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
  358. expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
  359. expect(expectedPartIDs.length).toBe(331)
  360. }
  361. async function openProject(page: Page, projectName: string) {
  362. await page.goto("/")
  363. await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
  364. }
  365. async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
  366. // Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
  367. await page
  368. .locator(`a[href*="${sessionId}"]`)
  369. .first()
  370. .evaluate((el) => (el as HTMLElement).click())
  371. await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
  372. }
  373. async function expectSessionReady(page: Page, projectName: string) {
  374. await expect(page.getByText(projectName).first()).toBeVisible()
  375. await expect(page.getByText("Ask anything...")).toBeVisible()
  376. }