navigation-panes.e2e.ts 28 KB

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