@@ -62,6 +62,34 @@ const CAPABILITIES_FIXTURE = `<!doctype html><title>Capabilities fixture</title>
6262const POPUP_FIXTURE = `<!doctype html><title>Authorize fixture</title>
6363<button onclick="window.opener.postMessage('granted', '*'); window.close()">Allow</button>`
6464
65+ const UPLOAD_TARGET_FIXTURE = `<!doctype html><title>Upload target fixture</title>
66+ <div id="surface"></div>
67+ <script>
68+ const params = new URLSearchParams(location.search);
69+ const surface = document.getElementById('surface');
70+ const root = params.get('shadow') === '1' ? surface.attachShadow({ mode: 'open' }) : surface;
71+ root.innerHTML = '<div role="button" aria-label="Upload original">Attach file<input id="original" type="file" hidden></div><input id="decoy" type="file" hidden>';
72+ const original = root.querySelector('#original');
73+ const decoy = root.querySelector('#decoy');
74+ window.uploadTestState = async () => ({
75+ original: await Promise.all(Array.from(original.files, async (file) => ({ name: file.name, text: await file.text() }))),
76+ decoy: await Promise.all(Array.from(decoy.files, async (file) => ({ name: file.name, text: await file.text() }))),
77+ currentCount: root.querySelector('#original').files.length,
78+ });
79+ window.mutateUploadTarget = (mutation) => {
80+ if (mutation === 'replace') original.replaceWith(original.cloneNode(true));
81+ if (mutation === 'disable') original.disabled = true;
82+ };
83+ if (params.get('steal') === '1') {
84+ new MutationObserver(() => {
85+ const marker = original.getAttribute('data-sim-agent-upload');
86+ if (!marker) return;
87+ original.removeAttribute('data-sim-agent-upload');
88+ decoy.setAttribute('data-sim-agent-upload', marker);
89+ }).observe(root, { subtree: true, attributes: true, attributeFilter: ['data-sim-agent-upload'] });
90+ }
91+ </script>`
92+
6593test . describe ( 'browser tools' , ( ) => {
6694 const calls = new Map <
6795 string ,
@@ -76,6 +104,7 @@ test.describe('browser tools', () => {
76104 let app : ElectronApplication
77105 let window : Page
78106 let callCount = 0
107+ let beforeUploadResponse : ( ( ) => Promise < void > ) | undefined
79108
80109 test . beforeAll ( async ( ) => {
81110 server = createServer ( async ( request , response ) => {
@@ -129,13 +158,33 @@ test.describe('browser tools', () => {
129158 const { toolCallId, index } = JSON . parse ( body )
130159 const reference = claimed . get ( toolCallId ) ?. args . paths
131160 const found = Array . isArray ( reference ) && reference [ index ] === 'files/receipt.txt'
161+ const beforeResponse = beforeUploadResponse
162+ beforeUploadResponse = undefined
163+ try {
164+ await beforeResponse ?.( )
165+ } catch ( error ) {
166+ response . writeHead ( 500 , { 'Content-Type' : 'text/plain' } )
167+ response . end ( String ( error ) )
168+ return
169+ }
132170 response . writeHead ( found ? 200 : 404 , {
133171 'Content-Type' : found ? 'application/octet-stream' : 'application/json' ,
134172 ...( found ? { 'Content-Disposition' : 'attachment; filename="receipt.txt"' } : { } ) ,
135173 } )
136174 response . end ( found ? 'receipt-bytes' : JSON . stringify ( { error : 'File not found' } ) )
137175 return
138176 }
177+ if ( path === '/upload-target' || path === '/upload-host' ) {
178+ const params = new URL ( request . url ?? '/' , 'http://127.0.0.1' ) . searchParams
179+ const frameOrigin = params . get ( 'kind' ) === 'oopif' ? origin : site
180+ response . writeHead ( 200 , { 'Content-Type' : 'text/html' } )
181+ response . end (
182+ path === '/upload-target'
183+ ? UPLOAD_TARGET_FIXTURE
184+ : `<!doctype html><title>Upload frame fixture</title><iframe src="${ frameOrigin } /upload-target" title="Upload frame"></iframe>`
185+ )
186+ return
187+ }
139188 if ( path === '/doc.pdf' ) {
140189 response . writeHead ( 200 , { 'Content-Type' : 'application/pdf' } )
141190 response . end (
@@ -226,6 +275,7 @@ test.describe('browser tools', () => {
226275 } )
227276
228277 test . afterEach ( async ( ) => {
278+ beforeUploadResponse = undefined
229279 await app ?. close ( )
230280 calls . clear ( )
231281 claimed . clear ( )
@@ -330,6 +380,136 @@ test.describe('browser tools', () => {
330380 await expect . poll ( dataset ) . toMatchObject ( { upload : 'receipt.txt:receipt-bytes' } )
331381 } )
332382
383+ async function openUploadTarget (
384+ kind : 'root' | 'same-origin' | 'oopif' | 'shadow' ,
385+ steal = false
386+ ) {
387+ const framed = kind === 'same-origin' || kind === 'oopif'
388+ const url = framed
389+ ? `${ site } /upload-host?kind=${ kind } `
390+ : `${ site } /upload-target?shadow=${ kind === 'shadow' ? '1' : '0' } &steal=${ steal ? '1' : '0' } `
391+ const response = await execute ( 'browser_open_url' , { url } )
392+ expect ( response . ok , response . error ) . toBe ( true )
393+ const evaluate = ( expression : string , inTopFrame = false ) =>
394+ app . evaluate (
395+ ( { webContents } , { url, framed, expression } ) => {
396+ const contents = webContents
397+ . getAllWebContents ( )
398+ . find ( ( contents ) => contents . getURL ( ) === url )
399+ const frame = framed ? contents ?. mainFrame . frames [ 0 ] : contents ?. mainFrame
400+ if ( ! frame ) throw new Error ( 'Missing upload fixture frame' )
401+ return frame . executeJavaScript ( expression )
402+ } ,
403+ { url, framed : framed && ! inTopFrame , expression }
404+ )
405+ await expect . poll ( ( ) => evaluate ( 'typeof window.uploadTestState' ) ) . toBe ( 'function' )
406+ if ( kind === 'oopif' ) {
407+ expect (
408+ await app . evaluate ( ( { webContents } , url ) => {
409+ const contents = webContents
410+ . getAllWebContents ( )
411+ . find ( ( contents ) => contents . getURL ( ) === url )
412+ const child = contents ?. mainFrame . frames [ 0 ]
413+ return child && child . processId !== contents ?. mainFrame . processId
414+ } , url )
415+ ) . toBe ( true )
416+ }
417+ const snapshot = await execute ( 'browser_snapshot' , { } )
418+ expect ( snapshot . ok , snapshot . error ) . toBe ( true )
419+ const outline = ( snapshot . result as { outline : string } ) . outline
420+ const match = outline
421+ . split ( '\n' )
422+ . find ( ( line ) => line . includes ( '"Upload original"' ) )
423+ ?. match ( / \[ r e f = ( \d + ) \] / )
424+ expect ( match , outline ) . toBeTruthy ( )
425+ return { elementId : Number ( match ?. [ 1 ] ) , evaluate }
426+ }
427+
428+ test ( 'pins uploads to the original input when page code steals a DOM marker' , async ( ) => {
429+ const { elementId, evaluate } = await openUploadTarget ( 'root' , true )
430+ const upload = await execute ( 'browser_upload_file' , { elementId, paths : [ 'files/receipt.txt' ] } )
431+
432+ expect ( upload . ok , upload . error ) . toBe ( true )
433+ expect ( await evaluate ( 'window.uploadTestState()' ) ) . toEqual ( {
434+ original : [ { name : 'receipt.txt' , text : 'receipt-bytes' } ] ,
435+ decoy : [ ] ,
436+ currentCount : 1 ,
437+ } )
438+ } )
439+
440+ for ( const mutation of [ 'replace' , 'disable' , 'navigate-frame' ] as const ) {
441+ test ( `refuses uploads when the target changes during staging: ${ mutation } ` , async ( ) => {
442+ const { elementId, evaluate } = await openUploadTarget (
443+ mutation === 'navigate-frame' ? 'same-origin' : 'root'
444+ )
445+ let mutated = false
446+ beforeUploadResponse = async ( ) => {
447+ if ( mutation === 'navigate-frame' ) {
448+ await evaluate ( 'parent.previousUploadInput = document.querySelector("#original")' )
449+ await evaluate ( 'location.replace("/upload-target?after=1")' )
450+ await expect . poll ( ( ) => evaluate ( 'location.search' ) ) . toBe ( '?after=1' )
451+ await expect . poll ( ( ) => evaluate ( 'typeof window.uploadTestState' ) ) . toBe ( 'function' )
452+ } else {
453+ await evaluate ( `window.mutateUploadTarget(${ JSON . stringify ( mutation ) } )` )
454+ }
455+ mutated = true
456+ }
457+ const upload = await execute ( 'browser_upload_file' , {
458+ elementId,
459+ paths : [ 'files/receipt.txt' ] ,
460+ } )
461+
462+ expect ( mutated ) . toBe ( true )
463+ expect ( upload . ok , JSON . stringify ( upload ) ) . toBe ( false )
464+ expect ( await evaluate ( 'window.uploadTestState()' ) ) . toEqual ( {
465+ original : [ ] ,
466+ decoy : [ ] ,
467+ currentCount : 0 ,
468+ } )
469+ if ( mutation === 'navigate-frame' ) {
470+ expect ( await evaluate ( 'parent.previousUploadInput.files.length' ) ) . toBe ( 0 )
471+ }
472+ } )
473+ }
474+
475+ test ( 'refuses uploads after their same-origin frame is removed during staging' , async ( ) => {
476+ const { elementId, evaluate } = await openUploadTarget ( 'same-origin' )
477+ let removed = false
478+ beforeUploadResponse = async ( ) => {
479+ await evaluate (
480+ 'window.removedUploadInput = document.querySelector("iframe").contentDocument.querySelector("#original"); document.querySelector("iframe").remove()' ,
481+ true
482+ )
483+ removed = true
484+ }
485+ const upload = await execute ( 'browser_upload_file' , {
486+ elementId,
487+ paths : [ 'files/receipt.txt' ] ,
488+ } )
489+
490+ expect ( removed ) . toBe ( true )
491+ expect ( upload . ok , JSON . stringify ( upload ) ) . toBe ( false )
492+ expect ( await evaluate ( 'window.removedUploadInput.files.length' , true ) ) . toBe ( 0 )
493+ } )
494+
495+ for ( const kind of [ 'same-origin' , 'oopif' , 'shadow' ] as const ) {
496+ test ( `uploads through a pinned input in a ${ kind } context` , async ( ) => {
497+ const { elementId, evaluate } = await openUploadTarget ( kind )
498+ const upload = await execute ( 'browser_upload_file' , {
499+ elementId,
500+ paths : [ 'files/receipt.txt' ] ,
501+ } )
502+
503+ expect ( upload . ok , upload . error ) . toBe ( true )
504+ expect ( upload . result ) . toMatchObject ( { uploaded : [ { name : 'receipt.txt' , size : 13 } ] } )
505+ expect ( await evaluate ( 'window.uploadTestState()' ) ) . toEqual ( {
506+ original : [ { name : 'receipt.txt' , text : 'receipt-bytes' } ] ,
507+ decoy : [ ] ,
508+ currentCount : 1 ,
509+ } )
510+ } )
511+ }
512+
333513 test ( 'keeps window.opener for page popups and returns to the opener when they close' , async ( ) => {
334514 const { ref, dataset } = await openCapabilities ( )
335515
0 commit comments