Merge branch 'master' into agent/web-file-reference-prompt

This commit is contained in:
Ziya
2026-08-12 23:19:35 +08:00
committed by GitHub
118 changed files with 2483 additions and 332 deletions

View File

@@ -250,13 +250,16 @@ function scrollGeometry(page: Page): Promise<ScrollGeometry> {
}))
}
async function conversationTurns(page: Page): Promise<number> {
const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
await stats.waitFor({ timeout: 15_000 })
const value = await stats.textContent()
const match = value?.match(/^(\d+) turns · \d+ steps$/)
if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
return Number(match[1])
/**
* 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> {
@@ -425,9 +428,9 @@ async function loadEarlierWithAnchor(page: Page): Promise<void> {
const older = page.getByRole('button', { name: 'Load earlier', exact: true })
await older.waitFor({ timeout: 10_000 })
const anchor = await visibleFlowAnchor(page)
const before = await conversationTurns(page)
const before = await loadedFlowRows(page)
await older.click()
await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
await expect.poll(() => loadedFlowRows(page), { timeout: 30_000 }).toBeGreaterThan(before)
await nextPaint(page)
await expectSameFlowTop(page, anchor)
}
@@ -498,7 +501,7 @@ describe('web e2e: long Chat scroll contract', () => {
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
await wheelToHistoryStart(world.page)
const beforeTurns = await conversationTurns(world.page)
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)
@@ -511,7 +514,7 @@ describe('web e2e: long Chat scroll contract', () => {
).toBeGreaterThan(chunksAfterAnchor + 5)
releaseHistory()
await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
await expect.poll(() => loadedFlowRows(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeRows)
await nextPaint(world.page)
await expectSameFlowTop(world.page, readerAnchor)
} finally {
@@ -531,7 +534,11 @@ describe('web e2e: long Chat scroll contract', () => {
additionalPages += 1
}
expect(additionalPages).toBeGreaterThan(0)
expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
// 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)
})

View File

@@ -797,12 +797,11 @@ async function stableCount(
}
async function conversationTurns(page: Page): Promise<number> {
const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
await stats.waitFor({ timeout: 15_000 })
const value = await stats.textContent()
const match = value?.match(/^(\d+) turns · \d+ steps$/)
if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
return Number(match[1])
// Loaded-window turn count: one mounted turn-tail footer per settled turn in
// the window (context keys are `${kind.length}:${kind}${id}`). The stats
// strip cannot serve as this probe: its counts ride the whole-log
// sessionStats projection and stay fixed across paging by design.
return stableCount(page.locator('[data-chat-flow-key^="9:turn-tail"]'), count => count > 0)
}
function retainedDelta(

View File

@@ -120,7 +120,7 @@ describe('web e2e: settled Markdown math rendering', () => {
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
expect(await page.locator('.katex-error').count()).toBe(0)
await expect.poll(
() => page.getByText('Input 0 tok · Output 0 tok', { exact: false }).count(),
() => page.getByText('1 turns · 1 steps', { exact: false }).count(),
{ timeout: 10_000 },
).toBe(1)

View File

@@ -1,5 +1,5 @@
// Web e2e scenario: the Plugins settings section — the cards a deployment's
// exposed host-plane namespaces produce, one field edited through the real
// Web e2e scenario: the configurable tab in Plugins settings — the cards a
// deployment's exposed host-plane namespaces produce, one field edited through the real
// wire down to `$DSH_HOME/settings.yaml`, and the override badge and reset
// that layering produces. Zero model calls: everything is client state plus
// the settings document on a blank frame, so there is no fixture and a stray
@@ -56,9 +56,12 @@ describe('web e2e: plugin configuration section', () => {
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '插件配置', exact: true }).click()
await dialog.getByRole('button', { name: '插件', exact: true }).click()
await expect
.poll(() => dialog.getByRole('button', { name: '插件配置', exact: true }).getAttribute('aria-current'), { timeout: 5_000 })
.poll(() => dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current'), { timeout: 5_000 })
.toBe('true')
await expect
.poll(() => dialog.getByRole('tab', { name: '插件配置', exact: true }).getAttribute('aria-selected'), { timeout: 5_000 })
.toBe('true')
return dialog
}

View File

@@ -256,6 +256,12 @@ describe('web e2e: seeded history renders through cold resume', () => {
// client's "omitted key = capability absent → clear the row" rule from
// wiping preset-owned projections on cold reads.
expect(projections?.values).toHaveProperty('todos', null)
// The session-stats unit is a shipped web-app bundle row: whole-log
// turn/step counts ride the same tail block (the stats strip's source).
const sessionStats = projections?.values.sessionStats as { turns: number; steps: number } | undefined
expect(sessionStats).toBeDefined()
expect(sessionStats?.turns).toBeGreaterThanOrEqual(1)
expect(sessionStats?.steps).toBeGreaterThanOrEqual(sessionStats?.turns ?? 0)
})
it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {

View File

@@ -99,6 +99,7 @@ describe('web e2e: settings modal and General preferences', () => {
// an unrelated plugin does not rewrite this surface's golden.
await dialog.getByRole('button', { name: '插件', exact: true }).click()
await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
await pluginRow.waitFor({ timeout: 10_000 })
const expectedPluginCount = [...scaffold.ctx.loader.entries()]
@@ -109,6 +110,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
.toBe(String(expectedPluginCount))
expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true')
expect(await dialog.getByRole('tab', { name: '插件列表', exact: true }).getAttribute('aria-selected')).toBe('true')
expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull()
const pluginsSnapshot = await captureStableAria(
page,

View File

@@ -13,9 +13,6 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -13,9 +13,6 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -13,9 +13,6 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -31,4 +31,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
- text: 1 turns · 1 steps

View File

@@ -27,3 +27,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps

View File

@@ -53,4 +53,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
- text: 1 turns · 1 steps LLM {{duration}}

View File

@@ -32,4 +32,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
- text: 1 turns · 1 steps LLM {{duration}}

View File

@@ -44,4 +44,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
- text: 1 turns · 1 steps LLM {{duration}}

View File

@@ -48,4 +48,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
- text: 1 turns · 1 steps LLM {{duration}}

View File

@@ -13,9 +13,6 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -13,9 +13,6 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -13,9 +13,6 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -13,9 +13,6 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -13,9 +13,6 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -13,9 +13,6 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -13,25 +13,26 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "插件配置" [level=2]
- paragraph: 配置本部署已安装的插件。
- list:
- listitem:
- 'button "展开设置: 终端"':
- text: 终端 限制 agent 运行的每一条命令。
- img
- listitem:
- 'button "展开设置: Agent 循环"':
- text: Agent 循环 Agent 如何派发工具调用
- img
- listitem:
- 'button "展开设置: 网页搜索"':
- text: 网页搜索 DeepSeek 搜索提供方
- img
- heading "插件" [level=2]
- paragraph: 配置和查看本部署已安装的插件。
- tablist "插件视图":
- tab "插件配置" [selected]
- tab "插件列表"
- tabpanel "插件配置":
- list:
- listitem:
- 'button "展开设置: 终端"':
- text: 终端 限制 agent 运行的每一条命令
- img
- listitem:
- 'button "展开设置: Agent 循环"':
- text: Agent 循环 Agent 如何派发工具调用
- img
- listitem:
- 'button "展开设置: 网页搜索"':
- text: 网页搜索 DeepSeek 搜索提供方。
- img

View File

@@ -50,4 +50,4 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
- text: 1 turns · 1 steps

View File

@@ -13,9 +13,6 @@
- button "Agent 预设":
- img
- text: Agent 预设
- button "插件配置":
- img
- text: 插件配置
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -0,0 +1,357 @@
- banner:
- navigation "Session hierarchy":
- button "{{workspace}}" [disabled]
- button "Session log":
- text: Session log
- img
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: m1 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r1
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m2 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r2
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m3 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r3
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m4 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r4
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m5 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r5
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m6 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r6
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m7 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r7
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m8 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r8
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m9 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r9
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m10 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r10
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m11 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r11
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m12 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r12
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m13 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r13
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m14 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r14
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m15 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r15
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m16 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r16
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m17 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r17
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m18 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r18
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m19 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r19
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m20 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r20
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m21 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r21
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m22 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r22
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m23 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r23
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m24 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r24
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m25 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r25
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m26 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r26
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m27 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r27
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m28 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r28
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}}
- button "Back to bottom":
- img
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 28 turns · 28 steps LLM {{duration}}

View File

@@ -0,0 +1,136 @@
// Web e2e scenario: full-session stats over paged history. A deterministic
// 28-turn log (56 surface messages — more than one 50-message history page)
// seeded cold through the REAL persistence API must render whole-log turn/step
// counts from the sessionStats projection on first open, and loading the
// older page must NOT change them. This pins the bug the projection fixed:
// the pre-projection window fold recounted per loaded page, so 加载更早 grew
// the counter. Zero model calls; the seed is generated, not recorded, because
// no line of it is model output.
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 {
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/stats-paged-history', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/stats-paged-history/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'stats-paged-history-web-e2e'
/** Turn count: 2 surface messages per turn, so 28 turns overflow one 50-message page. */
const TURNS = 28
const FULL_COUNTS = `${TURNS} turns · ${TURNS} steps`
/**
* Generate the seed: TURNS closed single-step turns of one short user prompt
* and one short assistant reply each. Times are fixed so the fixture is
* byte-deterministic; message ids are synthetic uuids (aria normalizes them).
* @param turns - closed turns to generate.
* @returns session.jsonl text for {@link seedSession}.
*/
function buildSeed(turns: number): string {
const lines = [JSON.stringify({
type: 'session', version: 0, id: '{{sessionId}}', createdAt: 1784974100000, cwd: '{{cwd}}/workspace',
})]
let seq = 0
let time = 1784974100000
const at = (event: Record<string, unknown>): void => {
lines.push(JSON.stringify({ ...event, seq: seq++, time: time++ }))
}
for (let turn = 1; turn <= turns; turn++) {
at({ type: 'turn/start', data: { turn } })
at({
type: 'user/message',
data: { content: [{ type: 'text', text: `m${turn}` }], source: { kind: 'user' } },
surfaceOp: 'append',
})
at({ type: 'step/start', data: { turn, step: 1 } })
at({
type: 'assistant/message',
data: {
turn,
step: 1,
message: {
id: `00000000-0000-4000-8000-${String(turn).padStart(12, '0')}`,
role: 'assistant',
content: [{ type: 'text', text: `r${turn}` }],
source: { kind: 'model', provider: 'snapshot', model: 'snapshot-replier' },
},
},
sourceEventSeqs: [],
surfaceOp: 'append',
})
at({ type: 'step/end', data: { turn, step: 1 } })
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
return `${lines.join('\n')}\n`
}
describe('web e2e: whole-session stats survive history paging', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
if (MODE === 'record') throw new Error('stats-paged-history is a keyless assembled snapshot')
scaffold = await launchWebScaffold({})
await seedSession(scaffold, buildSeed(TURNS), 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('renders full-session counts on the partial tail page and keeps them across load-older', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-stats-paged'))
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()
// Settled barrier: the newest recorded reply renders from the tail page.
await expect.poll(() => page.getByText(`r${TURNS}`, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// The tail page is partial (56 messages > one 50-message page): the first
// turns are NOT loaded, yet the strip already reports the whole log —
// the sessionStats projection, not the window fold.
expect(await page.getByText('m1', { exact: true }).count()).toBe(0)
await expect.poll(() => page.getByText(FULL_COUNTS, { exact: false }).count(), { timeout: 10_000 }).toBe(1)
const strip = page.getByText(FULL_COUNTS, { exact: false }).locator('..')
const stripBeforePaging = await strip.textContent()
// 加载更早: prepending the older page must not move ANY strip figure —
// counts, wall times, or token groups.
await page.getByRole('button', { name: 'Load earlier' }).click()
await expect.poll(() => page.getByText('m1', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
expect(await strip.textContent()).toBe(stripBeforePaging)
// With the whole log loaded, the window mounts one turn-tail footer per
// settled turn — the loaded-window probe the scroll/perf lanes count now
// that the strip is whole-log-scoped.
expect(await page.locator('[data-chat-flow-key^="9:turn-tail"]').count()).toBe(TURNS)
}, 60_000)
it('matches the paged-stats aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-stats-paged-aria'))
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it('issued zero model calls and stayed clean', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -48,6 +48,7 @@
"tests/replay-round-trip.e2e.ts",
"tests/hmr-live.e2e.ts",
"tests/seeded-history.e2e.ts",
"tests/stats-paged-history.e2e.ts",
"tests/sidebar-scrollbar.e2e.ts",
"tests/conversation-column-overflow.e2e.ts",
"tests/code-mode-round.e2e.ts",