2026-07-26 03:32:17 +08:00
// Web e2e scenario: the resident question composer. The shipped composition
// already exposes ask_user_question (the ui-question row's node half mounts
// the tool), so a recorded turn where the model asks blocks mid-turn on the
// real userInteraction seam: the composer renders in the browser, the test
// answers through it, and the turn completes with the answer in the log.
// Replay is fully deterministic — the question content arrives from replayed
// chunks, the composer wait is real, and the answer click is the test's own
// gesture (the ONE place a drive step legitimately reacts to model content:
// the turn cannot complete without it, in record and replay alike).
import { readFile } 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 type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory , captureStableAria , compareOrRefreshGolden , fixtureUserPrompts ,
launchWebScaffold , recordFixture , watchConsole , webSnapshotMode , type WebScaffold ,
} from './scaffold.ts'
2026-07-27 08:25:00 +08:00
import { connectFreshWorkspace , saveFailureShot } from './support.ts'
2026-07-26 03:32:17 +08:00
const SNAPSHOT_DIR = fileURLToPath ( new URL ( './snapshots/question-composer' , import . meta . url ) )
const FIXTURE = join ( SNAPSHOT_DIR , 'session.jsonl' )
const UI_EXPECTED = join ( SNAPSHOT_DIR , 'ui.expected.md' )
2026-07-26 12:45:44 +08:00
// Second golden: the answered transcript — the question resolved into its
// tool round trip and the final reply, the state the waiting golden cannot see.
const ANSWERED_EXPECTED = join ( SNAPSHOT_DIR , 'answered.expected.md' )
2026-07-26 03:32:17 +08:00
const MODE = webSnapshotMode ( )
2026-07-27 19:20:33 +08:00
// The options carry long descriptions on purpose: the squeeze assertion below
// needs option copy that WRAPS, which is the only shape that reproduces a
// collapsed row painting its copy outside its own box.
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.'
2026-07-26 03:32:17 +08:00
describe ( 'web e2e: resident question composer round trip' , ( ) = > {
let scaffold : WebScaffold
let browser : Browser
let page : Page
let tripwire : ReturnType < typeof watchConsole >
const sessionEvents : SessionEvent [ ] = [ ]
beforeAll ( async ( ) = > {
scaffold = await launchWebScaffold ( MODE === 'record' ? { } : { replayFixture : FIXTURE , paceMs : 15 } )
scaffold . ctx . on ( 'session/event' , ( _session , event : SessionEvent ) = > { sessionEvents . push ( event ) } )
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 } )
2026-07-27 08:25:00 +08:00
// Fresh world: connect a Workspace so the composer scenarios start live.
await connectFreshWorkspace ( page )
2026-07-26 03:32:17 +08:00
} , 120 _000 )
afterAll ( async ( ) = > {
await browser ? . close ( )
await scaffold ? . close ( )
} )
it ( 'asks through the composer, answers, and completes with the answer logged' , async ( ) = > {
onTestFailed ( ( ) = > saveFailureShot ( page , 'web-e2e-question' ) )
if ( MODE !== 'record' ) {
expect ( fixtureUserPrompts ( await readFile ( FIXTURE , 'utf8' ) ) ) . toEqual ( [ PROMPT ] )
}
const input = page . locator ( 'textarea' ) . first ( )
await input . waitFor ( { timeout : 10_000 } )
const settled = scaffold . whenTurnSettled ( MODE === 'record' ? 180_000 : 30_000 )
await input . fill ( PROMPT )
await input . press ( 'Enter' )
// The composer takes over the input area while the tool blocks. Its
// presence is a STABLE waiting state (not a transient): it stays until
// answered, so a plain waitFor is race-free.
const composer = page . locator ( '[data-question-key]' )
await composer . waitFor ( { timeout : MODE === 'record' ? 120_000 : 30_000 } )
await expect . poll ( ( ) = > composer . getByText ( 'Which color do you prefer?' ) . count ( ) , { timeout : 10_000 } ) . toBeGreaterThan ( 0 )
if ( MODE !== 'record' ) {
2026-07-26 22:38:33 +08:00
// This golden owns the stable question surface; the answered-state
// golden below owns the resulting transcript.
2026-07-26 03:32:17 +08:00
const snapshot = await captureStableAria ( page , '[data-question-key]' , scaffold . workspaceCwd )
await compareOrRefreshGolden ( UI_EXPECTED , snapshot , MODE )
}
2026-07-27 19:20:33 +08:00
// Squeezed card: the option rows are the capped card's scroll content, so
// shrinking the seat must push overflow into the option list, never
// collapse a row below the height its own copy needs — a collapsed row
// paints its centered copy outside the row box, over the title and the
// neighbouring rows. Measured on the live composer at seat heights that
// force the cap, then restored for the answer gesture below. Replay only:
// record mode must reach the recording write below, not abort on layout.
if ( MODE !== 'record' ) {
const original = page . viewportSize ( ) ? ? { width : 1680 , height : 1000 }
for ( const height of [ 520 , 440 , 380 ] ) {
await page . setViewportSize ( { width : 900 , height } )
const squeeze = await composer . evaluate ( ( card ) = > {
// Role/ARIA selectors, not the CSS-module class names: the built
// client hashes those.
const rows = [ . . . card . querySelectorAll < HTMLElement > (
'[role="radio"], [role="checkbox"], [aria-expanded]' ,
) ]
const spill = rows . map ( row = > Math . max ( . . . [ . . . row . children ] . map ( ( child ) = > {
const box = row . getBoundingClientRect ( )
const inner = child . getBoundingClientRect ( )
return Math . max ( box . top - inner . top , inner . bottom - box . bottom )
} ) ) )
const list = rows [ 0 ] ? . parentElement ? ? null
return {
rows : rows.length ,
spill : Math.max ( . . . spill ) ,
// Wrapped copy is the shape that overflows a collapsed row, and a
// scrolling list proves the seat is genuinely capped. Without both,
// the spill assertion would hold vacuously.
wrappedRows : rows.filter ( row = > row . getBoundingClientRect ( ) . height > 42 ) . length ,
scrolls : list === null ? false : list . scrollHeight > list . clientHeight ,
}
} )
expect ( squeeze . rows ) . toBeGreaterThan ( 0 )
expect ( squeeze . wrappedRows ) . toBeGreaterThan ( 0 )
expect ( squeeze . scrolls ) . toBe ( true )
// Sub-pixel tolerance: every row's copy stays inside its border box.
expect ( squeeze . spill ) . toBeLessThan ( 0.6 )
}
await page . setViewportSize ( original )
}
2026-07-26 03:32:17 +08:00
await composer . getByRole ( 'radio' , { name : 'Blue' } ) . click ( )
// Submit: Enter on the focused option (the composer's documented submit).
await composer . getByRole ( 'radio' , { name : 'Blue' } ) . press ( 'Enter' )
const sessionId = await settled
if ( MODE === 'record' ) {
await recordFixture ( scaffold , sessionId , FIXTURE )
return
}
// World state: the tool result carries the chosen answer, and DONE lands.
const results = sessionEvents . filter ( e = > e . type === 'tool/result' )
expect ( JSON . stringify ( results . at ( - 1 ) ) ) . toContain ( 'Blue' )
await expect . poll ( ( ) = > page . getByText ( 'DONE' , { exact : true } ) . count ( ) , { timeout : 15_000 } ) . toBeGreaterThanOrEqual ( 1 )
// Composer gone; regular input restored.
expect ( await page . locator ( '[data-question-key]' ) . count ( ) ) . toBe ( 0 )
await expect . poll ( ( ) = > page . locator ( 'textarea' ) . first ( ) . isEnabled ( ) , { timeout : 10_000 } ) . toBe ( true )
2026-07-26 12:45:44 +08:00
// Golden of the answered transcript: the ask_user_question round trip
// rendered as history (question tool row + DONE), composer takeover gone.
const snapshot = await captureStableAria ( page , '[class*="centerCol"]' , scaffold . workspaceCwd )
await compareOrRefreshGolden ( ANSWERED_EXPECTED , snapshot , MODE )
2026-07-26 03:32:17 +08:00
expect ( tripwire . pageErrors ) . toEqual ( [ ] )
2026-07-26 22:38:33 +08:00
expect ( tripwire . warnings ) . toEqual ( [ ] )
2026-07-26 03:32:17 +08:00
} , 200 _000 )
it . skipIf ( MODE === 'record' ) ( 'keeps the fixture inventory closed' , async ( ) = > {
2026-07-26 12:45:44 +08:00
await assertFixtureInventory ( SNAPSHOT_DIR , [ 'session.jsonl' , 'ui.expected.md' , 'answered.expected.md' ] )
2026-07-26 03:32:17 +08:00
} )
} )