session-timeline.spec.ts 28 KB

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