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 ( )
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 options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop.'
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 )
}
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
} )
} )