Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion src/browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { BrowserBridge, generateStealthJs } from './browser/index.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js';
import { withTimeoutMs } from './runtime.js';
import { browserSession, withTimeoutMs } from './runtime.js';
import { __test__ as cdpTest } from './browser/cdp.js';
import { classifyBrowserError } from './browser/errors.js';
import * as daemonTransport from './browser/daemon-transport.js';
Expand Down Expand Up @@ -54,6 +54,22 @@ describe('browser helpers', () => {
await expect(withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
});

it('passes releasePageOnClose through browserSession close()', async () => {
const close = vi.fn().mockResolvedValue(undefined);
class TestBrowser {
async connect() {
return {} as any;
}
async close(opts?: { releasePage?: boolean }) {
return close(opts);
}
}

await browserSession(TestBrowser, async () => 'ok', { releasePageOnClose: true });

expect(close).toHaveBeenCalledWith({ releasePage: true });
});

it('classifies browser errors with correct kind and retry advice', () => {
// CDP target navigation — page-level settle retry
const nav = classifyBrowserError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'));
Expand Down Expand Up @@ -128,6 +144,30 @@ describe('BrowserBridge state', () => {
expect(bridge.state).toBe('closed');
});

it('does not release the active browser page lease by default on close()', async () => {
const bridge = new BrowserBridge();
const closeWindow = vi.fn().mockResolvedValue(undefined);
(bridge as unknown as { _state: string })._state = 'connected';
(bridge as unknown as { _page: { closeWindow: typeof closeWindow } })._page = { closeWindow };

await bridge.close();

expect(closeWindow).not.toHaveBeenCalled();
expect(bridge.state).toBe('closed');
});

it('releases the active browser page lease when requested on close()', async () => {
const bridge = new BrowserBridge();
const closeWindow = vi.fn().mockResolvedValue(undefined);
(bridge as unknown as { _state: string })._state = 'connected';
(bridge as unknown as { _page: { closeWindow: typeof closeWindow } })._page = { closeWindow };

await bridge.close({ releasePage: true });

expect(closeWindow).toHaveBeenCalledTimes(1);
expect(bridge.state).toBe('closed');
});

it('rejects connect() after the session has been closed', async () => {
const bridge = new BrowserBridge();
await bridge.close();
Expand Down
15 changes: 11 additions & 4 deletions src/browser/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,20 @@ export class BrowserBridge implements IBrowserFactory {
}
}

async close(): Promise<void> {
async close(opts: { releasePage?: boolean } = {}): Promise<void> {
if (this._state === 'closed') return;
this._state = 'closing';
// We don't kill the daemon — it's persistent.
// Just clean up our reference.
this._page = null;
this._state = 'closed';
// Optionally release the session lease before dropping the Page reference.
const page = opts.releasePage ? this._page : null;
try {
await page?.closeWindow?.();
} catch {
// Best-effort cleanup: callers historically could close even if the daemon was gone.
} finally {
this._page = null;
this._state = 'closed';
}
}

private async _ensureDaemon(timeoutSeconds?: number, contextId?: string, preferredContextId?: string): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ export async function executeCommand(
if (!keepTab) await page.closeWindow?.().catch(() => {});
throw err;
}
}, { session, cdpEndpoint, ...profileRouting, windowMode, surface: 'adapter', siteSession });
}, { session, cdpEndpoint, ...profileRouting, windowMode, surface: 'adapter', siteSession, releasePageOnClose: !keepTab });
} catch (err) {
browserRunError = err;
throw err;
Expand Down
6 changes: 3 additions & 3 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ export function withTimeoutMs<T>(
/** Interface for browser factory (BrowserBridge or test mocks) */
export interface IBrowserFactory {
connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent' }): Promise<IPage>;
close(): Promise<void>;
close(opts?: { releasePage?: boolean }): Promise<void>;
}

export async function browserSession<T>(
BrowserFactory: new () => IBrowserFactory,
fn: (page: IPage) => Promise<T>,
opts: { session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent' } = {},
opts: { session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent'; releasePageOnClose?: boolean } = {},
): Promise<T> {
const browser = new BrowserFactory();
try {
Expand All @@ -78,6 +78,6 @@ export async function browserSession<T>(
});
return await fn(page);
} finally {
await browser.close().catch(() => {});
await browser.close({ releasePage: opts.releasePageOnClose === true }).catch(() => {});
}
}