navigation-panes.e2e.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. // Web e2e scenarios: navigation & panes — the Trajectory view and timing
  2. // overview, its local details inspector, 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 ledger/timing lanes), turn 2 a markdown-rich reply.
  9. import { mkdir, readFile, writeFile } from 'node:fs/promises'
  10. import { fileURLToPath } from 'node:url'
  11. import { join } from 'node:path'
  12. import type { Browser, Page } from 'playwright'
  13. import { chromium } from 'playwright'
  14. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  15. import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
  16. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  17. import {
  18. assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  19. launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
  20. } from './scaffold.ts'
  21. import { newEnglishPage, saveFailureShot } from './support.ts'
  22. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
  23. const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
  24. const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
  25. const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
  26. const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
  27. const MODE = webSnapshotMode()
  28. const SEED_ID = 'navigation-panes-web-e2e'
  29. // Turn 1 leads with a distinctive word: the session-title fallback takes the
  30. // first words of the first message, so the sidebar-search scenario has a
  31. // known-matching query ('navscenario') without depending on a live title call.
  32. 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.'
  33. 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.'
  34. describe('web e2e: navigation & panes over a rich seeded session', () => {
  35. let scaffold: WebScaffold
  36. let browser: Browser
  37. let page: Page
  38. let tripwire: ReturnType<typeof watchConsole>
  39. let slotErrors: string[]
  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 newEnglishPage(browser)
  57. tripwire = watchConsole(page)
  58. slotErrors = []
  59. page.on('console', (message) => {
  60. if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
  61. slotErrors.push(message.text())
  62. }
  63. })
  64. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  65. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  66. }, 120_000)
  67. afterAll(async () => {
  68. await browser?.close()
  69. await scaffold?.close()
  70. })
  71. it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
  72. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-record'))
  73. const input = page.locator('textarea').first()
  74. await input.waitFor({ timeout: 10_000 })
  75. let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
  76. for (const prompt of [PROMPT_TURN1, PROMPT_TURN2]) {
  77. const settled = scaffold.whenTurnSettled()
  78. // Turn 2 types into the same composer once turn 1 unlocks it.
  79. await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
  80. await input.fill(prompt)
  81. await input.press('Enter')
  82. sessionId = await settled
  83. }
  84. await recordFixture(scaffold, sessionId!, SEED)
  85. // Fixture honesty: the recording must carry the shape the replay
  86. // scenarios assert on — three calls in turn 1 and two closed turns.
  87. const recorded = parseSessionLog(await readFile(SEED, 'utf8'))
  88. expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2)
  89. const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call')
  90. expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read'])
  91. }, 400_000)
  92. it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
  93. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
  94. const search = page.getByPlaceholder('Search name, keywords', { exact: false })
  95. // The cold row has not been opened, so only the persisted log can satisfy
  96. // this query. First search lazily reconciles the SQLite content index.
  97. await search.fill('zzzqx-no-such-session')
  98. await page.getByText('No matching sessions').waitFor({ timeout: 30_000 })
  99. await expect.poll(
  100. () => page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem').count(),
  101. { timeout: 10_000 },
  102. ).toBe(0)
  103. await search.fill('WATERFALL')
  104. const resultTree = page.getByRole('tree', { name: 'Search results' })
  105. const result = resultTree.getByRole('treeitem')
  106. await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
  107. await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
  108. timeout: 10_000,
  109. }).toBeGreaterThanOrEqual(1)
  110. const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd))
  111. .split(SEED_ID).join('{{seededId}}')
  112. await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE)
  113. await result.click()
  114. // Search navigation addresses the session, not a specific event, and the
  115. // query remains until the user explicitly clears it.
  116. await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL')
  117. await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  118. await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
  119. await page.getByRole('button', { name: 'Clear search' }).click()
  120. await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
  121. await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
  122. }, 90_000)
  123. it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
  124. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
  125. await page.getByRole('tab', { name: 'Trajectory' }).click()
  126. await page.waitForTimeout(100)
  127. expect({
  128. pageErrors: tripwire.pageErrors,
  129. slotErrors,
  130. warnings: tripwire.warnings,
  131. }).toEqual({
  132. pageErrors: [],
  133. slotErrors: [],
  134. warnings: [],
  135. })
  136. // Turn rules partition the ledger without restoring a separate header row.
  137. await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
  138. await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
  139. await page.locator('tr[data-kind="tool"]').first().click()
  140. await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1)
  141. await page.getByRole('tab', { name: 'Result' }).click()
  142. await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
  143. const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
  144. .split(SEED_ID).join('{{seededId}}')
  145. await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
  146. await page.getByRole('complementary', { name: 'Event details' })
  147. .getByRole('button', { name: 'Close details' }).click()
  148. }, 60_000)
  149. it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
  150. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
  151. const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
  152. const before = await page.locator('tr[data-kind]').count()
  153. const box = await plot.boundingBox()
  154. if (box === null) throw new Error('trajectory timeline plot has no layout box')
  155. await page.mouse.move(box.x + box.width * 0.55, box.y + box.height / 2)
  156. await page.mouse.down()
  157. await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2)
  158. await page.mouse.up()
  159. await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 })
  160. .toBeGreaterThan(0)
  161. await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before)
  162. await plot.click({ button: 'right' })
  163. await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
  164. }, 60_000)
  165. it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
  166. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
  167. await page.getByRole('tab', { name: 'Chat' }).click()
  168. const bashRow = page.locator('[data-sample="bash-global"]').first()
  169. await bashRow.waitFor({ timeout: 15_000 })
  170. const frame = page.locator('[style*="grid-template-columns"]').first()
  171. expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
  172. // The row click is the card's expand toggle (unified tool-row
  173. // interaction); it must not drive layout geometry either way.
  174. await bashRow.click()
  175. await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
  176. // The card's own controls are outside the summary row and must not open
  177. // details either — the expanded terminal card is read in place.
  178. await page.locator('[data-sample="bash-global"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
  179. await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
  180. // Read summaries are host-open file links; they also must not open details.
  181. const fileLink = page.locator('[data-variant="read"] button').first()
  182. await fileLink.waitFor({ timeout: 10_000 })
  183. await fileLink.click()
  184. await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
  185. }, 60_000)
  186. it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
  187. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
  188. await page.getByRole('tab', { name: 'Chat' }).click()
  189. // The card is expand-gated behind the whole-row toggle (the unified
  190. // tool-row interaction): open it if a previous case left it collapsed.
  191. // Expanded, the recorded command's own output sits in the message flow,
  192. // derived from the logged call/result presentations alone.
  193. const bashRow = page.locator('[data-sample="bash-global"]').first()
  194. await bashRow.waitFor({ timeout: 15_000 })
  195. if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
  196. const card = page.locator('[data-sample="bash-global"] ~ div [data-terminal]').first()
  197. await card.waitFor({ timeout: 15_000 })
  198. // Real layout, not jsdom's stub (which computes no geometry at all):
  199. // squeeze the output pane below its content width and the line must keep
  200. // its single row and overflow sideways instead of folding. Soft-wrapping
  201. // here is what shredded the column alignment this card exists to hold.
  202. const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
  203. const pane = node as HTMLElement
  204. const row = pane.querySelector<HTMLElement>('[class*="_line_"]')
  205. if (row === null) throw new Error('output pane has no line')
  206. const before = row.offsetHeight
  207. const restore = pane.style.width
  208. pane.style.width = '8px'
  209. const squeezed = { wrapped: row.offsetHeight > before, scrollsSideways: pane.scrollWidth > pane.clientWidth }
  210. pane.style.width = restore
  211. return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed }
  212. })
  213. expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true })
  214. // The run-state dot's color is the whole point of it and is the one thing
  215. // jsdom cannot report: --dsw-* tokens resolve only against the real theme
  216. // stylesheet. This command settled cleanly, so the dot must be the green
  217. // success token — a red one here would read as a failed command.
  218. const dot = await card.locator('[class*="_runState_"][data-state]').first().evaluate((node) => {
  219. // The token lives on body, so the probe must sit in the same cascade.
  220. const probe = document.createElement('span')
  221. probe.style.color = 'var(--dsw-alias-state-success-primary)'
  222. document.body.appendChild(probe)
  223. const success = getComputedStyle(probe).color
  224. probe.remove()
  225. return {
  226. state: node.getAttribute('data-state'),
  227. color: getComputedStyle(node as HTMLElement).color,
  228. success,
  229. // One label per card (the state is the call's), so it hangs off the
  230. // prompt column rather than the row the dot sits in.
  231. label: node.closest('[class*="_prompt_"]')?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
  232. // The dot precedes the prompt label in document order, which is what
  233. // puts it to the left of the `$`.
  234. beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!)
  235. === Node.DOCUMENT_POSITION_FOLLOWING,
  236. // The dot lives in the card's OWN left padding, so it sits inside the
  237. // card box yet left of the prompt text. Owning the reservation as padding
  238. // rather than margin is what keeps a consumer's own margin from
  239. // cancelling it and letting a container clip the dot — geometry jsdom
  240. // cannot compute.
  241. insideCard: (node as HTMLElement).getBoundingClientRect().left
  242. >= (node.closest('[data-terminal]')?.getBoundingClientRect().left ?? Infinity),
  243. leftOfPrompt: (node as HTMLElement).getBoundingClientRect().right
  244. <= (node.closest('[class*="_promptLine_"]')
  245. ?.querySelector('[class*="_cwd_"]')
  246. ?.getBoundingClientRect().left ?? -Infinity),
  247. }
  248. })
  249. expect(dot.state).toBe('done')
  250. expect(dot.label).toBe('Done')
  251. expect(dot.beforePrompt).toBe(true)
  252. expect(dot.insideCard).toBe(true)
  253. expect(dot.leftOfPrompt).toBe(true)
  254. // Resolved through the theme token, not a literal hex in the component.
  255. expect(dot.success).toMatch(/^rgb/)
  256. expect(dot.color).toBe(dot.success)
  257. // Golden of the card at rest — captured before the copy click, whose
  258. // confirmation label self-reverts on a timer and would not hold still.
  259. const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
  260. .split(SEED_ID).join('{{seededId}}')
  261. await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
  262. // Copy writes the raw output through the browser's own clipboard, which in
  263. // a real page is the async Clipboard API rather than the jsdom fallback.
  264. await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
  265. await card.locator('[class*="_copyButton_"]').first().click()
  266. await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 })
  267. .toBe('Copied')
  268. expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
  269. }, 60_000)
  270. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
  271. expect(tripwire.pageErrors).toEqual([])
  272. expect(slotErrors).toEqual([])
  273. expect(tripwire.warnings).toEqual([])
  274. await assertFixtureInventory(SNAPSHOT_DIR, [
  275. 'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
  276. 'terminal-card.expected.md',
  277. ])
  278. })
  279. })