Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

Resolve translation-pairing hashes after the merge and adapt the
math-rendering e2e fixture to the simplified turn/start event shape.
This commit is contained in:
_Kerman
2026-08-05 21:42:33 +08:00
60 changed files with 3207 additions and 376 deletions

View File

@@ -1,7 +1,7 @@
// 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 let tests identify semantic
// rows without depending on CSS-module names or the eventual virtualizer DOM.
// persisted conversations, while unique markers identify semantic rows
// without depending on CSS-module names or virtualizer DOM positions.
import {
CallId,
createAssistantMessage,

View File

@@ -0,0 +1,127 @@
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/math-rendering', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/math-rendering/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'math-rendering-web-e2e'
const DONE = 'MATH_RENDERING_DONE'
/** Build a settled assistant reply that exercises every supported math delimiter. */
function mathFixture(): string {
const session = Session.create(SessionId('math-rendering-source'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Render this mathematical proof.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Math rendering',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{
type: 'text',
text: [
'## Math rendering',
'',
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
'',
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
'',
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
'',
DONE,
].join('\n'),
}],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}),
...session.events.map(event => JSON.stringify(event)),
'',
].join('\n')
}
describe('web e2e: settled Markdown math rendering', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, mathFixture(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('renders the settled reply without KaTeX errors', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
expect(await page.locator('.katex-error').count()).toBe(0)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
}, 60_000)
})

View File

@@ -0,0 +1,47 @@
- banner:
- navigation "Session hierarchy":
- button "Math rendering" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Render this mathematical proof. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- heading "Math rendering" [level=2]
- paragraph:
- text: Inline dollar
- math: θ
- text: and backslash
- math: 1 5
- text: .
- math: π 4 < θ < π 2
- math: θ ∈ ( π 4 , π 2 ) . (1)
- table:
- rowgroup:
- row "Symbol Value":
- columnheader "Symbol"
- columnheader "Value"
- rowgroup:
- row:
- cell:
- math: θ
- cell:
- math: 1 5
- paragraph: MATH_RENDERING_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}}Ran for {{duration}}
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok

View File

@@ -5,7 +5,7 @@
- img
- searchbox "Search trajectory"
- region "Trajectory timeline":
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1.5 s · TTFT 368 ms · Decoding 1.2 s"
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1,542 ms · TTFT 368 ms · Decoding 1,174 ms"
- table:
- rowgroup:
- row "SYSTEM, Initial System Prompt":

View 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)
})

View File

@@ -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",
@@ -48,6 +50,7 @@
"tests/web-search-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/markdown-images.e2e.ts",
"tests/math-rendering.e2e.ts",
"tests/queue-actions.e2e.ts",
"tests/skill-invocation-policy.e2e.ts",
"tests/permission-policy-context.e2e.ts",