fix(trajectory): bound work during long streams
This commit is contained in:
235
apps/web/tests/chat-scroll-fixture.ts
Normal file
235
apps/web/tests/chat-scroll-fixture.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
// Synthetic long-chat history for browser behavior contracts. The fixture is
|
||||
// generated through Session so pagination exercises the same event shapes as
|
||||
// persisted conversations, while unique markers identify semantic rows
|
||||
// without depending on CSS-module names or virtualizer DOM positions.
|
||||
import {
|
||||
CallId,
|
||||
createAssistantMessage,
|
||||
createToolResultMessage,
|
||||
createUserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
// Carries the session/title event declaration into this fixture builder.
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** Options for one deterministic long-chat fixture. */
|
||||
export interface ChatScrollFixtureOptions {
|
||||
/** Marker namespace, used when two sessions share one browser world. */
|
||||
readonly markerPrefix: string
|
||||
/** Searchable title projected into the sidebar. */
|
||||
readonly title: string
|
||||
/** Number of closed turns to generate. */
|
||||
readonly turns?: number
|
||||
}
|
||||
|
||||
/** Semantic marker helpers returned with a generated fixture. */
|
||||
interface ChatScrollMarkers {
|
||||
/** Marker painted in the human message for a turn. */
|
||||
user(turn: number): string
|
||||
/** Marker painted in the final assistant message for a turn. */
|
||||
assistant(turn: number): string
|
||||
/** Marker painted in one seeded bash call and result. */
|
||||
tool(turn: number, index: number): string
|
||||
}
|
||||
|
||||
/** Generated JSONL plus the stable facts browser scenarios assert. */
|
||||
export interface ChatScrollFixture {
|
||||
readonly log: string
|
||||
readonly markers: ChatScrollMarkers
|
||||
readonly title: string
|
||||
readonly turns: number
|
||||
}
|
||||
|
||||
const DEFAULT_TURNS = 88
|
||||
const TOOL_INTERVAL = 8
|
||||
const CODE_INTERVAL = 11
|
||||
|
||||
function text(value: string): { type: 'text'; text: string }[] {
|
||||
return [{ type: 'text', text: value }]
|
||||
}
|
||||
|
||||
function suffix(turn: number): string {
|
||||
return String(turn).padStart(3, '0')
|
||||
}
|
||||
|
||||
function markerHelpers(prefix: string): ChatScrollMarkers {
|
||||
return {
|
||||
user: turn => `CHAT_SCROLL_${prefix}_USER_${suffix(turn)}`,
|
||||
assistant: turn => `CHAT_SCROLL_${prefix}_ASSISTANT_${suffix(turn)}`,
|
||||
tool: (turn, index) => `CHAT_SCROLL_${prefix}_TOOL_${suffix(turn)}_${String(index)}`,
|
||||
}
|
||||
}
|
||||
|
||||
function appendRequestHeader(session: Session, turn: number, step: number): void {
|
||||
session.append('request/header', {
|
||||
header: {
|
||||
config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`,
|
||||
},
|
||||
reason: turn === 1 && step === 1 ? 'initial' : 'change',
|
||||
})
|
||||
}
|
||||
|
||||
function appendAssistant(session: Session, turn: number, step: number, body: string): void {
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step,
|
||||
message: createAssistantMessage({
|
||||
content: text(body),
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
usage: {
|
||||
inputTokens: 2_000 + turn * 7,
|
||||
outputTokens: 180 + step * 20,
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function codeBlock(turn: number): string {
|
||||
if (turn % CODE_INTERVAL !== 0) return ''
|
||||
const lines = Array.from(
|
||||
{ length: 30 },
|
||||
(_, index) => `const scroll_case_${suffix(turn)}_${String(index).padStart(2, '0')} = ${String(turn + index)}`,
|
||||
)
|
||||
return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
|
||||
}
|
||||
|
||||
function appendToolStep(
|
||||
session: Session,
|
||||
markers: ChatScrollMarkers,
|
||||
turn: number,
|
||||
): void {
|
||||
const calls = [1, 2].map((index) => {
|
||||
const marker = markers.tool(turn, index)
|
||||
const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`)
|
||||
const args = JSON.stringify({
|
||||
command: `printf '${marker}\\n'`,
|
||||
description: marker,
|
||||
})
|
||||
return { args, callId, marker }
|
||||
})
|
||||
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'reasoning', text: `Inspecting two scroll fixtures for turn ${String(turn)}.` },
|
||||
...calls.map(call => ({
|
||||
type: 'tool-call' as const,
|
||||
id: call.callId,
|
||||
name: 'bash',
|
||||
arguments: call.args,
|
||||
})),
|
||||
],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
usage: { inputTokens: 2_000 + turn * 7, outputTokens: 240, reasoningTokens: 30 },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
for (const call of calls) {
|
||||
const source = session.append('tool/call', {
|
||||
turn,
|
||||
step: 1,
|
||||
callId: call.callId,
|
||||
name: 'bash',
|
||||
arguments: call.args,
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: call.callId,
|
||||
content: text(Array.from(
|
||||
{ length: 12 },
|
||||
(_, line) => `${call.marker} output line ${String(line + 1).padStart(2, '0')}`,
|
||||
).join('\n')),
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
|
||||
}
|
||||
}
|
||||
|
||||
function fixtureLog(session: Session): string {
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: Date.now() - 60_000,
|
||||
cwd: '{{cwd}}',
|
||||
delegationDepth: 0,
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multi-page conversation with prose, fenced code, and paired bash
|
||||
* calls/results. Every turn is closed, so cold resume cannot repair or mutate
|
||||
* the seed before the browser observes it.
|
||||
* @param options - Fixture identity and optional turn count.
|
||||
* @returns Canonical JSONL and semantic marker helpers.
|
||||
*/
|
||||
export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
|
||||
const turns = options.turns ?? DEFAULT_TURNS
|
||||
const markers = markerHelpers(options.markerPrefix)
|
||||
const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
|
||||
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: text(
|
||||
`${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. `
|
||||
+ 'Keep the visible message stable while history, tools, and new output change around it.',
|
||||
),
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
if (turn === 1) {
|
||||
session.append('session/title', {
|
||||
title: options.title,
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
}
|
||||
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
appendRequestHeader(session, turn, 1)
|
||||
if (turn % TOOL_INTERVAL === 0) {
|
||||
appendToolStep(session, markers, turn)
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('step/start', { turn, step: 2 })
|
||||
appendRequestHeader(session, turn, 2)
|
||||
appendAssistant(
|
||||
session,
|
||||
turn,
|
||||
2,
|
||||
`${markers.assistant(turn)} Both tool results are accounted for. `
|
||||
+ `This settled response keeps turn ${String(turn)} identifiable after paging.${codeBlock(turn)}`,
|
||||
)
|
||||
session.append('step/end', { turn, step: 2 })
|
||||
} else {
|
||||
appendAssistant(
|
||||
session,
|
||||
turn,
|
||||
1,
|
||||
`${markers.assistant(turn)} The conversation remains readable after several paragraphs.\n\n`
|
||||
+ `Turn ${String(turn)} deliberately carries enough prose to wrap at narrower viewport widths. `
|
||||
+ 'The semantic marker stays near the start so geometry probes can find the same rendered row.\n\n'
|
||||
+ `The closing paragraph makes this a realistic assistant response rather than a one-line list item.${codeBlock(turn)}`,
|
||||
)
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
}
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
return { log: fixtureLog(session), markers, title: options.title, turns }
|
||||
}
|
||||
309
apps/web/tests/trajectory-virtualization.e2e.ts
Normal file
309
apps/web/tests/trajectory-virtualization.e2e.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Browser contract for the tail-paged, virtualized Trajectory ledger. The
|
||||
// scenario proves that semantic row identity survives an older-page prepend,
|
||||
// DOM mounting stays bounded, and every scroll range remains reachable.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const SESSION_ID = 'trajectory-virtualization-e2e'
|
||||
const FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'TRAJECTORY_VIRTUAL',
|
||||
title: 'TRAJECTORY_VIRTUAL long ledger',
|
||||
turns: 88,
|
||||
})
|
||||
const MAX_MOUNTED_ROWS = 160
|
||||
const GEOMETRY_TOLERANCE = 2
|
||||
const STREAM_MARKER = 'TRAJECTORY_VIRTUAL_STREAM_FINISHED'
|
||||
const STREAM_TEXT = Array.from(
|
||||
{ length: 80 },
|
||||
(_, index) => `stream fragment ${String(index + 1).padStart(2, '0')} `,
|
||||
).join('') + STREAM_MARKER
|
||||
|
||||
const STREAM_CHUNKS: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from({ length: 80 }, (_, index): StreamChunk => ({
|
||||
type: 'text-delta',
|
||||
index: 0,
|
||||
text: `stream fragment ${String(index + 1).padStart(2, '0')} `,
|
||||
})),
|
||||
{ type: 'text-delta', index: 0, text: STREAM_MARKER },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: STREAM_TEXT } },
|
||||
{ type: 'usage', usage: { inputTokens: 2_700, outputTokens: 240 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
interface ScrollGeometry {
|
||||
readonly clientHeight: number
|
||||
readonly scrollHeight: number
|
||||
readonly scrollTop: number
|
||||
}
|
||||
|
||||
interface RowAnchor {
|
||||
readonly key: string
|
||||
readonly top: number
|
||||
}
|
||||
|
||||
async function openSeed(page: Page): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
|
||||
await result.click()
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).waitFor({ timeout: 30_000 })
|
||||
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
|
||||
.last()
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function openTrajectory(page: Page): Promise<void> {
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
const pane = page.locator('[data-trajectory-scroll]')
|
||||
await pane.waitFor({ timeout: 30_000 })
|
||||
await page.locator('[data-trajectory-scroll] table[data-scroll-ready="true"]')
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function logicalRows(page: Page): Promise<number> {
|
||||
const raw = await page.locator('[data-trajectory-scroll] table').getAttribute('aria-rowcount')
|
||||
if (raw === null || !/^\d+$/.test(raw)) {
|
||||
throw new Error(`trajectory table has invalid aria-rowcount ${JSON.stringify(raw)}`)
|
||||
}
|
||||
return Number(raw)
|
||||
}
|
||||
|
||||
async function mountedRows(page: Page): Promise<number> {
|
||||
return page.locator('[data-trajectory-scroll] tr[data-trajectory-row-key]').count()
|
||||
}
|
||||
|
||||
async function geometry(page: Page): Promise<ScrollGeometry> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate(host => ({
|
||||
clientHeight: host.clientHeight,
|
||||
scrollHeight: host.scrollHeight,
|
||||
scrollTop: host.scrollTop,
|
||||
}))
|
||||
}
|
||||
|
||||
async function nextPaint(page: Page): Promise<void> {
|
||||
await page.evaluate(() => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => { resolve() }))
|
||||
}))
|
||||
}
|
||||
|
||||
async function scrollToRatio(page: Page, ratio: number): Promise<void> {
|
||||
await page.locator('[data-trajectory-scroll]').evaluate((host, value) => {
|
||||
const maximum = Math.max(0, host.scrollHeight - host.clientHeight)
|
||||
host.scrollTop = Math.round(maximum * value)
|
||||
host.dispatchEvent(new Event('scroll'))
|
||||
}, ratio)
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function firstVisibleRow(page: Page): Promise<RowAnchor> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate((host) => {
|
||||
const hostBox = host.getBoundingClientRect()
|
||||
const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
const row = rows.find((candidate) => {
|
||||
const box = candidate.getBoundingClientRect()
|
||||
return candidate.dataset.requestOnly !== 'true'
|
||||
&& box.bottom > hostBox.top
|
||||
&& box.top < hostBox.bottom
|
||||
})
|
||||
const key = row?.dataset.trajectoryRowKey
|
||||
if (row === undefined || key === undefined) {
|
||||
throw new Error('trajectory scrollport has no visible semantic row')
|
||||
}
|
||||
return { key, top: row.getBoundingClientRect().top - hostBox.top }
|
||||
})
|
||||
}
|
||||
|
||||
async function rowTop(page: Page, key: string): Promise<number | null> {
|
||||
return page.locator('[data-trajectory-scroll]').evaluate((host, targetKey) => {
|
||||
const rows = [...host.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
|
||||
const row = rows.find(candidate => candidate.dataset.trajectoryRowKey === targetKey)
|
||||
return row === undefined
|
||||
? null
|
||||
: row.getBoundingClientRect().top - host.getBoundingClientRect().top
|
||||
}, key)
|
||||
}
|
||||
|
||||
async function loadToFirstTurn(page: Page): Promise<void> {
|
||||
const marker = FIXTURE.markers.user(1)
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
await scrollToRatio(page, 0)
|
||||
if (await page.getByText(marker, { exact: false }).count() > 0) return
|
||||
const before = await logicalRows(page)
|
||||
await expect.poll(async () => ({
|
||||
marker: await page.getByText(marker, { exact: false }).count() > 0,
|
||||
rows: await logicalRows(page),
|
||||
}), { timeout: 30_000 }).not.toEqual({ marker: false, rows: before })
|
||||
}
|
||||
throw new Error('trajectory did not reach the first turn after twelve older-page requests')
|
||||
}
|
||||
|
||||
describe('web e2e: Trajectory virtualization over tail-paged history', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let replayDir: string
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-trajectory-virtualization-'))
|
||||
const replayFixture = join(replayDir, 'session.jsonl')
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayFixture, FIXTURE.log)
|
||||
await writeFile(replayOverride, JSON.stringify([{
|
||||
kind: 'chunks',
|
||||
chunks: STREAM_CHUNKS,
|
||||
} satisfies ReplayEntry]))
|
||||
scaffold = await launchWebScaffold({
|
||||
paceMs: 10,
|
||||
replayFixture,
|
||||
replayOverride,
|
||||
})
|
||||
await seedSession(scaffold, FIXTURE.log, SESSION_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser, 900)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('retains identity on prepend and reaches the bounded virtual range', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-trajectory-virtualization'))
|
||||
await openSeed(page)
|
||||
|
||||
let held = false
|
||||
let releaseHistory: () => void = () => {}
|
||||
let finishHeldRequest: () => void = () => {}
|
||||
const gate = new Promise<void>((resolve) => { releaseHistory = resolve })
|
||||
const heldRequestFinished = new Promise<void>((resolve) => { finishHeldRequest = resolve })
|
||||
await page.route('**/api/session.history', async (route) => {
|
||||
const request = route.request().postDataJSON() as {
|
||||
method?: string
|
||||
payload?: { beforeSeq?: number }
|
||||
}
|
||||
if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
|
||||
held = true
|
||||
await gate
|
||||
try {
|
||||
await route.continue()
|
||||
} finally {
|
||||
finishHeldRequest()
|
||||
}
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
try {
|
||||
await openTrajectory(page)
|
||||
const initialRows = await logicalRows(page)
|
||||
expect(initialRows).toBeGreaterThan(0)
|
||||
expect(await page.getByText('Initial System Prompt', { exact: true }).count()).toBe(0)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
await scrollToRatio(page, 0)
|
||||
await expect.poll(() => held, { timeout: 15_000 }).toBe(true)
|
||||
const anchor = await firstVisibleRow(page)
|
||||
const selectedRow = page.locator(
|
||||
`[data-trajectory-scroll] tr[data-trajectory-row-key=${JSON.stringify(anchor.key)}]`,
|
||||
)
|
||||
await selectedRow.click()
|
||||
await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 })
|
||||
.toBe('true')
|
||||
|
||||
releaseHistory()
|
||||
await expect.poll(() => logicalRows(page), { timeout: 60_000 }).toBeGreaterThan(initialRows)
|
||||
await nextPaint(page)
|
||||
await expect.poll(async () => {
|
||||
const top = await rowTop(page, anchor.key)
|
||||
return top === null ? Number.POSITIVE_INFINITY : Math.abs(top - anchor.top)
|
||||
}, { timeout: 15_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expect.poll(() => selectedRow.getAttribute('aria-selected'), { timeout: 10_000 })
|
||||
.toBe('true')
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
await loadToFirstTurn(page)
|
||||
await expect.poll(
|
||||
() => page.getByText(FIXTURE.markers.user(1), { exact: false }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThan(0)
|
||||
const fullRows = await logicalRows(page)
|
||||
|
||||
await scrollToRatio(page, 0.5)
|
||||
const middle = await geometry(page)
|
||||
const maximum = middle.scrollHeight - middle.clientHeight
|
||||
expect(middle.scrollTop).toBeGreaterThan(maximum * 0.25)
|
||||
expect(middle.scrollTop).toBeLessThan(maximum * 0.75)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
expect(await mountedRows(page)).toBeLessThan(fullRows)
|
||||
|
||||
await scrollToRatio(page, 1)
|
||||
await expect.poll(async () => {
|
||||
const value = await geometry(page)
|
||||
return value.scrollHeight - value.clientHeight - value.scrollTop
|
||||
}, { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expect.poll(
|
||||
() => page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThan(0)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
|
||||
const trajectoryScroll = page.locator('[data-trajectory-scroll]')
|
||||
await trajectoryScroll.evaluate((host) => {
|
||||
const measuredWindow = window as Window & { __trajectoryScrollCalls?: number }
|
||||
measuredWindow.__trajectoryScrollCalls = 0
|
||||
const original = host.scrollTo.bind(host)
|
||||
const trackedScrollTo = (...args: [ScrollToOptions?] | [number, number]) => {
|
||||
measuredWindow.__trajectoryScrollCalls = (measuredWindow.__trajectoryScrollCalls ?? 0) + 1
|
||||
Reflect.apply(original, host, args)
|
||||
}
|
||||
host.scrollTo = trackedScrollTo as typeof host.scrollTo
|
||||
})
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('Stream one deterministic response while Trajectory remains visible.')
|
||||
await input.press('Enter')
|
||||
await settled
|
||||
await page.getByText('stream fragment 01', { exact: false }).waitFor({ timeout: 30_000 })
|
||||
await nextPaint(page)
|
||||
const streamingScrollCalls = await trajectoryScroll.evaluate(() => {
|
||||
return (window as Window & { __trajectoryScrollCalls?: number })
|
||||
.__trajectoryScrollCalls ?? 0
|
||||
})
|
||||
expect(streamingScrollCalls).toBeLessThanOrEqual(5)
|
||||
expect(await mountedRows(page)).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
warnings: tripwire.warnings,
|
||||
}).toEqual({ pageErrors: [], warnings: [] })
|
||||
} finally {
|
||||
releaseHistory()
|
||||
if (held) await heldRequestFinished
|
||||
await page.unroute('**/api/session.history')
|
||||
}
|
||||
}, 180_000)
|
||||
})
|
||||
@@ -31,6 +31,8 @@
|
||||
"tests/plan-review.e2e.ts",
|
||||
"tests/steering.e2e.ts",
|
||||
"tests/navigation-panes.e2e.ts",
|
||||
"tests/chat-scroll-fixture.ts",
|
||||
"tests/trajectory-virtualization.e2e.ts",
|
||||
"tests/lifecycle-chrome.e2e.ts",
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user