navigation-panes.e2e.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  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, Response } from 'playwright'
  13. import { chromium } from 'playwright'
  14. import { strFromU8, unzipSync } from 'fflate'
  15. import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, vi } 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 { newEnglishPage, saveFailureShot } from './support.ts'
  23. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url))
  24. const SEED = join(SNAPSHOT_DIR, 'session.jsonl')
  25. const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
  26. const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
  27. const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.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. async function baselineResponse(
  36. page: Page,
  37. ): Promise<Response> {
  38. return page.waitForResponse(response => (
  39. response.request().method() === 'POST'
  40. && new URL(response.url()).pathname === '/api/session/list'
  41. ), { timeout: 30_000 })
  42. }
  43. async function assertBaselineSucceeded(response: Response, method: string): Promise<void> {
  44. expect(response.ok(), `${method} baseline HTTP response`).toBe(true)
  45. const body = await response.json() as { result?: { ok?: unknown } }
  46. expect(body.result?.ok, `${method} baseline RPC result`).toBe(true)
  47. }
  48. async function ensureSeedOpen(page: Page): Promise<void> {
  49. const welcome = page.locator('[class*="onboardingOverlay"]')
  50. if (await welcome.count() > 0) {
  51. await welcome.getByRole('button').click()
  52. await welcome.waitFor({ state: 'detached', timeout: 15_000 })
  53. }
  54. const chat = page.getByRole('tab', { name: 'Chat', exact: true })
  55. // Search is a collapsed header action; expand it so the input is actionable.
  56. const searchButton = page.getByRole('button', { name: 'Search sessions' })
  57. if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
  58. const search = page.getByPlaceholder('Search sessions', { exact: false })
  59. if (await chat.count() === 0) {
  60. await search.fill('WATERFALL')
  61. const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
  62. await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
  63. await result.click()
  64. await chat.waitFor({ timeout: 15_000 })
  65. }
  66. await chat.click()
  67. await page.getByText('FIRST_DONE', { exact: true }).waitFor({ timeout: 15_000 })
  68. if (await search.inputValue() !== '') {
  69. await search.fill('')
  70. await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
  71. }
  72. }
  73. describe('web e2e: navigation & panes over a rich seeded session', () => {
  74. let scaffold: WebScaffold
  75. let browser: Browser
  76. let page: Page
  77. let tripwire: ReturnType<typeof watchConsole> = { warnings: [], pageErrors: [] }
  78. let slotErrors: string[] = []
  79. beforeAll(async () => {
  80. scaffold = await launchWebScaffold({})
  81. // The workspace-aware flow runs sessions in <workspaceCwd>/workspace;
  82. // the read targets must live in that session cwd (pre-creation is safe
  83. // because the picker adopts an existing directory by path).
  84. const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
  85. await mkdir(sessionCwd, { recursive: true })
  86. await writeFile(join(sessionCwd, 'nav-a.md'), '# alpha nav\n')
  87. await writeFile(join(sessionCwd, 'nav-b.md'), '# beta nav\n')
  88. if (MODE !== 'record') {
  89. const raw = await readFile(SEED, 'utf8')
  90. expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
  91. .toEqual([PROMPT_TURN1, PROMPT_TURN2])
  92. await seedSession(scaffold, raw, SEED_ID)
  93. }
  94. browser = await chromium.launch()
  95. }, 120_000)
  96. beforeEach(async () => {
  97. page = await newEnglishPage(browser)
  98. tripwire = watchConsole(page)
  99. slotErrors = []
  100. page.on('console', (message) => {
  101. if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
  102. slotErrors.push(message.text())
  103. }
  104. })
  105. // Arm before navigation so the Session response cannot be missed. The
  106. // Workspace stream settles through the user-visible Ungrouped barrier.
  107. const sessionBaseline = baselineResponse(page)
  108. const [, sessionResponse] = await Promise.all([
  109. page.goto(scaffold.baseUrl, { waitUntil: 'load' }),
  110. sessionBaseline,
  111. ])
  112. await assertBaselineSucceeded(sessionResponse, 'session.list')
  113. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  114. // The frame mounts before the asynchronous session-list baseline lands.
  115. // Search must target the settled seeded row, not the startup input that
  116. // the ready projection replaces (the compact layout dropped group session
  117. // counts; the Ungrouped bucket row is the barrier).
  118. await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
  119. }, 120_000)
  120. afterEach(async () => {
  121. const failures: unknown[] = []
  122. try {
  123. expect({
  124. pageErrors: tripwire.pageErrors,
  125. slotErrors,
  126. warnings: tripwire.warnings,
  127. }).toEqual({
  128. pageErrors: [],
  129. slotErrors: [],
  130. warnings: [],
  131. })
  132. } catch (error) {
  133. failures.push(error)
  134. }
  135. await page?.close().catch((error: unknown) => failures.push(error))
  136. if (failures.length === 1) throw failures[0]
  137. if (failures.length > 1) throw new AggregateError(failures, 'navigation case cleanup failed')
  138. })
  139. afterAll(async () => {
  140. const failures: unknown[] = []
  141. await browser?.close().catch((error: unknown) => failures.push(error))
  142. await scaffold?.close().catch((error: unknown) => failures.push(error))
  143. if (failures.length === 1) throw failures[0]
  144. if (failures.length > 1) throw new AggregateError(failures, 'navigation e2e cleanup failed')
  145. })
  146. it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
  147. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-record'))
  148. const input = page.locator('textarea').first()
  149. await input.waitFor({ timeout: 10_000 })
  150. let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
  151. for (const prompt of [PROMPT_TURN1, PROMPT_TURN2]) {
  152. const settled = scaffold.whenTurnSettled()
  153. // Turn 2 types into the same composer once turn 1 unlocks it.
  154. await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
  155. await input.fill(prompt)
  156. await input.press('Enter')
  157. sessionId = await settled
  158. }
  159. await recordFixture(scaffold, sessionId!, SEED)
  160. // Fixture honesty: the recording must contain the events the replay
  161. // scenarios assert on: three calls in turn 1 and two closed turns.
  162. const recorded = parseSessionLog(await readFile(SEED, 'utf8'))
  163. expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2)
  164. const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call')
  165. expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read'])
  166. }, 400_000)
  167. it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
  168. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
  169. // The API baselines can settle before React commits their projection. The
  170. // seeded Ungrouped bucket row is the final user-visible barrier before
  171. // editing search (the compact layout dropped group session counts).
  172. await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
  173. // Search is a collapsed header action; expand it so the input is actionable.
  174. const searchButton = page.getByRole('button', { name: 'Search sessions' })
  175. if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
  176. const search = page.getByPlaceholder('Search sessions', { exact: false })
  177. // The cold row has not been opened, so only the persisted log can satisfy
  178. // this query. First search lazily reconciles the SQLite content index.
  179. await search.fill('zzzqx-no-such-session')
  180. await page.getByText('No matching sessions').waitFor({ timeout: 30_000 })
  181. await expect.poll(
  182. () => page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem').count(),
  183. { timeout: 10_000 },
  184. ).toBe(0)
  185. await search.fill('WATERFALL')
  186. const resultTree = page.getByRole('tree', { name: 'Search results' })
  187. const result = resultTree.getByRole('treeitem')
  188. await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
  189. await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
  190. timeout: 10_000,
  191. }).toBeGreaterThanOrEqual(1)
  192. const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd))
  193. .split(SEED_ID).join('{{seededId}}')
  194. await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE)
  195. await result.click()
  196. // Search navigation addresses the session, not a specific event, and the
  197. // query remains until the user explicitly clears it.
  198. await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL')
  199. await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  200. await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
  201. await page.getByRole('button', { name: 'Clear search' }).click()
  202. await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
  203. await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
  204. }, 90_000)
  205. it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
  206. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
  207. await ensureSeedOpen(page)
  208. await page.getByRole('tab', { name: 'Trajectory' }).click()
  209. await page.waitForTimeout(100)
  210. const overlayLayout = await page.getByRole('table').evaluate((table) => {
  211. const host = table.closest('[data-conversation-scroll]')
  212. const seat = host?.querySelector('[data-composer-seat]') ?? null
  213. const pane = table.parentElement
  214. return {
  215. hostPosition: host === null ? null : getComputedStyle(host).position,
  216. paneOverflowX: pane === null ? null : getComputedStyle(pane).overflowX,
  217. paneScrollableWidth: pane === null ? null : pane.scrollWidth - pane.clientWidth,
  218. seatPosition: seat === null ? null : getComputedStyle(seat).position,
  219. }
  220. })
  221. expect(overlayLayout).toEqual({
  222. hostPosition: 'relative',
  223. paneOverflowX: 'hidden',
  224. paneScrollableWidth: 0,
  225. seatPosition: 'absolute',
  226. })
  227. expect({
  228. pageErrors: tripwire.pageErrors,
  229. slotErrors,
  230. warnings: tripwire.warnings,
  231. }).toEqual({
  232. pageErrors: [],
  233. slotErrors: [],
  234. warnings: [],
  235. })
  236. // Turn rules partition the ledger without restoring a separate header row.
  237. await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
  238. await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
  239. await page.locator('tr[data-kind="tool"]').first().click()
  240. const details = page.getByRole('complementary', { name: 'Event details' })
  241. await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1)
  242. expect(await details.getByRole('tabpanel').evaluate(panel => getComputedStyle(panel).overflowX))
  243. .toBe('hidden')
  244. await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
  245. const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({
  246. heading: getComputedStyle(heading).backgroundColor,
  247. panel: getComputedStyle(heading.closest('[aria-label="Event details"]')!).backgroundColor,
  248. }))
  249. expect(darkSummarySurfaces.heading).toBe(darkSummarySurfaces.panel)
  250. await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
  251. await page.getByRole('tab', { name: 'Result' }).click()
  252. await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
  253. const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first()
  254. await assistantSpan.hover()
  255. const timingTooltip = page.getByRole('tooltip')
  256. await timingTooltip.waitFor({ timeout: 5_000 })
  257. await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/)
  258. const assistantTimingStyle = await assistantSpan.evaluate(node => ({
  259. background: getComputedStyle(node).backgroundImage,
  260. ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'),
  261. }))
  262. expect(assistantTimingStyle.background).toContain('linear-gradient')
  263. expect(assistantTimingStyle.ttft).toMatch(/%$/)
  264. const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
  265. .split(SEED_ID).join('{{seededId}}')
  266. await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
  267. await details.getByRole('button', { name: 'Close details' }).click()
  268. }, 60_000)
  269. it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
  270. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
  271. await ensureSeedOpen(page)
  272. const exportButton = page.getByRole('button', { name: 'Session log' })
  273. expect(await exportButton.isDisabled()).toBe(false)
  274. const header = exportButton.locator('xpath=ancestor::header[1]')
  275. const [buttonBox, headerBox] = await Promise.all([
  276. exportButton.boundingBox(), header.boundingBox(),
  277. ])
  278. if (buttonBox === null || headerBox === null) {
  279. throw new Error('Session Header export geometry is unavailable')
  280. }
  281. expect(headerBox.x + headerBox.width - (buttonBox.x + buttonBox.width)).toBeLessThanOrEqual(32)
  282. const responsePromise = page.waitForResponse(response =>
  283. response.request().method() === 'HEAD'
  284. && new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 })
  285. const downloadPromise = page.waitForEvent('download', { timeout: 30_000 })
  286. await exportButton.click()
  287. const response = await responsePromise
  288. expect(response.status()).toBe(200)
  289. const download = await downloadPromise
  290. expect(download.suggestedFilename()).toMatch(/^dsh-session-.+\.zip$/)
  291. const dialog = page.getByRole('dialog', { name: 'Session download started' })
  292. await dialog.waitFor({ timeout: 30_000 })
  293. // The real host streamed the ZIP; its root entry is the persisted log
  294. // text verbatim (the assembled seam: real route, real persistence read).
  295. const files = unzipSync(await readFile(await download.path()))
  296. expect(Object.keys(files)).toEqual(['session.jsonl'])
  297. const content = strFromU8(files['session.jsonl'] as Uint8Array)
  298. expect(content.split('\n')[0]).toContain(SEED_ID)
  299. expect(content).toContain('FIRST_DONE')
  300. await dialog.getByText('Close', { exact: true }).click()
  301. const observer = await newEnglishPage(browser)
  302. const observerTripwire = watchConsole(observer)
  303. const observerSlotErrors: string[] = []
  304. let observerDownloads = 0
  305. observer.on('download', () => { observerDownloads += 1 })
  306. observer.on('console', (message) => {
  307. if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
  308. observerSlotErrors.push(message.text())
  309. }
  310. })
  311. const observerSessionBaseline = baselineResponse(observer)
  312. const [, observerSessionResponse] = await Promise.all([
  313. observer.goto(scaffold.baseUrl, { waitUntil: 'load' }),
  314. observerSessionBaseline,
  315. ])
  316. await assertBaselineSucceeded(observerSessionResponse, 'observer session.list')
  317. await observer.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
  318. await ensureSeedOpen(observer)
  319. try {
  320. const input = page.locator('textarea').first()
  321. const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 })
  322. await input.fill('/export')
  323. await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 })
  324. await input.press('Enter')
  325. const slashDownload = await slashDownloadPromise
  326. expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename())
  327. const slashFiles = unzipSync(await readFile(await slashDownload.path()))
  328. const slashContent = strFromU8(slashFiles['session.jsonl'] as Uint8Array)
  329. const slashEvents = parseSessionLog(slashContent)
  330. const exportRun = slashEvents.findLast(event => event.type === 'command/run' && event.data.name === 'export')
  331. if (exportRun?.type !== 'command/run') throw new Error('slash ZIP has no export command/run')
  332. const exportDone = slashEvents.find(event =>
  333. event.type === 'command/done' && event.data.commandId === exportRun.data.commandId)
  334. expect(exportDone?.type).toBe('command/done')
  335. await page.getByRole('dialog', { name: 'Session download started' }).waitFor({ timeout: 30_000 })
  336. await page.getByRole('dialog', { name: 'Session download started' })
  337. .getByText('Close', { exact: true }).click()
  338. await observer.getByText('Session log download requested.', { exact: true }).waitFor({ timeout: 30_000 })
  339. expect(observerDownloads).toBe(0)
  340. expect(await observer.getByRole('dialog', { name: 'Session download started' }).count()).toBe(0)
  341. expect({
  342. pageErrors: observerTripwire.pageErrors,
  343. slotErrors: observerSlotErrors,
  344. warnings: observerTripwire.warnings,
  345. }).toEqual({ pageErrors: [], slotErrors: [], warnings: [] })
  346. } finally {
  347. await observer.close()
  348. }
  349. }, 120_000)
  350. it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
  351. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
  352. await ensureSeedOpen(page)
  353. await page.getByRole('tab', { name: 'Trajectory' }).click()
  354. const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
  355. await plot.waitFor({ timeout: 15_000 })
  356. const before = await page.locator('tr[data-kind]').count()
  357. const box = await plot.boundingBox()
  358. if (box === null) throw new Error('trajectory timeline plot has no layout box')
  359. await page.mouse.move(box.x + box.width * 0.55, box.y + box.height / 2)
  360. await page.mouse.down()
  361. await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2)
  362. await page.mouse.up()
  363. await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 })
  364. .toBeGreaterThan(0)
  365. await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before)
  366. await plot.click({ button: 'right' })
  367. await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
  368. }, 60_000)
  369. it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
  370. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
  371. await ensureSeedOpen(page)
  372. const bashRow = page.locator('[data-sample="bash"]').first()
  373. await bashRow.waitFor({ timeout: 15_000 })
  374. const frame = page.locator('[style*="grid-template-columns"]').first()
  375. expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
  376. // The row click is the card's expand toggle (unified tool-row
  377. // interaction); it must not drive layout geometry either way.
  378. await bashRow.click()
  379. await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
  380. // The card's own controls are outside the summary row and must not open
  381. // details either — the expanded terminal card is read in place.
  382. await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
  383. await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
  384. // Read summaries are host-open file links; they also must not open details.
  385. const fileLink = page.locator('[data-variant="read"] button').first()
  386. await fileLink.waitFor({ timeout: 10_000 })
  387. const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath')
  388. .mockImplementation(async (request, _signal) => ({
  389. rpcId: request.rpcId,
  390. result: { ok: true, value: { opened: true as const } },
  391. }))
  392. try {
  393. await fileLink.click()
  394. await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
  395. } finally {
  396. openPath.mockRestore()
  397. }
  398. }, 60_000)
  399. it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
  400. onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
  401. await ensureSeedOpen(page)
  402. // The card is expand-gated behind the whole-row toggle (the unified
  403. // tool-row interaction): open it if this fresh view leaves it collapsed.
  404. // Expanded, the recorded command's own output sits in the message flow,
  405. // derived from the logged call/result presentations alone.
  406. const bashRow = page.locator('[data-sample="bash"]').first()
  407. await bashRow.waitFor({ timeout: 15_000 })
  408. if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
  409. const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first()
  410. await card.waitFor({ timeout: 15_000 })
  411. // Real layout, not jsdom's stub (which computes no geometry at all):
  412. // squeeze the output pane below its content width and the line must keep
  413. // its single row and overflow sideways instead of folding. Soft-wrapping
  414. // here shreds the column alignment this card exists to hold.
  415. const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
  416. const pane = node as HTMLElement
  417. const row = pane.querySelector<HTMLElement>('[class*="_line_"]')
  418. if (row === null) throw new Error('output pane has no line')
  419. const before = row.offsetHeight
  420. const restore = pane.style.width
  421. pane.style.width = '8px'
  422. const squeezed = { wrapped: row.offsetHeight > before, scrollsSideways: pane.scrollWidth > pane.clientWidth }
  423. pane.style.width = restore
  424. return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed }
  425. })
  426. expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true })
  427. // The run-state dot's color is the whole point of it and is the one thing
  428. // jsdom cannot report: --dsw-* tokens resolve only against the real theme
  429. // stylesheet. This command settled cleanly, so the dot must be the green
  430. // success token — a red one here would read as a failed command.
  431. const dot = await card.locator('[class*="_runState_"][data-state]').first().evaluate((node) => {
  432. // The token lives on body, so the probe must sit in the same cascade.
  433. const probe = document.createElement('span')
  434. probe.style.color = 'var(--dsw-alias-state-success-primary)'
  435. document.body.appendChild(probe)
  436. const success = getComputedStyle(probe).color
  437. probe.remove()
  438. return {
  439. state: node.getAttribute('data-state'),
  440. color: getComputedStyle(node as HTMLElement).color,
  441. success,
  442. // One label per card (the state is the call's), so it hangs off the
  443. // prompt column rather than the row the dot sits in.
  444. label: node.closest('[class*="_prompt_"]')?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
  445. // The dot precedes the prompt label in document order, which is what
  446. // puts it to the left of the `$`.
  447. beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!)
  448. === Node.DOCUMENT_POSITION_FOLLOWING,
  449. // The dot lives in the card's OWN left padding, so it sits inside the
  450. // card box yet left of the prompt text. Owning the reservation as padding
  451. // rather than margin is what keeps a consumer's own margin from
  452. // cancelling it and letting a container clip the dot — geometry jsdom
  453. // cannot compute.
  454. insideCard: (node as HTMLElement).getBoundingClientRect().left
  455. >= (node.closest('[data-terminal]')?.getBoundingClientRect().left ?? Infinity),
  456. leftOfPrompt: (node as HTMLElement).getBoundingClientRect().right
  457. <= (node.closest('[class*="_promptLine_"]')
  458. ?.querySelector('[class*="_cwd_"]')
  459. ?.getBoundingClientRect().left ?? -Infinity),
  460. }
  461. })
  462. expect(dot.state).toBe('done')
  463. expect(dot.label).toBe('Done')
  464. expect(dot.beforePrompt).toBe(true)
  465. expect(dot.insideCard).toBe(true)
  466. expect(dot.leftOfPrompt).toBe(true)
  467. // Resolved through the theme token, not a literal hex in the component.
  468. expect(dot.success).toMatch(/^rgb/)
  469. expect(dot.color).toBe(dot.success)
  470. // Golden of the card at rest — captured before the copy click, whose
  471. // confirmation label self-reverts on a timer and would not hold still.
  472. const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
  473. .split(SEED_ID).join('{{seededId}}')
  474. await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
  475. // Copy writes the raw output through the browser's own clipboard, which in
  476. // a real page is the async Clipboard API rather than the jsdom fallback.
  477. await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
  478. await card.locator('[class*="_copyButton_"]').first().click()
  479. await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 })
  480. .toBe('Copied')
  481. expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
  482. }, 60_000)
  483. it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
  484. await assertFixtureInventory(SNAPSHOT_DIR, [
  485. 'session.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
  486. 'terminal-card.expected.md',
  487. ])
  488. })
  489. })