chat-scroll-contract.e2e.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. // Browser geometry contracts for a long Chat transcript. These scenarios are
  2. // deliberately virtualizer-neutral: they assert semantic-row position,
  3. // bottom ownership, interaction state, and the real outer scroll host rather
  4. // than DOM cardinality or implementation-specific spacer markup.
  5. import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
  6. import { tmpdir } from 'node:os'
  7. import { join } from 'node:path'
  8. import type { Browser, Page } from 'playwright'
  9. import { chromium } from 'playwright'
  10. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  11. import type { StreamChunk } from '@deepseek-ai/dsh-llm'
  12. import { CallId } from '@deepseek-ai/dsh-llm'
  13. import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
  14. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  15. import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
  16. import {
  17. launchWebScaffold,
  18. seedSession,
  19. watchConsole,
  20. webSnapshotMode,
  21. type WebScaffold,
  22. } from './scaffold.ts'
  23. import { newEnglishPage, saveFailureShot } from './support.ts'
  24. const MODE = webSnapshotMode()
  25. const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
  26. const TOOL_SESSION_ID = 'chat-scroll-tool-e2e'
  27. const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e'
  28. const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e'
  29. const REPLAY_CONTEXT_WINDOW = 10_000_000
  30. const STREAM_PACE_MS = 24
  31. const GEOMETRY_TOLERANCE = 2
  32. const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.'
  33. const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST'
  34. const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE'
  35. const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.'
  36. const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call')
  37. const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT'
  38. const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
  39. const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
  40. const TOOL_READY_FILE = '.chat-scroll-tool-ready'
  41. const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
  42. const HISTORY_FIXTURE = createChatScrollFixture({
  43. markerPrefix: 'HISTORY',
  44. title: 'CHAT_SCROLL_HISTORY long paging session',
  45. })
  46. const TOOL_FIXTURE = createChatScrollFixture({
  47. markerPrefix: 'TOOL',
  48. title: 'CHAT_SCROLL_TOOL live tool session',
  49. })
  50. const RESTORE_FIXTURE_A = createChatScrollFixture({
  51. markerPrefix: 'RESTORE_A',
  52. title: 'CHAT_SCROLL_RESTORE_A long session',
  53. })
  54. const RESTORE_FIXTURE_B = createChatScrollFixture({
  55. markerPrefix: 'RESTORE_B',
  56. title: 'CHAT_SCROLL_RESTORE_B comparison session',
  57. turns: 32,
  58. })
  59. interface ScrollGeometry {
  60. readonly distanceFromBottom: number
  61. readonly scrollTop: number
  62. }
  63. interface FlowAnchor {
  64. readonly key: string
  65. readonly top: number
  66. }
  67. interface ScrollWorld {
  68. readonly events: SessionEvent[]
  69. readonly page: Page
  70. readonly replayDir?: string
  71. readonly scaffold: WebScaffold
  72. readonly tripwire: ReturnType<typeof watchConsole>
  73. }
  74. interface ScrollWorldOptions {
  75. readonly failureShot: string
  76. readonly replay?: ReplayOverrideDoc
  77. readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[]
  78. }
  79. function textStream(first: string, done: string, deltaCount: number): StreamChunk[] {
  80. const deltas = Array.from({ length: deltaCount }, (_, index) => {
  81. if (index === 0) return `${first} `
  82. if (index === deltaCount - 1) return `${done}.`
  83. return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}`
  84. })
  85. const response = deltas.join('')
  86. return [
  87. { type: 'block-start', index: 0, blockType: 'text' },
  88. ...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
  89. { type: 'block-end', index: 0, block: { type: 'text', text: response } },
  90. {
  91. type: 'usage',
  92. usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) },
  93. },
  94. { type: 'finish', reason: { kind: 'stop' } },
  95. ]
  96. }
  97. function toolStream(): StreamChunk[] {
  98. const command = [
  99. `: > ${TOOL_READY_FILE}`,
  100. `while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`,
  101. 'line=1',
  102. `while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`,
  103. ].join('; ')
  104. const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT })
  105. return [
  106. { type: 'block-start', index: 0, blockType: 'tool-call' },
  107. {
  108. type: 'tool-call-delta',
  109. index: 0,
  110. id: LIVE_TOOL_CALL_ID,
  111. name: 'bash',
  112. argumentsDelta: args,
  113. },
  114. {
  115. type: 'block-end',
  116. index: 0,
  117. block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args },
  118. },
  119. { type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } },
  120. { type: 'finish', reason: { kind: 'tool-calls' } },
  121. ]
  122. }
  123. function replayEntry(chunks: StreamChunk[]): ReplayEntry {
  124. return { kind: 'chunks', chunks }
  125. }
  126. async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWorld> {
  127. let replayDir: string | undefined
  128. let scaffold: WebScaffold | undefined
  129. let page: Page | undefined
  130. try {
  131. if (options.replay !== undefined) {
  132. replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-'))
  133. const replayOverride = join(replayDir, 'replay.override.json')
  134. await writeFile(replayOverride, JSON.stringify(options.replay))
  135. scaffold = await launchWebScaffold({
  136. replayFixture: join(replayDir, 'override-only.jsonl'),
  137. replayOverride,
  138. paceMs: STREAM_PACE_MS,
  139. replayContextWindow: REPLAY_CONTEXT_WINDOW,
  140. })
  141. } else {
  142. scaffold = await launchWebScaffold({})
  143. }
  144. for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id)
  145. const events: SessionEvent[] = []
  146. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
  147. page = await newEnglishPage(browser, 900)
  148. const tripwire = watchConsole(page)
  149. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  150. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  151. // Session-list bootstrap can replace the controlled search state. Wait
  152. // for the seeded baseline before openSeed starts the lazy content query.
  153. await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
  154. return {
  155. events,
  156. page,
  157. scaffold,
  158. tripwire,
  159. ...(replayDir === undefined ? {} : { replayDir }),
  160. }
  161. } catch (error) {
  162. const failures: unknown[] = [error]
  163. if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError))
  164. if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError))
  165. if (replayDir !== undefined) {
  166. await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
  167. }
  168. if (failures.length === 1) throw error
  169. throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete')
  170. }
  171. }
  172. async function closeScrollWorld(world: ScrollWorld): Promise<void> {
  173. const failures: unknown[] = []
  174. // newEnglishPage/browser.newPage owns an isolated context. Close the whole
  175. // context so its SSE connection and cache cannot leak into the next world
  176. // in this file's shared Chromium process.
  177. await world.page.context().close().catch((error: unknown) => failures.push(error))
  178. await world.scaffold.close().catch((error: unknown) => failures.push(error))
  179. if (world.replayDir !== undefined) {
  180. await rm(world.replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  181. }
  182. if (failures.length === 1) throw failures[0]
  183. if (failures.length > 1) throw new AggregateError(failures, 'chat-scroll browser world cleanup failed')
  184. }
  185. async function withScrollWorld(
  186. options: ScrollWorldOptions,
  187. run: (world: ScrollWorld) => Promise<void>,
  188. ): Promise<void> {
  189. const world = await launchScrollWorld(options)
  190. let runFailure: unknown
  191. try {
  192. await run(world)
  193. } catch (error) {
  194. runFailure = error
  195. try {
  196. await saveFailureShot(world.page, options.failureShot)
  197. } catch {
  198. // Best-effort evidence must never prevent cleanup of the owned world.
  199. }
  200. }
  201. let cleanupFailure: unknown
  202. try {
  203. await closeScrollWorld(world)
  204. } catch (error) {
  205. cleanupFailure = error
  206. }
  207. if (runFailure !== undefined && cleanupFailure !== undefined) {
  208. throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed')
  209. }
  210. if (runFailure !== undefined) throw runFailure
  211. if (cleanupFailure !== undefined) throw cleanupFailure
  212. }
  213. async function nextPaint(page: Page): Promise<void> {
  214. await page.evaluate(async () => {
  215. await document.fonts.ready
  216. await new Promise<void>(resolve => requestAnimationFrame(() => {
  217. requestAnimationFrame(() => { resolve() })
  218. }))
  219. })
  220. }
  221. function scrollGeometry(page: Page): Promise<ScrollGeometry> {
  222. return page.locator('[data-conversation-scroll]').evaluate(host => ({
  223. distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop,
  224. scrollTop: host.scrollTop,
  225. }))
  226. }
  227. async function conversationTurns(page: Page): Promise<number> {
  228. const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
  229. await stats.waitFor({ timeout: 15_000 })
  230. const value = await stats.textContent()
  231. const match = value?.match(/^(\d+) turns · \d+ steps$/)
  232. if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
  233. return Number(match[1])
  234. }
  235. async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
  236. const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
  237. // Cold summaries initially show the temporary workspace basename, so the
  238. // persisted first-message marker is the stable user-facing identity. The
  239. // query itself triggers lazy content-index reconciliation; no transient
  240. // empty-state paint is used as a barrier.
  241. await search.fill(fixture.markers.user(1))
  242. const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
  243. await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
  244. await results.click()
  245. await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
  246. if (tailMarker !== undefined) {
  247. await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 })
  248. }
  249. await nextPaint(page)
  250. }
  251. async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
  252. const box = await page.locator('[data-conversation-scroll]').boundingBox()
  253. if (box === null) throw new Error('conversation scrollport has no layout box')
  254. await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
  255. await page.mouse.wheel(0, deltaY)
  256. await nextPaint(page)
  257. }
  258. async function wheelToHistoryStart(page: Page): Promise<void> {
  259. for (let attempt = 0; attempt < 12; attempt += 1) {
  260. if ((await scrollGeometry(page)).scrollTop <= 1) break
  261. await wheelTranscript(page, -2_400)
  262. }
  263. await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
  264. .toBeLessThanOrEqual(1)
  265. }
  266. async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
  267. for (let attempt = 0; attempt < 16; attempt += 1) {
  268. if (await page.locator(selector).count() > 0) return
  269. await wheelTranscript(page, deltaY)
  270. }
  271. throw new Error(`selector did not mount during transcript wheel: ${selector}`)
  272. }
  273. async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise<void> {
  274. const target = page.locator(selector)
  275. for (let attempt = 0; attempt < 32; attempt += 1) {
  276. if (await target.count() > 0 && await target.evaluate((row) => {
  277. const host = row.closest<HTMLElement>('[data-conversation-scroll]')
  278. if (host === null) return false
  279. const viewport = host.getBoundingClientRect()
  280. const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
  281. const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
  282. const rect = row.getBoundingClientRect()
  283. return rect.bottom > viewport.top && rect.top < visibleBottom
  284. })) return
  285. await wheelTranscript(page, deltaY)
  286. }
  287. throw new Error(`selector did not become visible during transcript wheel: ${selector}`)
  288. }
  289. function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
  290. return page.locator('[data-conversation-scroll]').evaluate((host) => {
  291. const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
  292. const viewport = host.getBoundingClientRect()
  293. const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
  294. const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
  295. const visible = rows.filter((candidate) => {
  296. const rect = candidate.getBoundingClientRect()
  297. return rect.bottom > viewport.top && rect.top < visibleBottom
  298. })
  299. const row = visible[0]
  300. if (row?.dataset.chatAnchorKey === undefined) {
  301. throw new Error(`no visible settled Chat row: ${JSON.stringify({
  302. composerTop: visibleBottom,
  303. host: { bottom: viewport.bottom, top: viewport.top },
  304. rows: rows.slice(0, 4).map(candidate => ({
  305. callId: candidate.dataset.chatCallId,
  306. key: candidate.dataset.chatAnchorKey,
  307. rect: {
  308. bottom: candidate.getBoundingClientRect().bottom,
  309. top: candidate.getBoundingClientRect().top,
  310. },
  311. })),
  312. totalRows: rows.length,
  313. })}`)
  314. }
  315. return {
  316. key: row.dataset.chatAnchorKey,
  317. top: row.getBoundingClientRect().top - viewport.top,
  318. }
  319. })
  320. }
  321. function flowTop(page: Page, key: string): Promise<number> {
  322. return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => {
  323. const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey)
  324. if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`)
  325. const host = row.closest('[data-conversation-scroll]')
  326. if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport')
  327. return row.getBoundingClientRect().top - host.getBoundingClientRect().top
  328. }, key)
  329. }
  330. async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise<void> {
  331. await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), {
  332. timeout: 10_000,
  333. message: `flow row ${anchor.key} moved relative to the transcript viewport`,
  334. }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
  335. }
  336. async function expectBottom(page: Page): Promise<void> {
  337. await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), {
  338. timeout: 10_000,
  339. }).toBeLessThanOrEqual(1)
  340. }
  341. async function expectMarkerAboveComposer(page: Page, marker: string): Promise<void> {
  342. const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => {
  343. const row = node.closest('[data-chat-flow-key], [data-streaming]')
  344. const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]')
  345. if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) {
  346. throw new Error('latest marker or composer geometry is unavailable')
  347. }
  348. return {
  349. composerTop: composer.getBoundingClientRect().top,
  350. rowBottom: row.getBoundingClientRect().bottom,
  351. }
  352. })
  353. expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE)
  354. }
  355. async function loadEarlierWithAnchor(page: Page): Promise<void> {
  356. await wheelToHistoryStart(page)
  357. const older = page.getByRole('button', { name: 'Load earlier', exact: true })
  358. await older.waitFor({ timeout: 10_000 })
  359. const anchor = await visibleFlowAnchor(page)
  360. const before = await conversationTurns(page)
  361. await older.click()
  362. await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
  363. await nextPaint(page)
  364. await expectSameFlowTop(page, anchor)
  365. }
  366. async function fileExists(path: string): Promise<boolean> {
  367. try {
  368. await access(path)
  369. return true
  370. } catch {
  371. return false
  372. }
  373. }
  374. function eventCarries(event: SessionEvent, marker: string): boolean {
  375. return JSON.stringify(event).includes(marker)
  376. }
  377. function assertClean(world: ScrollWorld): void {
  378. expect(world.tripwire.pageErrors).toEqual([])
  379. expect(world.tripwire.warnings).toEqual([])
  380. }
  381. let browser: Browser
  382. describe('web e2e: long Chat scroll contract', () => {
  383. beforeAll(async () => {
  384. browser = await chromium.launch()
  385. })
  386. afterAll(async () => {
  387. await browser?.close()
  388. })
  389. it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
  390. await withScrollWorld({
  391. failureShot: 'web-e2e-chat-scroll-history-stream',
  392. replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))],
  393. seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
  394. }, async (world) => {
  395. await openSeed(
  396. world.page,
  397. HISTORY_FIXTURE,
  398. HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns),
  399. )
  400. await expectBottom(world.page)
  401. let releaseHistory = (): void => {}
  402. let held = false
  403. let releaseGate: (() => void) | undefined
  404. const gate = new Promise<void>((resolve) => { releaseGate = resolve })
  405. releaseHistory = () => { releaseGate?.() }
  406. await world.page.route('**/api/session.history', async (route) => {
  407. const request = route.request().postDataJSON() as {
  408. method?: string
  409. payload?: { beforeSeq?: number }
  410. }
  411. if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
  412. held = true
  413. await gate
  414. }
  415. await route.continue()
  416. })
  417. const settled = world.scaffold.whenTurnSettled(60_000)
  418. try {
  419. const composer = world.page.locator('textarea:enabled').last()
  420. await composer.fill(LIVE_TEXT_PROMPT)
  421. await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
  422. await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
  423. await wheelToHistoryStart(world.page)
  424. const beforeTurns = await conversationTurns(world.page)
  425. await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
  426. await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
  427. await wheelTranscript(world.page, 420)
  428. const readerAnchor = await visibleFlowAnchor(world.page)
  429. const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length
  430. await expect.poll(
  431. () => world.events.filter(event => event.type === 'assistant/chunk').length,
  432. { timeout: 10_000 },
  433. ).toBeGreaterThan(chunksAfterAnchor + 5)
  434. releaseHistory()
  435. await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
  436. await nextPaint(world.page)
  437. await expectSameFlowTop(world.page, readerAnchor)
  438. } finally {
  439. releaseHistory()
  440. }
  441. await settled
  442. await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
  443. await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
  444. await world.page.unroute('**/api/session.history')
  445. let additionalPages = 0
  446. while (additionalPages < 8) {
  447. await wheelToHistoryStart(world.page)
  448. if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break
  449. await loadEarlierWithAnchor(world.page)
  450. additionalPages += 1
  451. }
  452. expect(additionalPages).toBeGreaterThan(0)
  453. expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
  454. expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
  455. assertClean(world)
  456. })
  457. }, 180_000)
  458. it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
  459. await withScrollWorld({
  460. failureShot: 'web-e2e-chat-scroll-live-tool',
  461. replay: [
  462. replayEntry(toolStream()),
  463. replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)),
  464. ],
  465. seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_SESSION_ID }],
  466. }, async (world) => {
  467. const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
  468. const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
  469. await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns))
  470. const settled = world.scaffold.whenTurnSettled(60_000)
  471. let released = false
  472. try {
  473. const composer = world.page.locator('textarea:enabled').last()
  474. await composer.fill(LIVE_TOOL_PROMPT)
  475. await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
  476. await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
  477. const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`)
  478. await liveRow.waitFor({ timeout: 15_000 })
  479. expect(await liveRow.getAttribute('data-state')).toBe('running')
  480. await expectBottom(world.page)
  481. await wheelTranscript(world.page, -1_200)
  482. await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })
  483. const awayAnchor = await visibleFlowAnchor(world.page)
  484. const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
  485. await writeFile(releasePath, 'release\n')
  486. released = true
  487. await expect.poll(
  488. () => world.events.some(event => event.type === 'tool/result'),
  489. { timeout: 15_000 },
  490. ).toBe(true)
  491. await expect.poll(
  492. () => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)),
  493. { timeout: 15_000 },
  494. ).toBe(true)
  495. await expect.poll(
  496. () => world.events.filter(event => event.type === 'assistant/chunk').length,
  497. { timeout: 15_000 },
  498. ).toBeGreaterThan(chunksBeforeRelease + 5)
  499. await expectSameFlowTop(world.page, awayAnchor)
  500. const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
  501. await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
  502. await expectBottom(world.page)
  503. await expect.poll(
  504. () => world.events.filter(event => event.type === 'assistant/chunk').length,
  505. { timeout: 15_000 },
  506. ).toBeGreaterThan(chunksAtRepin + 5)
  507. await expectBottom(world.page)
  508. } finally {
  509. if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
  510. }
  511. await settled
  512. await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
  513. await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
  514. await expectBottom(world.page)
  515. await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE)
  516. const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
  517. const liveRow = world.page.locator(liveRowSelector)
  518. await wheelUntilVisible(world.page, liveRowSelector, -300)
  519. const toolAnchor = await liveRow.evaluate((row) => {
  520. const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
  521. const host = row.closest<HTMLElement>('[data-conversation-scroll]')
  522. if (flow?.dataset.chatAnchorKey === undefined || host === null) {
  523. throw new Error('live tool row has no settled flow identity')
  524. }
  525. return {
  526. key: flow.dataset.chatAnchorKey,
  527. top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top,
  528. }
  529. })
  530. await liveRow.click()
  531. await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
  532. await expectSameFlowTop(world.page, toolAnchor)
  533. await wheelToHistoryStart(world.page)
  534. await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
  535. await expectBottom(world.page)
  536. await wheelUntilMounted(world.page, liveRowSelector, -1_100)
  537. const restoredRow = world.page.locator(liveRowSelector)
  538. await restoredRow.waitFor({ timeout: 10_000 })
  539. expect(await restoredRow.getAttribute('aria-expanded')).toBe('true')
  540. expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0)
  541. assertClean(world)
  542. })
  543. }, 180_000)
  544. it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
  545. await withScrollWorld({
  546. failureShot: 'web-e2e-chat-scroll-restore-composer',
  547. seeds: [
  548. { fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID },
  549. { fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID },
  550. ],
  551. }, async (world) => {
  552. await openSeed(
  553. world.page,
  554. RESTORE_FIXTURE_A,
  555. RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
  556. )
  557. await loadEarlierWithAnchor(world.page)
  558. await loadEarlierWithAnchor(world.page)
  559. await wheelToHistoryStart(world.page)
  560. await wheelTranscript(world.page, 1_300)
  561. const sessionAnchor = await visibleFlowAnchor(world.page)
  562. await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
  563. await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
  564. await world.page.setViewportSize({ width: 700, height: 900 })
  565. // The narrow breakpoint auto-collapses the sidebar. Re-open it because
  566. // this scenario switches sessions while pinning the narrow Chat scroll owner.
  567. await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
  568. await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
  569. await nextPaint(world.page)
  570. await expectSameFlowTop(world.page, sessionAnchor)
  571. await openSeed(
  572. world.page,
  573. RESTORE_FIXTURE_B,
  574. RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
  575. )
  576. await openSeed(
  577. world.page,
  578. RESTORE_FIXTURE_A,
  579. )
  580. await expectSameFlowTop(world.page, sessionAnchor)
  581. const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
  582. await backToBottom.evaluate((button) => {
  583. if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
  584. button.click()
  585. const trajectory = [...document.querySelectorAll<HTMLElement>('[role="tab"]')]
  586. .find(tab => tab.textContent?.trim() === 'Trajectory')
  587. if (!(trajectory instanceof HTMLElement)) {
  588. throw new Error('Trajectory tab is unavailable during pinned remount')
  589. }
  590. trajectory.click()
  591. })
  592. await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
  593. await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
  594. await expectBottom(world.page)
  595. await openSeed(
  596. world.page,
  597. RESTORE_FIXTURE_B,
  598. RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
  599. )
  600. await openSeed(
  601. world.page,
  602. RESTORE_FIXTURE_A,
  603. RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
  604. )
  605. await expectBottom(world.page)
  606. const composer = world.page.locator('textarea:enabled').last()
  607. const longDraft = Array.from(
  608. { length: 18 },
  609. (_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`,
  610. ).join('\n')
  611. await composer.fill(longDraft)
  612. await nextPaint(world.page)
  613. await expectBottom(world.page)
  614. await expectMarkerAboveComposer(
  615. world.page,
  616. RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
  617. )
  618. await composer.fill('short draft')
  619. await nextPaint(world.page)
  620. await wheelTranscript(world.page, -900)
  621. const resizeAnchor = await visibleFlowAnchor(world.page)
  622. await composer.fill(longDraft)
  623. await nextPaint(world.page)
  624. await expectSameFlowTop(world.page, resizeAnchor)
  625. await composer.fill('short draft')
  626. await nextPaint(world.page)
  627. await expectSameFlowTop(world.page, resizeAnchor)
  628. const beforeChain = await scrollGeometry(world.page)
  629. await composer.hover()
  630. await world.page.mouse.wheel(0, -320)
  631. await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 })
  632. .toBeLessThan(beforeChain.scrollTop)
  633. assertClean(world)
  634. })
  635. }, 180_000)
  636. })