11import { mkdtempSync , writeFileSync } from 'node:fs'
22import { tmpdir } from 'node:os'
33import { join } from 'node:path'
4- import type { MenuItemConstructorOptions } from 'electron'
4+ import type { MenuItemConstructorOptions , WebContents } from 'electron'
55import { beforeEach , describe , expect , it , vi } from 'vitest'
66
77vi . mock ( 'electron' , ( ) => import ( '@/test/electron-mock' ) )
@@ -25,6 +25,7 @@ interface MockView {
2525 setWindowOpenHandler : ReturnType < typeof vi . fn >
2626 loadURL : ReturnType < typeof vi . fn >
2727 reload : ReturnType < typeof vi . fn >
28+ forcefullyCrashRenderer : ReturnType < typeof vi . fn >
2829 getURL : ReturnType < typeof vi . fn >
2930 getTitle : ReturnType < typeof vi . fn >
3031 close : ReturnType < typeof vi . fn >
@@ -40,6 +41,13 @@ interface MockView {
4041 capturePage : ReturnType < typeof vi . fn >
4142 findInPage : ReturnType < typeof vi . fn >
4243 stopFindInPage : ReturnType < typeof vi . fn >
44+ navigationHistory : {
45+ canGoBack : ReturnType < typeof vi . fn >
46+ canGoForward : ReturnType < typeof vi . fn >
47+ getActiveIndex : ReturnType < typeof vi . fn >
48+ goBack : ReturnType < typeof vi . fn >
49+ goForward : ReturnType < typeof vi . fn >
50+ }
4351 }
4452 setBackgroundColor : ReturnType < typeof vi . fn >
4553 setBounds : ReturnType < typeof vi . fn >
@@ -77,6 +85,7 @@ function freshSession(
7785 onSessionClosed : vi . fn ( ) ,
7886 onTabCreated : vi . fn ( ) ,
7987 onActiveTabChanged : vi . fn ( ) ,
88+ onPageStateChanged : vi . fn ( ) ,
8089 onTabsChanged : vi . fn ( ) ,
8190 onTabThemeChanged : vi . fn ( ) ,
8291 onTabNavigated : vi . fn ( ) ,
@@ -244,7 +253,12 @@ describe('browser-agent session', () => {
244253 ) ?. [ 1 ] as ( ( event : unknown , details : { reason : string } ) => void ) | undefined
245254 renderGone ?.( { } , { reason : 'crashed' } )
246255
247- expect ( session . withBrowserScope ( 'chat-a' , ( ) => session . listTabs ( ) ) ) . toEqual ( [ ] )
256+ expect ( session . withBrowserScope ( 'chat-a' , ( ) => session . listTabs ( ) ) ) . toEqual ( [
257+ expect . objectContaining ( {
258+ tabId : first . id ,
259+ issue : expect . objectContaining ( { kind : 'crashed' , reason : 'crashed' } ) ,
260+ } ) ,
261+ ] )
248262 expect ( session . withBrowserScope ( 'chat-b' , ( ) => session . listTabs ( ) ) ) . toHaveLength ( 1 )
249263 } )
250264
@@ -852,6 +866,126 @@ describe('browser-agent session', () => {
852866 expect ( win . webContents . send ) . toHaveBeenCalledWith ( 'browser-agent:close-find' , 'chat-test' )
853867 } )
854868
869+ it ( 'treats a failed navigation as a synthetic Back and Forward history entry' , async ( ) => {
870+ const mockContents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
871+ const contents = mockContents as unknown as WebContents
872+ mockContents . getURL . mockReturnValue ( 'https://example.com/committed' )
873+ mockContents . navigationHistory . getActiveIndex . mockReturnValue ( 3 )
874+ session . recordPageLoadFailure ( contents , {
875+ kind : 'load-error' ,
876+ code : - 102 ,
877+ description : 'ERR_CONNECTION_REFUSED' ,
878+ url : 'https://example.com/failed' ,
879+ } )
880+
881+ expect ( session . canGoBack ( contents ) ) . toBe ( true )
882+ expect ( session . listTabs ( ) [ 0 ] ) . toMatchObject ( {
883+ url : 'https://example.com/failed' ,
884+ issue : { kind : 'load-error' } ,
885+ } )
886+
887+ expect ( session . goBack ( contents ) ) . toBe ( true )
888+ expect ( session . listTabs ( ) [ 0 ] ) . toMatchObject ( { url : 'https://example.com/committed' } )
889+ expect ( session . listTabs ( ) [ 0 ] ) . not . toHaveProperty ( 'issue' )
890+ expect ( session . canGoForward ( contents ) ) . toBe ( true )
891+
892+ mockContents . navigationHistory . getActiveIndex . mockReturnValue ( 2 )
893+ mockContents . navigationHistory . canGoForward . mockReturnValue ( true )
894+ expect ( session . goForward ( contents ) ) . toBe ( true )
895+ expect ( mockContents . navigationHistory . goForward ) . toHaveBeenCalledTimes ( 1 )
896+ session . notePageLoadStarted ( contents )
897+
898+ mockContents . navigationHistory . getActiveIndex . mockReturnValue ( 3 )
899+ expect ( session . goForward ( contents ) ) . toBe ( true )
900+ expect ( mockContents . loadURL ) . toHaveBeenCalledWith ( 'https://example.com/failed' )
901+ } )
902+
903+ it ( 'discards a dismissed failed navigation when a fresh navigation starts' , ( ) => {
904+ const mockContents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
905+ const contents = mockContents as unknown as WebContents
906+ session . recordPageLoadFailure ( contents , {
907+ kind : 'load-error' ,
908+ code : - 105 ,
909+ description : 'ERR_NAME_NOT_RESOLVED' ,
910+ url : 'https://missing.invalid' ,
911+ } )
912+ session . goBack ( contents )
913+
914+ session . notePageLoadStarted ( contents )
915+
916+ expect ( session . canGoForward ( contents ) ) . toBe ( false )
917+ } )
918+
919+ it ( 'keeps recovery state scoped to its tab while the user switches tabs' , ( ) => {
920+ const first = session . ensureTab ( )
921+ const second = session . addTab ( )
922+ const firstContents = ( first . view as unknown as MockView ) . webContents as unknown as WebContents
923+ session . recordPageLoadFailure ( firstContents , {
924+ kind : 'load-error' ,
925+ code : - 105 ,
926+ description : 'ERR_NAME_NOT_RESOLVED' ,
927+ url : 'https://missing.invalid' ,
928+ } )
929+
930+ session . switchTab ( second . id )
931+ expect ( session . listTabs ( ) . find ( ( tab ) => tab . tabId === first . id ) ?. issue ) . toMatchObject ( {
932+ kind : 'load-error' ,
933+ } )
934+ expect ( session . listTabs ( ) . find ( ( tab ) => tab . tabId === second . id ) ) . not . toHaveProperty ( 'issue' )
935+
936+ session . switchTab ( first . id )
937+ expect ( session . requireTab ( ) . id ) . toBe ( first . id )
938+ expect ( session . pageIssueForContents ( firstContents ) ) . toMatchObject ( { kind : 'load-error' } )
939+ } )
940+
941+ it ( 'hands focus to an accessible recovery page for active-tab failures' , ( ) => {
942+ panel . setPanelBounds ( { x : 100 , y : 50 , width : 800 , height : 600 } )
943+ const onPageStateChanged = vi . fn ( )
944+ session = freshSession ( win , { onPageStateChanged } )
945+ panel . setPanelBounds ( { x : 100 , y : 50 , width : 800 , height : 600 } )
946+ const mockContents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
947+ const contents = mockContents as unknown as WebContents
948+
949+ session . recordPageLoadFailure ( contents , {
950+ kind : 'load-error' ,
951+ code : - 7 ,
952+ description : 'ERR_TIMED_OUT' ,
953+ url : 'https://slow.example.com' ,
954+ } )
955+
956+ expect ( win . webContents . focus ) . toHaveBeenCalled ( )
957+ expect ( onPageStateChanged ) . toHaveBeenCalledWith ( contents )
958+ } )
959+
960+ it ( 'recovers unresponsive tabs and clears the issue when Chromium responds again' , ( ) => {
961+ const mockContents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
962+ const contents = mockContents as unknown as WebContents
963+ mockContents . getURL . mockReturnValue ( 'https://example.com' )
964+ const unresponsive = mockContents . on . mock . calls . find (
965+ ( [ eventName ] ) => eventName === 'unresponsive'
966+ ) ?. [ 1 ] as ( ( ) => void ) | undefined
967+ const responsive = mockContents . on . mock . calls . find (
968+ ( [ eventName ] ) => eventName === 'responsive'
969+ ) ?. [ 1 ] as ( ( ) => void ) | undefined
970+ const gone = mockContents . on . mock . calls . find (
971+ ( [ eventName ] ) => eventName === 'render-process-gone'
972+ ) ?. [ 1 ] as ( ( event : unknown , details : { reason : string } ) => void ) | undefined
973+
974+ unresponsive ?.( )
975+ expect ( session . pageIssueForContents ( contents ) ) . toEqual ( {
976+ kind : 'unresponsive' ,
977+ url : 'https://example.com' ,
978+ } )
979+ responsive ?.( )
980+ expect ( session . pageIssueForContents ( contents ) ) . toBeUndefined ( )
981+
982+ unresponsive ?.( )
983+ session . reloadPage ( contents )
984+ expect ( mockContents . forcefullyCrashRenderer ) . toHaveBeenCalled ( )
985+ gone ?.( { } , { reason : 'killed' } )
986+ expect ( mockContents . reload ) . toHaveBeenCalled ( )
987+ } )
988+
855989 it ( 'drops the find when the user switches to another tab' , ( ) => {
856990 panel . setPanelBounds ( { x : 100 , y : 50 , width : 800 , height : 600 } )
857991 const first = session . requireTab ( )
@@ -1888,7 +2022,7 @@ describe('browser-agent session', () => {
18882022 )
18892023 } )
18902024
1891- it ( 'drops a tab whose renderer crashed instead of wedging the session ' , ( ) => {
2025+ it ( 'keeps a crashed tab recoverable without disturbing sibling tabs ' , ( ) => {
18922026 const first = session . ensureTab ( )
18932027 const second = session . addTab ( )
18942028 const crashed = ( second . view as unknown as MockView ) . webContents
@@ -1898,14 +2032,17 @@ describe('browser-agent session', () => {
18982032
18992033 onGone ( { } , { reason : 'crashed' } )
19002034
1901- // Left in place, activeTab() filters the dead view out while activeTabId
1902- // still names it, so requireTab() reports "no page is open" even though
1903- // another tab is right there.
1904- expect ( session . listTabs ( ) . map ( ( tab ) => tab . tabId ) ) . toEqual ( [ first . id ] )
1905- expect ( session . requireTab ( ) . id ) . toBe ( first . id )
2035+ expect ( session . listTabs ( ) ) . toEqual ( [
2036+ expect . objectContaining ( { tabId : first . id } ) ,
2037+ expect . objectContaining ( {
2038+ tabId : second . id ,
2039+ issue : expect . objectContaining ( { kind : 'crashed' , reason : 'crashed' } ) ,
2040+ } ) ,
2041+ ] )
2042+ expect ( session . requireTab ( ) . id ) . toBe ( second . id )
19062043 } )
19072044
1908- it ( 'reports the session closed when the only tab crashes ' , async ( ) => {
2045+ it ( 'keeps the only crashed tab open for recovery ' , async ( ) => {
19092046 const onSessionClosed = vi . fn ( )
19102047 session = freshSession ( win , { onSessionClosed } )
19112048 const contents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
@@ -1915,8 +2052,12 @@ describe('browser-agent session', () => {
19152052
19162053 onGone ( { } , { reason : 'oom' } )
19172054
1918- expect ( session . listTabs ( ) ) . toHaveLength ( 0 )
1919- expect ( onSessionClosed ) . toHaveBeenCalled ( )
2055+ expect ( session . listTabs ( ) ) . toEqual ( [
2056+ expect . objectContaining ( {
2057+ issue : expect . objectContaining ( { kind : 'crashed' , reason : 'oom' } ) ,
2058+ } ) ,
2059+ ] )
2060+ expect ( onSessionClosed ) . not . toHaveBeenCalled ( )
19202061 } )
19212062
19222063 it ( 'hides the panel when the renderer stops renewing its bounds lease' , async ( ) => {
0 commit comments