diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index 61fa36d79..eab3e74b6 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -72,7 +72,12 @@ const SEARCH_PAGE_TREE: AXNode[] = [ // ── Mock WebContents factory ── -function createMockGuest(id: number, url: string, title: string) { +function createMockGuest( + id: number, + url: string, + title: string, + options?: { readyState?: string | (() => string) } +) { let currentUrl = url let currentTitle = title let currentTree = EXAMPLE_COM_TREE @@ -111,7 +116,14 @@ function createMockGuest(id: number, url: string, title: string) { case 'Runtime.evaluate': { const expr = (params as { expression: string }).expression if (expr === 'document.readyState') { - return { result: { value: 'complete' } } + return { + result: { + value: + typeof options?.readyState === 'function' + ? options.readyState() + : (options?.readyState ?? 'complete') + } + } } if (expr === 'location.origin') { return { result: { value: new URL(currentUrl).origin } } @@ -413,6 +425,43 @@ describe('Browser automation pipeline (integration)', () => { expect((res.error as { code: string }).code).toBe('browser_navigation_failed') }) + it('clears readyState polling timers when navigation times out', async () => { + vi.useFakeTimers() + try { + const slowGuestHarness = createMockGuest(6001, 'https://slow.example.com', 'Slow Page', { + readyState: 'loading' + }) + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === 6001) { + return slowGuestHarness.guest + } + return null + }) + + const browserManager = new BrowserManager() + browserManager.attachGuestPolicies(slowGuestHarness.guest as never) + browserManager.registerGuest({ + browserPageId: 'slow-page', + webContentsId: 6001, + rendererWebContentsId: RENDERER_WC_ID + }) + const bridge = new CdpBridge(browserManager) + bridge.setActiveTab(6001) + + const gotoResult = bridge.goto('https://slow.example.com/still-loading').then( + () => null, + (error: unknown) => error + ) + + await vi.advanceTimersByTimeAsync(25_000) + + await expect(gotoResult).resolves.toMatchObject({ code: 'browser_timeout' }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + // ── Fill ── it('fills an input by ref', async () => { diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index 532b2223c..5390455a2 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -1634,28 +1634,51 @@ export class CdpBridge { // Phase 1: wait for readyState=complete await new Promise((resolve, reject) => { + let settled = false + let pollTimer: ReturnType | null = null + + const finish = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + callback() + } + const timeout = setTimeout(() => { - reject(new BrowserError('browser_timeout', 'Page load timed out.')) + finish(() => reject(new BrowserError('browser_timeout', 'Page load timed out.'))) }, TIMEOUT_MS) const check = async (): Promise => { + if (settled) { + return + } try { const { result } = (await sender('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true })) as { result: { value: string } } + if (settled) { + return + } if (result.value === 'complete') { - clearTimeout(timeout) - resolve() + finish(resolve) } else { - setTimeout(check, 100) + pollTimer = setTimeout(() => { + pollTimer = null + void check() + }, 100) } } catch { - clearTimeout(timeout) - resolve() + finish(resolve) } } - check() + void check() }) // Phase 2: wait for network idle