1874 lines
96 KiB
TypeScript
1874 lines
96 KiB
TypeScript
// 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, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import type { Browser, Page, Route } from 'playwright'
|
|
import { chromium } from 'playwright'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import { ToolCallId, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
|
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
|
import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
|
|
import {
|
|
launchWebScaffold,
|
|
parseSeedFixture,
|
|
seedSession,
|
|
watchConsole,
|
|
webSnapshotMode,
|
|
type WebScaffold,
|
|
} from './scaffold.ts'
|
|
import { expandOwningTurnProcess, newEnglishPage, openSettings, saveFailureShot } from './support.ts'
|
|
|
|
const MODE = webSnapshotMode()
|
|
const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
|
|
const TOOL_SESSION_ID = SessionId('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 RESPONSIVE_REFLOW_TOLERANCE = 32
|
|
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 = ToolCallId('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 INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
|
|
const RAIL_SESSION_ID = 'chat-scroll-rail-e2e'
|
|
const FLING_SESSION_ID = SessionId('chat-scroll-fling-e2e')
|
|
const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
|
|
const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
|
|
const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE'
|
|
|
|
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,
|
|
})
|
|
const INPUTS_FIXTURE = createChatScrollFixture({
|
|
markerPrefix: 'INPUTS',
|
|
title: 'CHAT_SCROLL_INPUTS non-wheel reader input session',
|
|
})
|
|
|
|
interface FoldMotionSample {
|
|
readonly t: number
|
|
readonly ordinaryGrowth: boolean
|
|
readonly top: number
|
|
readonly bodyY: number
|
|
readonly earlierY: number
|
|
readonly messageY: number | null
|
|
readonly stepY: number | null
|
|
readonly flowHeight: number
|
|
readonly height: number
|
|
readonly spacer: number
|
|
readonly folding: boolean
|
|
readonly raceApplied: boolean
|
|
readonly composerTop: number
|
|
}
|
|
|
|
interface ScrollGeometry {
|
|
readonly distanceFromBottom: number
|
|
readonly scrollTop: number
|
|
}
|
|
|
|
interface FlowAnchor {
|
|
readonly key: string
|
|
readonly top: number
|
|
}
|
|
|
|
interface ScrollWorld {
|
|
readonly assistantFrames: AssistantStreamFrame[]
|
|
readonly events: SessionEvent[]
|
|
readonly page: Page
|
|
readonly replayDir?: string
|
|
readonly scaffold: WebScaffold
|
|
readonly tripwire: ReturnType<typeof watchConsole>
|
|
}
|
|
|
|
interface ScrollWorldOptions {
|
|
readonly failureShot: string
|
|
readonly paceMs?: number
|
|
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' } },
|
|
]
|
|
}
|
|
|
|
/** Hold replay text after the initial deltas until browser setup releases it. */
|
|
function holdTextAfter(world: ScrollWorld, initialDeltas: number): () => void {
|
|
const gate = Promise.withResolvers<undefined>()
|
|
const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
|
let deltas = 0
|
|
for await (const chunk of next()) {
|
|
if (chunk.type === 'text-delta' && deltas++ === initialDeltas) await gate.promise
|
|
yield chunk
|
|
}
|
|
})
|
|
return () => {
|
|
gate.resolve(undefined)
|
|
dispose()
|
|
}
|
|
}
|
|
|
|
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: options.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[] = []
|
|
const assistantFrames: AssistantStreamFrame[] = []
|
|
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
|
|
scaffold.ctx.on('agent/assistant-stream', ({ frame }) => { assistantFrames.push(frame) })
|
|
page = await newEnglishPage(browser, 900)
|
|
const tripwire = watchConsole(page)
|
|
await page.goto(scaffold.authenticatedUrl, { 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
|
|
// (the compact layout dropped group session counts; the Ungrouped bucket
|
|
// row is the barrier).
|
|
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
|
|
return {
|
|
assistantFrames,
|
|
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,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Rendered transcript rows in the loaded window. The stats strip cannot serve
|
|
* as this probe: its turn/step counts ride the whole-log sessionStats
|
|
* projection and stay fixed across paging by design, while the row count is
|
|
* exactly what grows when an older page prepends or a live turn streams in.
|
|
* @param page - the scenario page.
|
|
* @returns the number of mounted chat flow rows.
|
|
*/
|
|
async function loadedFlowRows(page: Page): Promise<number> {
|
|
return page.locator('[data-chat-flow-key]').count()
|
|
}
|
|
|
|
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
|
|
// Search collapsed into a header action; expand it before filling.
|
|
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
|
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
|
const search = page.getByRole('textbox', { name: 'Search session names', exact: true })
|
|
// Cold summaries initially show the temporary workspace basename, so the
|
|
// persisted first-prompt 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)
|
|
}
|
|
|
|
/**
|
|
* Touch-style momentum fling over the transcript. Headless Chromium in the
|
|
* test lane cannot synthesize device scrolling (Input.synthesizeScrollGesture
|
|
* and Input.dispatchTouchEvent both deliver DOM events without moving any
|
|
* scroller, and compositor scrollbars ignore synthetic mouse input), so the
|
|
* fling replays the signature a real pan leaves on the scrollport: per-frame
|
|
* decaying displacements the component never authored, carrying no wheel
|
|
* events. Wheel-sign semantics: positive deltaY reads downward.
|
|
*/
|
|
async function flingTranscript(page: Page, deltaY: number): Promise<void> {
|
|
await page.locator('[data-conversation-scroll]').evaluate(async (host, delta) => {
|
|
const direction = Math.sign(delta)
|
|
let remaining = Math.abs(delta)
|
|
// Fast launch decaying toward a floor speed, like a released finger. The
|
|
// floor stays above the follow threshold so contended frames (streaming
|
|
// writes racing the fling) still deviate far enough to read as input.
|
|
let velocity = Math.max(120, remaining / 8)
|
|
while (remaining > 0) {
|
|
const step = Math.min(velocity, remaining)
|
|
host.scrollTop += direction * step
|
|
remaining -= step
|
|
velocity = Math.max(48, velocity * 0.9)
|
|
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
|
}
|
|
}, 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}`)
|
|
}
|
|
|
|
async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise<void> {
|
|
const target = page.locator(selector)
|
|
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
if (await target.count() > 0 && await target.evaluate((row) => {
|
|
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
|
|
if (host === null) return false
|
|
const viewport = host.getBoundingClientRect()
|
|
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
|
|
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
|
|
const rect = row.getBoundingClientRect()
|
|
return rect.bottom > viewport.top && rect.top < visibleBottom
|
|
})) return
|
|
await wheelTranscript(page, deltaY)
|
|
}
|
|
throw new Error(`selector did not become visible 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]:not([hidden])')]
|
|
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,
|
|
tolerance = GEOMETRY_TOLERANCE,
|
|
): 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(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 })
|
|
const loading = page.getByRole('button', { name: 'Loading…', exact: true })
|
|
await older.waitFor({ timeout: 10_000 })
|
|
const anchor = await visibleFlowAnchor(page)
|
|
const before = await loadedFlowRows(page)
|
|
await older.click()
|
|
await expect.poll(async () => (
|
|
await loadedFlowRows(page) > before && await loading.count() === 0
|
|
), { timeout: 30_000 }).toBe(true)
|
|
await nextPaint(page)
|
|
if (await page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) {
|
|
expect(await page.locator('[data-turn-process][aria-expanded="false"]').count()).toBeGreaterThan(0)
|
|
return
|
|
}
|
|
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([])
|
|
}
|
|
|
|
it('generates a current-format scroll seed with a protected system head and intact references', () => {
|
|
const { header, events } = parseSeedFixture(HISTORY_FIXTURE.log)
|
|
expect(header.version).toBe(SESSION_FORMAT_VERSION)
|
|
expect(events.slice(0, 5).map(event => event.type)).toEqual([
|
|
'turn/start', 'step/start', 'system/message', 'user/message', 'session/title',
|
|
])
|
|
expect(events.filter(event => event.type === 'system/message')).toHaveLength(1)
|
|
const firstUser = events.find(event => event.type === 'user/message')!
|
|
const title = events.find(event => event.type === 'session/title')!
|
|
expect(title.data.messageSeqs).toEqual([firstUser.seq])
|
|
const calls = events.filter(event => event.type === 'tool/call')
|
|
const results = events.filter(event => event.type === 'tool/result')
|
|
expect(calls).toHaveLength(22)
|
|
expect(results).toHaveLength(calls.length)
|
|
for (const result of results) {
|
|
const call = calls.find(event => event.data.callId === result.data.message.source.callId)!
|
|
expect(result.sourceEventSeqs).toEqual([call.seq])
|
|
expect(call.seq).toBeLessThan(result.seq)
|
|
}
|
|
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(HISTORY_FIXTURE.turns)
|
|
expect(events.at(-1)?.type).toBe('turn/end')
|
|
})
|
|
|
|
let browser: Browser
|
|
|
|
describe('web e2e: long Chat scroll contract', () => {
|
|
beforeAll(async () => {
|
|
browser = await chromium.launch()
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await browser?.close()
|
|
})
|
|
|
|
for (const scenario of [
|
|
{ name: 'body-only', bodyParagraphs: 32, inputLines: 1, reduced: false },
|
|
{ name: 'visible-steps', bodyParagraphs: 2, inputLines: 1, reduced: false },
|
|
{ name: 'large-input', bodyParagraphs: 2, inputLines: 18, reduced: false },
|
|
{ name: 'reduced-motion', bodyParagraphs: 2, inputLines: 1, reduced: true },
|
|
{ name: 'cancel-all-during-fold', bodyParagraphs: 2, inputLines: 1, reduced: false },
|
|
{ name: 'content-growth-during-fold', bodyParagraphs: 32, inputLines: 1, reduced: false },
|
|
]) {
|
|
it.skipIf(MODE === 'record')(`uses only visible fold room in one upward movement: ${scenario.name}`, async () => {
|
|
const base = createChatScrollFixture({ markerPrefix: 'VISIBLE_FOLD', title: `CHAT_VISIBLE_FOLD_${scenario.name}`, turns: 8 })
|
|
const parsed = parseSeedFixture(base.log)
|
|
const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === 8)
|
|
const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = [
|
|
...parsed.events.slice(0, boundary),
|
|
{ type: 'turn/start', data: { turn: 8 } },
|
|
{ type: 'user/message', data: createUserMessage({
|
|
content: [{ type: 'text', text: 'VISIBLE_FOLD_EARLIER_ANCHOR' }], source: { kind: 'user' },
|
|
}), surfaceOp: 'append' },
|
|
]
|
|
for (let step = 1; step <= 6; step++) {
|
|
drafts.push({ type: 'step/start', data: { turn: 8, step } })
|
|
drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: {
|
|
turn: 8, step, stream: [], message: createAssistantMessage({
|
|
content: [
|
|
{ type: 'reasoning', text: `VISIBLE_FOLD_STEP_${step}\n\n${Array.from({ length: 18 }, (_, line) =>
|
|
`Reasoning stage ${step}, paragraph ${line}: retained work details explain this intermediate decision.`).join('\n\n')}` },
|
|
{ type: 'text', text: `VISIBLE_FOLD_PROGRESS_${step}` },
|
|
], source: { provider: 'fixture', model: 'fixture' },
|
|
}),
|
|
} })
|
|
drafts.push({ type: 'step/end', data: { turn: 8, step } })
|
|
}
|
|
const bodyMarker = 'VISIBLE_FOLD_BODY'
|
|
drafts.push({ type: 'step/start', data: { turn: 8, step: 7 } })
|
|
drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: {
|
|
turn: 8, step: 7, stream: [], message: createAssistantMessage({
|
|
content: [{ type: 'text', text: `${bodyMarker}\n\n${Array.from({ length: scenario.bodyParagraphs }, (_, line) =>
|
|
`Final answer paragraph ${line}: this content remains after the preceding work details fold.`).join('\n\n')}` }],
|
|
source: { provider: 'fixture', model: 'fixture' },
|
|
}),
|
|
} })
|
|
drafts.push({ type: 'step/end', data: { turn: 8, step: 7 } },
|
|
{ type: 'turn/end', data: { turn: 8, reason: { kind: 'completed' } } })
|
|
const fixture: ChatScrollFixture = { ...base, log: [parsed.headerLine,
|
|
...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n') }
|
|
const input = Array.from({ length: scenario.inputLines }, (_, index) => `VISIBLE_FOLD_NEXT_${index}`).join('\n')
|
|
const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/visible-fold/', import.meta.url))
|
|
await mkdir(artifactRoot, { recursive: true })
|
|
const evidence = await mkdtemp(join(artifactRoot, `${scenario.name}-`))
|
|
await withScrollWorld({
|
|
failureShot: `visible-fold-${scenario.name}`,
|
|
replay: [replayEntry(textStream('VISIBLE_FOLD_REPLY', 'VISIBLE_FOLD_DONE', 2))],
|
|
seeds: [{ fixture, id: `visible-fold-${scenario.name}` }],
|
|
}, async (world) => {
|
|
const { page } = world
|
|
await page.setViewportSize({ width: 1280, height: 720 })
|
|
await page.emulateMedia({ reducedMotion: scenario.reduced ? 'reduce' : 'no-preference' })
|
|
await openSeed(page, fixture, bodyMarker)
|
|
await openSettings(page, 'en')
|
|
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
|
const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..')
|
|
await timing.getByRole('button').click()
|
|
await page.getByRole('menuitem', { name: 'On next message', exact: true }).click()
|
|
const details = dialog.getByText('Work details', { exact: true }).locator('../..')
|
|
await details.getByRole('button').click()
|
|
await page.getByRole('menuitem', { name: 'Detailed', exact: true }).click()
|
|
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
|
const thoughts = page.locator('[data-chat-turn="8"] [data-variant="think"]')
|
|
await expect.poll(() => thoughts.count()).toBe(6)
|
|
for (let index = 0; index < 6; index++) {
|
|
const button = thoughts.nth(index).getByRole('button').first()
|
|
if (await button.getAttribute('aria-expanded') !== 'true') await button.click()
|
|
}
|
|
await wheelTranscript(page, -600)
|
|
await page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
|
await expectBottom(page)
|
|
await nextPaint(page)
|
|
await page.locator('[data-composer-input][contenteditable="true"]').last().fill(input)
|
|
await nextPaint(page)
|
|
if (scenario.name === 'visible-steps') {
|
|
const offset = 20
|
|
await thoughts.last().getByRole('button').first().evaluate((button, offset) => {
|
|
const host = button.closest<HTMLElement>('[data-conversation-scroll]')!
|
|
const group = button.closest<HTMLElement>('[data-chat-group-key]')!
|
|
host.scrollTop += group.getBoundingClientRect().top - host.getBoundingClientRect().top - offset
|
|
host.dispatchEvent(new Event('scroll'))
|
|
}, offset)
|
|
await nextPaint(page)
|
|
}
|
|
const before = await page.locator('[data-conversation-scroll]').evaluate((host) => {
|
|
const viewport = host.getBoundingClientRect()
|
|
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')!
|
|
const body = [...host.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)!
|
|
const groups = [...host.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-key]')]
|
|
const candidates = [...host.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-key], [data-chat-turn="8"][data-turn-process-member="true"]')]
|
|
.filter(element => !element.parentElement?.closest('[data-chat-turn="8"][data-chat-group-key], [data-chat-turn="8"][data-turn-process-member="true"]'))
|
|
return { top: host.scrollTop, height: host.scrollHeight, viewportTop: viewport.top,
|
|
removed: candidates.map((element) => {
|
|
const rect = element.getBoundingClientRect()
|
|
const margin = Number.parseFloat(getComputedStyle(element).marginTop) || 0
|
|
return { top: rect.top - viewport.top - margin, height: rect.height + margin }
|
|
}),
|
|
floor: Math.max(0, host.scrollHeight - host.clientHeight),
|
|
viewport: host.clientHeight, visibleHeight: composer.getBoundingClientRect().top - viewport.top,
|
|
bodyTop: body.getBoundingClientRect().top, bodyHeight: body.getBoundingClientRect().height,
|
|
flowHeight: host.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!.getBoundingClientRect().height,
|
|
groupHeight: groups.reduce((sum, group) => sum + group.getBoundingClientRect().height, 0),
|
|
visibleSteps: groups.reduce((sum, group) => {
|
|
const rect = group.getBoundingClientRect()
|
|
return sum + Math.max(0, Math.min(rect.bottom, composer.getBoundingClientRect().top) - Math.max(rect.top, viewport.top))
|
|
}, 0) }
|
|
})
|
|
expect(before.groupHeight).toBeGreaterThan(before.visibleHeight * 3)
|
|
if (scenario.bodyParagraphs === 32) expect(before.visibleSteps).toBe(0)
|
|
else expect(before.visibleSteps).toBeGreaterThan(0)
|
|
if (scenario.name === 'cancel-all-during-fold') {
|
|
await openSettings(page, 'en')
|
|
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
|
|
await settings.getByText('Work details', { exact: true }).locator('../..').getByRole('button').click()
|
|
await page.getByRole('menuitem', { name: 'Verbose', exact: true }).waitFor()
|
|
}
|
|
const release = holdTextAfter(world, 0)
|
|
const finishGate = Promise.withResolvers<undefined>()
|
|
const finishBlocked = Promise.withResolvers<undefined>()
|
|
const holdFinish = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
|
for await (const chunk of next()) {
|
|
if (chunk.type === 'finish') {
|
|
finishBlocked.resolve(undefined)
|
|
await finishGate.promise
|
|
}
|
|
yield chunk
|
|
}
|
|
})
|
|
const promptGate = Promise.withResolvers<undefined>()
|
|
const promptBlocked = Promise.withResolvers<undefined>()
|
|
const holdPrompt = async (route: Route): Promise<void> => {
|
|
promptBlocked.resolve(undefined)
|
|
await promptGate.promise
|
|
await route.continue()
|
|
}
|
|
await page.route('**/api/session/prompt', holdPrompt)
|
|
const trace = page.evaluate(({ marker, race }) => new Promise<FoldMotionSample[]>((resolveTrace) => {
|
|
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')!
|
|
const column = host.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!
|
|
const body = [...column.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)!
|
|
const earlier = [...column.querySelectorAll<HTMLElement>('*')].find(element =>
|
|
element.children.length === 0 && element.textContent === 'VISIBLE_FOLD_EARLIER_ANCHOR')!
|
|
const samples: FoldMotionSample[] = []
|
|
const visibleStep = [...column.querySelectorAll<HTMLElement>('[data-chat-turn="8"] [data-variant="think"] [role="button"]')].at(-1)
|
|
let frame = 0
|
|
let ordinaryGrowth = false
|
|
let raceApplied = false
|
|
let closingHeight: number | undefined
|
|
const markGrowth = (): void => { ordinaryGrowth = true }
|
|
host.addEventListener('dsh-test-fold-growth', markGrowth)
|
|
const stepPosition = (): number | null => {
|
|
if (visibleStep === undefined || !visibleStep.isConnected || visibleStep.closest('[hidden]') !== null) return null
|
|
const rect = visibleStep.getBoundingClientRect()
|
|
if (rect.height === 0 || rect.width === 0) return null
|
|
const walker = document.createTreeWalker(visibleStep, NodeFilter.SHOW_TEXT)
|
|
let textNode = walker.nextNode()
|
|
while (textNode !== null && !(textNode.textContent ?? '').trim()) textNode = walker.nextNode()
|
|
if (textNode === null) return null
|
|
const range = document.createRange()
|
|
range.selectNodeContents(textNode)
|
|
const glyph = range.getBoundingClientRect()
|
|
return glyph.height === 0 ? null : glyph.top
|
|
}
|
|
const sample = (): void => {
|
|
const closing = [...column.querySelectorAll<HTMLElement>('[data-chat-motion="collapse"]')]
|
|
.filter(element => element.parentElement?.closest('[data-chat-motion="collapse"]') === null)
|
|
const height = closing.reduce((sum, element) => sum + element.getBoundingClientRect().height, 0)
|
|
if (closing.length > 0) closingHeight ??= height
|
|
if (!raceApplied && closingHeight !== undefined && height > 0 && height < closingHeight - 1) {
|
|
if (race === 'cancel-all-during-fold') {
|
|
const verbose = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
|
.find(element => element.textContent?.trim() === 'Verbose')
|
|
if (verbose !== undefined) {
|
|
raceApplied = true
|
|
verbose.click()
|
|
}
|
|
} else if (race === 'content-growth-during-fold') {
|
|
// Controlled layout growth exercises ResizeObserver overlap, not Session-event delivery.
|
|
raceApplied = true
|
|
column.style.paddingBottom = '320px'
|
|
}
|
|
}
|
|
const message = [...column.querySelectorAll<HTMLElement>('[data-submission-echo], [data-chat-flow-kind="user"]')]
|
|
.find(element => element.textContent?.includes(marker))
|
|
const step = stepPosition()
|
|
samples.push({ t: performance.now(), ordinaryGrowth, top: host.scrollTop, bodyY: body.getBoundingClientRect().top,
|
|
earlierY: earlier.getBoundingClientRect().top, messageY: message?.getBoundingClientRect().top ?? null,
|
|
stepY: step,
|
|
flowHeight: column.getBoundingClientRect().height, height: host.scrollHeight,
|
|
spacer: host.querySelector<HTMLElement>('[data-chat-turn-spacer]')!.getBoundingClientRect().height,
|
|
folding: column.querySelector('[data-chat-motion="collapse"]') !== null, raceApplied,
|
|
composerTop: host.querySelector<HTMLElement>('[data-composer-seat]')!.getBoundingClientRect().top })
|
|
}
|
|
const tick = (): void => { sample(); frame = requestAnimationFrame(tick) }
|
|
host.addEventListener('dsh-test-fold-stop', () => {
|
|
cancelAnimationFrame(frame); sample()
|
|
host.removeEventListener('dsh-test-fold-growth', markGrowth)
|
|
host.removeAttribute('data-fold-probe-ready'); resolveTrace(samples)
|
|
}, { once: true })
|
|
sample(); host.setAttribute('data-fold-probe-ready', ''); frame = requestAnimationFrame(tick)
|
|
}), { marker: 'VISIBLE_FOLD_NEXT_0', race: scenario.name })
|
|
await page.locator('[data-fold-probe-ready]').waitFor()
|
|
let settled: ReturnType<WebScaffold['whenTurnSettled']> | undefined
|
|
let samples: Awaited<typeof trace> = []
|
|
try {
|
|
settled = world.scaffold.whenTurnSettled(30_000)
|
|
if (scenario.name === 'cancel-all-during-fold') {
|
|
await page.getByRole('button', { name: 'Send message', exact: true }).evaluate((button) => {
|
|
if (!(button instanceof HTMLButtonElement)) throw new Error('Send control is not a button')
|
|
button.click()
|
|
})
|
|
} else await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
|
await promptBlocked.promise
|
|
await page.getByText(input, { exact: true }).waitFor()
|
|
await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0)
|
|
if (scenario.name !== 'cancel-all-during-fold') await expectBottom(page)
|
|
await page.locator('[data-conversation-scroll]').evaluate(async (host) => {
|
|
let previous = host.scrollTop
|
|
let stableSince = performance.now()
|
|
await new Promise<void>((resolveStable) => {
|
|
const check = (): void => {
|
|
const now = performance.now()
|
|
if (Math.abs(host.scrollTop - previous) > 0.25) stableSince = now
|
|
previous = host.scrollTop
|
|
if (now - stableSince >= 240) resolveStable()
|
|
else requestAnimationFrame(check)
|
|
}
|
|
requestAnimationFrame(check)
|
|
})
|
|
})
|
|
await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-growth')))
|
|
promptGate.resolve(undefined)
|
|
release()
|
|
await finishBlocked.promise
|
|
await page.getByText('VISIBLE_FOLD_DONE', { exact: false }).waitFor()
|
|
await expectBottom(page)
|
|
await nextPaint(page)
|
|
} finally {
|
|
promptGate.resolve(undefined)
|
|
release()
|
|
await page.unroute('**/api/session/prompt', holdPrompt)
|
|
await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop')))
|
|
samples = await trace
|
|
finishGate.resolve(undefined)
|
|
holdFinish()
|
|
await settled
|
|
}
|
|
const allSamples = samples
|
|
const ordinarySamples = samples.filter(sample => sample.ordinaryGrowth)
|
|
samples = samples.filter(sample => !sample.ordinaryGrowth)
|
|
const first = samples[0]!
|
|
const last = samples.at(-1)!
|
|
const closedBoxesHeight = before.removed.reduce((sum, box) => sum + box.height, 0)
|
|
const removedHeight = first.bodyY + first.top - last.bodyY - last.top
|
|
const boundaryGrowth = closedBoxesHeight - removedHeight
|
|
const aboveRemoved = Math.min(removedHeight,
|
|
before.removed.reduce((sum, box) => sum + Math.min(box.height, Math.max(0, -box.top)), 0))
|
|
const remainingHeight = last.height - last.spacer
|
|
const newHeight = remainingHeight - before.height + removedHeight
|
|
const baseTop = Math.max(0, before.top - aboveRemoved)
|
|
const expectedTop = Math.max(baseTop, remainingHeight - before.viewport, 0)
|
|
const expectedSpacer = expectedTop === 0 ? 0 : Math.max(0, expectedTop + before.viewport - remainingHeight)
|
|
const ordinaryFollow = expectedTop - baseTop
|
|
const expectedBodyShift = removedHeight - aboveRemoved + ordinaryFollow
|
|
const bodyShift = first.bodyY - last.bodyY
|
|
const reversalOf = (values: readonly number[]): number => {
|
|
let lowest = values[0] ?? 0
|
|
let reversal = 0
|
|
for (const value of values) {
|
|
lowest = Math.min(lowest, value)
|
|
reversal = Math.max(reversal, value - lowest)
|
|
}
|
|
return reversal
|
|
}
|
|
const reversal = reversalOf(samples.map(sample => sample.bodyY))
|
|
const inputReversal = reversalOf(samples.flatMap(sample => sample.messageY === null ? [] : [sample.messageY]))
|
|
const ordinaryBodyReversal = reversalOf([samples.at(-1)!.bodyY, ...ordinarySamples.map(sample => sample.bodyY)])
|
|
const ordinaryInputReversal = reversalOf(ordinarySamples.flatMap(sample => sample.messageY === null ? [] : [sample.messageY]))
|
|
const stepReversal = reversalOf(samples.flatMap(sample => sample.stepY === null ? [] : [sample.stepY]))
|
|
const firstFoldIdle = samples.findIndex((sample, index) => index > 0 && !sample.folding && samples[index - 1]!.folding)
|
|
const afterFold = firstFoldIdle < 0 ? [] : samples.slice(firstFoldIdle)
|
|
const range = (values: number[]): number => values.length === 0 ? 0 : Math.max(...values) - Math.min(...values)
|
|
const foldBoundaryMovement = firstFoldIdle > 0
|
|
? Math.abs(samples[firstFoldIdle]!.bodyY - samples[firstFoldIdle - 1]!.bodyY) : 0
|
|
const priorFrameMovements = samples.slice(1, Math.max(1, firstFoldIdle - 1))
|
|
.map((sample, index) => Math.abs(sample.bodyY - samples[index]!.bodyY))
|
|
const secondMovement = range(afterFold.map(sample => sample.bodyY))
|
|
const secondInputMovement = range(afterFold.flatMap(sample => sample.messageY === null ? [] : [sample.messageY]))
|
|
const earlierShift = first.earlierY - last.earlierY
|
|
await writeFile(join(evidence, 'motion.json'), JSON.stringify({ scenario, before, closedBoxesHeight, removedHeight, boundaryGrowth, aboveRemoved,
|
|
remainingHeight, newHeight, expectedTop, expectedSpacer, expectedBodyShift, bodyShift, ordinaryFollow,
|
|
earlierShift, reversal, inputReversal, stepReversal, secondMovement, secondInputMovement,
|
|
ordinaryBodyReversal, ordinaryInputReversal, samples: allSamples }, null, 2))
|
|
console.log(`visible-fold ${scenario.name}: spacer=${last.spacer}/${expectedSpacer}, top=${last.top}/${expectedTop}, reversal=${reversal}, secondMovement=${secondMovement}, evidence=${evidence}`)
|
|
if (scenario.name === 'cancel-all-during-fold') {
|
|
expect(samples.some(sample => sample.raceApplied)).toBe(true)
|
|
expect(last.folding).toBe(false)
|
|
expect(last.spacer).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect(await page.getByRole('dialog', { name: 'Settings', exact: true })
|
|
.getByText('Work details', { exact: true }).locator('../..')
|
|
.getByRole('button', { name: 'Verbose', exact: true }).count()).toBe(1)
|
|
expect(await page.locator('[data-input-fold]').count()).toBe(0)
|
|
assertClean(world)
|
|
return
|
|
}
|
|
if (scenario.name === 'content-growth-during-fold') {
|
|
expect(samples.some(sample => sample.raceApplied && sample.folding)).toBe(true)
|
|
expect.soft(foldBoundaryMovement).toBeLessThanOrEqual(Math.max(...priorFrameMovements) + GEOMETRY_TOLERANCE)
|
|
}
|
|
expect.soft(removedHeight).toBeGreaterThan(before.visibleHeight * 3)
|
|
if (!scenario.reduced) {
|
|
expect.soft(samples.some(sample => sample.folding)).toBe(true)
|
|
const movingSamples = samples.filter((sample, index) => index > 0
|
|
&& Math.abs(sample.bodyY - samples[index - 1]!.bodyY) > 0.25)
|
|
expect.soft(movingSamples.length).toBeGreaterThan(1)
|
|
expect.soft(firstFoldIdle).toBeGreaterThan(0)
|
|
}
|
|
if (scenario.name === 'visible-steps') {
|
|
expect.soft(first.stepY).not.toBeNull()
|
|
expect.soft(first.stepY!).toBeGreaterThanOrEqual(before.viewportTop)
|
|
expect.soft(first.stepY!).toBeLessThan(first.composerTop)
|
|
const initial = samples.filter(sample => sample.stepY !== null && sample.t <= samples[firstFoldIdle - 1]!.t)
|
|
expect.soft(Math.max(...initial.map(sample => sample.stepY!)) - first.stepY!).toBeLessThanOrEqual(0.5)
|
|
}
|
|
expect.soft(Math.abs(last.top - expectedTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect.soft(Math.abs(last.spacer - expectedSpacer)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect.soft(Math.abs(bodyShift - expectedBodyShift)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect.soft(reversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect.soft(inputReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect.soft(stepReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect.soft(ordinarySamples.length).toBeGreaterThan(0)
|
|
expect.soft(ordinaryBodyReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect.soft(ordinaryInputReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect.soft(secondMovement).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect.soft(secondInputMovement).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
if (first.earlierY >= before.viewportTop) {
|
|
expect.soft(Math.abs(earlierShift - ordinaryFollow)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
}
|
|
if (scenario.name === 'body-only' || scenario.name === 'large-input') expect.soft(last.spacer).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
expect.soft(last.messageY).not.toBeNull()
|
|
expect.soft(last.messageY!).toBeLessThan(last.composerTop)
|
|
assertClean(world)
|
|
})
|
|
}, 180_000)
|
|
}
|
|
|
|
it.skipIf(MODE === 'record')('resets transcript height when switching from Verbose back to Standard during a reply', async () => {
|
|
const fixture = createChatScrollFixture({ markerPrefix: 'MODE', title: 'CHAT_MODE_HEIGHT', turns: 8 })
|
|
await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/chat-mode-height/', import.meta.url)), { recursive: true })
|
|
await withScrollWorld({
|
|
failureShot: 'screenshots/chat-mode-height/web-e2e-chat-mode-height',
|
|
replay: [replayEntry(textStream('CHAT_MODE_REPLY', 'CHAT_MODE_DONE', 2))],
|
|
seeds: [{ fixture, id: 'chat-mode-height-e2e' }],
|
|
}, async (world) => {
|
|
const { page } = world
|
|
await page.setViewportSize({ width: 1280, height: 720 })
|
|
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
|
const selectPreference = async (title: string, choice: string): Promise<void> => {
|
|
await openSettings(page, 'en')
|
|
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
|
const row = dialog.getByText(title, { exact: true }).locator('../..')
|
|
await row.getByRole('button').click()
|
|
await page.getByRole('menuitem', { name: choice, exact: true }).click()
|
|
await row.getByRole('button', { name: choice, exact: true }).waitFor()
|
|
if (title === 'Work details') {
|
|
await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value)
|
|
.toMatchObject({ transcriptView: choice.toLowerCase() })
|
|
}
|
|
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
|
await expect.poll(() => page.locator('[data-chat-motion="collapse"], [data-chat-motion="reveal"]').count()).toBe(0)
|
|
await nextPaint(page)
|
|
}
|
|
const returnToBottom = async (): Promise<void> => {
|
|
await wheelTranscript(page, -600)
|
|
await page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
|
await expectBottom(page)
|
|
}
|
|
const geometry = () => page.locator('[data-conversation-scroll]').evaluate(host => ({
|
|
height: host.scrollHeight,
|
|
viewport: host.clientHeight,
|
|
spacer: host.querySelector<HTMLElement>('[data-chat-turn-spacer]')!.getBoundingClientRect().height,
|
|
}))
|
|
await selectPreference('Work details', 'Standard')
|
|
await openSeed(page, fixture, fixture.markers.assistant(8))
|
|
// Hold a live reply so preference changes exercise the same fold animation as incoming content.
|
|
const release = holdTextAfter(world, 1)
|
|
let replyStarted = false
|
|
try {
|
|
await page.locator('[data-composer-input][contenteditable="true"]').last().fill('CHAT_MODE_NEXT_INPUT')
|
|
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
|
await page.getByText('CHAT_MODE_REPLY', { exact: false }).waitFor()
|
|
replyStarted = true
|
|
await selectPreference('When to Collapse Work Details', 'On next message')
|
|
await page.locator('[data-chat-flow][data-chat-motion=""]').waitFor()
|
|
await returnToBottom()
|
|
const standard = await geometry()
|
|
expect(standard.height).toBeGreaterThan(standard.viewport)
|
|
expect(standard.spacer).toBe(0)
|
|
for (let cycle = 0; cycle < 2; cycle += 1) {
|
|
await selectPreference('Work details', 'Verbose')
|
|
await expect.poll(async () => (await geometry()).height)
|
|
.toBeGreaterThan(standard.height + GEOMETRY_TOLERANCE)
|
|
await returnToBottom()
|
|
await selectPreference('Work details', 'Standard')
|
|
await expect.poll(async () => (await geometry()).spacer).toBe(0)
|
|
await expect.poll(async () => Math.abs((await geometry()).height - standard.height))
|
|
.toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
await expectBottom(page)
|
|
await expectMarkerAboveComposer(page, 'CHAT_MODE_REPLY')
|
|
}
|
|
} finally {
|
|
const settled = replyStarted ? world.scaffold.whenTurnSettled() : undefined
|
|
release()
|
|
await settled
|
|
}
|
|
assertClean(world)
|
|
})
|
|
})
|
|
|
|
it.skipIf(MODE === 'record')('keeps a running Session stationary when returning with output held', async () => {
|
|
const fixtureA = createChatScrollFixture({ markerPrefix: 'RETURN_A', title: 'CHAT_RUNNING_RETURN_A' })
|
|
const fixtureB = createChatScrollFixture({ markerPrefix: 'RETURN_B', title: 'CHAT_RUNNING_RETURN_B', turns: 32 })
|
|
const heldMarker = 'CHAT_RUNNING_RETURN_HELD'
|
|
const grownMarker = 'CHAT_RUNNING_RETURN_GROWN'
|
|
const doneMarker = 'CHAT_RUNNING_RETURN_DONE'
|
|
const paragraphs = (count: number, prefix: string): string => Array.from({ length: count }, (_, index) =>
|
|
`${prefix} paragraph ${index + 1}: ${'The recorded response stays readable while this Session is open. '.repeat(3)}`,
|
|
).join('\n\n')
|
|
const parts = [
|
|
`${paragraphs(32, 'Initial output')}\n\n${heldMarker}\n\n`,
|
|
`${paragraphs(12, 'Resumed output')}\n\n${grownMarker}\n\n`,
|
|
doneMarker,
|
|
]
|
|
const chunks: StreamChunk[] = [
|
|
{ type: 'block-start', index: 0, blockType: 'text' },
|
|
...parts.map((text): StreamChunk => ({ type: 'text-delta', index: 0, text })),
|
|
{ type: 'block-end', index: 0, block: { type: 'text', text: parts.join('') } },
|
|
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 2_500 } },
|
|
{ type: 'finish', reason: { kind: 'stop' } },
|
|
]
|
|
const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/chat-running-return/', import.meta.url))
|
|
await mkdir(artifactRoot, { recursive: true })
|
|
const evidence = await mkdtemp(join(artifactRoot, 'run-'))
|
|
await withScrollWorld({
|
|
failureShot: 'screenshots/chat-running-return/web-e2e-chat-running-return',
|
|
replay: [replayEntry(chunks)],
|
|
seeds: [
|
|
{ fixture: fixtureA, id: 'chat-running-return-a-e2e' },
|
|
{ fixture: fixtureB, id: 'chat-running-return-b-e2e' },
|
|
],
|
|
}, async (world) => {
|
|
const { page } = world
|
|
await page.setViewportSize({ width: 1280, height: 720 })
|
|
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
|
await openSettings(page, 'en')
|
|
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
|
for (const [title, choice] of [
|
|
['Work details', 'Standard'],
|
|
['When to Collapse Work Details', 'On next message'],
|
|
] as const) {
|
|
await dialog.getByText(title, { exact: true }).locator('../..').getByRole('button').click()
|
|
await page.getByRole('menuitem', { name: choice, exact: true }).click()
|
|
}
|
|
await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value)
|
|
.toMatchObject({ transcriptView: 'standard' })
|
|
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
|
await openSeed(page, fixtureA, fixtureA.markers.assistant(fixtureA.turns))
|
|
await expectBottom(page)
|
|
|
|
interface ReturnFrame {
|
|
frame: number
|
|
t: number
|
|
sessionA: boolean
|
|
markerY: number | null
|
|
markerVisible: boolean
|
|
scrollTop: number
|
|
floor: number
|
|
viewportHeight: number
|
|
columnHeight: number
|
|
spacer: number
|
|
motionEnabled: boolean
|
|
motion: string[]
|
|
following: boolean
|
|
running: boolean
|
|
responseLength: number | null
|
|
}
|
|
const sampleReturn = async (marker: string): Promise<ReturnFrame[]> => {
|
|
await openSeed(page, fixtureB, fixtureB.markers.assistant(fixtureB.turns))
|
|
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixtureA.markers.user(1))
|
|
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
|
await expect.poll(() => result.count()).toBe(1)
|
|
// Start before the click; the first visible A frame is part of the assertion, not a setup wait.
|
|
const trace = page.evaluate(({ marker, identity }) => new Promise<ReturnFrame[]>((resolve) => {
|
|
const samples: ReturnFrame[] = []
|
|
let frame = 0
|
|
let timer = 0
|
|
let visibleFrames = 0
|
|
let ordinal = 0
|
|
const finish = (): void => {
|
|
cancelAnimationFrame(frame)
|
|
window.clearTimeout(timer)
|
|
document.removeEventListener('dsh-test-return-stop', finish)
|
|
document.documentElement.dataset.returnProbe = 'done'
|
|
resolve(samples)
|
|
}
|
|
const sample = (): void => {
|
|
const host = [...document.querySelectorAll<HTMLElement>('[data-conversation-scroll]')]
|
|
.find(element => !element.closest('[inert], [hidden]') && element.checkVisibility())
|
|
const column = host?.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')
|
|
if (host !== undefined && column !== null && column !== undefined) {
|
|
const viewport = host.getBoundingClientRect()
|
|
const composerTop = host.querySelector('[data-composer-seat]')?.getBoundingClientRect().top ?? viewport.bottom
|
|
const node = [...column.querySelectorAll<HTMLElement>('p')]
|
|
.find(element => element.textContent?.trim() === marker)
|
|
const rect = node?.getBoundingClientRect()
|
|
const sessionA = column.textContent.includes(identity)
|
|
samples.push({
|
|
frame: ordinal++, t: performance.now(), sessionA,
|
|
markerY: rect === undefined ? null : rect.top - viewport.top,
|
|
markerVisible: rect !== undefined && rect.bottom > viewport.top && rect.top < composerTop,
|
|
scrollTop: host.scrollTop, floor: host.scrollHeight - host.clientHeight,
|
|
viewportHeight: host.clientHeight, columnHeight: column.getBoundingClientRect().height,
|
|
spacer: host.querySelector('[data-chat-turn-spacer]')?.getBoundingClientRect().height ?? 0,
|
|
motionEnabled: column.hasAttribute('data-chat-motion'),
|
|
motion: [...column.querySelectorAll<HTMLElement>('[data-chat-motion]')]
|
|
.map(element => element.dataset.chatMotion ?? ''),
|
|
following: host.querySelector('[data-chat-following-tail]') !== null,
|
|
running: host.querySelector('[data-chat-running]') !== null,
|
|
responseLength: node?.closest('[data-chat-flow-key]')?.textContent?.length ?? null,
|
|
})
|
|
if (sessionA) visibleFrames += 1
|
|
}
|
|
if (visibleFrames >= 60) finish()
|
|
else frame = requestAnimationFrame(tick)
|
|
}
|
|
// A zero-delay task samples after this frame's animation callbacks, including native scrolling.
|
|
const tick = (): void => { timer = window.setTimeout(sample, 0) }
|
|
document.addEventListener('dsh-test-return-stop', finish, { once: true })
|
|
document.documentElement.dataset.returnProbe = 'ready'
|
|
frame = requestAnimationFrame(tick)
|
|
}), { marker, identity: 'CHAT_SCROLL_RETURN_A_' })
|
|
try {
|
|
await page.locator('html[data-return-probe="ready"]').waitFor()
|
|
await result.click()
|
|
await page.locator('html[data-return-probe="done"]').waitFor({ timeout: 15_000 })
|
|
} finally {
|
|
await page.evaluate(() => { document.dispatchEvent(new Event('dsh-test-return-stop')) })
|
|
await trace
|
|
await page.evaluate(() => { delete document.documentElement.dataset.returnProbe })
|
|
}
|
|
return trace
|
|
}
|
|
const gates = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
|
|
const held = [false, false]
|
|
const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
|
let index = -1
|
|
for await (const chunk of next()) {
|
|
if (chunk.type === 'text-delta') {
|
|
const gate = gates[index++]
|
|
if (gate !== undefined) {
|
|
held[index - 1] = true
|
|
await gate.promise
|
|
}
|
|
}
|
|
yield chunk
|
|
}
|
|
})
|
|
let settled: ReturnType<WebScaffold['whenTurnSettled']> | undefined
|
|
await page.context().tracing.start({ screenshots: true, snapshots: true })
|
|
try {
|
|
await page.locator('[data-composer-input][contenteditable="true"]').last().fill('CHAT_RUNNING_RETURN_NEXT_INPUT')
|
|
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
|
await expect.poll(() => held[0]).toBe(true)
|
|
await page.getByText(heldMarker, { exact: true }).waitFor()
|
|
await page.locator('[data-chat-running]').waitFor()
|
|
await page.locator('[data-chat-flow][data-chat-motion=""]').waitFor()
|
|
await expect.poll(() => page.locator('[data-chat-motion="collapse"], [data-chat-motion="reveal"]').count()).toBe(0)
|
|
await expectBottom(page)
|
|
const before = await scrollGeometry(page)
|
|
const chunkCount = () => world.assistantFrames.filter(frame => frame.type === 'chunk').length
|
|
const heldChunks = chunkCount()
|
|
const running = await sampleReturn(heldMarker)
|
|
const chunksAfterReturn = chunkCount()
|
|
await writeFile(join(evidence, 'running-frames.json'), JSON.stringify({ before, heldChunks, chunksAfterReturn, samples: running }, null, 2))
|
|
await page.screenshot({ path: join(evidence, 'running-held.png') })
|
|
expect(chunksAfterReturn).toBe(heldChunks)
|
|
expect(world.events.some(event => event.type === 'turn/end')).toBe(false)
|
|
await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor()
|
|
|
|
const beforeGrowth = await scrollGeometry(page)
|
|
gates[0]!.resolve(undefined)
|
|
await expect.poll(() => held[1]).toBe(true)
|
|
await page.getByText(grownMarker, { exact: true }).waitFor()
|
|
await expectBottom(page)
|
|
const afterGrowth = await scrollGeometry(page)
|
|
expect(afterGrowth.scrollTop - beforeGrowth.scrollTop).toBeGreaterThan(200)
|
|
expect(await page.locator('[data-chat-following-tail]').count()).toBe(1)
|
|
await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor()
|
|
await expectMarkerAboveComposer(page, grownMarker)
|
|
settled = world.scaffold.whenTurnSettled()
|
|
gates[1]!.resolve(undefined)
|
|
await settled
|
|
await page.getByText(doneMarker, { exact: true }).waitFor()
|
|
await expect.poll(() => page.locator('[data-chat-running], [data-streaming="true"]').count()).toBe(0)
|
|
await expectBottom(page)
|
|
const completed = await sampleReturn(doneMarker)
|
|
await page.screenshot({ path: join(evidence, 'completed.png') })
|
|
const summarize = (samples: ReturnFrame[]) => {
|
|
const visible = samples.filter(sample => sample.sessionA)
|
|
const first = visible[0]!
|
|
const markerMovement = Math.max(...visible.map(sample =>
|
|
sample.markerY === null || first.markerY === null ? Infinity : Math.abs(sample.markerY - first.markerY)))
|
|
return { frames: visible.length, markerMovement, first, last: visible.at(-1)! }
|
|
}
|
|
const summary = { running: summarize(running), completed: summarize(completed) }
|
|
await writeFile(join(evidence, 'return-trace.json'), JSON.stringify({
|
|
viewport: { width: 1280, height: 720 }, mode: 'standard', collapseTiming: 'next-input',
|
|
historyTurns: { a: fixtureA.turns, b: fixtureB.turns }, initialTextLength: parts[0]!.length,
|
|
before, heldChunks, chunksAfterReturn, beforeGrowth, afterGrowth, summary, running, completed,
|
|
}, null, 2))
|
|
console.log(`running-return e2e: ${JSON.stringify(summary)}; evidence=${evidence}`)
|
|
for (const [name, samples] of [['completed', completed], ['running', running]] as const) {
|
|
const visible = samples.filter(sample => sample.sessionA)
|
|
expect(visible.length, `${name} return did not capture its first 60 visible frames`).toBe(60)
|
|
expect(visible.every(sample => sample.markerY !== null), `${name} return omitted the response in a visible frame`).toBe(true)
|
|
expect(visible.every(sample => sample.running === (name === 'running'))).toBe(true)
|
|
expect(new Set(visible.map(sample => sample.responseLength)).size, `${name} response changed while output was held`).toBe(1)
|
|
expect(summary[name].markerMovement, `${name} Session moved after its first visible frame; trace=${evidence}`)
|
|
.toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
}
|
|
await expectBottom(page)
|
|
assertClean(world)
|
|
} finally {
|
|
settled ??= world.events.some(event => event.type === 'turn/start')
|
|
&& !world.events.some(event => event.type === 'turn/end') ? world.scaffold.whenTurnSettled() : undefined
|
|
for (const gate of gates) gate.resolve(undefined)
|
|
dispose()
|
|
try {
|
|
await settled
|
|
} finally {
|
|
await page.context().tracing.stop({ path: join(evidence, 'browser-trace.zip') })
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => {
|
|
await withScrollWorld({
|
|
failureShot: 'web-e2e-chat-floating-controls',
|
|
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
|
|
}, async (world) => {
|
|
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
|
|
const host = world.page.locator('[data-conversation-scroll]')
|
|
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
|
const expectControls = async (): Promise<void> => {
|
|
await expect.poll(() => host.evaluate((element) => {
|
|
const rail = element.querySelector('nav[aria-label="Turn navigation"]')
|
|
const button = element.querySelector('button[aria-label="Back to bottom"]')
|
|
const composer = element.querySelector('[data-composer-seat]')
|
|
if (rail === null || button === null || composer === null) return Infinity
|
|
const viewport = element.getBoundingClientRect()
|
|
const railBox = rail.getBoundingClientRect()
|
|
const buttonBox = button.getBoundingClientRect()
|
|
const composerBox = composer.getBoundingClientRect()
|
|
const top = viewport.top + element.clientTop
|
|
return Math.max(
|
|
Math.abs((railBox.top + railBox.bottom) / 2 - (top + composerBox.top) / 2),
|
|
Math.abs(viewport.left + element.clientLeft + element.clientWidth - railBox.right - 12),
|
|
Math.abs(composerBox.top - buttonBox.bottom - 16),
|
|
)
|
|
}), { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
}
|
|
|
|
await wheelTranscript(world.page, -1_200)
|
|
await backToBottom.waitFor({ timeout: 10_000 })
|
|
await expectControls()
|
|
await wheelTranscript(world.page, -1_200)
|
|
await expectControls()
|
|
|
|
const seat = world.page.locator('[data-composer-seat]')
|
|
const initialHeight = await seat.evaluate(element => element.getBoundingClientRect().height)
|
|
const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
|
|
await composer.fill(Array.from({ length: 8 }, (_, index) => `draft line ${index}`).join('\n'))
|
|
await expect.poll(() => seat.evaluate(element => element.getBoundingClientRect().height))
|
|
.toBeGreaterThan(initialHeight + 40)
|
|
await expectControls()
|
|
await composer.fill('')
|
|
await expectControls()
|
|
|
|
const rail = world.page.getByRole('navigation', { name: 'Turn navigation', includeHidden: true })
|
|
const frame = rail.locator('../..')
|
|
const originalStyle = await frame.getAttribute('style')
|
|
try {
|
|
for (const clearance of [16, 8]) {
|
|
for (const contentWidth of [901, 900, 899, 901]) {
|
|
await frame.evaluate((element, { clearance, contentWidth }) => {
|
|
element.style.width = `${contentWidth + 2 * (clearance + 16)}px`
|
|
element.style.setProperty('--dsh-composer-side-clearance', `${clearance}px`)
|
|
}, { clearance, contentWidth })
|
|
await expect.poll(() => rail.isVisible()).toBe(contentWidth > 900)
|
|
}
|
|
}
|
|
} finally {
|
|
await frame.evaluate((element, style) => {
|
|
if (style === null) element.removeAttribute('style')
|
|
else element.setAttribute('style', style)
|
|
}, originalStyle)
|
|
}
|
|
await expectControls()
|
|
await backToBottom.click()
|
|
await expectBottom(world.page)
|
|
assertClean(world)
|
|
})
|
|
})
|
|
|
|
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/page', async (route) => {
|
|
const request = route.request().postDataJSON() as {
|
|
method?: string
|
|
payload?: { args?: { request?: { beforeSeq?: number } } }
|
|
}
|
|
if (!held && request.method === 'session/page'
|
|
&& request.payload?.args?.request?.beforeSeq !== undefined) {
|
|
held = true
|
|
await gate
|
|
}
|
|
await route.continue()
|
|
})
|
|
|
|
const settled = world.scaffold.whenTurnSettled(60_000)
|
|
const releaseText = holdTextAfter(world, 1)
|
|
try {
|
|
const composer = world.page.locator('[data-composer-input][contenteditable="true"]').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 beforeRows = await loadedFlowRows(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.assistantFrames.filter(frame => frame.type === 'chunk').length
|
|
releaseText()
|
|
await expect.poll(
|
|
() => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
|
|
{ timeout: 10_000 },
|
|
).toBeGreaterThan(chunksAfterAnchor + 5)
|
|
|
|
releaseHistory()
|
|
await expect.poll(() => loadedFlowRows(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeRows)
|
|
await nextPaint(world.page)
|
|
await expectSameFlowTop(world.page, readerAnchor)
|
|
} finally {
|
|
releaseText()
|
|
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/page')
|
|
|
|
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)
|
|
// The whole log is loaded: turn 1's unique marker renders in the
|
|
// transcript (scoped: the sidebar search row also carries it) and no
|
|
// page remains.
|
|
expect(await world.page.locator('[data-conversation-scroll]')
|
|
.getByText(HISTORY_FIXTURE.markers.user(1), { exact: false }).count()).toBe(1)
|
|
expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
|
|
assertClean(world)
|
|
})
|
|
}, 180_000)
|
|
|
|
it.skipIf(MODE === 'record')('follows a growing Standard-mode process group independently of the outer transcript', async () => {
|
|
const parts = [
|
|
'GROUP_SCROLL_START\n\n',
|
|
...Array.from({ length: 4 }, (_, batch) => Array.from({ length: 20 }, (_, row) =>
|
|
`Group batch ${batch} paragraph ${row}: inspect the next recorded operation.\n\n`).join('')),
|
|
'GROUP_SCROLL_END\n\n',
|
|
]
|
|
const chunks: StreamChunk[] = [
|
|
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
|
...parts.map((text): StreamChunk => ({ type: 'reasoning-delta', index: 0, text })),
|
|
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: parts.join('') } },
|
|
{ type: 'block-start', index: 1, blockType: 'text' },
|
|
{ type: 'text-delta', index: 1, text: 'GROUP_SCROLL_DONE' },
|
|
{ type: 'block-end', index: 1, block: { type: 'text', text: 'GROUP_SCROLL_DONE' } },
|
|
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 512 } },
|
|
{ type: 'finish', reason: { kind: 'stop' } },
|
|
]
|
|
await withScrollWorld({
|
|
failureShot: 'web-e2e-chat-process-follow',
|
|
replay: [replayEntry(chunks)],
|
|
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
|
|
}, async (world) => {
|
|
await world.page.emulateMedia({ reducedMotion: 'no-preference' })
|
|
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
|
|
await openSettings(world.page, 'en')
|
|
await world.page.getByRole('dialog', { name: 'Settings', exact: true })
|
|
.getByText('Work details', { exact: true }).locator('../..')
|
|
.getByRole('button', { name: 'Detailed', exact: true }).click()
|
|
await world.page.getByRole('menuitem', { name: 'Standard', exact: true }).click()
|
|
await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value)
|
|
.toMatchObject({ transcriptView: 'standard' })
|
|
await world.page.keyboard.press('Escape')
|
|
const gates = parts.slice(1).map(() => Promise.withResolvers<undefined>())
|
|
const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
|
let index = -1
|
|
for await (const chunk of next()) {
|
|
if (chunk.type === 'reasoning-delta') await gates[index++]?.promise
|
|
yield chunk
|
|
}
|
|
})
|
|
const settled = world.scaffold.whenTurnSettled(60_000)
|
|
try {
|
|
await world.page.locator('[data-composer-input][contenteditable="true"]').last().fill(
|
|
'Inspect the recorded operations while I scroll the conversation.',
|
|
)
|
|
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
|
const group = world.page.locator('[data-step-process]:has([data-variant="think"][data-state="running"])')
|
|
await group.locator('[data-process-activity]').click()
|
|
await group.locator('[data-variant="think"][data-state="running"] [data-disclosure-row]').click()
|
|
const body = group.locator('[data-step-process-body]')
|
|
await nextPaint(world.page)
|
|
expect(await body.evaluate(element => element.scrollHeight <= element.clientHeight)).toBe(true)
|
|
const height = () => body.evaluate(element => element.scrollHeight)
|
|
const top = () => body.evaluate(element => element.scrollTop)
|
|
const expectGroupBottom = async (): Promise<void> => {
|
|
await expect.poll(() => body.evaluate(element =>
|
|
element.scrollHeight - element.clientHeight - element.scrollTop))
|
|
.toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
}
|
|
const grow = async (index: number): Promise<void> => {
|
|
const before = await height()
|
|
gates[index]!.resolve(undefined)
|
|
await expect.poll(height).toBeGreaterThan(before + 200)
|
|
}
|
|
|
|
// Outer following is a precondition independent of opening the inner disclosure.
|
|
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
|
await wheelTranscript(world.page, -400)
|
|
await backToBottom.click()
|
|
await expectBottom(world.page)
|
|
await grow(0)
|
|
await expectGroupBottom()
|
|
await expectBottom(world.page)
|
|
// Per-frame growth exercises the real observer and native animation
|
|
// independently of transport batching while the next model chunk waits.
|
|
const content = group.locator('[data-step-process-content]')
|
|
const heightStyle = await content.evaluate(element => element.style.height)
|
|
const beforeGrowth = await top()
|
|
try {
|
|
const duringGrowth = await body.evaluate(async (scroller) => {
|
|
const content = scroller.querySelector<HTMLElement>('[data-step-process-content]')!
|
|
const initialHeight = content.getBoundingClientRect().height
|
|
let halfway = scroller.scrollTop
|
|
for (let frame = 1; frame <= 60; frame++) {
|
|
content.style.height = `${initialHeight + frame * 20}px`
|
|
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
|
if (frame === 30) halfway = scroller.scrollTop
|
|
}
|
|
return halfway
|
|
})
|
|
expect(duringGrowth).toBeGreaterThan(beforeGrowth + 20)
|
|
await expectGroupBottom()
|
|
} finally {
|
|
await content.evaluate((element, previous) => { element.style.height = previous }, heightStyle)
|
|
}
|
|
await expectGroupBottom()
|
|
await wheelTranscript(world.page, -800)
|
|
await backToBottom.waitFor()
|
|
const outerTop = (await scrollGeometry(world.page)).scrollTop
|
|
await grow(1)
|
|
await expectGroupBottom()
|
|
expect(Math.abs((await scrollGeometry(world.page)).scrollTop - outerTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
|
|
await backToBottom.click()
|
|
await expectBottom(world.page)
|
|
const pinnedTop = await top()
|
|
await body.hover()
|
|
await world.page.mouse.wheel(0, -160)
|
|
await expect.poll(top).toBeLessThan(pinnedTop - 100)
|
|
await nextPaint(world.page)
|
|
const readerTop = await top()
|
|
await grow(2)
|
|
expect(Math.abs(await top() - readerTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
await expectBottom(world.page)
|
|
|
|
await group.locator('[data-process-activity]').click()
|
|
await group.locator('[data-process-activity]').click()
|
|
await expectGroupBottom()
|
|
|
|
await body.hover()
|
|
await world.page.mouse.wheel(0, 10_000)
|
|
await expectGroupBottom()
|
|
await grow(3)
|
|
await expectGroupBottom()
|
|
assertClean(world)
|
|
} finally {
|
|
for (const gate of gates) gate.resolve(undefined)
|
|
dispose()
|
|
await settled
|
|
}
|
|
})
|
|
})
|
|
|
|
it.skipIf(MODE === 'record')('virtualizes the outline rail and jumps to an unloaded turn', async () => {
|
|
await withScrollWorld({
|
|
failureShot: 'web-e2e-turn-rail-jump',
|
|
seeds: [{ fixture: HISTORY_FIXTURE, id: RAIL_SESSION_ID }],
|
|
}, async (world) => {
|
|
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
|
|
await expectBottom(world.page)
|
|
|
|
const rail = world.page.getByRole('navigation', { name: 'Turn navigation' })
|
|
const latest = rail.getByRole('button', { name: `Jump to turn ${String(HISTORY_FIXTURE.turns)}`, exact: true })
|
|
await expect.poll(() => latest.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
|
|
expect(await rail.getByRole('button').count()).toBeLessThan(HISTORY_FIXTURE.turns)
|
|
const firstUnloaded = rail.getByRole('button', { name: 'Load and jump to turn 1', exact: true })
|
|
expect(await firstUnloaded.count()).toBe(0)
|
|
const railScroller = rail.locator('[class*="scroller"]')
|
|
await expect.poll(() => railScroller.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true)
|
|
await expect.poll(() => rail.locator('[class*="fadeTop"]').count(), { timeout: 15_000 }).toBe(1)
|
|
const bodyBeforeRailScroll = await scrollGeometry(world.page)
|
|
await railScroller.hover()
|
|
await world.page.mouse.wheel(0, -HISTORY_FIXTURE.turns * 10)
|
|
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0)
|
|
await firstUnloaded.waitFor({ state: 'visible' })
|
|
expect((await scrollGeometry(world.page)).scrollTop).toBe(bodyBeforeRailScroll.scrollTop)
|
|
|
|
const beforeRows = await loadedFlowRows(world.page)
|
|
await firstUnloaded.focus()
|
|
const tooltip = world.page.getByRole('tooltip')
|
|
await expect.poll(() => tooltip.count(), { timeout: 15_000 }).toBe(1)
|
|
await expect.poll(() => tooltip.textContent(), { timeout: 5_000 }).toContain(HISTORY_FIXTURE.markers.user(1))
|
|
expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.assistant(1))
|
|
const previewId = await tooltip.getAttribute('id')
|
|
const hoveredMark = () => rail.evaluate(element => element.querySelector<HTMLButtonElement>('button:hover')?.dataset.index ?? null)
|
|
await expect.poll(hoveredMark).not.toBeNull()
|
|
const hoveredBefore = await hoveredMark()
|
|
await world.page.mouse.wheel(0, 80)
|
|
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(80)
|
|
await expect.poll(hoveredMark).toBe(String(Number(hoveredBefore) + 8))
|
|
await nextPaint(world.page)
|
|
expect(await firstUnloaded.evaluate(element => document.activeElement === element)).toBe(true)
|
|
expect(await firstUnloaded.getAttribute('aria-describedby')).toBe(previewId)
|
|
expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1))
|
|
await world.page.mouse.wheel(0, -80)
|
|
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0)
|
|
const transcriptLayers = await tooltip.evaluate((preview) => {
|
|
const railSlot = preview.closest('nav')?.parentElement
|
|
const codeBanner = document.querySelector<HTMLElement>('.md-code-block > :first-child')
|
|
if (!(railSlot instanceof HTMLElement) || codeBanner === null) {
|
|
throw new Error('turn preview or code-block banner stacking context is unavailable')
|
|
}
|
|
return {
|
|
codeBanner: Number(getComputedStyle(codeBanner).zIndex),
|
|
rail: Number(getComputedStyle(railSlot).zIndex),
|
|
}
|
|
})
|
|
expect(transcriptLayers.rail).toBeGreaterThan(transcriptLayers.codeBanner)
|
|
await world.page.keyboard.press('Enter')
|
|
|
|
// The jump pages history in and lands on turn 1: its mark flips to the
|
|
// loaded label and becomes current, the window grew, and the turn-1
|
|
// user row sits at the reading line.
|
|
const firstLoaded = rail.getByRole('button', { name: 'Jump to turn 1', exact: true })
|
|
await expect.poll(() => firstLoaded.count(), { timeout: 60_000 }).toBe(1)
|
|
await expect.poll(() => firstLoaded.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
|
|
expect(await loadedFlowRows(world.page)).toBeGreaterThan(beforeRows)
|
|
// Drop mark focus so its hover/focus preview (which echoes the prompt
|
|
// marker) leaves the DOM before the transcript count below.
|
|
await firstLoaded.evaluate((el) => { (el as HTMLElement).blur() })
|
|
await expect.poll(() => world.page.getByRole('tooltip').count(), { timeout: 15_000 }).toBe(0)
|
|
await nextPaint(world.page)
|
|
const marker = world.page.locator('[data-conversation-scroll]')
|
|
.getByText(HISTORY_FIXTURE.markers.user(1), { exact: false })
|
|
expect(await marker.count()).toBe(1)
|
|
const scrollport = await world.page.locator('[data-conversation-scroll]').boundingBox()
|
|
const row = await marker.boundingBox()
|
|
if (scrollport === null || row === null) throw new Error('turn-1 row or scrollport has no layout box')
|
|
expect(row.y - scrollport.y).toBeGreaterThanOrEqual(0)
|
|
expect(row.y - scrollport.y).toBeLessThanOrEqual(160)
|
|
// The rail followed the landing to the ladder top, so the fade now
|
|
// marks the other (downward) end.
|
|
await expect.poll(() => rail.locator('[class*="fadeBottom"]').count(), { timeout: 15_000 }).toBe(1)
|
|
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 resolved = await world.scaffold.ctx.sessionController.resolveAgent(TOOL_SESSION_ID)
|
|
if ('error' in resolved) throw resolved.error
|
|
world.scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access')
|
|
const settled = world.scaffold.whenTurnSettled(60_000)
|
|
let released = false
|
|
try {
|
|
const composer = world.page.locator('[data-composer-input][contenteditable="true"]').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"]`)
|
|
await expandOwningTurnProcess(world.page, liveRow)
|
|
await liveRow.waitFor({ timeout: 15_000 })
|
|
expect(await liveRow.getAttribute('data-state')).toBe('running')
|
|
await expectBottom(world.page)
|
|
expect(await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).count()).toBe(0)
|
|
|
|
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.assistantFrames.filter(frame => frame.type === 'chunk').length
|
|
await writeFile(releasePath, 'release\n')
|
|
released = true
|
|
await expect.poll(
|
|
() => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
|
|
{ timeout: 15_000 },
|
|
).toBeGreaterThan(chunksBeforeRelease + 5)
|
|
await nextPaint(world.page)
|
|
// The loop appends tool/result before starting the next model request;
|
|
// these synchronous Host listeners therefore observe it before later chunks.
|
|
expect(world.events.some(event => event.type === 'tool/result')).toBe(true)
|
|
expect(Math.abs((await flowTop(world.page, awayAnchor.key)) - awayAnchor.top))
|
|
.toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
|
|
|
const chunksAtRepin = world.assistantFrames.filter(frame => frame.type === 'chunk').length
|
|
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
|
await expect.poll(
|
|
() => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
|
|
{ timeout: 15_000 },
|
|
).toBeGreaterThan(chunksAtRepin + 5)
|
|
await nextPaint(world.page)
|
|
expect(Math.abs((await scrollGeometry(world.page)).distanceFromBottom)).toBeLessThanOrEqual(1)
|
|
} finally {
|
|
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
|
|
}
|
|
|
|
await settled
|
|
expect(world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST))).toBe(true)
|
|
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"]`
|
|
const liveRow = world.page.locator(liveRowSelector)
|
|
await expandOwningTurnProcess(world.page, liveRow)
|
|
await wheelUntilVisible(world.page, liveRowSelector, -300)
|
|
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')('keeps composer resizing on the correct scroll owner across reopened Sessions', 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 })
|
|
// The narrow breakpoint auto-collapses the sidebar. Re-open it because
|
|
// this scenario switches sessions while pinning the narrow Chat scroll owner.
|
|
await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
|
|
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
|
|
await nextPaint(world.page)
|
|
await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE)
|
|
|
|
await openSeed(
|
|
world.page,
|
|
RESTORE_FIXTURE_B,
|
|
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
|
|
)
|
|
await openSeed(
|
|
world.page,
|
|
RESTORE_FIXTURE_A,
|
|
)
|
|
|
|
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
|
await backToBottom.waitFor({ timeout: 15_000 })
|
|
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('[data-composer-input][contenteditable="true"]').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)
|
|
|
|
// Keyboard is the only non-wheel device this lane's Chromium can drive for
|
|
// real (see flingTranscript for the probe results on touch and scrollbars),
|
|
// so it stands in for the whole hardware input pipeline here.
|
|
it.skipIf(MODE === 'record')('keyboard paging owns bottom-follow without wheel input', async () => {
|
|
await withScrollWorld({
|
|
failureShot: 'web-e2e-chat-scroll-keyboard',
|
|
seeds: [{ fixture: INPUTS_FIXTURE, id: INPUTS_SESSION_ID }],
|
|
}, async (world) => {
|
|
await openSeed(
|
|
world.page,
|
|
INPUTS_FIXTURE,
|
|
INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns),
|
|
)
|
|
await expectBottom(world.page)
|
|
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
|
|
|
// Focus rides the last seeded tool row (a tabbable button whose keydown
|
|
// handler passes scrolling keys through). End first normalizes the
|
|
// focus-driven scrollIntoView back to the floor.
|
|
const lastToolRow = world.page.locator(
|
|
`[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`,
|
|
)
|
|
await expandOwningTurnProcess(world.page, lastToolRow)
|
|
await lastToolRow.focus()
|
|
await world.page.keyboard.press('End')
|
|
await expectBottom(world.page)
|
|
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
|
|
for (let press = 0; press < 3; press += 1) {
|
|
await world.page.keyboard.press('PageUp')
|
|
await nextPaint(world.page)
|
|
}
|
|
await backToBottom.waitFor({ timeout: 10_000 })
|
|
await expect.poll(async () => (await scrollGeometry(world.page)).distanceFromBottom, { timeout: 10_000 })
|
|
.toBeGreaterThan(100)
|
|
await world.page.keyboard.press('End')
|
|
await expectBottom(world.page)
|
|
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
|
|
assertClean(world)
|
|
})
|
|
}, 180_000)
|
|
|
|
it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => {
|
|
await withScrollWorld({
|
|
failureShot: 'web-e2e-chat-scroll-fling-stream',
|
|
paceMs: 0,
|
|
replay: [
|
|
replayEntry(toolStream()),
|
|
replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)),
|
|
],
|
|
seeds: [{ fixture: INPUTS_FIXTURE, id: FLING_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, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns))
|
|
const resolved = await world.scaffold.ctx.sessionController.resolveAgent(FLING_SESSION_ID)
|
|
if ('error' in resolved) throw resolved.error
|
|
world.scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access')
|
|
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
|
const settled = world.scaffold.whenTurnSettled(60_000)
|
|
const releaseText = holdTextAfter(world, 16)
|
|
let released = false
|
|
try {
|
|
const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
|
|
await composer.fill(LIVE_FLING_PROMPT)
|
|
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
|
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
|
|
await expectBottom(world.page)
|
|
|
|
// Fling away while the turn is mid-flight: the scroll burst alone must
|
|
// release bottom ownership, exactly like a wheel scroll would, even
|
|
// while streaming keeps re-asserting the floor between frames.
|
|
await flingTranscript(world.page, -900)
|
|
await backToBottom.waitFor({ timeout: 10_000 })
|
|
const awayAnchor = await visibleFlowAnchor(world.page)
|
|
const chunksBeforeRelease = world.assistantFrames.filter(frame => frame.type === '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.assistantFrames.filter(frame => frame.type === 'chunk').length,
|
|
{ timeout: 15_000 },
|
|
).toBeGreaterThan(chunksBeforeRelease + 5)
|
|
await expectSameFlowTop(world.page, awayAnchor)
|
|
|
|
// Fling back to the floor: re-pin must come from the reader's scroll
|
|
// itself, and follow must then own the still-streaming tail. The
|
|
// retry loop chases the floor that streaming keeps pushing down.
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
if ((await scrollGeometry(world.page)).distanceFromBottom <= 1) break
|
|
await flingTranscript(world.page, 1_600)
|
|
}
|
|
await expectBottom(world.page)
|
|
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
|
|
const chunksAtRepin = world.assistantFrames.filter(frame => frame.type === 'chunk').length
|
|
releaseText()
|
|
await expect.poll(
|
|
() => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
|
|
{ timeout: 15_000 },
|
|
).toBeGreaterThan(chunksAtRepin + 5)
|
|
await expectBottom(world.page)
|
|
} finally {
|
|
releaseText()
|
|
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_FLING_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
|
await expectBottom(world.page)
|
|
assertClean(world)
|
|
})
|
|
}, 180_000)
|
|
})
|