session-timeline.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740
  1. import { expect, test, type Page } from "@playwright/test"
  2. import { base64Encode } from "@opencode-ai/core/util/encode"
  3. import { fixture, pageMessages } from "./session-timeline.fixture"
  4. import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
  5. import { mockOpenCodeServer } from "../utils/mock-server"
  6. import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
  7. const forbiddenText = ["Load details", "Show earlier steps"]
  8. type SmokeState = {
  9. ids: string[]
  10. visibleIds: string[]
  11. messageIds: string[]
  12. visibleMessageIds: string[]
  13. topVisibleId?: string
  14. signature: string
  15. scrollTop: number
  16. scrollHeight: number
  17. clientHeight: number
  18. errorToasts: string[]
  19. forbiddenText: string[]
  20. }
  21. type SmokeWindow = Window & {
  22. __timelineSmokeState?: () => SmokeState
  23. __timelineSmokeErrorToasts?: string[]
  24. __timelineSmokeForbiddenText?: string[]
  25. }
  26. test.describe("smoke: session timeline", () => {
  27. test.setTimeout(240_000)
  28. test("keeps the visible message fixed while prepending history", async ({ page }) => {
  29. const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
  30. await mockOpenCodeServer(page, {
  31. sessions: fixture.sessions,
  32. provider: fixture.provider,
  33. directory: fixture.directory,
  34. project: fixture.project,
  35. pageMessages,
  36. messageDelay: 3_000,
  37. onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }),
  38. })
  39. await configureSmokePage(page, fixture.directory)
  40. await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
  41. await waitForTimelineStable(page)
  42. const scroller = timelineScroller(page)
  43. await pointAtTimeline(page)
  44. const deadline = Date.now() + 120_000
  45. while (!requests.some((request) => request.before && request.phase === "start")) {
  46. if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
  47. await page.mouse.wheel(0, -240)
  48. await page.waitForTimeout(20)
  49. }
  50. expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
  51. for (let index = 0; index < 12; index++) {
  52. await page.mouse.wheel(0, -120)
  53. await page.waitForTimeout(20)
  54. }
  55. const keys = await scroller.evaluate((element) => {
  56. const view = element.getBoundingClientRect()
  57. return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
  58. .filter((row) => {
  59. const rect = row.getBoundingClientRect()
  60. return rect.bottom > view.top && rect.top < view.bottom
  61. })
  62. .map((row) => row.dataset.timelinePartId)
  63. .filter((id): id is string => !!id)
  64. .slice(0, 3)
  65. })
  66. expect(keys.length).toBeGreaterThan(0)
  67. const positions = () =>
  68. scroller.evaluate((element, keys) => {
  69. const top = element.getBoundingClientRect().top
  70. return Object.fromEntries(
  71. keys.map((key) => {
  72. const row = element.querySelector<HTMLElement>(`[data-timeline-part-id="${key}"]`)
  73. if (!row) throw new Error(`Missing stable timeline key: ${key}`)
  74. return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio]
  75. }),
  76. )
  77. }, keys)
  78. const before = await positions()
  79. expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
  80. await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true)
  81. await waitForTimelineStable(page)
  82. await expect.poll(positions).toEqual(before)
  83. })
  84. test("preserves the timeline gap above the composer", async ({ page }) => {
  85. await mockOpenCodeServer(page, {
  86. sessions: fixture.sessions,
  87. provider: fixture.provider,
  88. directory: fixture.directory,
  89. project: fixture.project,
  90. pageMessages,
  91. })
  92. await configureSmokePage(page, fixture.directory)
  93. await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
  94. await waitForTimelineStable(page)
  95. const scroller = timelineScroller(page)
  96. await scroller.evaluate((element) => {
  97. element.scrollTop = element.scrollHeight
  98. })
  99. await waitForTimelineStable(page)
  100. const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]')
  101. await expect(spacer).toBeVisible()
  102. expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
  103. await expect
  104. .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
  105. .toBeLessThanOrEqual(1)
  106. })
  107. test("paints cached session tabs at the latest message", async ({ page }) => {
  108. await mockOpenCodeServer(page, {
  109. sessions: fixture.sessions,
  110. provider: fixture.provider,
  111. directory: fixture.directory,
  112. project: fixture.project,
  113. pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
  114. })
  115. await configureSmokePage(page, fixture.directory)
  116. await page.addInitScript(
  117. ({ dirBase64, sourceID, targetID }) => {
  118. localStorage.setItem(
  119. "opencode.window.browser.dat:tabs",
  120. JSON.stringify(
  121. [sourceID, targetID].map((sessionId) => ({
  122. type: "session",
  123. server: "http://127.0.0.1:4096",
  124. dirBase64,
  125. sessionId,
  126. })),
  127. ),
  128. )
  129. },
  130. { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
  131. )
  132. await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
  133. await expectSessionTitle(page, fixture.expected.targetTitle)
  134. await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
  135. const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
  136. const last = fixture.expected.targetMessageIDs.at(-1)!
  137. await page.evaluate(
  138. ({ destination, last }) => {
  139. const ids = new Set(destination)
  140. const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = []
  141. const firstPaintNodes = new WeakSet<Node>()
  142. let firstPaint = false
  143. let removedFirstPaintNodes = 0
  144. let running = true
  145. new MutationObserver((records) => {
  146. if (!firstPaint || !running) return
  147. records.forEach((record) =>
  148. record.removedNodes.forEach((node) => {
  149. if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1
  150. if (!(node instanceof Element)) return
  151. node.querySelectorAll("*").forEach((element) => {
  152. if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1
  153. })
  154. }),
  155. )
  156. }).observe(document.documentElement, { childList: true, subtree: true })
  157. const sample = () => {
  158. if (!running) return
  159. setTimeout(() => {
  160. if (!running) return
  161. const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  162. element.querySelector("[data-timeline-row]"),
  163. )
  164. if (root) {
  165. const view = root.getBoundingClientRect()
  166. const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
  167. .filter((element) => {
  168. const rect = element.getBoundingClientRect()
  169. return rect.bottom > view.top && rect.top < view.bottom
  170. })
  171. .map((element) => element.dataset.messageId!)
  172. .filter((id) => ids.has(id))
  173. const bottom = root
  174. .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
  175. ?.getBoundingClientRect()
  176. samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
  177. if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
  178. firstPaint = true
  179. root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
  180. const rect = row.getBoundingClientRect()
  181. if (rect.bottom <= view.top || rect.top >= view.bottom) return
  182. firstPaintNodes.add(row)
  183. row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element))
  184. })
  185. }
  186. }
  187. requestAnimationFrame(sample)
  188. }, 0)
  189. }
  190. ;(
  191. window as Window & {
  192. __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
  193. }
  194. ).__sessionTabPaint = {
  195. samples,
  196. removed: () => removedFirstPaintNodes,
  197. stop: () => {
  198. running = false
  199. },
  200. }
  201. requestAnimationFrame(sample)
  202. },
  203. { destination, last },
  204. )
  205. await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
  206. await page.waitForFunction(() =>
  207. (
  208. window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
  209. ).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
  210. )
  211. await page.waitForTimeout(200)
  212. const first = await page.evaluate(() => {
  213. const probe = (
  214. window as Window & {
  215. __sessionTabPaint?: {
  216. samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
  217. removed: () => number
  218. stop: () => void
  219. }
  220. }
  221. ).__sessionTabPaint!
  222. probe.stop()
  223. return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() }
  224. })
  225. expect(first.first?.last).toBe(true)
  226. expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
  227. expect(first.removed).toBe(0)
  228. })
  229. test("paints a cold session tab at the latest message", async ({ page }) => {
  230. await mockOpenCodeServer(page, {
  231. sessions: fixture.sessions,
  232. provider: fixture.provider,
  233. directory: fixture.directory,
  234. project: fixture.project,
  235. pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
  236. })
  237. await configureSmokePage(page, fixture.directory)
  238. await page.addInitScript(
  239. ({ dirBase64, sourceID, targetID }) => {
  240. localStorage.setItem(
  241. "opencode.window.browser.dat:tabs",
  242. JSON.stringify(
  243. [sourceID, targetID].map((sessionId) => ({
  244. type: "session",
  245. server: "http://127.0.0.1:4096",
  246. dirBase64,
  247. sessionId,
  248. })),
  249. ),
  250. )
  251. },
  252. { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
  253. )
  254. await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
  255. await expectSessionTitle(page, fixture.expected.sourceTitle)
  256. const last = fixture.expected.targetMessageIDs.at(-1)!
  257. const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
  258. await page.evaluate(
  259. ({ destination, last }) => {
  260. const ids = new Set(destination)
  261. const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = []
  262. const sample = () => {
  263. const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  264. element.querySelector("[data-timeline-row]"),
  265. )
  266. if (root) {
  267. const view = root.getBoundingClientRect()
  268. const spacer = root
  269. .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
  270. ?.getBoundingClientRect()
  271. const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].filter((element) => {
  272. const rect = element.getBoundingClientRect()
  273. return rect.bottom > view.top && rect.top < view.bottom
  274. })
  275. samples.push({
  276. destination: messages.some((element) => ids.has(element.dataset.messageId!)),
  277. last: messages.some((element) => element.dataset.messageId === last),
  278. bottomError: spacer ? spacer.bottom - view.bottom : undefined,
  279. })
  280. }
  281. requestAnimationFrame(() => setTimeout(sample, 0))
  282. }
  283. ;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples
  284. requestAnimationFrame(() => setTimeout(sample, 0))
  285. },
  286. { destination, last },
  287. )
  288. await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
  289. await page.waitForFunction(() =>
  290. (window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some(
  291. (sample) => sample.destination,
  292. ),
  293. )
  294. const result = await page.evaluate(() => {
  295. const samples = (
  296. window as Window & {
  297. __coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }>
  298. }
  299. ).__coldTabSamples!
  300. return samples.find((sample) => sample.destination)!
  301. })
  302. expect(result.last).toBe(true)
  303. expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
  304. })
  305. test("renders seeded timeline in order while paging through history", async ({ page }) => {
  306. const errors = trackPageErrors(page)
  307. await mockOpenCodeServer(page, {
  308. sessions: fixture.sessions,
  309. provider: fixture.provider,
  310. directory: fixture.directory,
  311. project: fixture.project,
  312. pageMessages,
  313. })
  314. await configureSmokePage(page, fixture.directory)
  315. await selectHomeProject(page, fixture.project.name)
  316. await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
  317. await expectSessionReady(page)
  318. await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
  319. const expectedPartIDs = fixture.expected.targetPartIDs
  320. const expectedMessageIDs = fixture.expected.targetMessageIDs
  321. await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
  322. await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
  323. const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
  324. const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
  325. const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
  326. await expect(shellSubtitle).toHaveCount(0)
  327. await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
  328. await shellTrigger.click()
  329. await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
  330. await expect(shellSubtitle).toHaveText("bun typecheck")
  331. await shellTrigger.click()
  332. await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
  333. await expect(shellSubtitle).toHaveCount(0)
  334. })
  335. })
  336. async function configureSmokePage(page: Page, directory: string) {
  337. await page.addInitScript(() => {
  338. localStorage.setItem(
  339. "settings.v3",
  340. JSON.stringify({
  341. general: {
  342. editToolPartsExpanded: true,
  343. shellToolPartsExpanded: true,
  344. showReasoningSummaries: true,
  345. },
  346. }),
  347. )
  348. })
  349. await page.addInitScript((directory) => {
  350. localStorage.setItem(
  351. "opencode.global.dat:server",
  352. JSON.stringify({
  353. projects: {
  354. local: [{ worktree: directory, expanded: true }],
  355. },
  356. lastProject: {
  357. local: directory,
  358. },
  359. }),
  360. )
  361. }, directory)
  362. await page.addInitScript(() => {
  363. const smoke = window as SmokeWindow
  364. smoke.__timelineSmokeErrorToasts = []
  365. smoke.__timelineSmokeForbiddenText = []
  366. const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
  367. const idsOf = (el: HTMLElement) =>
  368. [el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
  369. smoke.__timelineSmokeState = () => {
  370. const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
  371. el.querySelector("[data-timeline-row], [data-session-title]"),
  372. )
  373. if (!scroller) {
  374. return {
  375. ids: [],
  376. visibleIds: [],
  377. messageIds: [],
  378. visibleMessageIds: [],
  379. topVisibleId: undefined,
  380. signature: "",
  381. scrollTop: 0,
  382. scrollHeight: 0,
  383. clientHeight: 0,
  384. errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
  385. forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
  386. }
  387. }
  388. const ids: string[] = []
  389. const visibleIds: string[] = []
  390. const scrollerRect = scroller.getBoundingClientRect()
  391. let topVisibleId: string | undefined
  392. for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
  393. const next = idsOf(el)
  394. ids.push(...next)
  395. const rect = el.getBoundingClientRect()
  396. if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
  397. if (!topVisibleId) topVisibleId = next[0]
  398. visibleIds.push(...next)
  399. }
  400. }
  401. const messageIds: string[] = []
  402. const visibleMessageIds: string[] = []
  403. const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
  404. const rect = el.getBoundingClientRect()
  405. const id = el.dataset.messageId
  406. if (id) {
  407. messageIds.push(id)
  408. if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
  409. }
  410. return {
  411. id,
  412. top: Math.round(rect.top),
  413. bottom: Math.round(rect.bottom),
  414. }
  415. })
  416. const signature = JSON.stringify({
  417. top: Math.round(scroller.scrollTop),
  418. height: Math.round(scroller.scrollHeight),
  419. rows,
  420. ids,
  421. })
  422. return {
  423. ids,
  424. visibleIds,
  425. messageIds,
  426. visibleMessageIds,
  427. topVisibleId,
  428. signature,
  429. scrollTop: Math.round(scroller.scrollTop),
  430. scrollHeight: Math.round(scroller.scrollHeight),
  431. clientHeight: Math.round(scroller.clientHeight),
  432. errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
  433. forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
  434. }
  435. }
  436. let recordFrame: number | undefined
  437. const record = () => {
  438. for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
  439. const text = toast.textContent?.trim()
  440. if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
  441. }
  442. const text = document.body?.textContent ?? ""
  443. for (const value of ["Load details", "Show earlier steps"]) {
  444. if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
  445. smoke.__timelineSmokeForbiddenText!.push(value)
  446. }
  447. }
  448. }
  449. const start = () => {
  450. const root = document.documentElement ?? document.body
  451. if (!root) return
  452. new MutationObserver(() => {
  453. if (recordFrame) return
  454. recordFrame = requestAnimationFrame(() => {
  455. recordFrame = undefined
  456. record()
  457. })
  458. }).observe(root, { childList: true, subtree: true })
  459. record()
  460. }
  461. if (document.documentElement ?? document.body) start()
  462. else document.addEventListener("DOMContentLoaded", start, { once: true })
  463. })
  464. }
  465. async function expectCanScrollToStart(
  466. page: Page,
  467. expectedPartIDs: string[],
  468. expectedMessageIDs: string[],
  469. errors: string[],
  470. ) {
  471. await pointAtTimeline(page)
  472. const seenParts = new Set<string>()
  473. const seenMessages = new Set<string>()
  474. const samples: TraversalSample[] = []
  475. let current = await timelineState(page)
  476. let unchangedAtTop = 0
  477. for (let attempt = 0; attempt < 600; attempt++) {
  478. collectSeen(current, seenParts, seenMessages)
  479. samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
  480. expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
  481. expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
  482. expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
  483. expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
  484. expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
  485. if (
  486. current.scrollTop <= 1 &&
  487. seenParts.size === expectedPartIDs.length &&
  488. seenMessages.size === expectedMessageIDs.length
  489. ) {
  490. expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
  491. return
  492. }
  493. const before = current
  494. const changed = await scrollTimelineUp(page, current)
  495. current = await timelineState(page)
  496. if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
  497. else unchangedAtTop = 0
  498. if (unchangedAtTop >= 2) break
  499. }
  500. collectSeen(current, seenParts, seenMessages)
  501. samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
  502. expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
  503. }
  504. async function timelineState(page: Page) {
  505. return page.evaluate(
  506. () =>
  507. (window as SmokeWindow).__timelineSmokeState?.() ?? {
  508. ids: [],
  509. visibleIds: [],
  510. messageIds: [],
  511. visibleMessageIds: [],
  512. topVisibleId: undefined,
  513. signature: "",
  514. scrollTop: 0,
  515. scrollHeight: 0,
  516. clientHeight: 0,
  517. errorToasts: [],
  518. forbiddenText: [],
  519. },
  520. )
  521. }
  522. function timelineScroller(page: Page) {
  523. return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
  524. }
  525. async function pointAtTimeline(page: Page) {
  526. const box = await timelineScroller(page).boundingBox()
  527. if (!box) throw new Error("Timeline scroller is not visible")
  528. await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
  529. }
  530. async function scrollTimelineUp(page: Page, before: SmokeState) {
  531. return page.evaluate(
  532. (prev) =>
  533. new Promise<boolean>((resolve) => {
  534. const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
  535. el.querySelector("[data-timeline-row], [data-session-title]"),
  536. )
  537. if (!scroller) {
  538. resolve(false)
  539. return
  540. }
  541. scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
  542. scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
  543. const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
  544. let frames = 0
  545. let stableFrames = 0
  546. let last = ""
  547. let changed = false
  548. const check = () => {
  549. const current = read()
  550. if (current !== prev) changed = true
  551. if (current === last) stableFrames++
  552. else {
  553. stableFrames = 0
  554. last = current
  555. }
  556. if (changed && stableFrames >= 2) {
  557. resolve(true)
  558. return
  559. }
  560. frames++
  561. if (frames >= 30) {
  562. resolve(changed)
  563. return
  564. }
  565. requestAnimationFrame(check)
  566. }
  567. requestAnimationFrame(check)
  568. }),
  569. before.signature,
  570. )
  571. }
  572. function expectOrderedIDs(expected: string[], actual: string[], label: string) {
  573. expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
  574. const actualSet = new Set(actual)
  575. expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
  576. }
  577. function unique(values: string[]) {
  578. return values.filter((value, index) => values.indexOf(value) === index)
  579. }
  580. function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
  581. for (const id of state.ids) seenParts.add(id)
  582. for (const id of state.visibleIds) seenParts.add(id)
  583. for (const id of state.messageIds) seenMessages.add(id)
  584. for (const id of state.visibleMessageIds) seenMessages.add(id)
  585. }
  586. type TraversalSample = ReturnType<typeof sampleTraversal>
  587. function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
  588. return {
  589. seenParts,
  590. seenMessages,
  591. mounted: state.ids.length,
  592. visible: state.visibleIds.length,
  593. mountedMessages: unique(state.messageIds).length,
  594. visibleMessages: unique(state.visibleMessageIds).length,
  595. top: state.scrollTop,
  596. height: state.scrollHeight,
  597. first: state.ids[0],
  598. last: state.ids.at(-1),
  599. topVisible: state.topVisibleId,
  600. visibleFirst: state.visibleIds[0],
  601. visibleLast: state.visibleIds.at(-1),
  602. }
  603. }
  604. function sampleSummary(samples: TraversalSample[]) {
  605. return samples
  606. .filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
  607. .map(
  608. (sample, index) =>
  609. `${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}`,
  610. )
  611. .join("\n")
  612. }
  613. async function waitForTimelineStable(page: Page) {
  614. await page.waitForFunction(
  615. () =>
  616. new Promise<boolean>((resolve) => {
  617. requestAnimationFrame(() => {
  618. const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
  619. requestAnimationFrame(() => {
  620. const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
  621. requestAnimationFrame(() =>
  622. resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
  623. )
  624. })
  625. })
  626. }),
  627. )
  628. }
  629. async function expectSessionTimelineReady(
  630. page: Page,
  631. expectedPartIDs: string[],
  632. expectedMessageIDs: string[],
  633. errors: string[],
  634. ) {
  635. await waitForTimelineStable(page)
  636. for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
  637. const currentState = await timelineState(page)
  638. expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
  639. expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
  640. expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
  641. expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
  642. expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
  643. }
  644. function expectCompleteScroll(
  645. state: SmokeState,
  646. expectedPartIDs: string[],
  647. expectedMessageIDs: string[],
  648. seenParts: Set<string>,
  649. seenMessages: Set<string>,
  650. samples: TraversalSample[],
  651. ) {
  652. expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
  653. expect(
  654. expectedPartIDs.filter((id) => !seenParts.has(id)),
  655. `missing visible timeline parts\n${sampleSummary(samples)}`,
  656. ).toEqual([])
  657. expect(
  658. expectedMessageIDs.filter((id) => !seenMessages.has(id)),
  659. `missing visible messages\n${sampleSummary(samples)}`,
  660. ).toEqual([])
  661. expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
  662. expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
  663. expect(expectedPartIDs.length).toBe(331)
  664. }
  665. async function selectHomeProject(page: Page, projectName: string) {
  666. await page.goto("/")
  667. const row = page
  668. .locator('[data-component="home-project-row"]')
  669. .filter({ hasText: new RegExp(projectName, "i") })
  670. .first()
  671. await expectAppVisible(row)
  672. await row.click()
  673. await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
  674. await expect(page).toHaveURL(/\/$/)
  675. }
  676. async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
  677. await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
  678. await expectSessionTitle(page, expectedTitle)
  679. }
  680. async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
  681. const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
  682. const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
  683. await expect(tab).toBeVisible()
  684. await tab.click()
  685. await expectSessionTitle(page, title)
  686. }
  687. async function expectSessionReady(page: Page) {
  688. await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
  689. }