chat-scroll-contract.e2e.ts 43 KB

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