2026-07-28 16:28:57 +08:00
// Web e2e scenarios: navigation & panes — the Trajectory view and timing
2026-07-28 18:06:04 +08:00
// overview, its local details inspector, and sidebar search, all over ONE rich
2026-07-26 05:15:47 +08:00
// 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
2026-07-28 18:06:04 +08:00
// trajectory ledger/timing lanes), turn 2 a markdown-rich reply.
2026-07-26 05:15:47 +08:00
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' )
2026-07-28 14:58:06 +08:00
const TERMINAL_EXPECTED = join ( SNAPSHOT_DIR , 'terminal-card.expected.md' )
2026-07-26 05:15:47 +08:00
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 >
2026-07-28 16:28:57 +08:00
let slotErrors : string [ ]
2026-07-26 05:15:47 +08:00
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 )
2026-07-28 16:28:57 +08:00
slotErrors = [ ]
page . on ( 'console' , ( message ) = > {
if ( message . type ( ) === 'error' && /slot entry crashed/i . test ( message . text ( ) ) ) {
slotErrors . push ( message . text ( ) )
}
} )
2026-07-26 05:15:47 +08:00
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 )
2026-07-27 15:47:44 +08:00
it . skipIf ( MODE === 'record' ) ( 'renders the trajectory ledger and opens its local record inspector' , async ( ) = > {
2026-07-26 05:15:47 +08:00
onTestFailed ( ( ) = > saveFailureShot ( page , 'web-e2e-navigation-trajectory' ) )
await page . getByRole ( 'tab' , { name : 'Trajectory' } ) . click ( )
2026-07-28 16:28:57 +08:00
await page . waitForTimeout ( 100 )
expect ( {
pageErrors : tripwire.pageErrors ,
slotErrors ,
warnings : tripwire.warnings ,
} ) . toEqual ( {
pageErrors : [ ] ,
slotErrors : [ ] ,
warnings : [ ] ,
} )
2026-07-28 10:23:44 +08:00
// Turn rules partition the ledger without restoring a separate header row.
2026-07-27 15:47:44 +08:00
await expect . poll ( ( ) = > page . locator ( 'tr[data-turn-start="true"]' ) . count ( ) , { timeout : 15_000 } ) . toBe ( 2 )
2026-07-28 10:23:44 +08:00
await expect . poll ( ( ) = > page . getByRole ( 'columnheader' ) . count ( ) , { timeout : 10_000 } ) . toBe ( 0 )
2026-07-27 15:47:44 +08:00
await page . locator ( 'tr[data-kind="tool"]' ) . first ( ) . click ( )
2026-07-28 10:23:44 +08:00
await expect . poll ( ( ) = > page . getByRole ( 'complementary' , { name : 'Event details' } ) . count ( ) , { timeout : 10_000 } ) . toBe ( 1 )
await page . getByRole ( 'tab' , { name : 'Result' } ) . click ( )
2026-07-27 15:47:44 +08:00
await expect . poll ( ( ) = > page . getByText ( 'NAVIGATION_OK' , { exact : false } ) . count ( ) , { timeout : 10_000 } ) . toBeGreaterThanOrEqual ( 1 )
2026-07-26 05:15:47 +08:00
const snapshot = ( await captureStableAria ( page , '[class*="viewArea"]' , scaffold . workspaceCwd ) )
. split ( SEED_ID ) . join ( '{{seededId}}' )
await compareOrRefreshGolden ( TRAJECTORY_EXPECTED , snapshot , MODE )
2026-07-28 10:23:44 +08:00
await page . getByRole ( 'complementary' , { name : 'Event details' } )
. getByRole ( 'button' , { name : 'Close details' } ) . click ( )
2026-07-26 05:15:47 +08:00
} , 60 _000 )
2026-07-28 16:28:57 +08:00
it . skipIf ( MODE === 'record' ) ( 'focuses the ledger by dragging an overview interval' , async ( ) = > {
onTestFailed ( ( ) = > saveFailureShot ( page , 'web-e2e-navigation-timeline' ) )
2026-07-28 18:02:19 +08:00
const plot = page . getByLabel ( 'Timeline overview; drag horizontally to focus events' )
2026-07-28 16:28:57 +08:00
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 ( )
2026-07-28 18:02:19 +08:00
await expect . poll ( ( ) = > page . locator ( 'tr[data-timeline-focus="outside"]' ) . count ( ) , { timeout : 10_000 } )
. toBeGreaterThan ( 0 )
2026-07-28 16:28:57 +08:00
await expect . poll ( ( ) = > page . locator ( 'tr[data-kind]' ) . count ( ) , { timeout : 10_000 } ) . toBe ( before )
2026-07-28 18:02:19 +08:00
await plot . click ( { button : 'right' } )
await expect . poll ( ( ) = > page . locator ( 'tr[data-timeline-focus]' ) . count ( ) , { timeout : 10_000 } ) . toBe ( 0 )
2026-07-26 05:15:47 +08:00
} , 60 _000 )
2026-07-29 17:31:33 +08:00
it . skipIf ( MODE === 'record' ) ( 'bash and file-path rows leave the default details column open' , async ( ) = > {
2026-07-26 05:15:47 +08:00
onTestFailed ( ( ) = > saveFailureShot ( page , 'web-e2e-navigation-details' ) )
await page . getByRole ( 'tab' , { name : 'Chat' } ) . click ( )
const bashRow = page . locator ( '[data-sample="bash-global"]' ) . first ( )
await bashRow . waitFor ( { timeout : 15_000 } )
2026-07-29 17:31:33 +08:00
const frame = page . locator ( '[style*="grid-template-columns"]' ) . first ( )
expect ( await frame . getAttribute ( 'data-details-collapsed' ) ) . toBeNull ( )
2026-07-26 05:15:47 +08:00
await bashRow . click ( )
2026-07-29 17:31:33 +08:00
await expect . poll ( ( ) = > frame . getAttribute ( 'data-details-collapsed' ) , { timeout : 5_000 } ) . toBeNull ( )
2026-07-28 19:03:03 +08:00
// The card's own controls are outside the summary row and must not open
// details either — the terminal card is read in place.
await page . locator ( '[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]' ) . first ( ) . click ( )
2026-07-29 20:58:04 +08:00
await expect . poll ( ( ) = > frame . getAttribute ( 'data-details-collapsed' ) , { timeout : 5_000 } ) . toBeNull ( )
2026-07-28 15:21:34 +08:00
// 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 ( )
2026-07-29 17:31:33 +08:00
await expect . poll ( ( ) = > frame . getAttribute ( 'data-details-collapsed' ) , { timeout : 5_000 } ) . toBeNull ( )
2026-07-26 05:15:47 +08:00
} , 60 _000 )
2026-07-28 14:58:06 +08:00
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 } )
2026-07-28 16:41:04 +08:00
// 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 ,
2026-07-28 18:48:57 +08:00
// 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 ,
2026-07-28 16:41:04 +08:00
// 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 ,
2026-07-29 15:36:16 +08:00
// 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 ) ,
2026-07-28 16:41:04 +08:00
}
} )
expect ( dot . state ) . toBe ( 'done' )
expect ( dot . label ) . toBe ( '已完成' )
expect ( dot . beforePrompt ) . toBe ( true )
2026-07-29 15:36:16 +08:00
expect ( dot . insideCard ) . toBe ( true )
expect ( dot . leftOfPrompt ) . toBe ( true )
2026-07-28 16:41:04 +08:00
// Resolved through the theme token, not a literal hex in the component.
expect ( dot . success ) . toMatch ( /^rgb/ )
expect ( dot . color ) . toBe ( dot . success )
2026-07-28 14:58:06 +08:00
// 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 )
2026-07-26 05:15:47 +08:00
it . skipIf ( MODE === 'record' ) ( 'issued zero model calls and stayed clean' , async ( ) = > {
expect ( tripwire . pageErrors ) . toEqual ( [ ] )
2026-07-28 16:28:57 +08:00
expect ( slotErrors ) . toEqual ( [ ] )
2026-07-26 05:15:47 +08:00
expect ( tripwire . warnings ) . toEqual ( [ ] )
2026-07-26 12:45:44 +08:00
await assertFixtureInventory ( SNAPSHOT_DIR , [
2026-07-29 20:58:04 +08:00
'seed.jsonl' , 'trajectory.expected.md' , 'terminal-card.expected.md' ,
2026-07-26 12:45:44 +08:00
] )
2026-07-26 05:15:47 +08:00
} )
} )