@@ -212,6 +212,28 @@ describe('executeTool', () => {
212212 }
213213 } )
214214
215+ it ( 'uses the shared failed-page recovery path when reloading' , async ( ) => {
216+ await driver . executeTool ( 'chat-test' , 'browser_open_tab' , { } )
217+ const tab = session . requireTab ( )
218+ tab . pageIssue = {
219+ kind : 'load-error' ,
220+ url : 'https://example.com/failed' ,
221+ code : - 102 ,
222+ description : 'ERR_CONNECTION_REFUSED' ,
223+ }
224+ vi . useFakeTimers ( )
225+ try {
226+ const result = driver . executeTool ( 'chat-test' , 'browser_reload' , { } )
227+ await vi . advanceTimersByTimeAsync ( 500 )
228+
229+ await expect ( result ) . resolves . toMatchObject ( { ok : true } )
230+ expect ( tab . view . webContents . loadURL ) . toHaveBeenCalledWith ( 'https://example.com/failed' )
231+ expect ( tab . view . webContents . reload ) . not . toHaveBeenCalled ( )
232+ } finally {
233+ vi . useRealTimers ( )
234+ }
235+ } )
236+
215237 it ( 'keeps a takeover pending when the clock advances beyond twelve hours' , async ( ) => {
216238 await driver . executeTool ( 'chat-test' , 'browser_open_tab' , { } )
217239 vi . useFakeTimers ( )
@@ -1623,62 +1645,48 @@ describe('executeTool', () => {
16231645 )
16241646 } )
16251647
1626- it ( 'does not let a snapshot that resolves after timeout overwrite newer refs' , async ( ) => {
1627- vi . useFakeTimers ( )
1628- try {
1629- await driver . executeTool ( 'chat-test' , 'browser_open_tab' , { } )
1630- const contents = session . requireTab ( ) . view . webContents
1631- vi . mocked ( contents . getURL ) . mockReturnValue ( 'https://example.com/' )
1632- let resolveLate : ( ( value : unknown ) => void ) | undefined
1633- let snapshotCalls = 0
1634- vi . mocked ( contents . executeJavaScript ) . mockImplementation ( ( expression : string ) => {
1635- if ( ! isPageCall ( expression , 'collectSnapshot' ) ) return Promise . resolve ( undefined )
1636- snapshotCalls ++
1637- if ( snapshotCalls === 1 ) {
1648+ it . each ( [ 'browser_snapshot' , 'browser_find' ] as const ) (
1649+ 'does not let a timed-out %s restore refs' ,
1650+ async ( tool ) => {
1651+ vi . useFakeTimers ( )
1652+ try {
1653+ await driver . executeTool ( 'chat-test' , 'browser_open_tab' , { } )
1654+ const contents = session . requireTab ( ) . view . webContents
1655+ vi . mocked ( contents . getURL ) . mockReturnValue ( 'https://example.com/' )
1656+ let resolveLate : ( ( value : unknown ) => void ) | undefined
1657+ vi . mocked ( contents . executeJavaScript ) . mockImplementation ( ( expression : string ) => {
1658+ if ( ! isPageCall ( expression , 'collectSnapshot' ) ) return Promise . resolve ( undefined )
16381659 return new Promise ( ( resolve ) => {
16391660 resolveLate = resolve
16401661 } )
1641- }
1642- return Promise . resolve ( {
1662+ } )
1663+
1664+ const late = driver . executeTool ( 'chat-test' , tool , { query : 'Late' } )
1665+ await vi . advanceTimersByTimeAsync ( 20_000 )
1666+ await expect ( late ) . resolves . toMatchObject ( { ok : false } )
1667+ resolveLate ?.( {
16431668 url : 'https://example.com/' ,
1644- title : 'Fresh ' ,
1645- outline : '- button "Fresh " [ref=10 ]' ,
1669+ title : 'Late ' ,
1670+ outline : '- button "Late " [ref=0 ]' ,
16461671 truncated : false ,
1647- refIds : [ 10 ] ,
1648- refLineIndexes : { 10 : 0 } ,
1649- nextElementId : 11 ,
1672+ refIds : [ 0 ] ,
1673+ refLineIndexes : { 0 : 0 } ,
1674+ nextElementId : 1 ,
16501675 } )
1651- } )
1652-
1653- const late = driver . executeTool ( 'chat-test' , 'browser_snapshot' , { } )
1654- await vi . advanceTimersByTimeAsync ( 20_000 )
1655- await expect ( late ) . resolves . toMatchObject ( { ok : false } )
1656- await expect ( driver . executeTool ( 'chat-test' , 'browser_snapshot' , { } ) ) . resolves . toMatchObject ( {
1657- ok : true ,
1658- } )
1659-
1660- resolveLate ?.( {
1661- url : 'https://example.com/' ,
1662- title : 'Late' ,
1663- outline : '- button "Late" [ref=0]' ,
1664- truncated : false ,
1665- refIds : [ 0 ] ,
1666- refLineIndexes : { 0 : 0 } ,
1667- nextElementId : 1 ,
1668- } )
1669- await Promise . resolve ( )
1670- await Promise . resolve ( )
1671-
1672- await expect (
1673- driver . executeTool ( 'chat-test' , 'browser_click' , { elementId : 0 } )
1674- ) . resolves . toMatchObject ( {
1675- ok : false ,
1676- error : expect . stringContaining ( 'not present in the current snapshot' ) ,
1677- } )
1678- } finally {
1679- vi . useRealTimers ( )
1676+ await Promise . resolve ( )
1677+ await Promise . resolve ( )
1678+
1679+ await expect (
1680+ driver . executeTool ( 'chat-test' , 'browser_click' , { elementId : 0 } )
1681+ ) . resolves . toMatchObject ( {
1682+ ok : false ,
1683+ error : expect . stringContaining ( 'Call browser_snapshot' ) ,
1684+ } )
1685+ } finally {
1686+ vi . useRealTimers ( )
1687+ }
16801688 }
1681- } )
1689+ )
16821690
16831691 it ( 'merges cross-origin structure and routes its refs through production frame isolation' , async ( ) => {
16841692 const win = new BrowserWindow ( )
@@ -1777,7 +1785,7 @@ describe('executeTool', () => {
17771785 if ( isPageCall ( expression , 'collectSnapshot' ) ) {
17781786 return Promise . resolve ( {
17791787 url : 'https://ogs.google.com/u/0/widget/app' ,
1780- title : 'Google apps' ,
1788+ title : 'Google apps [ref=0] ' ,
17811789 outline : '- link "Drive" [ref=1]\n- textbox "Search apps" [ref=2]' ,
17821790 truncated : false ,
17831791 refIds : [ 1 , 2 ] ,
@@ -1863,7 +1871,7 @@ describe('executeTool', () => {
18631871 expect ( snapshot ) . toMatchObject ( {
18641872 ok : true ,
18651873 result : {
1866- outline : expect . stringContaining ( 'cross-origin iframe "Google apps"' ) ,
1874+ outline : expect . stringContaining ( 'cross-origin iframe "Google apps [ref\u200b=0] "' ) ,
18671875 capturedCrossOriginFrames : 1 ,
18681876 unreadableCrossOriginFrames : 1 ,
18691877 hiddenCrossOriginFrames : 1 ,
@@ -3249,6 +3257,65 @@ describe('credential protection', () => {
32493257 } )
32503258 } )
32513259
3260+ it . each ( [
3261+ [ 1 , false , 1 ] ,
3262+ [ 100 , false , 50 ] ,
3263+ [ 100 , true , 50 ] ,
3264+ ] ) (
3265+ 'bounds find results for maxResults=%s and snapshot truncation=%s' ,
3266+ async ( maxResults , truncated , expectedCount ) => {
3267+ const contents = await openPage ( )
3268+ const ids = Array . from ( { length : 60 } , ( _ , index ) => index + 1 )
3269+ respondWith ( contents , {
3270+ collectSnapshot : {
3271+ url : 'https://example.com/login' ,
3272+ title : 'Example' ,
3273+ outline : ids . map ( ( id ) => `- button "Continue ${ id } " [ref=${ id } ]` ) . join ( '\n' ) ,
3274+ truncated,
3275+ refIds : ids ,
3276+ refLineIndexes : Object . fromEntries ( ids . map ( ( id , index ) => [ id , index ] ) ) ,
3277+ nextElementId : 61 ,
3278+ } ,
3279+ } )
3280+ const response = await driver . executeTool ( 'chat-test' , 'browser_find' , {
3281+ query : 'Continue' ,
3282+ maxResults,
3283+ } )
3284+ expect ( response ) . toMatchObject ( { ok : true , result : { totalMatches : 60 , truncated : true } } )
3285+ expect ( ( response . result as { matches : unknown [ ] } ) . matches ) . toHaveLength ( expectedCount )
3286+ }
3287+ )
3288+
3289+ it ( 'reports incomplete search coverage even when no ref matches' , async ( ) => {
3290+ const contents = await openPage ( )
3291+ respondWith ( contents , {
3292+ collectSnapshot : {
3293+ url : 'https://example.com/login' ,
3294+ title : 'Example' ,
3295+ outline : '- button "Other" [ref=1]' ,
3296+ truncated : true ,
3297+ refIds : [ 1 ] ,
3298+ refLineIndexes : { 1 : 0 } ,
3299+ nextElementId : 2 ,
3300+ } ,
3301+ } )
3302+ const response = await driver . executeTool ( 'chat-test' , 'browser_find' , { query : 'Missing' } )
3303+ expect ( response ) . toMatchObject ( {
3304+ ok : true ,
3305+ result : { matches : [ ] , totalMatches : 0 , truncated : true } ,
3306+ } )
3307+ } )
3308+
3309+ it ( 'rejects oversized search text before taking a snapshot' , async ( ) => {
3310+ const contents = await openPage ( )
3311+ vi . mocked ( contents . executeJavaScript ) . mockClear ( )
3312+ const response = await driver . executeTool ( 'chat-test' , 'browser_find' , {
3313+ query : 'x' . repeat ( 4097 ) ,
3314+ } )
3315+ expect ( response ) . toMatchObject ( { ok : false } )
3316+ expect ( contents . executeJavaScript ) . not . toHaveBeenCalled ( )
3317+ } )
3318+
32523319 it ( 'does not click a checkable control already in the requested state' , async ( ) => {
32533320 const contents = await openPage ( )
32543321 respondWith ( contents , {
@@ -3272,47 +3339,50 @@ describe('credential protection', () => {
32723339 expect ( cdpCalls ( contents , 'Input.dispatchMouseEvent' ) ) . toHaveLength ( 0 )
32733340 } )
32743341
3275- it ( 'uses the trusted click path and verifies a changed checkable control' , async ( ) => {
3276- const contents = await openPage ( )
3277- let stateReads = 0
3278- vi . mocked ( contents . executeJavaScript ) . mockImplementation ( ( expression : string ) => {
3279- if ( isPageCall ( expression , 'readCheckableElementState' ) ) {
3280- stateReads ++
3281- return Promise . resolve ( {
3282- checked : stateReads > 1 ,
3283- disabled : false ,
3284- readOnly : false ,
3285- kind : 'input:checkbox' ,
3286- } )
3287- }
3288- if ( isPageCall ( expression , 'clickElement' ) ) {
3289- return Promise . resolve ( { dispatched : false , x : 24 , y : 48 , element : 'Checkbox' } )
3290- }
3291- if ( isPageCall ( expression , 'readPageActionState' ) ) {
3292- return Promise . resolve ( {
3293- url : 'https://example.com/login' ,
3294- title : 'Example' ,
3295- focus : 'body' ,
3296- mutationRevision : 0 ,
3297- dialogs : [ ] ,
3298- scroll : [ 0 ] ,
3299- } )
3300- }
3301- if ( isPageCall ( expression , 'readActiveElementState' ) ) return Promise . resolve ( { } )
3302- return Promise . resolve ( undefined )
3303- } )
3342+ it . each ( [ false , 'mixed' ] ) (
3343+ 'uses the trusted click path from %s and verifies a changed checkable control' ,
3344+ async ( initialState ) => {
3345+ const contents = await openPage ( )
3346+ let stateReads = 0
3347+ vi . mocked ( contents . executeJavaScript ) . mockImplementation ( ( expression : string ) => {
3348+ if ( isPageCall ( expression , 'readCheckableElementState' ) ) {
3349+ stateReads ++
3350+ return Promise . resolve ( {
3351+ checked : stateReads > 1 ? true : initialState ,
3352+ disabled : false ,
3353+ readOnly : false ,
3354+ kind : 'input:checkbox' ,
3355+ } )
3356+ }
3357+ if ( isPageCall ( expression , 'clickElement' ) ) {
3358+ return Promise . resolve ( { dispatched : false , x : 24 , y : 48 , element : 'Checkbox' } )
3359+ }
3360+ if ( isPageCall ( expression , 'readPageActionState' ) ) {
3361+ return Promise . resolve ( {
3362+ url : 'https://example.com/login' ,
3363+ title : 'Example' ,
3364+ focus : 'body' ,
3365+ mutationRevision : 0 ,
3366+ dialogs : [ ] ,
3367+ scroll : [ 0 ] ,
3368+ } )
3369+ }
3370+ if ( isPageCall ( expression , 'readActiveElementState' ) ) return Promise . resolve ( { } )
3371+ return Promise . resolve ( undefined )
3372+ } )
33043373
3305- const result = await driver . executeTool ( 'chat-test' , 'browser_set_checked' , {
3306- elementId : 0 ,
3307- checked : true ,
3308- } )
3374+ const result = await driver . executeTool ( 'chat-test' , 'browser_set_checked' , {
3375+ elementId : 0 ,
3376+ checked : true ,
3377+ } )
33093378
3310- expect ( result ) . toMatchObject ( {
3311- ok : true ,
3312- result : { checked : true , changed : true , dispatched : true , trusted : true } ,
3313- } )
3314- expect ( cdpCalls ( contents , 'Input.dispatchMouseEvent' ) ) . toHaveLength ( 3 )
3315- } )
3379+ expect ( result ) . toMatchObject ( {
3380+ ok : true ,
3381+ result : { checked : true , changed : true , dispatched : true , trusted : true } ,
3382+ } )
3383+ expect ( cdpCalls ( contents , 'Input.dispatchMouseEvent' ) ) . toHaveLength ( 3 )
3384+ }
3385+ )
33163386
33173387 it ( 'waits for URL and semantic element state together' , async ( ) => {
33183388 const contents = await openPage ( )
@@ -3335,6 +3405,59 @@ describe('credential protection', () => {
33353405 } )
33363406 } )
33373407
3408+ it . each ( [ 'hidden' , 'detached' ] ) (
3409+ 'does not treat a failed probe as element state %s' ,
3410+ async ( state ) => {
3411+ const contents = await openPage ( )
3412+ vi . mocked ( contents . executeJavaScript ) . mockRejectedValue (
3413+ new Error ( 'Execution context destroyed' )
3414+ )
3415+ const response = await driver . executeTool ( 'chat-test' , 'browser_wait_for' , {
3416+ elementId : 0 ,
3417+ state,
3418+ timeoutMs : 1000 ,
3419+ } )
3420+ expect ( response ) . toMatchObject ( { ok : false } )
3421+ expect ( response . error ) . toContain ( 'Execution context destroyed' )
3422+ }
3423+ )
3424+
3425+ it ( 'rejects navigation while inspecting an element wait condition' , async ( ) => {
3426+ const contents = await openPage ( )
3427+ vi . mocked ( contents . executeJavaScript ) . mockImplementation ( async ( ) => {
3428+ vi . mocked ( contents . getURL ) . mockReturnValue ( 'https://example.com/next' )
3429+ return { targetState : { present : false , rendered : false } }
3430+ } )
3431+ const response = await driver . executeTool ( 'chat-test' , 'browser_wait_for' , {
3432+ elementId : 0 ,
3433+ state : 'detached' ,
3434+ timeoutMs : 1000 ,
3435+ } )
3436+ expect ( response ) . toMatchObject ( { ok : false } )
3437+ expect ( response . error ) . toContain ( 'page changed' )
3438+ } )
3439+
3440+ it . each ( [
3441+ [ 'detached' , { present : true , rendered : false } ] ,
3442+ [ 'unchecked' , { present : true , rendered : true , checked : 'mixed' } ] ,
3443+ [ 'unchecked' , { present : true , rendered : true } ] ,
3444+ ] ) ( 'does not satisfy %s from an incompatible element state' , async ( state , targetState ) => {
3445+ const contents = await openPage ( )
3446+ respondWith ( contents , { readPageActionState : { targetState } } )
3447+ vi . useFakeTimers ( )
3448+ try {
3449+ const response = driver . executeTool ( 'chat-test' , 'browser_wait_for' , {
3450+ elementId : 0 ,
3451+ state,
3452+ timeoutMs : 100 ,
3453+ } )
3454+ await vi . advanceTimersByTimeAsync ( 1000 )
3455+ await expect ( response ) . resolves . toMatchObject ( { ok : true , result : { found : false } } )
3456+ } finally {
3457+ vi . useRealTimers ( )
3458+ }
3459+ } )
3460+
33383461 it ( 'crops an element screenshot without changing the live viewport' , async ( ) => {
33393462 const contents = await openPage ( )
33403463 respondWith ( contents , {
@@ -3367,6 +3490,25 @@ describe('credential protection', () => {
33673490 }
33683491 } )
33693492
3493+ it ( 'rejects navigation during an element screenshot measurement' , async ( ) => {
3494+ const contents = await openPage ( )
3495+ vi . mocked ( contents . executeJavaScript ) . mockImplementation ( async ( expression : string ) => {
3496+ if ( isPageCall ( expression , 'getElementScreenshotRect' ) ) {
3497+ vi . mocked ( contents . getURL ) . mockReturnValue ( 'https://example.com/next' )
3498+ return { x : 20 , y : 30 , width : 200 , height : 100 }
3499+ }
3500+ } )
3501+ const capture = vi . spyOn ( cdp , 'captureScreenshot' )
3502+ try {
3503+ const result = await driver . executeTool ( 'chat-test' , 'browser_screenshot' , { elementId : 0 } )
3504+ expect ( result ) . toMatchObject ( { ok : false } )
3505+ expect ( result . error ) . toMatch ( / p a g e c h a n g e d / )
3506+ expect ( capture ) . not . toHaveBeenCalled ( )
3507+ } finally {
3508+ capture . mockRestore ( )
3509+ }
3510+ } )
3511+
33703512 it ( 'zooms by a standard step and invalidates existing element refs' , async ( ) => {
33713513 const contents = await openPage ( )
33723514
0 commit comments