navigation-panes.e2e.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. // Web e2e scenarios: navigation & panes — the view tabs (Trajectory /
  2. // Waterfall), the details column, and sidebar search, all over ONE rich
  3. // two-turn seeded fixture rendered purely from the log (the seeded-history
  4. // pattern: zero model calls in replay, so every surface here is the client
  5. // fold + host history RPC, not replay binding). The seed is recorded live
  6. // under the standard discipline: turn 1 produces a bash call plus two
  7. // parallel reads in one assistant message (tool-call density for the
  8. // trajectory/waterfall lanes and a details-capable bash row), turn 2 a
  9. // markdown-rich reply (a second turn so the waterfall has two lanes).
  10. import { mkdir, readFile, writeFile } from 'node:fs/promises'
  11. import { fileURLToPath } from 'node:url'
  12. import { join } from 'node:path'
  13. import type { Browser, Page } from 'playwright'
  14. import { chromium } from 'playwright'
  15. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  16. import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
  17. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  18. import {
  19. assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  20. launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
  21. } from './scaffold.ts'
  22. import { saveFailureShot } from './support.ts'
  23. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
  24. const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
  25. const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
  26. const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
  27. const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md')
  28. const MODE = webSnapshotMode()
  29. const SEED_ID = 'navigation-panes-web-e2e'
  30. // Turn 1 leads with a distinctive word: the session-title fallback takes the
  31. // first words of the first message, so the sidebar-search scenario has a
  32. // known-matching query ('navscenario') without depending on a live title call.
  33. const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.'
  34. const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.'
  35. describe('web e2e: navigation & panes over a rich seeded session', () => {
  36. let scaffold: WebScaffold
  37. let browser: Browser
  38. let page: Page
  39. let tripwire: ReturnType<typeof watchConsole>
  40. beforeAll(async () => {
  41. scaffold = await launchWebScaffold({})
  42. // The workspace-aware flow runs sessions in <workspaceRoot>/workspace;
  43. // the read targets must live in that session cwd (pre-creation is safe:
  44. // create-by-name adopts an existing directory).
  45. const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
  46. await mkdir(sessionCwd, { recursive: true })
  47. await writeFile(join(sessionCwd, 'nav-a.md'), '# alpha nav\n')
  48. await writeFile(join(sessionCwd, 'nav-b.md'), '# beta nav\n')
  49. if (MODE !== 'record') {
  50. const raw = await readFile(SEED, 'utf8')
  51. expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
  52. .toEqual([PROMPT_TURN1, PROMPT_TURN2])
  53. await seedSession(scaffold, raw, SEED_ID)
  54. }
  55. browser = await chromium.launch()
  56. page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
  57. tripwire = watchConsole(page)
  58. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  59. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  60. }, 120_000)
  61. afterAll(async () => {
  62. await browser?.close()
  63. await scaffold?.close()
  64. })
  65. it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
  66. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-record'))
  67. const input = page.locator('textarea').first()
  68. await input.waitFor({ timeout: 10_000 })
  69. let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
  70. for (const prompt of [PROMPT_TURN1, PROMPT_TURN2]) {
  71. const settled = scaffold.whenTurnSettled()
  72. // Turn 2 types into the same composer once turn 1 unlocks it.
  73. await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
  74. await input.fill(prompt)
  75. await input.press('Enter')
  76. sessionId = await settled
  77. }
  78. await recordFixture(scaffold, sessionId!, SEED)
  79. // Fixture honesty: the recording must carry the shape the replay
  80. // scenarios assert on — three calls in turn 1 and two closed turns.
  81. const recorded = parseSessionLog(await readFile(SEED, 'utf8'))
  82. expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2)
  83. const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call')
  84. expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read'])
  85. }, 400_000)
  86. it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => {
  87. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open'))
  88. // Expand the collapsed group row, then open the revealed session row.
  89. const groupRow = page.locator('[role="treeitem"]').first()
  90. await groupRow.waitFor({ timeout: 15_000 })
  91. await groupRow.click()
  92. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  93. await sessionRow.waitFor({ timeout: 10_000 })
  94. await sessionRow.click()
  95. await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  96. await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
  97. }, 90_000)
  98. it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => {
  99. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
  100. // Runs after the session is open: a cold summary carries no title (the
  101. // sidebar shows the cwd basename), and the durable title lands with the
  102. // attach subscription's baseline — which is itself worth pinning: search
  103. // matches the title the user sees, not a hidden cold field.
  104. const search = page.getByPlaceholder('Search name, keywords', { exact: false })
  105. await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  106. // Negative: a garbage query empties the tree (group rows hide too).
  107. await search.fill('zzzqx-no-such-session')
  108. await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0)
  109. // Positive: a title word narrows to the matched session + its group,
  110. // force-expanded by search mode (case-insensitive client-side filter).
  111. await search.fill('navscenario')
  112. await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
  113. // Clear restores the unfiltered tree.
  114. await page.getByRole('button', { name: 'Clear search' }).click()
  115. await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
  116. await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
  117. }, 60_000)
  118. it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => {
  119. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
  120. await page.getByRole('tab', { name: 'Trajectory' }).click()
  121. // Two sticky turn sections; turn 1's step group summarizes its tool mix
  122. // (bash + the two parallel reads collapse to 'bash read×2').
  123. await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
  124. await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  125. await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
  126. const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
  127. .split(SEED_ID).join('{{seededId}}')
  128. await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
  129. }, 60_000)
  130. it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => {
  131. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall'))
  132. await page.getByRole('tab', { name: 'Waterfall' }).click()
  133. // The stats header rides the waterfall body. The span fold counts THREE
  134. // spans for this two-turn log: only assistant/steering nodes carry a turn
  135. // number, so the first user message lands in a turn-0 prologue span (a
  136. // P-I placeholder shape — pinned as-is; real spans are deferred to
  137. // P-III per the view's deviation ledger). Calls: bash + two reads.
  138. await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1)
  139. // One lane per span, tagged by turn number, prologue included.
  140. for (const tag of ['turn 0', 'turn 1', 'turn 2']) {
  141. await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  142. }
  143. const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
  144. .split(SEED_ID).join('{{seededId}}')
  145. await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE)
  146. }, 60_000)
  147. it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {
  148. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
  149. await page.getByRole('tab', { name: 'Chat' }).click()
  150. // The bash toolview row routes its click to openDetails (read rows are
  151. // expand-in-place instead — the seeded-history scenario owns that fold).
  152. const bashRow = page.locator('[data-sample="bash-global"]').first()
  153. await bashRow.waitFor({ timeout: 15_000 })
  154. // Open/closed is the frame's collapsed attribute: the column collapses to
  155. // width 0 but its subtree deliberately never unmounts (hidden, not
  156. // absent), so element presence/visibility cannot express the state.
  157. const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
  158. expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
  159. await bashRow.click()
  160. await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull()
  161. // The open panel shows the selected call's name, arguments, and durable
  162. // result (NAVIGATION_OK appears in the chat row too, hence >= 2 total).
  163. await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
  164. // Golden of the open panel: tool name header, Input args, Output result.
  165. const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd))
  166. .split(SEED_ID).join('{{seededId}}')
  167. await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE)
  168. await page.getByRole('button', { name: '关闭详情' }).click()
  169. await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull()
  170. }, 60_000)
  171. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
  172. expect(tripwire.pageErrors).toEqual([])
  173. expect(tripwire.warnings).toEqual([])
  174. await assertFixtureInventory(SNAPSHOT_DIR, [
  175. 'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md',
  176. ])
  177. })
  178. })