@@ -52,13 +52,27 @@ const CLICK_FIXTURE = `<!doctype html><title>Click fixture</title>
5252 });
5353</script>`
5454
55+ const CAPABILITIES_FIXTURE = `<!doctype html><title>Capabilities fixture</title>
56+ <button id="delete" onclick="document.body.dataset.deleted = confirm('Delete the report?')">Delete report</button>
57+ <div id="row" oncontextmenu="event.preventDefault(); document.body.dataset.menu = event.button + ':' + event.shiftKey">Report row</div>
58+ <div id="zone" role="button" aria-label="Attach receipt">Drop a receipt<input id="receipt" type="file" hidden onchange="this.files[0].text().then(text => document.body.dataset.upload = this.files[0].name + ':' + text)"></div>
59+ <button onclick="window.open(new URLSearchParams(location.search).get('popup'), 'auth', 'width=400,height=400')">Connect</button>
60+ <script>addEventListener('message', (event) => { document.body.dataset.connected = event.data })</script>`
61+
62+ const POPUP_FIXTURE = `<!doctype html><title>Authorize fixture</title>
63+ <button onclick="window.opener.postMessage('granted', '*'); window.close()">Allow</button>`
64+
5565test . describe ( 'browser tools' , ( ) => {
5666 const calls = new Map <
5767 string ,
5868 { chatId : string ; toolName : BrowserToolName | 'terminal' ; args : Record < string , unknown > }
5969 > ( )
70+ const claimed = new Map < string , { args : Record < string , unknown > } > ( )
6071 let server : Server
72+ let popupServer : Server
6173 let origin : string
74+ let site : string
75+ let popupOrigin : string
6276 let app : ElectronApplication
6377 let window : Page
6478 let callCount = 0
@@ -109,11 +123,32 @@ test.describe('browser tools', () => {
109123 )
110124 return
111125 }
126+ if ( path === '/api/desktop/tool/file' ) {
127+ let body = ''
128+ for await ( const chunk of request ) body += chunk . toString ( )
129+ const { toolCallId, index } = JSON . parse ( body )
130+ const reference = claimed . get ( toolCallId ) ?. args . paths
131+ const found = Array . isArray ( reference ) && reference [ index ] === 'files/receipt.txt'
132+ response . writeHead ( found ? 200 : 404 , {
133+ 'Content-Type' : found ? 'application/octet-stream' : 'application/json' ,
134+ ...( found ? { 'Content-Disposition' : 'attachment; filename="receipt.txt"' } : { } ) ,
135+ } )
136+ response . end ( found ? 'receipt-bytes' : JSON . stringify ( { error : 'File not found' } ) )
137+ return
138+ }
139+ if ( path === '/doc.pdf' ) {
140+ response . writeHead ( 200 , { 'Content-Type' : 'application/pdf' } )
141+ response . end (
142+ '%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n'
143+ )
144+ return
145+ }
112146 if ( path === '/api/desktop/tool/authorize' ) {
113147 let body = ''
114148 for await ( const chunk of request ) body += chunk . toString ( )
115149 const authorization = calls . get ( JSON . parse ( body ) . toolCallId )
116150 calls . delete ( JSON . parse ( body ) . toolCallId )
151+ if ( authorization ) claimed . set ( JSON . parse ( body ) . toolCallId , authorization )
117152 response . writeHead ( authorization ? 200 : 403 , { 'Content-Type' : 'application/json' } )
118153 response . end ( JSON . stringify ( authorization ?? { } ) )
119154 return
@@ -127,15 +162,27 @@ test.describe('browser tools', () => {
127162 response . end (
128163 path === '/click'
129164 ? CLICK_FIXTURE
130- : path === '/form'
131- ? FORM
132- : '<!doctype html><title>Sim fixture</title><h1>Browser tools fixture</h1>'
165+ : path === '/capabilities'
166+ ? CAPABILITIES_FIXTURE
167+ : path === '/form'
168+ ? FORM
169+ : '<!doctype html><title>Sim fixture</title><h1>Browser tools fixture</h1>'
133170 )
134171 } )
135172 await new Promise < void > ( ( resolve ) => server . listen ( 0 , '127.0.0.1' , resolve ) )
136173 const address = server . address ( )
137174 if ( ! address || typeof address === 'string' ) throw new Error ( 'Missing fixture address' )
138175 origin = `http://127.0.0.1:${ address . port } `
176+ /** Pages outside the app origin browse in the agent partition, like any third-party site. */
177+ site = origin . replace ( '127.0.0.1' , 'localhost' )
178+ popupServer = createServer ( ( _request , response ) => {
179+ response . writeHead ( 200 , { 'Content-Type' : 'text/html' } )
180+ response . end ( POPUP_FIXTURE )
181+ } )
182+ await new Promise < void > ( ( resolve ) => popupServer . listen ( 0 , '127.0.0.1' , resolve ) )
183+ const popupAddress = popupServer . address ( )
184+ if ( ! popupAddress || typeof popupAddress === 'string' ) throw new Error ( 'Missing popup address' )
185+ popupOrigin = `http://localhost:${ popupAddress . port } `
139186 } )
140187
141188 test . beforeEach ( async ( ) => {
@@ -148,6 +195,11 @@ test.describe('browser tools', () => {
148195 SIM_DESKTOP_USER_DATA : mkdtempSync ( join ( tmpdir ( ) , 'sim-browser-tools-e2e-' ) ) ,
149196 } ,
150197 } )
198+ // A dialog listener stops Playwright auto-dismissing page dialogs, so the desktop's own CDP
199+ // dialog handling decides their outcome exactly as it does in production.
200+ const leaveDialogsToDesktop = ( page : Page ) => page . on ( 'dialog' , ( ) => { } )
201+ app . context ( ) . pages ( ) . forEach ( leaveDialogsToDesktop )
202+ app . context ( ) . on ( 'page' , leaveDialogsToDesktop )
151203 window = await app . firstWindow ( )
152204 await app . evaluate ( ( { app, BrowserWindow } ) => {
153205 const host = BrowserWindow . getAllWindows ( ) [ 0 ]
@@ -176,12 +228,15 @@ test.describe('browser tools', () => {
176228 test . afterEach ( async ( ) => {
177229 await app ?. close ( )
178230 calls . clear ( )
231+ claimed . clear ( )
179232 } )
180233
181234 test . afterAll ( async ( ) => {
182- await new Promise < void > ( ( resolve , reject ) =>
183- server . close ( ( error ) => ( error ? reject ( error ) : resolve ( ) ) )
184- )
235+ for ( const listener of [ server , popupServer ] ) {
236+ await new Promise < void > ( ( resolve , reject ) =>
237+ listener . close ( ( error ) => ( error ? reject ( error ) : resolve ( ) ) )
238+ )
239+ }
185240 } )
186241
187242 async function execute ( tool : BrowserToolName , args : Record < string , unknown > ) {
@@ -211,6 +266,109 @@ test.describe('browser tools', () => {
211266 }
212267 }
213268
269+ async function openCapabilities ( ) {
270+ const url = `${ site } /capabilities?popup=${ encodeURIComponent ( `${ popupOrigin } /authorize` ) } `
271+ const response = await execute ( 'browser_open_url' , { url } )
272+ expect ( response . ok , response . error ) . toBe ( true )
273+ const outline = ( response . result as { snapshot : { outline : string } } ) . snapshot . outline
274+ const ref = ( name : string ) => {
275+ const match = outline
276+ . split ( '\n' )
277+ . find ( ( line ) => line . includes ( `"${ name } "` ) && / \[ r e f = \d + \] / . test ( line ) )
278+ ?. match ( / \[ r e f = ( \d + ) \] / )
279+ if ( ! match ) throw new Error ( `No reference for ${ name } : ${ outline } ` )
280+ return Number ( match [ 1 ] )
281+ }
282+ const dataset = ( ) =>
283+ app . evaluate (
284+ ( { webContents } , url ) =>
285+ webContents
286+ . getAllWebContents ( )
287+ . find ( ( contents ) => contents . getURL ( ) === url )
288+ ?. executeJavaScript ( '({ ...document.body.dataset })' ) ,
289+ url
290+ )
291+ return { ref, dataset }
292+ }
293+
294+ test ( 'answers confirm dialogs only when the action asks and right-clicks reach the page' , async ( ) => {
295+ const { ref, dataset } = await openCapabilities ( )
296+
297+ const dismissed = await execute ( 'browser_click' , { elementId : ref ( 'Delete report' ) } )
298+ expect ( JSON . stringify ( dismissed . result ) ) . toContain ( 'which was dismissed' )
299+ expect ( await dataset ( ) ) . toMatchObject ( { deleted : 'false' } )
300+
301+ const accepted = await execute ( 'browser_click' , {
302+ elementId : ref ( 'Delete report' ) ,
303+ dialog : { accept : true } ,
304+ } )
305+ expect ( JSON . stringify ( accepted . result ) ) . toContain ( 'accepted as requested' )
306+ expect ( await dataset ( ) ) . toMatchObject ( { deleted : 'true' } )
307+
308+ const menu = await execute ( 'browser_click' , {
309+ elementId : ref ( 'Report row' ) ,
310+ button : 'right' ,
311+ modifiers : [ 'Shift' ] ,
312+ } )
313+ expect ( menu . ok , menu . error ) . toBe ( true )
314+ expect ( await dataset ( ) ) . toMatchObject ( { menu : '2:true' } )
315+ } )
316+
317+ test ( 'uploads a workspace file into the hidden input behind a drop zone' , async ( ) => {
318+ const { ref, dataset } = await openCapabilities ( )
319+
320+ const upload = await execute ( 'browser_upload_file' , {
321+ elementId : ref ( 'Attach receipt' ) ,
322+ paths : [ 'files/receipt.txt' ] ,
323+ } )
324+
325+ expect ( upload . ok , upload . error ) . toBe ( true )
326+ expect ( upload . result ) . toMatchObject ( {
327+ uploaded : [ { name : 'receipt.txt' , size : 13 } ] ,
328+ effectObserved : true ,
329+ } )
330+ await expect . poll ( dataset ) . toMatchObject ( { upload : 'receipt.txt:receipt-bytes' } )
331+ } )
332+
333+ test ( 'keeps window.opener for page popups and returns to the opener when they close' , async ( ) => {
334+ const { ref, dataset } = await openCapabilities ( )
335+
336+ expect ( ( await execute ( 'browser_click' , { elementId : ref ( 'Connect' ) } ) ) . ok ) . toBe ( true )
337+ const popup = await execute ( 'browser_snapshot' , { } )
338+ const allow = ( popup . result as { outline : string } ) . outline . match (
339+ / b u t t o n " A l l o w " \[ r e f = ( \d + ) \] /
340+ ) ?. [ 1 ]
341+ expect ( allow , JSON . stringify ( popup . result ) ) . toBeTruthy ( )
342+ expect ( ( await execute ( 'browser_click' , { elementId : Number ( allow ) } ) ) . ok ) . toBe ( true )
343+
344+ await expect . poll ( dataset ) . toMatchObject ( { connected : 'granted' } )
345+ await expect
346+ . poll (
347+ async ( ) => ( ( await execute ( 'browser_list_tabs' , { } ) ) . result as { tabs : unknown [ ] } ) . tabs
348+ )
349+ . toHaveLength ( 1 )
350+ } )
351+
352+ test ( 'renders PDFs in the built-in viewer' , async ( ) => {
353+ const response = await execute ( 'browser_open_url' , { url : `${ site } /doc.pdf` } )
354+ expect ( response . ok , response . error ) . toBe ( true )
355+
356+ await expect
357+ . poll ( ( ) =>
358+ app . evaluate (
359+ ( { webContents } , url ) =>
360+ webContents
361+ . getAllWebContents ( )
362+ . find ( ( contents ) => contents . getURL ( ) === url )
363+ ?. mainFrame . framesInSubtree . some ( ( frame ) =>
364+ frame . url . startsWith ( 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/' )
365+ ) ,
366+ `${ site } /doc.pdf`
367+ )
368+ )
369+ . toBe ( true )
370+ } )
371+
214372 test ( 'shares desktop authentication for private HTML previews and deployed chats' , async ( ) => {
215373 for ( const path of [ '/private-preview' , '/private-chat' ] ) {
216374 const result = await execute ( 'browser_open_url' , { url : `${ origin } ${ path } ` } )
0 commit comments