1- import { mkdirSync , mkdtempSync , rmSync , writeFileSync } from 'node:fs'
1+ import { mkdirSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from 'node:fs'
22import { createServer , type Server } from 'node:http'
33import { tmpdir } from 'node:os'
44import { dirname , join } from 'node:path'
55import { fileURLToPath } from 'node:url'
66import {
7+ type Dialog ,
78 type ElectronApplication ,
89 _electron as electron ,
910 expect ,
@@ -13,12 +14,18 @@ import {
1314import type { BrowserPageDialog , BrowserToolName } from '@sim/browser-protocol'
1415import type { SimDesktopApi } from '@sim/desktop-bridge'
1516import { getErrorMessage } from '@sim/utils/errors'
17+ import { build } from 'esbuild'
18+ import postcss from 'postcss'
19+ import loadPostcssConfig from 'postcss-load-config'
1620
1721const DESKTOP_DIR = fileURLToPath ( new URL ( '..' , import . meta. url ) )
1822const SCOPE = 'browser-page-dialogs-e2e'
19- const SHELL_FIXTURE = '<!doctype html><title>Sim fixture</title><h1>Browser dialogs fixture</h1>'
23+ const SIM_DIR = fileURLToPath ( new URL ( '../../sim/' , import . meta. url ) )
24+ const SHELL_FIXTURE =
25+ '<!doctype html><title>Sim fixture</title><link rel="stylesheet" href="/fixture.css"><h1>Browser dialogs fixture</h1><div id="root"></div><div id="native-panel" style="position:absolute;top:80px;bottom:0;left:0;right:0"></div><script src="/fixture.js"></script>'
2026const FORM_FIXTURE = `<!doctype html><title>form</title>
2127<input id="draft" aria-label="Draft">
28+ <button id="long-message" onclick="alert('Details '.repeat(150) + 'Final decision detail')">Long message</button>
2229<button id="delete" onclick="document.title = 'confirm:' + confirm('Delete the report?')">Delete</button>
2330<script>addEventListener('beforeunload', (event) => {
2431 if (document.getElementById('draft').value) { event.preventDefault(); event.returnValue = '' }
@@ -27,6 +34,7 @@ const FORM_FIXTURE = `<!doctype html><title>form</title>
2734type Bridge = typeof globalThis & {
2835 simDesktop : SimDesktopApi
2936 pageDialog ?: BrowserPageDialog | null
37+ pageIssue ?: string | null
3038 boundsTimer ?: number
3139}
3240
@@ -60,9 +68,64 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
6068 throw error
6169 }
6270 }
71+ const browserComponents = join (
72+ SIM_DIR ,
73+ 'app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session'
74+ )
75+ const bundle = await build ( {
76+ stdin : {
77+ contents : `import { createElement } from 'react';
78+ import { createRoot } from 'react-dom/client';
79+ import { useBrowserSessionStore as store } from ${ JSON . stringify ( join ( SIM_DIR , 'stores/browser-session/store.ts' ) ) } ;
80+ import { BrowserPageDialogModal } from ${ JSON . stringify ( join ( browserComponents , 'browser-page-dialog.tsx' ) ) } ;
81+ import { useBrowserPanelOcclusion } from ${ JSON . stringify ( join ( browserComponents , 'browser-panel-occlusion.ts' ) ) } ;
82+ const scope = ${ JSON . stringify ( SCOPE ) } ;
83+ const api = globalThis.simDesktop.browserAgent;
84+ api.onPageState(state => store.getState().setPageState(state));
85+ store.subscribe(state => {
86+ globalThis.pageDialog = state.sessions[scope]?.pageState?.dialog ?? null;
87+ globalThis.pageIssue = state.sessions[scope]?.pageState?.issue?.kind ?? null;
88+ });
89+ function Fixture() {
90+ const page = store(state => state.sessions[scope]?.pageState);
91+ useBrowserPanelOcclusion(scope, page?.tabId ?? null, true, () => document.getElementById('native-panel')?.getBoundingClientRect() ?? null);
92+ return createElement(BrowserPageDialogModal, {
93+ dialog: page?.dialog,
94+ open: Boolean(page?.dialog),
95+ onAnswer: (requestId, allowed) => api.panelAction({action:'respond-dialog',requestId,allowed},scope),
96+ });
97+ }
98+ createRoot(document.getElementById('root')).render(createElement(Fixture));` ,
99+ resolveDir : SIM_DIR ,
100+ loader : 'tsx' ,
101+ } ,
102+ bundle : true ,
103+ write : false ,
104+ outfile : test . info ( ) . outputPath ( 'fixture.js' ) ,
105+ external : [ 'node:async_hooks' ] ,
106+ banner : { js : 'var process={env:{NODE_ENV:"development"},browser:true};' } ,
107+ format : 'iife' ,
108+ platform : 'browser' ,
109+ tsconfig : join ( SIM_DIR , 'tsconfig.json' ) ,
110+ define : { 'process.env.NODE_ENV' : '"development"' } ,
111+ } )
112+ const config = await loadPostcssConfig ( { } , SIM_DIR )
113+ const stylesheet = join ( SIM_DIR , 'app/_styles/globals.css' )
114+ const css = await postcss ( config . plugins ) . process ( readFileSync ( stylesheet , 'utf8' ) , {
115+ from : stylesheet ,
116+ } )
63117 const calls = new Map < string , { chatId : string ; toolName : BrowserToolName ; args : unknown } > ( )
64118 const server : Server = createServer ( async ( request , response ) => {
65119 const path = new URL ( request . url ?? '/' , 'http://localhost' ) . pathname
120+ if ( path === '/fixture.js' || path === '/fixture.css' ) {
121+ response . setHeader ( 'Content-Type' , path . endsWith ( '.js' ) ? 'text/javascript' : 'text/css' )
122+ response . end (
123+ path . endsWith ( '.js' )
124+ ? bundle . outputFiles . find ( ( file ) => file . path . endsWith ( '.js' ) ) ?. text
125+ : css . css
126+ )
127+ return
128+ }
66129 if ( path === '/api/desktop/tool/authorize' ) {
67130 let body = ''
68131 for await ( const chunk of request ) body += chunk . toString ( )
@@ -71,6 +134,10 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
71134 response . end ( JSON . stringify ( authorization ?? { } ) )
72135 return
73136 }
137+ if ( path === '/broken' ) {
138+ response . destroy ( )
139+ return
140+ }
74141 // The app origin serves the shell; the same server on localhost is the web.
75142 const isSite = request . headers . host ?. startsWith ( 'localhost' ) === true
76143 response . writeHead ( 200 , {
@@ -102,10 +169,24 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
102169 app = shellApp
103170 // A dialog listener stops Playwright auto-dismissing page dialogs, so the desktop's own
104171 // handling decides their outcome exactly as it does in production.
105- const leaveDialogsToDesktop = ( page : Page ) => page . on ( 'dialog' , ( ) => { } )
172+ const nativeDialogs : Dialog [ ] = [ ]
173+ const leaveDialogsToDesktop = ( page : Page ) =>
174+ page . on ( 'dialog' , ( dialog ) => nativeDialogs . push ( dialog ) )
175+ const nextNativeDialog = async ( ) => {
176+ await expect . poll ( ( ) => nativeDialogs . length ) . toBeGreaterThan ( 0 )
177+ const dialog = nativeDialogs . shift ( )
178+ if ( ! dialog ) throw new Error ( 'Missing native dialog' )
179+ return dialog
180+ }
106181 shellApp . context ( ) . pages ( ) . forEach ( leaveDialogsToDesktop )
107182 shellApp . context ( ) . on ( 'page' , leaveDialogsToDesktop )
108183 const shell = await shellApp . firstWindow ( )
184+ await shellApp . evaluate ( ( { app, BrowserWindow } ) => {
185+ const host = BrowserWindow . getAllWindows ( ) [ 0 ]
186+ if ( ! host ) throw new Error ( 'Missing host window' )
187+ app . focus ( { steal : true } )
188+ host . focus ( )
189+ } )
109190 await expect ( shell . getByRole ( 'heading' ) ) . toHaveText ( 'Browser dialogs fixture' )
110191 await shell . evaluate ( async ( scope ) => {
111192 const bridge = globalThis as Bridge
@@ -120,9 +201,6 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
120201 updateBounds ( )
121202 bridge . boundsTimer = window . setInterval ( updateBounds , 200 )
122203 bridge . pageDialog = null
123- api . onPageState ( ( state ) => {
124- bridge . pageDialog = state . dialog ?? null
125- } )
126204 } , SCOPE )
127205
128206 let callCount = 0
@@ -139,6 +217,15 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
139217 }
140218 /** Browser-chrome actions are user gestures, so each follows a real click in Sim. */
141219 const panelAction = async ( action : Record < string , unknown > ) => {
220+ if ( action . action === 'respond-dialog' && ( await shell . getByRole ( 'dialog' ) . count ( ) ) ) {
221+ await shell
222+ . getByRole ( 'button' , {
223+ name : action . allowed ? 'Leave' : 'Stay' ,
224+ exact : true ,
225+ } )
226+ . click ( )
227+ return
228+ }
142229 await shell . getByRole ( 'heading' ) . click ( )
143230 await shell . evaluate (
144231 ( { action, scope } ) =>
@@ -209,16 +296,51 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
209296 await panelAction ( { action : 'enable-page-dialogs' } )
210297 await inPage ( "document.title = 'form'" )
211298
212- await check ( "the user's confirm waits for their answer" , async ( ) => {
299+ nativeDialogs . length = 0
300+ await check ( "the user's confirm waits for their answer without a duplicate modal" , async ( ) => {
213301 await userInput ( '#delete' )
214- await expect
215- . poll ( pageDialog )
216- . toMatchObject ( { kind : 'confirm' , message : ' Delete the report?' } )
302+ const dialog = await nextNativeDialog ( )
303+ expect ( dialog . type ( ) ) . toBe ( 'confirm' )
304+ expect ( dialog . message ( ) ) . toBe ( ' Delete the report?')
217305 expect ( await pageTitle ( ) ) . toBe ( 'form' )
218- const dialog = await pageDialog ( )
219- await panelAction ( { action : 'respond-dialog' , requestId : dialog ?. requestId , allowed : true } )
306+ expect ( await pageDialog ( ) ) . toBeNull ( )
307+ await expect ( shell . getByRole ( 'dialog' ) ) . toHaveCount ( 0 )
308+ await dialog . accept ( )
309+ await expect . poll ( ( ) => pageTitle ( ) ) . toBe ( 'confirm:true' )
310+ } )
311+
312+ await check ( 'a user sees the complete decision text beyond the diagnostic limit' , async ( ) => {
313+ await userInput ( '#long-message' )
314+ const dialog = await nextNativeDialog ( )
315+ expect ( dialog . message ( ) ) . toBe ( `${ 'Details ' . repeat ( 150 ) } Final decision detail` )
316+ await dialog . accept ( )
317+ } )
318+
319+ await check ( 'automation leaves a user-owned confirmation unanswered' , async ( ) => {
320+ await inPage ( "document.title = 'form'" )
321+ await userInput ( '#delete' )
322+ const dialog = await nextNativeDialog ( )
323+ const callId = `browser-dialogs-${ ++ callCount } `
324+ calls . set ( callId , { chatId : SCOPE , toolName : 'browser_snapshot' , args : { } } )
325+ const result = await shell . evaluate (
326+ ( { callId, scope } ) =>
327+ ( globalThis as Bridge ) . simDesktop . browserAgent . executeTool (
328+ callId ,
329+ 'browser_snapshot' ,
330+ { } ,
331+ scope
332+ ) ,
333+ { callId, scope : SCOPE }
334+ )
335+ expect ( result . ok ) . toBe ( false )
336+ await shell . evaluate (
337+ ( { callId, scope } ) =>
338+ ( globalThis as Bridge ) . simDesktop . browserAgent . cancelTool ?.( callId , scope ) ,
339+ { callId, scope : SCOPE }
340+ )
341+ expect ( await pageTitle ( ) ) . toBe ( 'form' )
342+ await dialog . accept ( )
220343 await expect . poll ( ( ) => pageTitle ( ) ) . toBe ( 'confirm:true' )
221- await expect . poll ( pageDialog ) . toBeNull ( )
222344 } )
223345
224346 await check ( "the agent's dialogs never wait on the user" , async ( ) => {
@@ -237,6 +359,11 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
237359 expect ( await pageDialog ( ) ) . toBeNull ( )
238360 } )
239361
362+ // CDP answers the JavaScript call but Electron owns its native sheet. Reload
363+ // ends the native dialogs before testing the renderer's leave-site modal.
364+ await panelAction ( { action : 'reload' } )
365+ await expect . poll ( ( ) => pageTitle ( ) ) . toBe ( 'form' )
366+
240367 await check ( 'leaving a draft from the URL bar asks, and Stay keeps it' , async ( ) => {
241368 await userInput ( '#draft' , 'draft' )
242369 await expect
@@ -260,6 +387,25 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
260387 expect ( await inPage < string > ( "document.getElementById('draft').value" ) ) . toBe ( 'draft' )
261388 } )
262389
390+ await check ( 'Escape keeps the draft and returns typing to the page' , async ( ) => {
391+ await userInput ( '#draft' )
392+ await panelAction ( { action : 'reload' } )
393+ await expect ( shell . getByRole ( 'button' , { name : 'Stay' , exact : true } ) ) . toBeFocused ( )
394+ await shell . keyboard . press ( 'Escape' )
395+ await expect . poll ( pageDialog ) . toBeNull ( )
396+ await expect
397+ . poll ( ( ) =>
398+ shellApp . evaluate ( ( { webContents } ) => webContents . getFocusedWebContents ( ) ?. getURL ( ) )
399+ )
400+ . toBe ( `${ site } /form` )
401+ await shellApp . evaluate ( ( { webContents } ) => {
402+ const contents = webContents . getFocusedWebContents ( )
403+ if ( ! contents ) throw new Error ( 'Missing focused page' )
404+ contents . sendInputEvent ( { type : 'char' , keyCode : 'x' } )
405+ } )
406+ expect ( await inPage < string > ( "document.getElementById('draft').value" ) ) . toBe ( 'xdraft' )
407+ } )
408+
263409 await check ( 'Leave lets the navigation through without asking again' , async ( ) => {
264410 await panelAction ( { action : 'navigate' , url : `${ site } /next` } )
265411 await expect . poll ( pageDialog ) . toMatchObject ( { kind : 'beforeunload' } )
@@ -268,6 +414,41 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
268414 await expect . poll ( pageUrl ) . toBe ( `${ site } /next` )
269415 expect ( await pageDialog ( ) ) . toBeNull ( )
270416 } )
417+ await check ( 'a crashed page drops its pending Leave decision' , async ( ) => {
418+ await panelAction ( { action : 'navigate' , url : `${ site } /form` } )
419+ await expect . poll ( pageUrl ) . toBe ( `${ site } /form` )
420+ await userInput ( '#draft' , 'draft' )
421+ await panelAction ( { action : 'reload' } )
422+ await expect . poll ( pageDialog ) . toMatchObject ( { kind : 'beforeunload' } )
423+ const stale = await pageDialog ( )
424+ await shellApp . evaluate ( ( { webContents } , url ) => {
425+ webContents
426+ . getAllWebContents ( )
427+ . find ( ( contents ) => contents . getURL ( ) === url )
428+ ?. forcefullyCrashRenderer ( )
429+ } , `${ site } /form` )
430+ await expect . poll ( pageDialog ) . toBeNull ( )
431+ await panelAction ( { action : 'respond-dialog' , requestId : stale ?. requestId , allowed : true } )
432+ await panelAction ( { action : 'reload' } )
433+ await expect . poll ( ( ) => pageTitle ( ) ) . toBe ( 'form' )
434+ await expect . poll ( pageDialog ) . toBeNull ( )
435+ } )
436+
437+ await check ( 'Back from a failed load does not capture a later page navigation' , async ( ) => {
438+ await userInput ( '#draft' , 'draft' )
439+ await panelAction ( { action : 'navigate' , url : `${ site } /broken` } )
440+ await expect . poll ( pageDialog ) . toMatchObject ( { kind : 'beforeunload' } )
441+ const dialog = await pageDialog ( )
442+ await panelAction ( { action : 'respond-dialog' , requestId : dialog ?. requestId , allowed : true } )
443+ await expect
444+ . poll ( ( ) => shell . evaluate ( ( ) => ( globalThis as Bridge ) . pageIssue ) )
445+ . toBe ( 'load-error' )
446+ await panelAction ( { action : 'back' } )
447+ await inPage ( `setTimeout(() => { location.href = ${ JSON . stringify ( `${ site } /next` ) } })` )
448+ await expect . poll ( pageUrl ) . toBe ( `${ site } /next` )
449+ expect ( await pageDialog ( ) ) . toBeNull ( )
450+ } )
451+
271452 /** Opens a page-initiated confirm (no user gesture) on the tab at `path`. */
272453 const confirmFromPage = ( path : string ) =>
273454 shellApp . evaluate ( ( { webContents } , url ) => {
@@ -306,6 +487,14 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
306487 )
307488
308489 await check ( 'a dialog while the browser is off screen stays automatic' , async ( ) => {
490+ const tabs = await shell . evaluate (
491+ async ( scope ) =>
492+ ( await ( globalThis as Bridge ) . simDesktop . browserAgent . activateScope ( scope ) ) . tabs ,
493+ SCOPE
494+ )
495+ const tabId = tabs . find ( ( tab ) => tab . url === `${ site } /agent` ) ?. tabId
496+ expect ( tabId ) . toBeTruthy ( )
497+ await panelAction ( { action : 'switch-tab' , tabId } )
309498 await shell . evaluate ( ( scope ) => {
310499 const bridge = globalThis as Bridge
311500 window . clearInterval ( bridge . boundsTimer )
0 commit comments