// Web e2e scenarios: navigation & panes — the Trajectory view and timing // overview, its local details inspector, and sidebar search, all over ONE rich // two-turn seeded fixture rendered purely from the log (the seeded-history // pattern: zero model calls in replay, so every surface here is the client // fold + host history RPC, not replay binding). The seed is recorded live // under the standard discipline: turn 1 produces a bash call plus two // parallel reads in one assistant message (tool-call density for the // trajectory ledger/timing lanes), turn 2 a markdown-rich reply. import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' // Turn 1 leads with a distinctive word: the session-title fallback takes the // first words of the first message, so the sidebar-search scenario has a // known-matching query ('navscenario') without depending on a live title call. 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.' 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.' async function baselineResponse( page: Page, method: 'session.list' | 'workspace.list', ): Promise { return page.waitForResponse(response => ( response.request().method() === 'POST' && new URL(response.url()).pathname === `/api/${method}` ), { timeout: 30_000 }) } async function assertBaselineSucceeded(response: Response, method: string): Promise { expect(response.ok(), `${method} baseline HTTP response`).toBe(true) const body = await response.json() as { result?: { ok?: unknown } } expect(body.result?.ok, `${method} baseline RPC result`).toBe(true) } async function ensureSeedOpen(page: Page): Promise { const chat = page.getByRole('tab', { name: 'Chat', exact: true }) const search = page.getByPlaceholder('Search name, keywords', { exact: false }) if (await chat.count() === 0) { await search.fill('WATERFALL') const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1) await result.click() await chat.waitFor({ timeout: 15_000 }) } await chat.click() await page.getByText('FIRST_DONE', { exact: true }).waitFor({ timeout: 15_000 }) if (await search.inputValue() !== '') { await search.fill('') await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') } } describe('web e2e: navigation & panes over a rich seeded session', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType = { warnings: [], pageErrors: [] } let slotErrors: string[] = [] beforeAll(async () => { scaffold = await launchWebScaffold({}) // The workspace-aware flow runs sessions in /workspace; // the read targets must live in that session cwd (pre-creation is safe: // create-by-name adopts an existing directory). const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'nav-a.md'), '# alpha nav\n') await writeFile(join(sessionCwd, 'nav-b.md'), '# beta nav\n') if (MODE !== 'record') { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts') .toEqual([PROMPT_TURN1, PROMPT_TURN2]) await seedSession(scaffold, raw, SEED_ID) } browser = await chromium.launch() }, 120_000) beforeEach(async () => { page = await newEnglishPage(browser) tripwire = watchConsole(page) slotErrors = [] page.on('console', (message) => { if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) { slotErrors.push(message.text()) } }) // Initial navigation and list ownership settle only after both independent // RPC baselines succeed; arm before navigation so neither response is missed. const sessionBaseline = baselineResponse(page, 'session.list') const workspaceBaseline = baselineResponse(page, 'workspace.list') const [, sessionResponse, workspaceResponse] = await Promise.all([ page.goto(scaffold.baseUrl, { waitUntil: 'load' }), sessionBaseline, workspaceBaseline, ]) await Promise.all([ assertBaselineSucceeded(sessionResponse, 'session.list'), assertBaselineSucceeded(workspaceResponse, 'workspace.list'), ]) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // The frame mounts before the asynchronous session-list baseline lands. // Search must target the settled seeded row, not the startup input that // the ready projection replaces. await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 }) }, 120_000) afterEach(async () => { const failures: unknown[] = [] try { expect({ pageErrors: tripwire.pageErrors, slotErrors, warnings: tripwire.warnings, }).toEqual({ pageErrors: [], slotErrors: [], warnings: [], }) } catch (error) { failures.push(error) } await page?.close().catch((error: unknown) => failures.push(error)) if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'navigation case cleanup failed') }) afterAll(async () => { const failures: unknown[] = [] await browser?.close().catch((error: unknown) => failures.push(error)) await scaffold?.close().catch((error: unknown) => failures.push(error)) if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'navigation e2e cleanup failed') }) it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-record')) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) let sessionId: Awaited> | undefined for (const prompt of [PROMPT_TURN1, PROMPT_TURN2]) { const settled = scaffold.whenTurnSettled() // Turn 2 types into the same composer once turn 1 unlocks it. await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true) await input.fill(prompt) await input.press('Enter') sessionId = await settled } await recordFixture(scaffold, sessionId!, SEED) // Fixture honesty: the recording must carry the shape the replay // scenarios assert on — three calls in turn 1 and two closed turns. const recorded = parseSessionLog(await readFile(SEED, 'utf8')) expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2) const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call') expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read']) }, 400_000) it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) // The API baselines can settle before React commits their projection. The // seeded count is the final user-visible barrier before editing search. await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 }) const search = page.getByPlaceholder('Search name, keywords', { exact: false }) // The cold row has not been opened, so only the persisted log can satisfy // this query. First search lazily reconciles the SQLite content index. await search.fill('zzzqx-no-such-session') await page.getByText('No matching sessions').waitFor({ timeout: 30_000 }) await expect.poll( () => page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem').count(), { timeout: 10_000 }, ).toBe(0) await search.fill('WATERFALL') const resultTree = page.getByRole('tree', { name: 'Search results' }) const result = resultTree.getByRole('treeitem') await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1) await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), { timeout: 10_000, }).toBeGreaterThanOrEqual(1) const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE) await result.click() // Search navigation addresses the session, not a specific event, and the // query remains until the user explicitly clears it. await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL') await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1) await page.getByRole('button', { name: 'Clear search' }).click() await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) }, 90_000) it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) await ensureSeedOpen(page) await page.getByRole('tab', { name: 'Trajectory' }).click() await page.waitForTimeout(100) const overlayLayout = await page.getByRole('table').evaluate((table) => { const host = table.closest('[data-conversation-scroll]') const seat = host?.querySelector('[data-composer-seat]') ?? null const pane = table.parentElement return { hostPosition: host === null ? null : getComputedStyle(host).position, paneOverflowX: pane === null ? null : getComputedStyle(pane).overflowX, paneScrollableWidth: pane === null ? null : pane.scrollWidth - pane.clientWidth, seatPosition: seat === null ? null : getComputedStyle(seat).position, } }) expect(overlayLayout).toEqual({ hostPosition: 'relative', paneOverflowX: 'hidden', paneScrollableWidth: 0, seatPosition: 'absolute', }) expect({ pageErrors: tripwire.pageErrors, slotErrors, warnings: tripwire.warnings, }).toEqual({ pageErrors: [], slotErrors: [], warnings: [], }) // Turn rules partition the ledger without restoring a separate header row. await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2) await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0) await page.locator('tr[data-kind="tool"]').first().click() const details = page.getByRole('complementary', { name: 'Event details' }) await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1) expect(await details.getByRole('tabpanel').evaluate(panel => getComputedStyle(panel).overflowX)) .toBe('hidden') await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({ heading: getComputedStyle(heading).backgroundColor, panel: getComputedStyle(heading.closest('[aria-label="Event details"]')!).backgroundColor, })) expect(darkSummarySurfaces.heading).toBe(darkSummarySurfaces.panel) await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) await page.getByRole('tab', { name: 'Result' }).click() await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first() await assistantSpan.hover() const timingTooltip = page.getByRole('tooltip') await timingTooltip.waitFor({ timeout: 5_000 }) await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/) const assistantTimingStyle = await assistantSpan.evaluate(node => ({ background: getComputedStyle(node).backgroundImage, ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'), })) expect(assistantTimingStyle.background).toContain('linear-gradient') expect(assistantTimingStyle.ttft).toMatch(/%$/) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) await details.getByRole('button', { name: 'Close details' }).click() }, 60_000) it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) await ensureSeedOpen(page) await page.getByRole('tab', { name: 'Trajectory' }).click() const plot = page.getByLabel('Timeline overview; drag horizontally to focus events') await plot.waitFor({ timeout: 15_000 }) const before = await page.locator('tr[data-kind]').count() const box = await plot.boundingBox() if (box === null) throw new Error('trajectory timeline plot has no layout box') await page.mouse.move(box.x + box.width * 0.55, box.y + box.height / 2) await page.mouse.down() await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2) await page.mouse.up() await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 }) .toBeGreaterThan(0) await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before) await plot.click({ button: 'right' }) await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) await ensureSeedOpen(page) const bashRow = page.locator('[data-sample="bash"]').first() await bashRow.waitFor({ timeout: 15_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') // The row click is the card's expand toggle (unified tool-row // interaction); it must not drive layout geometry either way. await bashRow.click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') // The card's own controls are outside the summary row and must not open // details either — the expanded terminal card is read in place. await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') // Read summaries are host-open file links; they also must not open details. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) await fileLink.click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') }, 60_000) it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal')) await ensureSeedOpen(page) // The card is expand-gated behind the whole-row toggle (the unified // tool-row interaction): open it if this fresh view leaves it collapsed. // Expanded, the recorded command's own output sits in the message flow, // derived from the logged call/result presentations alone. const bashRow = page.locator('[data-sample="bash"]').first() await bashRow.waitFor({ timeout: 15_000 }) if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click() const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first() await card.waitFor({ timeout: 15_000 }) // Real layout, not jsdom's stub (which computes no geometry at all): // squeeze the output pane below its content width and the line must keep // its single row and overflow sideways instead of folding. Soft-wrapping // here is what shredded the column alignment this card exists to hold. const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => { const pane = node as HTMLElement const row = pane.querySelector('[class*="_line_"]') if (row === null) throw new Error('output pane has no line') const before = row.offsetHeight const restore = pane.style.width pane.style.width = '8px' const squeezed = { wrapped: row.offsetHeight > before, scrollsSideways: pane.scrollWidth > pane.clientWidth } pane.style.width = restore return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed } }) expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true }) // The run-state dot's color is the whole point of it and is the one thing // jsdom cannot report: --dsw-* tokens resolve only against the real theme // stylesheet. This command settled cleanly, so the dot must be the green // success token — a red one here would read as a failed command. const dot = await card.locator('[class*="_runState_"][data-state]').first().evaluate((node) => { // The token lives on body, so the probe must sit in the same cascade. const probe = document.createElement('span') probe.style.color = 'var(--dsw-alias-state-success-primary)' document.body.appendChild(probe) const success = getComputedStyle(probe).color probe.remove() return { state: node.getAttribute('data-state'), color: getComputedStyle(node as HTMLElement).color, success, // One label per card (the state is the call's), so it hangs off the // prompt column rather than the row the dot sits in. label: node.closest('[class*="_prompt_"]')?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null, // The dot precedes the prompt label in document order, which is what // puts it to the left of the `$`. beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!) === Node.DOCUMENT_POSITION_FOLLOWING, // The dot lives in the card's OWN left padding, so it sits inside the // card box yet left of the prompt text. Owning the reservation as padding // rather than margin is what keeps a consumer's own margin from // cancelling it and letting a container clip the dot — geometry jsdom // cannot compute. insideCard: (node as HTMLElement).getBoundingClientRect().left >= (node.closest('[data-terminal]')?.getBoundingClientRect().left ?? Infinity), leftOfPrompt: (node as HTMLElement).getBoundingClientRect().right <= (node.closest('[class*="_promptLine_"]') ?.querySelector('[class*="_cwd_"]') ?.getBoundingClientRect().left ?? -Infinity), } }) expect(dot.state).toBe('done') expect(dot.label).toBe('Done') expect(dot.beforePrompt).toBe(true) expect(dot.insideCard).toBe(true) expect(dot.leftOfPrompt).toBe(true) // Resolved through the theme token, not a literal hex in the component. expect(dot.success).toMatch(/^rgb/) expect(dot.color).toBe(dot.success) // Golden of the card at rest — captured before the copy click, whose // confirmation label self-reverts on a timer and would not hold still. const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE) // Copy writes the raw output through the browser's own clipboard, which in // a real page is the async Clipboard API rather than the jsdom fallback. await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) await card.locator('[class*="_copyButton_"]').first().click() await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 }) .toBe('Copied') expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK') }, 60_000) it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', ]) }) })