stats-paged-history.e2e.ts 6.6 KB

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