2026-07-30 17:50:04 +08:00
// Web e2e scenario: the composer-takeover approval panel under a long
// command. The shipped composition confines bash through the sandbox policy
// and routes its escalation through the approval seam, so a read-only session
// asked to write a file produces a REAL pending approval — the panel renders
// in the browser, the test measures its geometry, answers through it, and the
// escalated command then runs. Replay is deterministic: the denial, the
// escalation retry and its command text arrive from replayed chunks, and the
// answer click is the test's own gesture (the same sanctioned reaction to
// model content as the question composer: the turn cannot complete without it).
//
// Geometry is the point of the scenario. The command is unbounded model text,
// and before the cap a long one grew the card until the refuse/allow buttons
// left the viewport — an approval the user could see and not answer.
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'
// Empty type import: carries the approval package's session-event merge, so
// the decided-outcome assertion below type-checks against the real union.
import type { } from '@deepseek-ai/dsh-user-approval'
import {
assertFixtureInventory , captureStableAria , compareOrRefreshGolden , fixtureUserPrompts ,
launchWebScaffold , recordFixture , watchConsole , webSnapshotMode , type WebScaffold ,
} from './scaffold.ts'
import { connectFreshWorkspace , newEnglishPage , saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath ( new URL ( './snapshots/approval-composer' , import . meta . url ) )
const FIXTURE = join ( SNAPSHOT_DIR , 'session.jsonl' )
2026-07-30 18:11:05 +08:00
// The scenario's one golden: the waiting panel. Everything the answered state
// proves is asserted directly — see the world-state block at the end.
2026-07-30 17:50:04 +08:00
const UI_EXPECTED = join ( SNAPSHOT_DIR , 'ui.expected.md' )
const MODE = webSnapshotMode ( )
// Irreducible payload: the command has to be long enough to pass the card's
// height cap, which is the only shape that reproduces an action row pushed off
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
// short command proves nothing here. The formula keeps the source small; the
// model receives the expanded literal it has to put in the command.
const TOKENS = Array . from ( { length : 220 } , ( _ , index ) = > ` tok ${ ( ( index + 1 ) * 7919 % 99991 ) . toString ( 36 ) } ` ) . join ( ' ' )
const PROMPT = ` Write a file named notes.txt in the workspace containing exactly this text on one line: ${ TOKENS } . Use one bash command with the literal text inline. Then reply with the single word DONE and stop. `
/** Draft used to measure the composer's own text cap: enough lines to pass it. */
const CAP_PROBE = Array . from ( { length : 40 } , ( _ , index ) = > ` line ${ index } ` ) . join ( '\n' )
describe ( 'web e2e: approval takeover keeps its actions reachable' , ( ) = > {
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 newEnglishPage ( browser )
tripwire = watchConsole ( page )
await page . goto ( scaffold . baseUrl , { waitUntil : 'load' } )
await page . waitForSelector ( '[class*="frame"]' , { timeout : 30_000 } )
2026-07-31 15:47:40 +08:00
await connectFreshWorkspace ( page , scaffold . workspaceCwd )
2026-07-30 17:50:04 +08:00
} , 120 _000 )
afterAll ( async ( ) = > {
await browser ? . close ( )
await scaffold ? . close ( )
} )
it ( 'caps the long command, answers through the panel, and runs the escalated command' , async ( ) = > {
onTestFailed ( ( ) = > saveFailureShot ( page , 'web-e2e-approval' ) )
if ( MODE !== 'record' ) {
expect ( fixtureUserPrompts ( await readFile ( FIXTURE , 'utf8' ) ) ) . toEqual ( [ PROMPT ] )
}
const input = page . locator ( 'textarea' ) . first ( )
await input . waitFor ( { timeout : 10_000 } )
2026-07-31 15:25:29 +08:00
// The composer's own text cap, measured on the live draft scrollport before
// the takeover replaces it — the box that carries the cap, while the
// textarea inside it is as tall as the whole draft. The panel's scroll
// region must stop at the same height (the designer's requirement: one cap
// for the composer seat), and measuring it here keeps the assertion free of
// the px value itself.
2026-07-30 17:50:04 +08:00
await input . fill ( CAP_PROBE )
2026-07-31 15:25:29 +08:00
const composerCap = await input . evaluate ( el = > el . closest ( '[data-input-scroll]' ) ? . clientHeight ? ? 0 )
2026-07-30 17:50:04 +08:00
expect ( composerCap ) . toBeGreaterThan ( 0 )
await input . fill ( '' )
// Read-only: the mode whose denial the model escalates from. Switched
// through the shipped access-mode chip, not a test-only seam.
await page . locator ( '[aria-label^="Access mode"]' ) . click ( )
await page . getByRole ( 'menuitem' , { name : 'Read Only' } ) . click ( )
await expect . poll (
( ) = > page . locator ( '[aria-label="Access mode, current: Read Only"]' ) . count ( ) ,
{ timeout : 15_000 } ,
) . toBe ( 1 )
const settled = scaffold . whenTurnSettled ( MODE === 'record' ? 240_000 : 60_000 )
await input . fill ( PROMPT )
await input . press ( 'Enter' )
// The panel takes over the input area while the tool blocks. Its presence
// is a STABLE waiting state (it stays until answered), so waitFor is
// race-free.
const panel = page . locator ( '[data-approval-key]' )
await panel . waitFor ( { timeout : MODE === 'record' ? 180_000 : 60_000 } )
const scroll = panel . locator ( '[data-approval-scroll]' )
await expect . poll ( ( ) = > scroll . getByText ( /tok/ ) . count ( ) , { timeout : 15_000 } ) . toBeGreaterThan ( 0 )
if ( MODE !== 'record' ) {
// This golden owns the stable waiting surface; the answered golden below
// owns the resulting transcript.
const snapshot = await captureStableAria ( page , '[data-approval-key]' , scaffold . workspaceCwd )
await compareOrRefreshGolden ( UI_EXPECTED , snapshot , MODE )
// The regression this scenario exists for: an uncapped card grew with
// the command until the action row left the viewport. Measured at the
// lane baseline and at a short viewport, on the live panel.
const original = page . viewportSize ( ) ? ? { width : 1680 , height : 1000 }
for ( const height of [ 1000 , 700 ] ) {
await page . setViewportSize ( { width : 900 , height } )
const geometry = await panel . evaluate ( ( root ) = > {
const region = root . querySelector < HTMLElement > ( '[data-approval-scroll]' )
const card = region ? . parentElement ? ? null
// Role/text, not the CSS-module class names: the built client hashes those.
const buttons = [ . . . root . querySelectorAll < HTMLElement > ( 'button' ) ]
const rows = buttons . map ( button = > button . getBoundingClientRect ( ) )
return {
buttons : buttons.length ,
capped : region === null ? 0 : region.clientHeight ,
// A scrolling region proves the cap is genuinely engaged; without
// it every assertion below would hold vacuously.
scrolls : region === null ? false : region . scrollHeight > region . clientHeight ,
cardBottom : card === null ? Number . NaN : card . getBoundingClientRect ( ) . bottom ,
actionsTop : Math.min ( . . . rows . map ( rect = > rect . top ) ) ,
actionsBottom : Math.max ( . . . rows . map ( rect = > rect . bottom ) ) ,
viewport : window.innerHeight ,
}
} )
expect ( geometry . buttons ) . toBe ( 2 )
expect ( geometry . scrolls ) . toBe ( true )
// One cap for the seat: the panel's text region stops where the
// composer draft does (sub-pixel tolerance for the shared padding).
expect ( Math . abs ( geometry . capped - composerCap ) ) . toBeLessThan ( 1 )
// Both buttons stay inside the card AND inside the viewport — the
// answerable state the cap exists to guarantee.
expect ( geometry . actionsTop ) . toBeGreaterThan ( 0 )
expect ( geometry . actionsBottom ) . toBeLessThanOrEqual ( geometry . viewport )
expect ( geometry . actionsBottom ) . toBeLessThanOrEqual ( geometry . cardBottom )
}
await page . setViewportSize ( original )
}
2026-07-31 02:01:22 +08:00
await panel . getByRole ( 'button' , { name : 'Allow once' } ) . click ( )
2026-07-30 17:50:04 +08:00
const sessionId = await settled
if ( MODE === 'record' ) {
await recordFixture ( scaffold , sessionId , FIXTURE )
return
}
// World state: the granted escalation is what let the command run, and the
2026-07-30 18:11:05 +08:00
// panel leaves with the regular composer restored. Asserted on the world
// and the DOM rather than through a transcript golden — the denied first
// attempt renders the OS's own refusal ("Operation not permitted" on
// macOS, "Read-only file system" on Linux), so the answered transcript is
// not a platform-neutral golden surface.
2026-07-30 17:50:04 +08:00
expect ( JSON . stringify ( sessionEvents . filter ( e = > e . type === 'approval/decided' ) . at ( - 1 ) ) )
. toContain ( 'allowed-once' )
2026-07-30 18:11:05 +08:00
const written = await readFile ( join ( scaffold . workspaceCwd , 'workspace' , 'notes.txt' ) , 'utf8' )
expect ( written ) . toContain ( TOKENS . slice ( 0 , 64 ) )
2026-07-30 17:50:04 +08:00
await expect . poll ( ( ) = > page . getByText ( 'DONE' , { exact : true } ) . count ( ) , { timeout : 20_000 } ) . toBeGreaterThanOrEqual ( 1 )
expect ( await page . locator ( '[data-approval-key]' ) . count ( ) ) . toBe ( 0 )
await expect . poll ( ( ) = > page . locator ( 'textarea' ) . first ( ) . isEnabled ( ) , { timeout : 10_000 } ) . toBe ( true )
expect ( tripwire . pageErrors ) . toEqual ( [ ] )
expect ( tripwire . warnings ) . toEqual ( [ ] )
} , 300 _000 )
it . skipIf ( MODE === 'record' ) ( 'keeps the fixture inventory closed' , async ( ) = > {
2026-07-30 18:11:05 +08:00
await assertFixtureInventory ( SNAPSHOT_DIR , [ 'session.jsonl' , 'ui.expected.md' ] )
2026-07-30 17:50:04 +08:00
} )
} )