| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410 |
- // 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<Response> {
- 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<void> {
- 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<void> {
- 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<typeof watchConsole> = { warnings: [], pageErrors: [] }
- let slotErrors: string[] = []
- beforeAll(async () => {
- scaffold = await launchWebScaffold({})
- // The workspace-aware flow runs sessions in <workspaceCwd>/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<ReturnType<WebScaffold['whenTurnSettled']>> | 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<HTMLElement>('[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',
- ])
- })
- })
|