navigation-panes.e2e.ts 29 KB

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