stats-paged-history.e2e.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. // Web e2e scenario: full-session stats over paged history. A deterministic
  2. // 28-turn log (56 surface messages — more than one 50-message history page)
  3. // seeded cold through the REAL persistence API must render whole-log turn/step
  4. // counts from the sessionStats projection on first open, and loading the
  5. // older page must NOT change them. This pins the bug the projection fixed:
  6. // the pre-projection window fold recounted per loaded page, so 加载更早 grew
  7. // the counter. Zero model calls; the seed is generated, not recorded, because
  8. // no line of it is model output.
  9. import { fileURLToPath } from 'node:url'
  10. import type { Browser, Page } from 'playwright'
  11. import { chromium } from 'playwright'
  12. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  13. import {
  14. assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  15. launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
  16. } from './scaffold.ts'
  17. import { newEnglishPage, saveFailureShot } from './support.ts'
  18. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/stats-paged-history', import.meta.url))
  19. const UI_EXPECTED = fileURLToPath(new URL('./expected/stats-paged-history/ui.expected.md', import.meta.url))
  20. const MODE = webSnapshotMode()
  21. const SEED_ID = 'stats-paged-history-web-e2e'
  22. /** Turn count: 2 surface messages per turn, so 28 turns overflow one 50-message page. */
  23. const TURNS = 28
  24. const FULL_COUNTS = `${TURNS} turns · ${TURNS} steps`
  25. /**
  26. * Generate the seed: TURNS closed single-step turns of one short user prompt
  27. * and one short assistant reply each. Times are fixed so the fixture is
  28. * byte-deterministic; message ids are synthetic uuids (aria normalizes them).
  29. * @param turns - closed turns to generate.
  30. * @returns session.jsonl text for {@link seedSession}.
  31. */
  32. function buildSeed(turns: number): string {
  33. const lines = [JSON.stringify({
  34. type: 'session', version: 0, id: '{{sessionId}}', createdAt: 1784974100000, cwd: '{{cwd}}/workspace',
  35. })]
  36. let seq = 0
  37. let time = 1784974100000
  38. const at = (event: Record<string, unknown>): void => {
  39. lines.push(JSON.stringify({ ...event, seq: seq++, time: time++ }))
  40. }
  41. for (let turn = 1; turn <= turns; turn++) {
  42. at({ type: 'turn/start', data: { turn } })
  43. at({
  44. type: 'user/message',
  45. data: { content: [{ type: 'text', text: `m${turn}` }], source: { kind: 'user' } },
  46. surfaceOp: 'append',
  47. })
  48. at({ type: 'step/start', data: { turn, step: 1 } })
  49. at({
  50. type: 'assistant/message',
  51. data: {
  52. turn,
  53. step: 1,
  54. message: {
  55. id: `00000000-0000-4000-8000-${String(turn).padStart(12, '0')}`,
  56. role: 'assistant',
  57. content: [{ type: 'text', text: `r${turn}` }],
  58. source: { kind: 'model', provider: 'snapshot', model: 'snapshot-replier' },
  59. },
  60. },
  61. sourceEventSeqs: [],
  62. surfaceOp: 'append',
  63. })
  64. at({ type: 'step/end', data: { turn, step: 1 } })
  65. at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
  66. }
  67. return `${lines.join('\n')}\n`
  68. }
  69. describe('web e2e: whole-session stats survive history paging', () => {
  70. let scaffold: WebScaffold
  71. let browser: Browser
  72. let page: Page
  73. let tripwire: ReturnType<typeof watchConsole>
  74. beforeAll(async () => {
  75. if (MODE === 'record') throw new Error('stats-paged-history is a keyless assembled snapshot')
  76. scaffold = await launchWebScaffold({})
  77. await seedSession(scaffold, buildSeed(TURNS), SEED_ID)
  78. browser = await chromium.launch()
  79. page = await newEnglishPage(browser)
  80. tripwire = watchConsole(page)
  81. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  82. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  83. }, 120_000)
  84. afterAll(async () => {
  85. await browser?.close()
  86. await scaffold?.close()
  87. })
  88. it('renders full-session counts on the partial tail page and keeps them across load-older', async () => {
  89. onTestFailed(() => saveFailureShot(page, 'web-e2e-stats-paged'))
  90. const groupRow = page.locator('[role="treeitem"]').first()
  91. await groupRow.waitFor({ timeout: 15_000 })
  92. await groupRow.click()
  93. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  94. await sessionRow.waitFor({ timeout: 10_000 })
  95. await sessionRow.click()
  96. // Settled barrier: the newest recorded reply renders from the tail page.
  97. await expect.poll(() => page.getByText(`r${TURNS}`, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
  98. // The tail page is partial (56 messages > one 50-message page): the first
  99. // turns are NOT loaded, yet the strip already reports the whole log —
  100. // the sessionStats projection, not the window fold.
  101. expect(await page.getByText('m1', { exact: true }).count()).toBe(0)
  102. await expect.poll(() => page.getByText(FULL_COUNTS, { exact: false }).count(), { timeout: 10_000 }).toBe(1)
  103. const strip = page.getByText(FULL_COUNTS, { exact: false }).locator('..')
  104. const stripBeforePaging = await strip.textContent()
  105. // 加载更早: prepending the older page must not move ANY strip figure —
  106. // counts, wall times, or token groups.
  107. await page.getByRole('button', { name: 'Load earlier' }).click()
  108. await expect.poll(() => page.getByText('m1', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  109. expect(await strip.textContent()).toBe(stripBeforePaging)
  110. // With the whole log loaded, the window mounts one turn-tail footer per
  111. // settled turn — the loaded-window probe the scroll/perf lanes count now
  112. // that the strip is whole-log-scoped.
  113. expect(await page.locator('[data-chat-flow-key^="9:turn-tail"]').count()).toBe(TURNS)
  114. }, 60_000)
  115. it('matches the paged-stats aria golden', async () => {
  116. onTestFailed(() => saveFailureShot(page, 'web-e2e-stats-paged-aria'))
  117. const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
  118. .split(SEED_ID).join('{{seededId}}')
  119. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  120. })
  121. it('issued zero model calls and stayed clean', async () => {
  122. expect(tripwire.pageErrors).toEqual([])
  123. expect(tripwire.warnings).toEqual([])
  124. await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
  125. })
  126. })