f4c243c75f
The terminal card showed no run state: a running command and a settled command that produced no output rendered the same prompt line, so whether a command was still running had to be inferred from the absence of output. Lead the prompt line with a StateDot in three of its states — the spinning ring while running, red for the same exit status that renders the status pill, green for a clean settle. That is the same indicator a tool row's leading icon carries, so a row and its own card cannot disagree about one command; the row/card agreement is pinned in the ui-conversation spec. StateDot is aria-hidden, so a visually hidden text label rides beside it, which is what the refreshed aria goldens now record. The e2e adds what jsdom cannot compute: the dot's color resolves to the green success token through the real theme stylesheet, and the dot precedes the prompt label in document order.
261 lines
16 KiB
TypeScript
261 lines
16 KiB
TypeScript
// Web e2e scenarios: navigation & panes — the view tabs (Trajectory /
|
||
// Waterfall), the details column, 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/waterfall lanes and a details-capable bash row), turn 2 a
|
||
// markdown-rich reply (a second turn so the waterfall has two lanes).
|
||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { join } from 'node:path'
|
||
import type { Browser, Page } from 'playwright'
|
||
import { chromium } from 'playwright'
|
||
import { afterAll, beforeAll, 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 { 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 WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
|
||
const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.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.'
|
||
|
||
describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||
let scaffold: WebScaffold
|
||
let browser: Browser
|
||
let page: Page
|
||
let tripwire: ReturnType<typeof watchConsole>
|
||
|
||
beforeAll(async () => {
|
||
scaffold = await launchWebScaffold({})
|
||
// The workspace-aware flow runs sessions in <workspaceRoot>/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()
|
||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||
tripwire = watchConsole(page)
|
||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||
}, 120_000)
|
||
|
||
afterAll(async () => {
|
||
await browser?.close()
|
||
await scaffold?.close()
|
||
})
|
||
|
||
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')('opens the seeded session and renders both turns from the log', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open'))
|
||
// Expand the collapsed group row, then open the revealed session row.
|
||
const groupRow = page.locator('[role="treeitem"]').first()
|
||
await groupRow.waitFor({ timeout: 15_000 })
|
||
await groupRow.click()
|
||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||
await sessionRow.waitFor({ timeout: 10_000 })
|
||
await sessionRow.click()
|
||
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)
|
||
}, 90_000)
|
||
|
||
it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
|
||
// Runs after the session is open: a cold summary carries no title (the
|
||
// sidebar shows the cwd basename), and the durable title lands with the
|
||
// attach subscription's baseline — which is itself worth pinning: search
|
||
// matches the title the user sees, not a hidden cold field.
|
||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||
await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||
// Negative: a garbage query empties the tree (group rows hide too).
|
||
await search.fill('zzzqx-no-such-session')
|
||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0)
|
||
// Positive: a title word narrows to the matched session + its group,
|
||
// force-expanded by search mode (case-insensitive client-side filter).
|
||
await search.fill('navscenario')
|
||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||
// Clear restores the unfiltered tree.
|
||
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)
|
||
}, 60_000)
|
||
|
||
it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
|
||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||
// Two sticky turn sections; turn 1's step group summarizes its tool mix
|
||
// (bash + the two parallel reads collapse to 'bash read×2').
|
||
await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||
await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||
await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
|
||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||
.split(SEED_ID).join('{{seededId}}')
|
||
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
|
||
}, 60_000)
|
||
|
||
it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall'))
|
||
await page.getByRole('tab', { name: 'Waterfall' }).click()
|
||
// The stats header rides the waterfall body. The span fold counts THREE
|
||
// spans for this two-turn log: only assistant/steering nodes carry a turn
|
||
// number, so the first user message lands in a turn-0 prologue span (a
|
||
// P-I placeholder shape — pinned as-is; real spans are deferred to
|
||
// P-III per the view's deviation ledger). Calls: bash + two reads.
|
||
await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1)
|
||
// One lane per span, tagged by turn number, prologue included.
|
||
for (const tag of ['turn 0', 'turn 1', 'turn 2']) {
|
||
await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||
}
|
||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||
.split(SEED_ID).join('{{seededId}}')
|
||
await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE)
|
||
}, 60_000)
|
||
|
||
it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
|
||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||
// The bash toolview row routes its click to openDetails (read rows are
|
||
// expand-in-place instead — the seeded-history scenario owns that fold).
|
||
const bashRow = page.locator('[data-sample="bash-global"]').first()
|
||
await bashRow.waitFor({ timeout: 15_000 })
|
||
// Open/closed is the frame's collapsed attribute: the column collapses to
|
||
// width 0 but its subtree deliberately never unmounts (hidden, not
|
||
// absent), so element presence/visibility cannot express the state.
|
||
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
|
||
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
|
||
await bashRow.click()
|
||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull()
|
||
// The open panel shows the selected call's name, arguments, and durable
|
||
// result. The chat row carries its own terminal card, so NAVIGATION_OK
|
||
// appears there too — in the prompt line and in the output.
|
||
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||
// Golden of the open panel: tool name header, Input args, and the Output
|
||
// section's terminal card (prompt line + captured output).
|
||
const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd))
|
||
.split(SEED_ID).join('{{seededId}}')
|
||
await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE)
|
||
await page.getByRole('button', { name: '关闭详情' }).click()
|
||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull()
|
||
}, 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 page.getByRole('tab', { name: 'Chat' }).click()
|
||
// The card is resident in the keyed bash row (no expand gesture): the
|
||
// recorded command's own output sits in the message flow, derived from the
|
||
// logged call/result presentations alone.
|
||
const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [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,
|
||
label: node.parentElement?.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,
|
||
}
|
||
})
|
||
expect(dot.state).toBe('done')
|
||
expect(dot.label).toBe('已完成')
|
||
expect(dot.beforePrompt).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('复制成功')
|
||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
|
||
}, 60_000)
|
||
|
||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||
'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md',
|
||
'terminal-card.expected.md',
|
||
])
|
||
})
|
||
})
|