chat-scroll-contract.e2e.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843
  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 INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
  43. const FLING_SESSION_ID = 'chat-scroll-fling-e2e'
  44. const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
  45. const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
  46. const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE'
  47. const HISTORY_FIXTURE = createChatScrollFixture({
  48. markerPrefix: 'HISTORY',
  49. title: 'CHAT_SCROLL_HISTORY long paging session',
  50. })
  51. const TOOL_FIXTURE = createChatScrollFixture({
  52. markerPrefix: 'TOOL',
  53. title: 'CHAT_SCROLL_TOOL live tool session',
  54. })
  55. const RESTORE_FIXTURE_A = createChatScrollFixture({
  56. markerPrefix: 'RESTORE_A',
  57. title: 'CHAT_SCROLL_RESTORE_A long session',
  58. })
  59. const RESTORE_FIXTURE_B = createChatScrollFixture({
  60. markerPrefix: 'RESTORE_B',
  61. title: 'CHAT_SCROLL_RESTORE_B comparison session',
  62. turns: 32,
  63. })
  64. const INPUTS_FIXTURE = createChatScrollFixture({
  65. markerPrefix: 'INPUTS',
  66. title: 'CHAT_SCROLL_INPUTS non-wheel reader input session',
  67. })
  68. interface ScrollGeometry {
  69. readonly distanceFromBottom: number
  70. readonly scrollTop: number
  71. }
  72. interface FlowAnchor {
  73. readonly key: string
  74. readonly top: number
  75. }
  76. interface ScrollWorld {
  77. readonly events: SessionEvent[]
  78. readonly page: Page
  79. readonly replayDir?: string
  80. readonly scaffold: WebScaffold
  81. readonly tripwire: ReturnType<typeof watchConsole>
  82. }
  83. interface ScrollWorldOptions {
  84. readonly failureShot: string
  85. readonly replay?: ReplayOverrideDoc
  86. readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[]
  87. }
  88. function textStream(first: string, done: string, deltaCount: number): StreamChunk[] {
  89. const deltas = Array.from({ length: deltaCount }, (_, index) => {
  90. if (index === 0) return `${first} `
  91. if (index === deltaCount - 1) return `${done}.`
  92. return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}`
  93. })
  94. const response = deltas.join('')
  95. return [
  96. { type: 'block-start', index: 0, blockType: 'text' },
  97. ...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
  98. { type: 'block-end', index: 0, block: { type: 'text', text: response } },
  99. {
  100. type: 'usage',
  101. usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) },
  102. },
  103. { type: 'finish', reason: { kind: 'stop' } },
  104. ]
  105. }
  106. function toolStream(): StreamChunk[] {
  107. const command = [
  108. `: > ${TOOL_READY_FILE}`,
  109. `while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`,
  110. 'line=1',
  111. `while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`,
  112. ].join('; ')
  113. const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT })
  114. return [
  115. { type: 'block-start', index: 0, blockType: 'tool-call' },
  116. {
  117. type: 'tool-call-delta',
  118. index: 0,
  119. id: LIVE_TOOL_CALL_ID,
  120. name: 'bash',
  121. argumentsDelta: args,
  122. },
  123. {
  124. type: 'block-end',
  125. index: 0,
  126. block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args },
  127. },
  128. { type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } },
  129. { type: 'finish', reason: { kind: 'tool-calls' } },
  130. ]
  131. }
  132. function replayEntry(chunks: StreamChunk[]): ReplayEntry {
  133. return { kind: 'chunks', chunks }
  134. }
  135. async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWorld> {
  136. let replayDir: string | undefined
  137. let scaffold: WebScaffold | undefined
  138. let page: Page | undefined
  139. try {
  140. if (options.replay !== undefined) {
  141. replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-'))
  142. const replayOverride = join(replayDir, 'replay.override.json')
  143. await writeFile(replayOverride, JSON.stringify(options.replay))
  144. scaffold = await launchWebScaffold({
  145. replayFixture: join(replayDir, 'override-only.jsonl'),
  146. replayOverride,
  147. paceMs: STREAM_PACE_MS,
  148. replayContextWindow: REPLAY_CONTEXT_WINDOW,
  149. })
  150. } else {
  151. scaffold = await launchWebScaffold({})
  152. }
  153. for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id)
  154. const events: SessionEvent[] = []
  155. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
  156. page = await newEnglishPage(browser, 900)
  157. const tripwire = watchConsole(page)
  158. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  159. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  160. // Session-list bootstrap can replace the controlled search state. Wait
  161. // for the seeded baseline before openSeed starts the lazy content query
  162. // (the compact layout dropped group session counts; the Ungrouped bucket
  163. // row is the barrier).
  164. await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
  165. return {
  166. events,
  167. page,
  168. scaffold,
  169. tripwire,
  170. ...(replayDir === undefined ? {} : { replayDir }),
  171. }
  172. } catch (error) {
  173. const failures: unknown[] = [error]
  174. if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError))
  175. if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError))
  176. if (replayDir !== undefined) {
  177. await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
  178. }
  179. if (failures.length === 1) throw error
  180. throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete')
  181. }
  182. }
  183. async function closeScrollWorld(world: ScrollWorld): Promise<void> {
  184. const failures: unknown[] = []
  185. // newEnglishPage/browser.newPage owns an isolated context. Close the whole
  186. // context so its SSE connection and cache cannot leak into the next world
  187. // in this file's shared Chromium process.
  188. await world.page.context().close().catch((error: unknown) => failures.push(error))
  189. await world.scaffold.close().catch((error: unknown) => failures.push(error))
  190. if (world.replayDir !== undefined) {
  191. await rm(world.replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  192. }
  193. if (failures.length === 1) throw failures[0]
  194. if (failures.length > 1) throw new AggregateError(failures, 'chat-scroll browser world cleanup failed')
  195. }
  196. async function withScrollWorld(
  197. options: ScrollWorldOptions,
  198. run: (world: ScrollWorld) => Promise<void>,
  199. ): Promise<void> {
  200. const world = await launchScrollWorld(options)
  201. let runFailure: unknown
  202. try {
  203. await run(world)
  204. } catch (error) {
  205. runFailure = error
  206. try {
  207. await saveFailureShot(world.page, options.failureShot)
  208. } catch {
  209. // Best-effort evidence must never prevent cleanup of the owned world.
  210. }
  211. }
  212. let cleanupFailure: unknown
  213. try {
  214. await closeScrollWorld(world)
  215. } catch (error) {
  216. cleanupFailure = error
  217. }
  218. if (runFailure !== undefined && cleanupFailure !== undefined) {
  219. throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed')
  220. }
  221. if (runFailure !== undefined) throw runFailure
  222. if (cleanupFailure !== undefined) throw cleanupFailure
  223. }
  224. async function nextPaint(page: Page): Promise<void> {
  225. await page.evaluate(async () => {
  226. await document.fonts.ready
  227. await new Promise<void>(resolve => requestAnimationFrame(() => {
  228. requestAnimationFrame(() => { resolve() })
  229. }))
  230. })
  231. }
  232. function scrollGeometry(page: Page): Promise<ScrollGeometry> {
  233. return page.locator('[data-conversation-scroll]').evaluate(host => ({
  234. distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop,
  235. scrollTop: host.scrollTop,
  236. }))
  237. }
  238. /**
  239. * Rendered transcript rows in the loaded window. The stats strip cannot serve
  240. * as this probe: its turn/step counts ride the whole-log sessionStats
  241. * projection and stay fixed across paging by design, while the row count is
  242. * exactly what grows when an older page prepends or a live turn streams in.
  243. * @param page - the scenario page.
  244. * @returns the number of mounted chat flow rows.
  245. */
  246. async function loadedFlowRows(page: Page): Promise<number> {
  247. return page.locator('[data-chat-flow-key]').count()
  248. }
  249. async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
  250. // Search collapsed into a header action; expand it before filling.
  251. const searchButton = page.getByRole('button', { name: 'Search sessions' })
  252. if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
  253. const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
  254. // Cold summaries initially show the temporary workspace basename, so the
  255. // persisted first-prompt marker is the stable user-facing identity. The
  256. // query itself triggers lazy content-index reconciliation; no transient
  257. // empty-state paint is used as a barrier.
  258. await search.fill(fixture.markers.user(1))
  259. const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
  260. await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
  261. await results.click()
  262. await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
  263. if (tailMarker !== undefined) {
  264. await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 })
  265. }
  266. await nextPaint(page)
  267. }
  268. async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
  269. const box = await page.locator('[data-conversation-scroll]').boundingBox()
  270. if (box === null) throw new Error('conversation scrollport has no layout box')
  271. await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
  272. await page.mouse.wheel(0, deltaY)
  273. await nextPaint(page)
  274. }
  275. /**
  276. * Touch-style momentum fling over the transcript. Headless Chromium in the
  277. * test lane cannot synthesize device scrolling (Input.synthesizeScrollGesture
  278. * and Input.dispatchTouchEvent both deliver DOM events without moving any
  279. * scroller, and compositor scrollbars ignore synthetic mouse input), so the
  280. * fling replays the signature a real pan leaves on the scrollport: per-frame
  281. * decaying displacements the component never authored, carrying no wheel
  282. * events. Wheel-sign semantics: positive deltaY reads downward.
  283. */
  284. async function flingTranscript(page: Page, deltaY: number): Promise<void> {
  285. await page.locator('[data-conversation-scroll]').evaluate(async (host, delta) => {
  286. const direction = Math.sign(delta)
  287. let remaining = Math.abs(delta)
  288. // Fast launch decaying toward a floor speed, like a released finger. The
  289. // floor stays above the follow threshold so contended frames (streaming
  290. // writes racing the fling) still deviate far enough to read as input.
  291. let velocity = Math.max(120, remaining / 8)
  292. while (remaining > 0) {
  293. const step = Math.min(velocity, remaining)
  294. host.scrollTop += direction * step
  295. remaining -= step
  296. velocity = Math.max(48, velocity * 0.9)
  297. await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
  298. }
  299. }, deltaY)
  300. await nextPaint(page)
  301. }
  302. async function wheelToHistoryStart(page: Page): Promise<void> {
  303. for (let attempt = 0; attempt < 12; attempt += 1) {
  304. if ((await scrollGeometry(page)).scrollTop <= 1) break
  305. await wheelTranscript(page, -2_400)
  306. }
  307. await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
  308. .toBeLessThanOrEqual(1)
  309. }
  310. async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
  311. for (let attempt = 0; attempt < 16; attempt += 1) {
  312. if (await page.locator(selector).count() > 0) return
  313. await wheelTranscript(page, deltaY)
  314. }
  315. throw new Error(`selector did not mount during transcript wheel: ${selector}`)
  316. }
  317. async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise<void> {
  318. const target = page.locator(selector)
  319. for (let attempt = 0; attempt < 32; attempt += 1) {
  320. if (await target.count() > 0 && await target.evaluate((row) => {
  321. const host = row.closest<HTMLElement>('[data-conversation-scroll]')
  322. if (host === null) return false
  323. const viewport = host.getBoundingClientRect()
  324. const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
  325. const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
  326. const rect = row.getBoundingClientRect()
  327. return rect.bottom > viewport.top && rect.top < visibleBottom
  328. })) return
  329. await wheelTranscript(page, deltaY)
  330. }
  331. throw new Error(`selector did not become visible during transcript wheel: ${selector}`)
  332. }
  333. function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
  334. return page.locator('[data-conversation-scroll]').evaluate((host) => {
  335. const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
  336. const viewport = host.getBoundingClientRect()
  337. const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
  338. const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
  339. const visible = rows.filter((candidate) => {
  340. const rect = candidate.getBoundingClientRect()
  341. return rect.bottom > viewport.top && rect.top < visibleBottom
  342. })
  343. const row = visible[0]
  344. if (row?.dataset.chatAnchorKey === undefined) {
  345. throw new Error(`no visible settled Chat row: ${JSON.stringify({
  346. composerTop: visibleBottom,
  347. host: { bottom: viewport.bottom, top: viewport.top },
  348. rows: rows.slice(0, 4).map(candidate => ({
  349. callId: candidate.dataset.chatCallId,
  350. key: candidate.dataset.chatAnchorKey,
  351. rect: {
  352. bottom: candidate.getBoundingClientRect().bottom,
  353. top: candidate.getBoundingClientRect().top,
  354. },
  355. })),
  356. totalRows: rows.length,
  357. })}`)
  358. }
  359. return {
  360. key: row.dataset.chatAnchorKey,
  361. top: row.getBoundingClientRect().top - viewport.top,
  362. }
  363. })
  364. }
  365. function flowTop(page: Page, key: string): Promise<number> {
  366. return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => {
  367. const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey)
  368. if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`)
  369. const host = row.closest('[data-conversation-scroll]')
  370. if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport')
  371. return row.getBoundingClientRect().top - host.getBoundingClientRect().top
  372. }, key)
  373. }
  374. async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise<void> {
  375. await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), {
  376. timeout: 10_000,
  377. message: `flow row ${anchor.key} moved relative to the transcript viewport`,
  378. }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
  379. }
  380. async function expectBottom(page: Page): Promise<void> {
  381. await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), {
  382. timeout: 10_000,
  383. }).toBeLessThanOrEqual(1)
  384. }
  385. async function expectMarkerAboveComposer(page: Page, marker: string): Promise<void> {
  386. const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => {
  387. const row = node.closest('[data-chat-flow-key], [data-streaming]')
  388. const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]')
  389. if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) {
  390. throw new Error('latest marker or composer geometry is unavailable')
  391. }
  392. return {
  393. composerTop: composer.getBoundingClientRect().top,
  394. rowBottom: row.getBoundingClientRect().bottom,
  395. }
  396. })
  397. expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE)
  398. }
  399. async function loadEarlierWithAnchor(page: Page): Promise<void> {
  400. await wheelToHistoryStart(page)
  401. const older = page.getByRole('button', { name: 'Load earlier', exact: true })
  402. await older.waitFor({ timeout: 10_000 })
  403. const anchor = await visibleFlowAnchor(page)
  404. const before = await loadedFlowRows(page)
  405. await older.click()
  406. await expect.poll(() => loadedFlowRows(page), { timeout: 30_000 }).toBeGreaterThan(before)
  407. await nextPaint(page)
  408. await expectSameFlowTop(page, anchor)
  409. }
  410. async function fileExists(path: string): Promise<boolean> {
  411. try {
  412. await access(path)
  413. return true
  414. } catch {
  415. return false
  416. }
  417. }
  418. function eventCarries(event: SessionEvent, marker: string): boolean {
  419. return JSON.stringify(event).includes(marker)
  420. }
  421. function assertClean(world: ScrollWorld): void {
  422. expect(world.tripwire.pageErrors).toEqual([])
  423. expect(world.tripwire.warnings).toEqual([])
  424. }
  425. let browser: Browser
  426. describe('web e2e: long Chat scroll contract', () => {
  427. beforeAll(async () => {
  428. browser = await chromium.launch()
  429. })
  430. afterAll(async () => {
  431. await browser?.close()
  432. })
  433. it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
  434. await withScrollWorld({
  435. failureShot: 'web-e2e-chat-scroll-history-stream',
  436. replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))],
  437. seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
  438. }, async (world) => {
  439. await openSeed(
  440. world.page,
  441. HISTORY_FIXTURE,
  442. HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns),
  443. )
  444. await expectBottom(world.page)
  445. let releaseHistory = (): void => {}
  446. let held = false
  447. let releaseGate: (() => void) | undefined
  448. const gate = new Promise<void>((resolve) => { releaseGate = resolve })
  449. releaseHistory = () => { releaseGate?.() }
  450. await world.page.route('**/api/session.history', async (route) => {
  451. const request = route.request().postDataJSON() as {
  452. method?: string
  453. payload?: { beforeSeq?: number }
  454. }
  455. if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
  456. held = true
  457. await gate
  458. }
  459. await route.continue()
  460. })
  461. const settled = world.scaffold.whenTurnSettled(60_000)
  462. try {
  463. const composer = world.page.locator('textarea:enabled').last()
  464. await composer.fill(LIVE_TEXT_PROMPT)
  465. await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
  466. await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
  467. await wheelToHistoryStart(world.page)
  468. const beforeRows = await loadedFlowRows(world.page)
  469. await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
  470. await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
  471. await wheelTranscript(world.page, 420)
  472. const readerAnchor = await visibleFlowAnchor(world.page)
  473. const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length
  474. await expect.poll(
  475. () => world.events.filter(event => event.type === 'assistant/chunk').length,
  476. { timeout: 10_000 },
  477. ).toBeGreaterThan(chunksAfterAnchor + 5)
  478. releaseHistory()
  479. await expect.poll(() => loadedFlowRows(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeRows)
  480. await nextPaint(world.page)
  481. await expectSameFlowTop(world.page, readerAnchor)
  482. } finally {
  483. releaseHistory()
  484. }
  485. await settled
  486. await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
  487. await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
  488. await world.page.unroute('**/api/session.history')
  489. let additionalPages = 0
  490. while (additionalPages < 8) {
  491. await wheelToHistoryStart(world.page)
  492. if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break
  493. await loadEarlierWithAnchor(world.page)
  494. additionalPages += 1
  495. }
  496. expect(additionalPages).toBeGreaterThan(0)
  497. // The whole log is loaded: turn 1's unique marker renders in the
  498. // transcript (scoped: the sidebar search row also carries it) and no
  499. // page remains.
  500. expect(await world.page.locator('[data-conversation-scroll]')
  501. .getByText(HISTORY_FIXTURE.markers.user(1), { exact: false }).count()).toBe(1)
  502. expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
  503. assertClean(world)
  504. })
  505. }, 180_000)
  506. it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
  507. await withScrollWorld({
  508. failureShot: 'web-e2e-chat-scroll-live-tool',
  509. replay: [
  510. replayEntry(toolStream()),
  511. replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)),
  512. ],
  513. seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_SESSION_ID }],
  514. }, async (world) => {
  515. const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
  516. const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
  517. await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns))
  518. const settled = world.scaffold.whenTurnSettled(60_000)
  519. let released = false
  520. try {
  521. const composer = world.page.locator('textarea:enabled').last()
  522. await composer.fill(LIVE_TOOL_PROMPT)
  523. await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
  524. await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
  525. const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`)
  526. await liveRow.waitFor({ timeout: 15_000 })
  527. expect(await liveRow.getAttribute('data-state')).toBe('running')
  528. await expectBottom(world.page)
  529. await wheelTranscript(world.page, -1_200)
  530. await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })
  531. const awayAnchor = await visibleFlowAnchor(world.page)
  532. const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
  533. await writeFile(releasePath, 'release\n')
  534. released = true
  535. await expect.poll(
  536. () => world.events.some(event => event.type === 'tool/result'),
  537. { timeout: 15_000 },
  538. ).toBe(true)
  539. await expect.poll(
  540. () => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)),
  541. { timeout: 15_000 },
  542. ).toBe(true)
  543. await expect.poll(
  544. () => world.events.filter(event => event.type === 'assistant/chunk').length,
  545. { timeout: 15_000 },
  546. ).toBeGreaterThan(chunksBeforeRelease + 5)
  547. await expectSameFlowTop(world.page, awayAnchor)
  548. const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
  549. await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
  550. await expectBottom(world.page)
  551. await expect.poll(
  552. () => world.events.filter(event => event.type === 'assistant/chunk').length,
  553. { timeout: 15_000 },
  554. ).toBeGreaterThan(chunksAtRepin + 5)
  555. await expectBottom(world.page)
  556. } finally {
  557. if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
  558. }
  559. await settled
  560. await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
  561. await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
  562. await expectBottom(world.page)
  563. await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE)
  564. const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
  565. const liveRow = world.page.locator(liveRowSelector)
  566. await wheelUntilVisible(world.page, liveRowSelector, -300)
  567. const toolAnchor = await liveRow.evaluate((row) => {
  568. const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
  569. const host = row.closest<HTMLElement>('[data-conversation-scroll]')
  570. if (flow?.dataset.chatAnchorKey === undefined || host === null) {
  571. throw new Error('live tool row has no settled flow identity')
  572. }
  573. return {
  574. key: flow.dataset.chatAnchorKey,
  575. top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top,
  576. }
  577. })
  578. await liveRow.click()
  579. await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
  580. await expectSameFlowTop(world.page, toolAnchor)
  581. await wheelToHistoryStart(world.page)
  582. await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
  583. await expectBottom(world.page)
  584. await wheelUntilMounted(world.page, liveRowSelector, -1_100)
  585. const restoredRow = world.page.locator(liveRowSelector)
  586. await restoredRow.waitFor({ timeout: 10_000 })
  587. expect(await restoredRow.getAttribute('aria-expanded')).toBe('true')
  588. expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0)
  589. assertClean(world)
  590. })
  591. }, 180_000)
  592. it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
  593. await withScrollWorld({
  594. failureShot: 'web-e2e-chat-scroll-restore-composer',
  595. seeds: [
  596. { fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID },
  597. { fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID },
  598. ],
  599. }, async (world) => {
  600. await openSeed(
  601. world.page,
  602. RESTORE_FIXTURE_A,
  603. RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
  604. )
  605. await loadEarlierWithAnchor(world.page)
  606. await loadEarlierWithAnchor(world.page)
  607. await wheelToHistoryStart(world.page)
  608. await wheelTranscript(world.page, 1_300)
  609. const sessionAnchor = await visibleFlowAnchor(world.page)
  610. await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
  611. await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
  612. await world.page.setViewportSize({ width: 700, height: 900 })
  613. // The narrow breakpoint auto-collapses the sidebar. Re-open it because
  614. // this scenario switches sessions while pinning the narrow Chat scroll owner.
  615. await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
  616. await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
  617. await nextPaint(world.page)
  618. await expectSameFlowTop(world.page, sessionAnchor)
  619. await openSeed(
  620. world.page,
  621. RESTORE_FIXTURE_B,
  622. RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
  623. )
  624. await openSeed(
  625. world.page,
  626. RESTORE_FIXTURE_A,
  627. )
  628. await expectSameFlowTop(world.page, sessionAnchor)
  629. const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
  630. await backToBottom.evaluate((button) => {
  631. if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
  632. button.click()
  633. const trajectory = [...document.querySelectorAll<HTMLElement>('[role="tab"]')]
  634. .find(tab => tab.textContent?.trim() === 'Trajectory')
  635. if (!(trajectory instanceof HTMLElement)) {
  636. throw new Error('Trajectory tab is unavailable during pinned remount')
  637. }
  638. trajectory.click()
  639. })
  640. await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
  641. await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
  642. await expectBottom(world.page)
  643. await openSeed(
  644. world.page,
  645. RESTORE_FIXTURE_B,
  646. RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
  647. )
  648. await openSeed(
  649. world.page,
  650. RESTORE_FIXTURE_A,
  651. RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
  652. )
  653. await expectBottom(world.page)
  654. const composer = world.page.locator('textarea:enabled').last()
  655. const longDraft = Array.from(
  656. { length: 18 },
  657. (_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`,
  658. ).join('\n')
  659. await composer.fill(longDraft)
  660. await nextPaint(world.page)
  661. await expectBottom(world.page)
  662. await expectMarkerAboveComposer(
  663. world.page,
  664. RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
  665. )
  666. await composer.fill('short draft')
  667. await nextPaint(world.page)
  668. await wheelTranscript(world.page, -900)
  669. const resizeAnchor = await visibleFlowAnchor(world.page)
  670. await composer.fill(longDraft)
  671. await nextPaint(world.page)
  672. await expectSameFlowTop(world.page, resizeAnchor)
  673. await composer.fill('short draft')
  674. await nextPaint(world.page)
  675. await expectSameFlowTop(world.page, resizeAnchor)
  676. const beforeChain = await scrollGeometry(world.page)
  677. await composer.hover()
  678. await world.page.mouse.wheel(0, -320)
  679. await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 })
  680. .toBeLessThan(beforeChain.scrollTop)
  681. assertClean(world)
  682. })
  683. }, 180_000)
  684. // Keyboard is the only non-wheel device this lane's Chromium can drive for
  685. // real (see flingTranscript for the probe results on touch and scrollbars),
  686. // so it stands in for the whole hardware input pipeline here.
  687. it.skipIf(MODE === 'record')('keyboard paging owns bottom-follow without wheel input', async () => {
  688. await withScrollWorld({
  689. failureShot: 'web-e2e-chat-scroll-keyboard',
  690. seeds: [{ fixture: INPUTS_FIXTURE, id: INPUTS_SESSION_ID }],
  691. }, async (world) => {
  692. await openSeed(
  693. world.page,
  694. INPUTS_FIXTURE,
  695. INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns),
  696. )
  697. await expectBottom(world.page)
  698. const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
  699. // Focus rides the last seeded tool row (a tabbable button whose keydown
  700. // handler passes scrolling keys through). End first normalizes the
  701. // focus-driven scrollIntoView back to the floor.
  702. const lastToolRow = world.page.locator(
  703. `[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`,
  704. )
  705. await lastToolRow.focus()
  706. await world.page.keyboard.press('End')
  707. await expectBottom(world.page)
  708. await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
  709. for (let press = 0; press < 3; press += 1) {
  710. await world.page.keyboard.press('PageUp')
  711. await nextPaint(world.page)
  712. }
  713. await backToBottom.waitFor({ timeout: 10_000 })
  714. await expect.poll(async () => (await scrollGeometry(world.page)).distanceFromBottom, { timeout: 10_000 })
  715. .toBeGreaterThan(100)
  716. await world.page.keyboard.press('End')
  717. await expectBottom(world.page)
  718. await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
  719. assertClean(world)
  720. })
  721. }, 180_000)
  722. it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => {
  723. await withScrollWorld({
  724. failureShot: 'web-e2e-chat-scroll-fling-stream',
  725. replay: [
  726. replayEntry(toolStream()),
  727. replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)),
  728. ],
  729. seeds: [{ fixture: INPUTS_FIXTURE, id: FLING_SESSION_ID }],
  730. }, async (world) => {
  731. const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
  732. const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
  733. await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns))
  734. const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
  735. const settled = world.scaffold.whenTurnSettled(60_000)
  736. let released = false
  737. try {
  738. const composer = world.page.locator('textarea:enabled').last()
  739. await composer.fill(LIVE_FLING_PROMPT)
  740. await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
  741. await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
  742. await expectBottom(world.page)
  743. // Fling away while the turn is mid-flight: the scroll burst alone must
  744. // release bottom ownership, exactly like a wheel scroll would, even
  745. // while streaming keeps re-asserting the floor between frames.
  746. await flingTranscript(world.page, -900)
  747. await backToBottom.waitFor({ timeout: 10_000 })
  748. const awayAnchor = await visibleFlowAnchor(world.page)
  749. const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
  750. await writeFile(releasePath, 'release\n')
  751. released = true
  752. await expect.poll(
  753. () => world.events.some(event => event.type === 'tool/result'),
  754. { timeout: 15_000 },
  755. ).toBe(true)
  756. await expect.poll(
  757. () => world.events.filter(event => event.type === 'assistant/chunk').length,
  758. { timeout: 15_000 },
  759. ).toBeGreaterThan(chunksBeforeRelease + 5)
  760. await expectSameFlowTop(world.page, awayAnchor)
  761. // Fling back to the floor: re-pin must come from the reader's scroll
  762. // itself, and follow must then own the still-streaming tail. The
  763. // retry loop chases the floor that streaming keeps pushing down.
  764. for (let attempt = 0; attempt < 8; attempt += 1) {
  765. if ((await scrollGeometry(world.page)).distanceFromBottom <= 1) break
  766. await flingTranscript(world.page, 1_600)
  767. }
  768. await expectBottom(world.page)
  769. await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
  770. const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
  771. await expect.poll(
  772. () => world.events.filter(event => event.type === 'assistant/chunk').length,
  773. { timeout: 15_000 },
  774. ).toBeGreaterThan(chunksAtRepin + 5)
  775. await expectBottom(world.page)
  776. } finally {
  777. if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
  778. }
  779. await settled
  780. await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
  781. await world.page.getByText(LIVE_FLING_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
  782. await expectBottom(world.page)
  783. assertClean(world)
  784. })
  785. }, 180_000)
  786. })