Skip to content

Commit 7ef6aa7

Browse files
committed
fix(desktop-browser): add recoverable page failure states
1 parent 4e1e291 commit 7ef6aa7

14 files changed

Lines changed: 865 additions & 104 deletions

File tree

apps/desktop/src/main/browser-agent/driver.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,51 @@ describe('executeTool', () => {
412412
)
413413
})
414414

415+
it('publishes main-frame load failures and retries their uncommitted URL', async () => {
416+
const onPageState = vi.fn()
417+
const win = new BrowserWindow()
418+
driver.initDriver(
419+
{
420+
onPageState,
421+
onTabsState: vi.fn(),
422+
onSessionStatus: vi.fn(),
423+
onFillAvailability: vi.fn(),
424+
},
425+
() => win
426+
)
427+
driver.activateBrowserScope('chat-test')
428+
await driver.executeTool('chat-test', 'browser_open_tab', {})
429+
const contents = session.requireTab().view.webContents
430+
const eventHandlers = (contents.on as unknown as ReturnType<typeof vi.fn>).mock.calls
431+
const failLoad = eventHandlers.find(([eventName]) => eventName === 'did-fail-load')?.[1] as
432+
| ((...args: unknown[]) => void)
433+
| undefined
434+
const failedUrl = 'http://localhost:3004/login'
435+
436+
onPageState.mockClear()
437+
failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, false)
438+
failLoad?.({}, -3, 'ERR_ABORTED', failedUrl, true)
439+
expect(onPageState).not.toHaveBeenCalled()
440+
441+
failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, true)
442+
443+
expect(onPageState).toHaveBeenLastCalledWith(
444+
expect.objectContaining({
445+
url: failedUrl,
446+
issue: {
447+
kind: 'load-error',
448+
code: -102,
449+
description: 'ERR_CONNECTION_REFUSED',
450+
url: failedUrl,
451+
},
452+
})
453+
)
454+
455+
vi.mocked(contents.loadURL).mockClear()
456+
await driver.handlePanelAction('chat-test', { action: 'reload' })
457+
expect(contents.loadURL).toHaveBeenCalledWith(failedUrl)
458+
})
459+
415460
it('forces fill availability to replay on scope activation and tab switches', async () => {
416461
const refreshAvailability = vi
417462
.spyOn(fillCoordinator()!, 'refreshAvailability')

apps/desktop/src/main/browser-agent/driver.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,16 @@ function recordNotice(notice: string): void {
264264
* navigations and tab switches.
265265
*/
266266
function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
267+
const issue = session.pageIssueForContents(contents)
267268
return {
268269
scopeId: session.getBrowserScopeId(),
269270
tabId,
270-
url: contents.getURL(),
271-
title: contents.getTitle(),
272-
loading: contents.isLoadingMainFrame(),
273-
canGoBack: contents.navigationHistory.canGoBack(),
274-
canGoForward: contents.navigationHistory.canGoForward(),
271+
url: issue?.url ?? contents.getURL(),
272+
title: issue?.kind === 'load-error' ? '' : contents.getTitle(),
273+
loading: issue ? false : contents.isLoadingMainFrame(),
274+
canGoBack: session.canGoBack(contents),
275+
canGoForward: session.canGoForward(contents),
276+
...(issue ? { issue } : {}),
275277
}
276278
}
277279

@@ -346,6 +348,18 @@ function instrumentTab(contents: WebContents): void {
346348
pushTabsState()
347349
})
348350
)
351+
contents.on(
352+
'did-fail-load',
353+
inScope((_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
354+
if (!isMainFrame || errorCode === 0 || errorCode === -3) return
355+
session.recordPageLoadFailure(contents, {
356+
kind: 'load-error',
357+
code: errorCode,
358+
description: errorDescription,
359+
url: validatedURL || contents.getURL(),
360+
})
361+
})
362+
)
349363
contents.on(
350364
'did-frame-navigate',
351365
inScope(
@@ -364,7 +378,6 @@ function instrumentTab(contents: WebContents): void {
364378
for (const event of [
365379
'did-navigate-in-page',
366380
'page-title-updated',
367-
'did-start-loading',
368381
'did-finish-load',
369382
'did-stop-loading',
370383
] as const) {
@@ -376,6 +389,14 @@ function instrumentTab(contents: WebContents): void {
376389
})
377390
)
378391
}
392+
contents.on(
393+
'did-start-loading',
394+
inScope(() => {
395+
session.notePageLoadStarted(contents)
396+
pushPageState(contents)
397+
pushTabsState()
398+
})
399+
)
379400
driverCallbacks?.onSessionStatus(true, scopeId)
380401
}
381402

@@ -435,6 +456,7 @@ export function initDriver(
435456
// The fill affordance belongs to whichever page is in front.
436457
void fillCoordinator()?.refreshAvailability(true)
437458
},
459+
onPageStateChanged: pushPageState,
438460
onTabsChanged: pushTabsState,
439461
onTabThemeChanged: (contents, theme) => {
440462
void cdp.setColorScheme(contents, theme).catch((error) => {
@@ -3814,13 +3836,13 @@ export async function handlePanelAction(
38143836
const contents = tab.view.webContents
38153837
switch (action.action) {
38163838
case 'reload':
3817-
contents.reload()
3839+
session.reloadPage(contents)
38183840
return
38193841
case 'back':
3820-
if (contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack()
3842+
session.goBack(contents)
38213843
return
38223844
case 'forward':
3823-
if (contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward()
3845+
session.goForward(contents)
38243846
return
38253847
case 'print':
38263848
contents.print({ printBackground: true })

apps/desktop/src/main/browser-agent/session.test.ts

Lines changed: 152 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { mkdtempSync, writeFileSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
4-
import type { MenuItemConstructorOptions } from 'electron'
4+
import type { MenuItemConstructorOptions, WebContents } from 'electron'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
vi.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

Comments
 (0)