@@ -18,7 +18,15 @@ const SCOPE = 'browser-tools-e2e'
1818const FORM = `<!doctype html><html><head><title>Form fixture</title></head><body>
1919 <label>Name <input id="name" autocomplete="off"></label>
2020 <label>Plan <select id="plan" aria-label="Plan"><option value="basic">Basic</option><option value="pro">Pro</option></select></label>
21+ <label>Regions <select id="regions" aria-label="Regions" multiple><option value="a">A</option><option value="b">B</option><option value="c" disabled>C</option></select></label>
2122 <label>Updates <input id="updates" type="checkbox"></label>
23+ <label>Date <input id="date" type="date" oninput="this.dataset.events = Number(this.dataset.events || 0) + 1"></label>
24+ <label>Time <input id="time" type="time"></label>
25+ <label>Appointment <input id="appointment" type="datetime-local"></label>
26+ <label>Month <input id="month" type="month"></label>
27+ <label>Week <input id="week" type="week"></label>
28+ <label>Color <input id="color" type="color"></label>
29+ <label>Range <input id="range" type="range"></label>
2230 <label>Password <input id="password" type="password"></label>
2331 <label>Route <input id="route" oninput="history.pushState({}, '', '/form?changed=1')"></label>
2432 <a href="/redirect">Other website</a>
@@ -27,6 +35,23 @@ const FORM = `<!doctype html><html><head><title>Form fixture</title></head><body
2735 </div>
2836</body></html>`
2937
38+ const CLICK_FIXTURE = `<!doctype html><title>Click fixture</title>
39+ <style>body{margin:0;height:2400px}button{position:absolute;left:100px;top:calc(100vh - 100px);width:200px;height:40px}</style>
40+ <button id="target" role="option" onclick="document.body.dataset.clicks = Number(document.body.dataset.clicks) + 1">Choose option</button>
41+ <script>
42+ document.body.dataset.clicks = '0'; document.body.dataset.scrolls = '0';
43+ const mode = new URLSearchParams(location.search).get('mode');
44+ if (mode === 'sticky') {
45+ document.getElementById('target').style.top = '1010px';
46+ document.body.insertAdjacentHTML('beforeend', '<div style="position:fixed;inset:0 0 auto;height:80px;background:white;z-index:2">Sticky header</div>');
47+ scrollTo(0,1000);
48+ }
49+ addEventListener('scroll', () => {
50+ document.body.dataset.scrolls = Number(document.body.dataset.scrolls) + 1;
51+ if (mode === 'menu' && document.body.dataset.armed === 'true') document.getElementById('target')?.remove();
52+ });
53+ </script>`
54+
3055test . describe ( 'browser tools' , ( ) => {
3156 const calls = new Map <
3257 string ,
@@ -56,9 +81,11 @@ test.describe('browser tools', () => {
5681 }
5782 response . writeHead ( 200 , { 'Content-Type' : 'text/html' } )
5883 response . end (
59- path === '/form'
60- ? FORM
61- : '<!doctype html><title>Sim fixture</title><h1>Browser tools fixture</h1>'
84+ path === '/click'
85+ ? CLICK_FIXTURE
86+ : path === '/form'
87+ ? FORM
88+ : '<!doctype html><title>Sim fixture</title><h1>Browser tools fixture</h1>'
6289 )
6390 } )
6491 await new Promise < void > ( ( resolve ) => server . listen ( 0 , '127.0.0.1' , resolve ) )
@@ -78,15 +105,21 @@ test.describe('browser tools', () => {
78105 } ,
79106 } )
80107 window = await app . firstWindow ( )
108+ await app . evaluate ( ( { BrowserWindow } ) =>
109+ BrowserWindow . getAllWindows ( ) [ 0 ] . webContents . setBackgroundThrottling ( false )
110+ )
81111 await expect ( window . getByRole ( 'heading' ) ) . toHaveText ( 'Browser tools fixture' )
82112 await window . evaluate ( async ( scope ) => {
83113 const api = ( globalThis as typeof globalThis & { simDesktop : SimDesktopApi } ) . simDesktop
84114 await api . browserAgent . activateScope ( scope )
85- api . browserAgent . setPanelBounds (
86- { x : 0 , y : 80 , width : innerWidth , height : innerHeight - 80 } ,
87- null ,
88- scope
89- )
115+ const updateBounds = ( ) =>
116+ api . browserAgent . setPanelBounds (
117+ { x : 0 , y : 80 , width : innerWidth , height : innerHeight - 80 } ,
118+ null ,
119+ scope
120+ )
121+ updateBounds ( )
122+ setInterval ( updateBounds , 200 )
90123 } , SCOPE )
91124 } )
92125
@@ -119,7 +152,9 @@ test.describe('browser tools', () => {
119152 const result = response . result as { snapshot : { outline : string } }
120153 expect ( result . snapshot . outline ) . toContain ( 'Name' )
121154 return ( name : string ) => {
122- const line = result . snapshot . outline . split ( '\n' ) . find ( ( line ) => line . includes ( `"${ name } "` ) )
155+ const line = result . snapshot . outline
156+ . split ( '\n' )
157+ . find ( ( line ) => line . includes ( `"${ name } "` ) && / \[ r e f = \d + \] / . test ( line ) )
123158 const match = line ?. match ( / \[ r e f = ( \d + ) \] / )
124159 if ( ! match ) throw new Error ( `No reference for ${ name } : ${ result . snapshot . outline } ` )
125160 return Number ( match [ 1 ] )
@@ -143,6 +178,229 @@ test.describe('browser tools', () => {
143178 } , origin )
144179 }
145180
181+ test ( 'sets and clears multiple selections without partial writes for invalid options' , async ( ) => {
182+ const ref = await openForm ( )
183+ const selected = await execute ( 'browser_select_option' , {
184+ elementId : ref ( 'Regions' ) ,
185+ values : [ 'A' , 'B' ] ,
186+ } )
187+ expect ( selected . ok , selected . error ) . toBe ( true )
188+ expect ( selected . result ) . toMatchObject ( {
189+ values : [ 'a' , 'b' ] ,
190+ effectObserved : true ,
191+ readback : { values : [ 'a' , 'b' ] } ,
192+ } )
193+ const invalid = await execute ( 'browser_select_option' , {
194+ elementId : ref ( 'Regions' ) ,
195+ values : [ 'B' , 'C' ] ,
196+ } )
197+ expect ( invalid . ok ) . toBe ( false )
198+ const values = await app . evaluate ( async ( { webContents } , origin ) => {
199+ const contents = webContents
200+ . getAllWebContents ( )
201+ . find ( ( wc ) => wc . getURL ( ) === `${ origin } /form` )
202+ if ( ! contents ) throw new Error ( 'Missing form fixture' )
203+ return contents . executeJavaScript (
204+ 'Array.from(document.getElementById("regions").selectedOptions, option => option.value)'
205+ )
206+ } , origin )
207+ expect ( values ) . toEqual ( [ 'a' , 'b' ] )
208+ const cleared = await execute ( 'browser_select_option' , {
209+ elementId : ref ( 'Regions' ) ,
210+ values : [ ] ,
211+ } )
212+ expect ( cleared . result ) . toMatchObject ( {
213+ values : [ ] ,
214+ effectObserved : true ,
215+ readback : { values : [ ] } ,
216+ } )
217+ } )
218+
219+ test ( 'fills structured native fields and leaves invalid dates unchanged' , async ( ) => {
220+ const ref = await openForm ( )
221+ for ( const [ name , text ] of [
222+ [ 'Date' , '2026-09-15' ] ,
223+ [ 'Time' , '15:48' ] ,
224+ [ 'Appointment' , '2026-09-15T15:48:00' ] ,
225+ [ 'Month' , '2026-09' ] ,
226+ [ 'Week' , '2026-W38' ] ,
227+ [ 'Color' , '#AABBCC' ] ,
228+ [ 'Range' , '75' ] ,
229+ ] ) {
230+ const response = await execute ( 'browser_type' , { elementId : ref ( name ) , text } )
231+ expect ( response . ok , response . error ) . toBe ( true )
232+ expect ( response . result ) . toMatchObject ( {
233+ trusted : false ,
234+ dispatched : true ,
235+ effectObserved : true ,
236+ } )
237+ }
238+ const invalid = await execute ( 'browser_type' , { elementId : ref ( 'Date' ) , text : '2026-02-30' } )
239+ expect ( invalid . ok ) . toBe ( false )
240+ expect ( invalid . error ) . toContain ( 'Invalid value' )
241+ const state = await app . evaluate ( async ( { webContents } , origin ) => {
242+ const contents = webContents
243+ . getAllWebContents ( )
244+ . find ( ( wc ) => wc . getURL ( ) === `${ origin } /form` )
245+ if ( ! contents ) throw new Error ( 'Missing form fixture' )
246+ return contents . executeJavaScript (
247+ '({date:document.getElementById("date").value,time:document.getElementById("time").value,appointment:document.getElementById("appointment").value,month:document.getElementById("month").value,week:document.getElementById("week").value,color:document.getElementById("color").value,range:document.getElementById("range").value,events:document.getElementById("date").dataset.events})'
248+ )
249+ } , origin )
250+ expect ( state ) . toEqual ( {
251+ date : '2026-09-15' ,
252+ time : '15:48' ,
253+ appointment : '2026-09-15T15:48' ,
254+ month : '2026-09' ,
255+ week : '2026-W38' ,
256+ color : '#aabbcc' ,
257+ range : '75' ,
258+ events : '1' ,
259+ } )
260+ } )
261+
262+ for ( const mode of [ 'menu' , 'sticky' ] ) {
263+ test ( `clicks a ${ mode } target without losing its identity` , async ( ) => {
264+ const opened = await execute ( 'browser_open_url' , { url : `${ origin } /click?mode=${ mode } ` } )
265+ expect ( opened . ok , opened . error ) . toBe ( true )
266+ await app . evaluate (
267+ async ( { webContents } , { origin, mode } ) => {
268+ const contents = webContents
269+ . getAllWebContents ( )
270+ . find ( ( wc ) => wc . getURL ( ) . startsWith ( `${ origin } /click` ) )
271+ if ( ! contents ) throw new Error ( 'Missing click fixture' )
272+ await contents . executeJavaScript ( `
273+ history.scrollRestoration = 'manual';
274+ document.getElementById('target').style.top = ${ mode === 'sticky' ? '1010' : 'innerHeight - 100' } + 'px';
275+ scrollTo(0, ${ mode === 'sticky' ? '1000' : '0' } );
276+ new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
277+ document.body.dataset.scrolls = '0'; document.body.dataset.armed = 'true'; resolve();
278+ })))
279+ ` )
280+ } ,
281+ { origin, mode }
282+ )
283+ const snapshot = await execute ( 'browser_snapshot' , { } )
284+ expect ( snapshot . ok , snapshot . error ) . toBe ( true )
285+ const outline = ( snapshot . result as { outline : string } ) . outline
286+ const line = outline . split ( '\n' ) . find ( ( line ) => line . includes ( '"Choose option"' ) )
287+ const match = line ?. match ( / \[ r e f = ( \d + ) \] / )
288+ if ( ! match ) throw new Error ( `Missing target: ${ outline } ` )
289+ const result = await execute ( 'browser_click' , { elementId : Number ( match [ 1 ] ) } )
290+ expect ( result . ok , result . error ) . toBe ( true )
291+ const state = await app . evaluate ( async ( { webContents } , origin ) => {
292+ const contents = webContents
293+ . getAllWebContents ( )
294+ . find ( ( wc ) => wc . getURL ( ) . startsWith ( `${ origin } /click` ) )
295+ if ( ! contents ) throw new Error ( 'Missing click fixture' )
296+ return contents . executeJavaScript (
297+ '({clicks:document.body.dataset.clicks,scrolls:document.body.dataset.scrolls,scrollY})'
298+ )
299+ } , origin )
300+ expect ( state . clicks ) . toBe ( '1' )
301+ if ( mode === 'menu' ) expect ( state ) . toMatchObject ( { scrolls : '0' , scrollY : 0 } )
302+ else expect ( state . scrollY ) . toBeLessThan ( 1000 )
303+ } )
304+ }
305+
306+ for ( const mode of [ 'visible' , 'hidden' , 'minimized' ] ) {
307+ test ( `captures a ${ mode } window without changing its state` , async ( ) => {
308+ test . skip (
309+ mode === 'minimized' && process . platform !== 'darwin' ,
310+ 'Requires a window manager with minimize events'
311+ )
312+ await openForm ( )
313+ await app . evaluate ( async ( { BrowserWindow } , mode ) => {
314+ const win = BrowserWindow . getAllWindows ( ) [ 0 ]
315+ win . blur ( )
316+ if ( mode === 'hidden' ) win . hide ( )
317+ if ( mode === 'minimized' ) {
318+ const minimized = new Promise < void > ( ( resolve ) => win . once ( 'minimize' , ( ) => resolve ( ) ) )
319+ win . minimize ( )
320+ await minimized
321+ }
322+ } , mode )
323+ const state = ( ) =>
324+ app . evaluate ( async ( { BrowserWindow, webContents } , origin ) => {
325+ const win = BrowserWindow . getAllWindows ( ) [ 0 ]
326+ const contents = webContents
327+ . getAllWebContents ( )
328+ . find ( ( wc ) => wc . getURL ( ) === `${ origin } /form` )
329+ if ( ! contents ) throw new Error ( 'Missing screenshot fixture' )
330+ return {
331+ visible : win . isVisible ( ) ,
332+ minimized : win . isMinimized ( ) ,
333+ bounds : win . getBounds ( ) ,
334+ focused : BrowserWindow . getFocusedWindow ( ) ?. id ?? null ,
335+ page : await contents . executeJavaScript (
336+ '({width:innerWidth,height:innerHeight,scrollX,scrollY,html:document.body.innerHTML,focus:document.activeElement?.id})'
337+ ) ,
338+ }
339+ } , origin )
340+ const before = await state ( )
341+ for ( let i = 0 ; i < 3 ; i ++ ) {
342+ const response = await execute ( 'browser_screenshot' , { } )
343+ expect ( response . ok , response . error ) . toBe ( true )
344+ const shot = response . result as {
345+ dataUrl : string
346+ scale : number
347+ viewport : { width : number ; height : number }
348+ }
349+ expect ( shot . dataUrl . length ) . toBeGreaterThan ( 1000 )
350+ expect ( shot . viewport . width ) . toBeGreaterThan ( 0 )
351+ expect ( shot . viewport . height ) . toBeGreaterThan ( 0 )
352+ const image = await app . evaluate ( ( { nativeImage } , dataUrl ) => {
353+ const image = nativeImage . createFromDataURL ( dataUrl )
354+ return { empty : image . isEmpty ( ) , ...image . getSize ( ) }
355+ } , shot . dataUrl )
356+ expect ( image ) . toEqual ( {
357+ empty : false ,
358+ width : Math . round ( shot . viewport . width * shot . scale ) ,
359+ height : Math . round ( shot . viewport . height * shot . scale ) ,
360+ } )
361+ expect ( await state ( ) ) . toEqual ( before )
362+ }
363+ } )
364+ }
365+
366+ test ( 'captures fresh pixels after resizing and repainting the viewport' , async ( ) => {
367+ await openForm ( )
368+ const viewportWidth = ( ) =>
369+ app . evaluate ( async ( { webContents } , origin ) => {
370+ const contents = webContents
371+ . getAllWebContents ( )
372+ . find ( ( wc ) => wc . getURL ( ) === `${ origin } /form` )
373+ return contents ?. executeJavaScript ( 'innerWidth' )
374+ } , origin )
375+ const beforeWidth = await viewportWidth ( )
376+ await app . evaluate ( ( { BrowserWindow } ) => BrowserWindow . getAllWindows ( ) [ 0 ] . setSize ( 1280 , 900 ) )
377+ await expect . poll ( viewportWidth ) . not . toBe ( beforeWidth )
378+ for ( const color of [ 'red' , 'blue' ] ) {
379+ await app . evaluate (
380+ async ( { webContents } , { origin, color } ) => {
381+ const contents = webContents
382+ . getAllWebContents ( )
383+ . find ( ( wc ) => wc . getURL ( ) === `${ origin } /form` )
384+ if ( ! contents ) throw new Error ( 'Missing screenshot fixture' )
385+ await contents . executeJavaScript (
386+ `document.body.style.background = ${ JSON . stringify ( color ) } ; void 0`
387+ )
388+ } ,
389+ { origin, color }
390+ )
391+ const response = await execute ( 'browser_screenshot' , { } )
392+ expect ( response . ok , response . error ) . toBe ( true )
393+ const shot = response . result as { dataUrl : string }
394+ const pixel = await app . evaluate ( ( { nativeImage } , dataUrl ) => {
395+ const image = nativeImage . createFromDataURL ( dataUrl )
396+ return Array . from ( image . toBitmap ( ) . subarray ( 0 , 4 ) )
397+ } , shot . dataUrl )
398+ const dominant = pixel [ color === 'red' ? 2 : 0 ]
399+ const other = pixel [ color === 'red' ? 0 : 2 ]
400+ expect ( dominant - other , `${ color } : ${ pixel } ` ) . toBeGreaterThan ( 150 )
401+ }
402+ } )
403+
146404 test ( 'opens with references, fills in order, and scrolls a horizontal pane' , async ( ) => {
147405 const ref = await openForm ( )
148406 const fill = await execute ( 'browser_fill_form' , {
0 commit comments