Merge branch 'master' into agent/web-file-reference-prompt
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,3 +27,4 @@
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
357
apps/web/tests/snapshots/stats-paged-history/ui.expected.md
Normal file
357
apps/web/tests/snapshots/stats-paged-history/ui.expected.md
Normal 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}}
|
||||
136
apps/web/tests/stats-paged-history.e2e.ts
Normal file
136
apps/web/tests/stats-paged-history.e2e.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user