|
|
@@ -0,0 +1,667 @@
|
|
|
+// Browser geometry contracts for a long Chat transcript. These scenarios are
|
|
|
+// deliberately virtualizer-neutral: they assert semantic-row position,
|
|
|
+// bottom ownership, interaction state, and the real outer scroll host rather
|
|
|
+// than DOM cardinality or implementation-specific spacer markup.
|
|
|
+import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
|
+import { tmpdir } from 'node:os'
|
|
|
+import { join } from 'node:path'
|
|
|
+import type { Browser, Page } from 'playwright'
|
|
|
+import { chromium } from 'playwright'
|
|
|
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
|
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
|
+import { CallId } from '@deepseek-ai/dsh-llm'
|
|
|
+import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
|
|
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
+import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
|
|
|
+import {
|
|
|
+ launchWebScaffold,
|
|
|
+ seedSession,
|
|
|
+ watchConsole,
|
|
|
+ webSnapshotMode,
|
|
|
+ type WebScaffold,
|
|
|
+} from './scaffold.ts'
|
|
|
+import { newEnglishPage, saveFailureShot } from './support.ts'
|
|
|
+
|
|
|
+const MODE = webSnapshotMode()
|
|
|
+const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
|
|
|
+const TOOL_SESSION_ID = 'chat-scroll-tool-e2e'
|
|
|
+const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e'
|
|
|
+const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e'
|
|
|
+const REPLAY_CONTEXT_WINDOW = 10_000_000
|
|
|
+const STREAM_PACE_MS = 24
|
|
|
+const GEOMETRY_TOLERANCE = 2
|
|
|
+const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.'
|
|
|
+const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST'
|
|
|
+const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE'
|
|
|
+const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.'
|
|
|
+const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call')
|
|
|
+const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT'
|
|
|
+const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
|
|
|
+const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
|
|
|
+const TOOL_READY_FILE = '.chat-scroll-tool-ready'
|
|
|
+const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
|
|
|
+
|
|
|
+const HISTORY_FIXTURE = createChatScrollFixture({
|
|
|
+ markerPrefix: 'HISTORY',
|
|
|
+ title: 'CHAT_SCROLL_HISTORY long paging session',
|
|
|
+})
|
|
|
+const TOOL_FIXTURE = createChatScrollFixture({
|
|
|
+ markerPrefix: 'TOOL',
|
|
|
+ title: 'CHAT_SCROLL_TOOL live tool session',
|
|
|
+})
|
|
|
+const RESTORE_FIXTURE_A = createChatScrollFixture({
|
|
|
+ markerPrefix: 'RESTORE_A',
|
|
|
+ title: 'CHAT_SCROLL_RESTORE_A long session',
|
|
|
+})
|
|
|
+const RESTORE_FIXTURE_B = createChatScrollFixture({
|
|
|
+ markerPrefix: 'RESTORE_B',
|
|
|
+ title: 'CHAT_SCROLL_RESTORE_B comparison session',
|
|
|
+ turns: 32,
|
|
|
+})
|
|
|
+
|
|
|
+interface ScrollGeometry {
|
|
|
+ readonly distanceFromBottom: number
|
|
|
+ readonly scrollTop: number
|
|
|
+}
|
|
|
+
|
|
|
+interface FlowAnchor {
|
|
|
+ readonly key: string
|
|
|
+ readonly top: number
|
|
|
+}
|
|
|
+
|
|
|
+interface ScrollWorld {
|
|
|
+ readonly events: SessionEvent[]
|
|
|
+ readonly page: Page
|
|
|
+ readonly replayDir?: string
|
|
|
+ readonly scaffold: WebScaffold
|
|
|
+ readonly tripwire: ReturnType<typeof watchConsole>
|
|
|
+}
|
|
|
+
|
|
|
+interface ScrollWorldOptions {
|
|
|
+ readonly failureShot: string
|
|
|
+ readonly replay?: ReplayOverrideDoc
|
|
|
+ readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[]
|
|
|
+}
|
|
|
+
|
|
|
+function textStream(first: string, done: string, deltaCount: number): StreamChunk[] {
|
|
|
+ const deltas = Array.from({ length: deltaCount }, (_, index) => {
|
|
|
+ if (index === 0) return `${first} `
|
|
|
+ if (index === deltaCount - 1) return `${done}.`
|
|
|
+ return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}`
|
|
|
+ })
|
|
|
+ const response = deltas.join('')
|
|
|
+ return [
|
|
|
+ { type: 'block-start', index: 0, blockType: 'text' },
|
|
|
+ ...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
|
|
|
+ { type: 'block-end', index: 0, block: { type: 'text', text: response } },
|
|
|
+ {
|
|
|
+ type: 'usage',
|
|
|
+ usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) },
|
|
|
+ },
|
|
|
+ { type: 'finish', reason: { kind: 'stop' } },
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+function toolStream(): StreamChunk[] {
|
|
|
+ const command = [
|
|
|
+ `: > ${TOOL_READY_FILE}`,
|
|
|
+ `while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`,
|
|
|
+ 'line=1',
|
|
|
+ `while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`,
|
|
|
+ ].join('; ')
|
|
|
+ const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT })
|
|
|
+ return [
|
|
|
+ { type: 'block-start', index: 0, blockType: 'tool-call' },
|
|
|
+ {
|
|
|
+ type: 'tool-call-delta',
|
|
|
+ index: 0,
|
|
|
+ id: LIVE_TOOL_CALL_ID,
|
|
|
+ name: 'bash',
|
|
|
+ argumentsDelta: args,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'block-end',
|
|
|
+ index: 0,
|
|
|
+ block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args },
|
|
|
+ },
|
|
|
+ { type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } },
|
|
|
+ { type: 'finish', reason: { kind: 'tool-calls' } },
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+function replayEntry(chunks: StreamChunk[]): ReplayEntry {
|
|
|
+ return { kind: 'chunks', chunks }
|
|
|
+}
|
|
|
+
|
|
|
+async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWorld> {
|
|
|
+ let replayDir: string | undefined
|
|
|
+ let scaffold: WebScaffold | undefined
|
|
|
+ let page: Page | undefined
|
|
|
+ try {
|
|
|
+ if (options.replay !== undefined) {
|
|
|
+ replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-'))
|
|
|
+ const replayOverride = join(replayDir, 'replay.override.json')
|
|
|
+ await writeFile(replayOverride, JSON.stringify(options.replay))
|
|
|
+ scaffold = await launchWebScaffold({
|
|
|
+ replayFixture: join(replayDir, 'override-only.jsonl'),
|
|
|
+ replayOverride,
|
|
|
+ paceMs: STREAM_PACE_MS,
|
|
|
+ replayContextWindow: REPLAY_CONTEXT_WINDOW,
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ scaffold = await launchWebScaffold({})
|
|
|
+ }
|
|
|
+ for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id)
|
|
|
+ const events: SessionEvent[] = []
|
|
|
+ scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
|
|
|
+ page = await newEnglishPage(browser, 900)
|
|
|
+ const tripwire = watchConsole(page)
|
|
|
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
|
|
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
|
+ // Session-list bootstrap can replace the controlled search state. Wait
|
|
|
+ // for the seeded baseline before openSeed starts the lazy content query.
|
|
|
+ await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
|
|
|
+ return {
|
|
|
+ events,
|
|
|
+ page,
|
|
|
+ scaffold,
|
|
|
+ tripwire,
|
|
|
+ ...(replayDir === undefined ? {} : { replayDir }),
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ const failures: unknown[] = [error]
|
|
|
+ if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError))
|
|
|
+ if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError))
|
|
|
+ if (replayDir !== undefined) {
|
|
|
+ await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
|
|
|
+ }
|
|
|
+ if (failures.length === 1) throw error
|
|
|
+ throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function closeScrollWorld(world: ScrollWorld): Promise<void> {
|
|
|
+ const failures: unknown[] = []
|
|
|
+ // newEnglishPage/browser.newPage owns an isolated context. Close the whole
|
|
|
+ // context so its SSE connection and cache cannot leak into the next world
|
|
|
+ // in this file's shared Chromium process.
|
|
|
+ await world.page.context().close().catch((error: unknown) => failures.push(error))
|
|
|
+ await world.scaffold.close().catch((error: unknown) => failures.push(error))
|
|
|
+ if (world.replayDir !== undefined) {
|
|
|
+ await rm(world.replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
|
|
+ }
|
|
|
+ if (failures.length === 1) throw failures[0]
|
|
|
+ if (failures.length > 1) throw new AggregateError(failures, 'chat-scroll browser world cleanup failed')
|
|
|
+}
|
|
|
+
|
|
|
+async function withScrollWorld(
|
|
|
+ options: ScrollWorldOptions,
|
|
|
+ run: (world: ScrollWorld) => Promise<void>,
|
|
|
+): Promise<void> {
|
|
|
+ const world = await launchScrollWorld(options)
|
|
|
+ let runFailure: unknown
|
|
|
+ try {
|
|
|
+ await run(world)
|
|
|
+ } catch (error) {
|
|
|
+ runFailure = error
|
|
|
+ try {
|
|
|
+ await saveFailureShot(world.page, options.failureShot)
|
|
|
+ } catch {
|
|
|
+ // Best-effort evidence must never prevent cleanup of the owned world.
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let cleanupFailure: unknown
|
|
|
+ try {
|
|
|
+ await closeScrollWorld(world)
|
|
|
+ } catch (error) {
|
|
|
+ cleanupFailure = error
|
|
|
+ }
|
|
|
+ if (runFailure !== undefined && cleanupFailure !== undefined) {
|
|
|
+ throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed')
|
|
|
+ }
|
|
|
+ if (runFailure !== undefined) throw runFailure
|
|
|
+ if (cleanupFailure !== undefined) throw cleanupFailure
|
|
|
+}
|
|
|
+
|
|
|
+async function nextPaint(page: Page): Promise<void> {
|
|
|
+ await page.evaluate(async () => {
|
|
|
+ await document.fonts.ready
|
|
|
+ await new Promise<void>(resolve => requestAnimationFrame(() => {
|
|
|
+ requestAnimationFrame(() => { resolve() })
|
|
|
+ }))
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function scrollGeometry(page: Page): Promise<ScrollGeometry> {
|
|
|
+ return page.locator('[data-conversation-scroll]').evaluate(host => ({
|
|
|
+ distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop,
|
|
|
+ scrollTop: host.scrollTop,
|
|
|
+ }))
|
|
|
+}
|
|
|
+
|
|
|
+async function conversationTurns(page: Page): Promise<number> {
|
|
|
+ const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
|
|
|
+ await stats.waitFor({ timeout: 15_000 })
|
|
|
+ const value = await stats.textContent()
|
|
|
+ const match = value?.match(/^(\d+) turns · \d+ steps$/)
|
|
|
+ if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
|
|
|
+ return Number(match[1])
|
|
|
+}
|
|
|
+
|
|
|
+async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
|
|
|
+ const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
|
|
+ // Cold summaries initially show the temporary workspace basename, so the
|
|
|
+ // persisted first-message marker is the stable user-facing identity. The
|
|
|
+ // query itself triggers lazy content-index reconciliation; no transient
|
|
|
+ // empty-state paint is used as a barrier.
|
|
|
+ await search.fill(fixture.markers.user(1))
|
|
|
+ const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
|
|
+ await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
|
|
|
+ await results.click()
|
|
|
+ await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
|
|
|
+ if (tailMarker !== undefined) {
|
|
|
+ await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 })
|
|
|
+ }
|
|
|
+ await nextPaint(page)
|
|
|
+}
|
|
|
+
|
|
|
+async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
|
|
|
+ const box = await page.locator('[data-conversation-scroll]').boundingBox()
|
|
|
+ if (box === null) throw new Error('conversation scrollport has no layout box')
|
|
|
+ await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
|
|
|
+ await page.mouse.wheel(0, deltaY)
|
|
|
+ await nextPaint(page)
|
|
|
+}
|
|
|
+
|
|
|
+async function wheelToHistoryStart(page: Page): Promise<void> {
|
|
|
+ for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
|
+ if ((await scrollGeometry(page)).scrollTop <= 1) break
|
|
|
+ await wheelTranscript(page, -2_400)
|
|
|
+ }
|
|
|
+ await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
|
|
|
+ .toBeLessThanOrEqual(1)
|
|
|
+}
|
|
|
+
|
|
|
+async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
|
|
|
+ for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
|
+ if (await page.locator(selector).count() > 0) return
|
|
|
+ await wheelTranscript(page, deltaY)
|
|
|
+ }
|
|
|
+ throw new Error(`selector did not mount during transcript wheel: ${selector}`)
|
|
|
+}
|
|
|
+
|
|
|
+function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
|
|
|
+ return page.locator('[data-conversation-scroll]').evaluate((host) => {
|
|
|
+ const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
|
|
|
+ const viewport = host.getBoundingClientRect()
|
|
|
+ const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
|
|
|
+ const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
|
|
|
+ const visible = rows.filter((candidate) => {
|
|
|
+ const rect = candidate.getBoundingClientRect()
|
|
|
+ return rect.bottom > viewport.top && rect.top < visibleBottom
|
|
|
+ })
|
|
|
+ const row = visible[0]
|
|
|
+ if (row?.dataset.chatAnchorKey === undefined) {
|
|
|
+ throw new Error(`no visible settled Chat row: ${JSON.stringify({
|
|
|
+ composerTop: visibleBottom,
|
|
|
+ host: { bottom: viewport.bottom, top: viewport.top },
|
|
|
+ rows: rows.slice(0, 4).map(candidate => ({
|
|
|
+ callId: candidate.dataset.chatCallId,
|
|
|
+ key: candidate.dataset.chatAnchorKey,
|
|
|
+ rect: {
|
|
|
+ bottom: candidate.getBoundingClientRect().bottom,
|
|
|
+ top: candidate.getBoundingClientRect().top,
|
|
|
+ },
|
|
|
+ })),
|
|
|
+ totalRows: rows.length,
|
|
|
+ })}`)
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ key: row.dataset.chatAnchorKey,
|
|
|
+ top: row.getBoundingClientRect().top - viewport.top,
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function flowTop(page: Page, key: string): Promise<number> {
|
|
|
+ return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => {
|
|
|
+ const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey)
|
|
|
+ if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`)
|
|
|
+ const host = row.closest('[data-conversation-scroll]')
|
|
|
+ if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport')
|
|
|
+ return row.getBoundingClientRect().top - host.getBoundingClientRect().top
|
|
|
+ }, key)
|
|
|
+}
|
|
|
+
|
|
|
+async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise<void> {
|
|
|
+ await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), {
|
|
|
+ timeout: 10_000,
|
|
|
+ message: `flow row ${anchor.key} moved relative to the transcript viewport`,
|
|
|
+ }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
|
+}
|
|
|
+
|
|
|
+async function expectBottom(page: Page): Promise<void> {
|
|
|
+ await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), {
|
|
|
+ timeout: 10_000,
|
|
|
+ }).toBeLessThanOrEqual(1)
|
|
|
+}
|
|
|
+
|
|
|
+async function expectMarkerAboveComposer(page: Page, marker: string): Promise<void> {
|
|
|
+ const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => {
|
|
|
+ const row = node.closest('[data-chat-flow-key], [data-streaming]')
|
|
|
+ const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]')
|
|
|
+ if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) {
|
|
|
+ throw new Error('latest marker or composer geometry is unavailable')
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ composerTop: composer.getBoundingClientRect().top,
|
|
|
+ rowBottom: row.getBoundingClientRect().bottom,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE)
|
|
|
+}
|
|
|
+
|
|
|
+async function loadEarlierWithAnchor(page: Page): Promise<void> {
|
|
|
+ await wheelToHistoryStart(page)
|
|
|
+ const older = page.getByRole('button', { name: 'Load earlier', exact: true })
|
|
|
+ await older.waitFor({ timeout: 10_000 })
|
|
|
+ const anchor = await visibleFlowAnchor(page)
|
|
|
+ const before = await conversationTurns(page)
|
|
|
+ await older.click()
|
|
|
+ await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
|
|
|
+ await nextPaint(page)
|
|
|
+ await expectSameFlowTop(page, anchor)
|
|
|
+}
|
|
|
+
|
|
|
+async function fileExists(path: string): Promise<boolean> {
|
|
|
+ try {
|
|
|
+ await access(path)
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function eventCarries(event: SessionEvent, marker: string): boolean {
|
|
|
+ return JSON.stringify(event).includes(marker)
|
|
|
+}
|
|
|
+
|
|
|
+function assertClean(world: ScrollWorld): void {
|
|
|
+ expect(world.tripwire.pageErrors).toEqual([])
|
|
|
+ expect(world.tripwire.warnings).toEqual([])
|
|
|
+}
|
|
|
+
|
|
|
+let browser: Browser
|
|
|
+
|
|
|
+describe('web e2e: long Chat scroll contract', () => {
|
|
|
+ beforeAll(async () => {
|
|
|
+ browser = await chromium.launch()
|
|
|
+ })
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await browser?.close()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
|
|
|
+ await withScrollWorld({
|
|
|
+ failureShot: 'web-e2e-chat-scroll-history-stream',
|
|
|
+ replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))],
|
|
|
+ seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
|
|
|
+ }, async (world) => {
|
|
|
+ await openSeed(
|
|
|
+ world.page,
|
|
|
+ HISTORY_FIXTURE,
|
|
|
+ HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns),
|
|
|
+ )
|
|
|
+ await expectBottom(world.page)
|
|
|
+
|
|
|
+ let releaseHistory = (): void => {}
|
|
|
+ let held = false
|
|
|
+ let releaseGate: (() => void) | undefined
|
|
|
+ const gate = new Promise<void>((resolve) => { releaseGate = resolve })
|
|
|
+ releaseHistory = () => { releaseGate?.() }
|
|
|
+ await world.page.route('**/api/session.history', async (route) => {
|
|
|
+ const request = route.request().postDataJSON() as {
|
|
|
+ method?: string
|
|
|
+ payload?: { beforeSeq?: number }
|
|
|
+ }
|
|
|
+ if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
|
|
|
+ held = true
|
|
|
+ await gate
|
|
|
+ }
|
|
|
+ await route.continue()
|
|
|
+ })
|
|
|
+
|
|
|
+ const settled = world.scaffold.whenTurnSettled(60_000)
|
|
|
+ try {
|
|
|
+ const composer = world.page.locator('textarea:enabled').last()
|
|
|
+ await composer.fill(LIVE_TEXT_PROMPT)
|
|
|
+ await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
|
|
+ await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
|
|
|
+ await wheelToHistoryStart(world.page)
|
|
|
+ const beforeTurns = await conversationTurns(world.page)
|
|
|
+ await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
|
|
|
+ await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
|
|
|
+
|
|
|
+ await wheelTranscript(world.page, 420)
|
|
|
+ const readerAnchor = await visibleFlowAnchor(world.page)
|
|
|
+ const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length
|
|
|
+ await expect.poll(
|
|
|
+ () => world.events.filter(event => event.type === 'assistant/chunk').length,
|
|
|
+ { timeout: 10_000 },
|
|
|
+ ).toBeGreaterThan(chunksAfterAnchor + 5)
|
|
|
+
|
|
|
+ releaseHistory()
|
|
|
+ await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
|
|
|
+ await nextPaint(world.page)
|
|
|
+ await expectSameFlowTop(world.page, readerAnchor)
|
|
|
+ } finally {
|
|
|
+ releaseHistory()
|
|
|
+ }
|
|
|
+
|
|
|
+ await settled
|
|
|
+ await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
|
|
+ await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
|
|
+ await world.page.unroute('**/api/session.history')
|
|
|
+
|
|
|
+ let additionalPages = 0
|
|
|
+ while (additionalPages < 8) {
|
|
|
+ await wheelToHistoryStart(world.page)
|
|
|
+ if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break
|
|
|
+ await loadEarlierWithAnchor(world.page)
|
|
|
+ additionalPages += 1
|
|
|
+ }
|
|
|
+ expect(additionalPages).toBeGreaterThan(0)
|
|
|
+ expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
|
|
|
+ expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
|
|
|
+ assertClean(world)
|
|
|
+ })
|
|
|
+ }, 180_000)
|
|
|
+
|
|
|
+ it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
|
|
|
+ await withScrollWorld({
|
|
|
+ failureShot: 'web-e2e-chat-scroll-live-tool',
|
|
|
+ replay: [
|
|
|
+ replayEntry(toolStream()),
|
|
|
+ replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)),
|
|
|
+ ],
|
|
|
+ seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_SESSION_ID }],
|
|
|
+ }, async (world) => {
|
|
|
+ const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
|
|
|
+ const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
|
|
|
+ await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns))
|
|
|
+ const settled = world.scaffold.whenTurnSettled(60_000)
|
|
|
+ let released = false
|
|
|
+ try {
|
|
|
+ const composer = world.page.locator('textarea:enabled').last()
|
|
|
+ await composer.fill(LIVE_TOOL_PROMPT)
|
|
|
+ await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
|
|
+ await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
|
|
|
+ const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash-global"]`)
|
|
|
+ await liveRow.waitFor({ timeout: 15_000 })
|
|
|
+ expect(await liveRow.getAttribute('data-state')).toBe('running')
|
|
|
+ await expectBottom(world.page)
|
|
|
+
|
|
|
+ await wheelTranscript(world.page, -1_200)
|
|
|
+ await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })
|
|
|
+ const awayAnchor = await visibleFlowAnchor(world.page)
|
|
|
+ const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
|
|
|
+ await writeFile(releasePath, 'release\n')
|
|
|
+ released = true
|
|
|
+ await expect.poll(
|
|
|
+ () => world.events.some(event => event.type === 'tool/result'),
|
|
|
+ { timeout: 15_000 },
|
|
|
+ ).toBe(true)
|
|
|
+ await expect.poll(
|
|
|
+ () => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)),
|
|
|
+ { timeout: 15_000 },
|
|
|
+ ).toBe(true)
|
|
|
+ await expect.poll(
|
|
|
+ () => world.events.filter(event => event.type === 'assistant/chunk').length,
|
|
|
+ { timeout: 15_000 },
|
|
|
+ ).toBeGreaterThan(chunksBeforeRelease + 5)
|
|
|
+ await expectSameFlowTop(world.page, awayAnchor)
|
|
|
+
|
|
|
+ const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
|
|
|
+ await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
|
|
+ await expectBottom(world.page)
|
|
|
+ await expect.poll(
|
|
|
+ () => world.events.filter(event => event.type === 'assistant/chunk').length,
|
|
|
+ { timeout: 15_000 },
|
|
|
+ ).toBeGreaterThan(chunksAtRepin + 5)
|
|
|
+ await expectBottom(world.page)
|
|
|
+ } finally {
|
|
|
+ if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
|
|
|
+ }
|
|
|
+
|
|
|
+ await settled
|
|
|
+ await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
|
|
+ await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
|
|
+ await expectBottom(world.page)
|
|
|
+ await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE)
|
|
|
+
|
|
|
+ const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash-global"]`
|
|
|
+ const liveRow = world.page.locator(liveRowSelector)
|
|
|
+ await liveRow.scrollIntoViewIfNeeded()
|
|
|
+ await nextPaint(world.page)
|
|
|
+ const toolAnchor = await liveRow.evaluate((row) => {
|
|
|
+ const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
|
|
|
+ const host = row.closest<HTMLElement>('[data-conversation-scroll]')
|
|
|
+ if (flow?.dataset.chatAnchorKey === undefined || host === null) {
|
|
|
+ throw new Error('live tool row has no settled flow identity')
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ key: flow.dataset.chatAnchorKey,
|
|
|
+ top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ await liveRow.click()
|
|
|
+ await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
|
|
|
+ await expectSameFlowTop(world.page, toolAnchor)
|
|
|
+ await wheelToHistoryStart(world.page)
|
|
|
+ await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
|
|
+ await expectBottom(world.page)
|
|
|
+ await wheelUntilMounted(world.page, liveRowSelector, -1_100)
|
|
|
+ const restoredRow = world.page.locator(liveRowSelector)
|
|
|
+ await restoredRow.waitFor({ timeout: 10_000 })
|
|
|
+ expect(await restoredRow.getAttribute('aria-expanded')).toBe('true')
|
|
|
+ expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0)
|
|
|
+ assertClean(world)
|
|
|
+ })
|
|
|
+ }, 180_000)
|
|
|
+
|
|
|
+ it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
|
|
|
+ await withScrollWorld({
|
|
|
+ failureShot: 'web-e2e-chat-scroll-restore-composer',
|
|
|
+ seeds: [
|
|
|
+ { fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID },
|
|
|
+ { fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID },
|
|
|
+ ],
|
|
|
+ }, async (world) => {
|
|
|
+ await openSeed(
|
|
|
+ world.page,
|
|
|
+ RESTORE_FIXTURE_A,
|
|
|
+ RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
|
|
|
+ )
|
|
|
+ await loadEarlierWithAnchor(world.page)
|
|
|
+ await loadEarlierWithAnchor(world.page)
|
|
|
+ await wheelToHistoryStart(world.page)
|
|
|
+ await wheelTranscript(world.page, 1_300)
|
|
|
+ const sessionAnchor = await visibleFlowAnchor(world.page)
|
|
|
+
|
|
|
+ await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
|
|
+ await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
|
|
|
+ await world.page.setViewportSize({ width: 700, height: 900 })
|
|
|
+ await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
|
|
|
+ await nextPaint(world.page)
|
|
|
+ await expectSameFlowTop(world.page, sessionAnchor)
|
|
|
+
|
|
|
+ await openSeed(
|
|
|
+ world.page,
|
|
|
+ RESTORE_FIXTURE_B,
|
|
|
+ RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
|
|
|
+ )
|
|
|
+ await openSeed(
|
|
|
+ world.page,
|
|
|
+ RESTORE_FIXTURE_A,
|
|
|
+ )
|
|
|
+ await expectSameFlowTop(world.page, sessionAnchor)
|
|
|
+
|
|
|
+ const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
|
|
+ await backToBottom.evaluate((button) => {
|
|
|
+ if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
|
|
|
+ button.click()
|
|
|
+ const trajectory = [...document.querySelectorAll<HTMLElement>('[role="tab"]')]
|
|
|
+ .find(tab => tab.textContent?.trim() === 'Trajectory')
|
|
|
+ if (!(trajectory instanceof HTMLElement)) {
|
|
|
+ throw new Error('Trajectory tab is unavailable during pinned remount')
|
|
|
+ }
|
|
|
+ trajectory.click()
|
|
|
+ })
|
|
|
+ await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
|
|
|
+ await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
|
|
|
+ await expectBottom(world.page)
|
|
|
+ await openSeed(
|
|
|
+ world.page,
|
|
|
+ RESTORE_FIXTURE_B,
|
|
|
+ RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
|
|
|
+ )
|
|
|
+ await openSeed(
|
|
|
+ world.page,
|
|
|
+ RESTORE_FIXTURE_A,
|
|
|
+ RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
|
|
|
+ )
|
|
|
+ await expectBottom(world.page)
|
|
|
+ const composer = world.page.locator('textarea:enabled').last()
|
|
|
+ const longDraft = Array.from(
|
|
|
+ { length: 18 },
|
|
|
+ (_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`,
|
|
|
+ ).join('\n')
|
|
|
+ await composer.fill(longDraft)
|
|
|
+ await nextPaint(world.page)
|
|
|
+ await expectBottom(world.page)
|
|
|
+ await expectMarkerAboveComposer(
|
|
|
+ world.page,
|
|
|
+ RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
|
|
|
+ )
|
|
|
+
|
|
|
+ await composer.fill('short draft')
|
|
|
+ await nextPaint(world.page)
|
|
|
+ await wheelTranscript(world.page, -900)
|
|
|
+ const resizeAnchor = await visibleFlowAnchor(world.page)
|
|
|
+ await composer.fill(longDraft)
|
|
|
+ await nextPaint(world.page)
|
|
|
+ await expectSameFlowTop(world.page, resizeAnchor)
|
|
|
+ await composer.fill('short draft')
|
|
|
+ await nextPaint(world.page)
|
|
|
+ await expectSameFlowTop(world.page, resizeAnchor)
|
|
|
+
|
|
|
+ const beforeChain = await scrollGeometry(world.page)
|
|
|
+ await composer.hover()
|
|
|
+ await world.page.mouse.wheel(0, -320)
|
|
|
+ await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 })
|
|
|
+ .toBeLessThan(beforeChain.scrollTop)
|
|
|
+ assertClean(world)
|
|
|
+ })
|
|
|
+ }, 180_000)
|
|
|
+})
|