Skip to content
Open
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
32 changes: 32 additions & 0 deletions extension/dist/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ const DAEMON_HOST = "localhost";
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;

const BEFORE_UNLOAD_GUARD_JS = `
(() => {
const holder = EventTarget.prototype;
const key = '__lsnUnload'; // looks like an internal listener cache
if (Object.getOwnPropertyDescriptor(holder, key)) return;
try {
Object.defineProperty(holder, key, { value: true, enumerable: false, configurable: true });
} catch {}
window.addEventListener('beforeunload', (event) => {
event.stopImmediatePropagation();
event.returnValue = '';
}, true);
window.onbeforeunload = null;
})()
`;
const attached = /* @__PURE__ */ new Set();
const tabFrameContexts = /* @__PURE__ */ new Map();
const frameTargets = /* @__PURE__ */ new Map();
Expand Down Expand Up @@ -106,6 +121,13 @@ async function ensureAttached(tabId, aggressiveRetry = false) {
await sendDebuggerCommand({ tabId }, "Runtime.enable");
} catch {
}
try {
await sendDebuggerCommand({ tabId }, "Page.enable");
await sendDebuggerCommand({ tabId }, "Page.addScriptToEvaluateOnNewDocument", {
source: BEFORE_UNLOAD_GUARD_JS
});
} catch {
}
if (preservedNetworkCapture) {
try {
await sendDebuggerCommand({ tabId }, "Network.enable");
Expand Down Expand Up @@ -567,6 +589,16 @@ function registerListeners() {
await detach(tabId);
}
});
chrome.debugger.onEvent.addListener(async (source, method, params) => {
if (method !== "Page.javascriptDialogOpening") return;
if (params?.type !== "beforeunload") return;
const tabId = source.tabId;
if (!tabId) return;
try {
await sendDebuggerCommand({ tabId }, "Page.handleJavaScriptDialog", { accept: true });
} catch {
}
});
chrome.debugger.onEvent.addListener(async (source, method, params) => {
const tabId = source.tabId;
if (!tabId) return;
Expand Down
103 changes: 76 additions & 27 deletions extension/src/cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,34 @@ describe('cdp network capture survives forced re-attach', () => {
});
});

describe('cdp network capture correctness', () => {
function createNetworkMock() {
const onEventListeners = [];
const debuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async () => {}),
sendCommand: vi.fn(async (_target, method, params) => {
if (method === 'Runtime.evaluate' && params?.expression === '1') return { result: { value: '1' } };
if (method === 'Network.getRequestPostData') return {}; // no override; use inline postData
return {};
}),
onDetach: { addListener: vi.fn() },
onEvent: { addListener: vi.fn((fn) => { onEventListeners.push(fn); }) },
};
const tabs = {
get: vi.fn(async () => ({ id: 1, windowId: 1, url: 'https://x.com/home' })),
onRemoved: { addListener: vi.fn() },
onUpdated: { addListener: vi.fn() },
};
const fire = async (method, params) => {
for (const fn of onEventListeners) await fn({ tabId: 1 }, method, params);
};
return {
chrome: { tabs, debugger: debuggerApi, scripting: {}, runtime: { id: 'opencli-test' } },
fire,
};
}

describe('cdp beforeunload guard', () => {
beforeEach(() => {
vi.resetModules();
});
Expand All @@ -475,32 +502,54 @@ describe('cdp network capture correctness', () => {
vi.unstubAllGlobals();
});

function createNetworkMock() {
const onEventListeners = [];
const debuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async () => {}),
sendCommand: vi.fn(async (_target, method, params) => {
if (method === 'Runtime.evaluate' && params?.expression === '1') return { result: { value: '1' } };
if (method === 'Network.getRequestPostData') return {}; // no override; use inline postData
return {};
}),
onDetach: { addListener: vi.fn() },
onEvent: { addListener: vi.fn((fn) => { onEventListeners.push(fn); }) },
};
const tabs = {
get: vi.fn(async () => ({ id: 1, windowId: 1, url: 'https://x.com/home' })),
onRemoved: { addListener: vi.fn() },
onUpdated: { addListener: vi.fn() },
};
const fire = async (method, params) => {
for (const fn of onEventListeners) await fn({ tabId: 1 }, method, params);
};
return {
chrome: { tabs, debugger: debuggerApi, scripting: {}, runtime: { id: 'opencli-test' } },
fire,
};
}
it('arms the beforeunload guard for future documents when it attaches', async () => {
const mock = createNetworkMock();
vi.stubGlobal('chrome', mock.chrome);
const mod = await import('./cdp');

await mod.ensureAttached(1);

const injected = mock.chrome.debugger.sendCommand.mock.calls
.find(([, method]) => method === 'Page.addScriptToEvaluateOnNewDocument');
expect(injected?.[2]?.source).toContain('beforeunload');
expect(injected?.[2]?.source).toContain('stopImmediatePropagation');
});

it('accepts a beforeunload prompt raised by a document that predates the attach', async () => {
const mock = createNetworkMock();
vi.stubGlobal('chrome', mock.chrome);
const mod = await import('./cdp');
mod.registerListeners();

await mock.fire('Page.javascriptDialogOpening', { type: 'beforeunload', message: 'Leave site?' });

const handled = mock.chrome.debugger.sendCommand.mock.calls
.find(([, method]) => method === 'Page.handleJavaScriptDialog');
expect(handled?.[2]).toEqual({ accept: true });
});

it('leaves an alert dialog for the caller to handle', async () => {
const mock = createNetworkMock();
vi.stubGlobal('chrome', mock.chrome);
const mod = await import('./cdp');
mod.registerListeners();

await mock.fire('Page.javascriptDialogOpening', { type: 'alert', message: 'hello' });

const handled = mock.chrome.debugger.sendCommand.mock.calls
.find(([, method]) => method === 'Page.handleJavaScriptDialog');
expect(handled).toBeUndefined();
});
});

describe('cdp network capture correctness', () => {
beforeEach(() => {
vi.resetModules();
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('preserves the original POST body when a captured request follows a redirect', async () => {
const mock = createNetworkMock();
Expand Down
56 changes: 56 additions & 0 deletions extension/src/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@
* tabs (resolveTabId in background.ts filters them).
*/

/**
* Injected into every document before its own scripts run.
*
* A page that arms `beforeunload` opens a native "Leave site?" prompt on the
* next navigation. The CLI cannot answer it, so navigation never returns and
* every later evaluate reads an empty document. Blink dispatches listeners on a
* target in registration order, so this has to be registered first, which means
* Page.addScriptToEvaluateOnNewDocument rather than a post-load evaluate.
*
* Kept in step with generateBeforeUnloadGuardJs() in
* src/browser/beforeunload-guard.ts, which serves the direct-CDP path; only
* the test-facing return values differ. The extension cannot import from the
* CLI package.
*/
const BEFORE_UNLOAD_GUARD_JS = `
(() => {
const holder = EventTarget.prototype;
const key = '__lsnUnload'; // looks like an internal listener cache
if (Object.getOwnPropertyDescriptor(holder, key)) return;
try {
Object.defineProperty(holder, key, { value: true, enumerable: false, configurable: true });
} catch {}
window.addEventListener('beforeunload', (event) => {
event.stopImmediatePropagation();
event.returnValue = '';
}, true);
window.onbeforeunload = null;
})()
`;

const attached = new Set<number>();

const tabFrameContexts = new Map<number, Map<string, number>>();
Expand Down Expand Up @@ -208,6 +238,18 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f
// Some pages may not need explicit enable
}

// Arm the beforeunload guard for every document this tab loads from now on,
// and dismiss a prompt that a document loaded before the attach can still
// raise. Both are best-effort: a tab that rejects Page is still usable.
try {
await sendDebuggerCommand({ tabId }, 'Page.enable');
await sendDebuggerCommand({ tabId }, 'Page.addScriptToEvaluateOnNewDocument', {
source: BEFORE_UNLOAD_GUARD_JS,
});
} catch {
// Page domain unavailable on this target
}

// Restore network capture that the re-attach (detach + onDetach) tore down.
// The detach always disables the CDP Network domain, so re-enable it and put
// the accumulated capture state back unconditionally. Done last (after the
Expand Down Expand Up @@ -832,6 +874,20 @@ export function registerListeners(): void {
await detach(tabId);
}
});
// A document that loaded before the attach never ran the guard, so its
// prompt can still open and block the tab. Accepting it lets the pending
// navigation finish; the CLI has no way to answer the dialog itself.
chrome.debugger.onEvent.addListener(async (source, method, params) => {
if (method !== 'Page.javascriptDialogOpening') return;
if ((params as { type?: string } | undefined)?.type !== 'beforeunload') return;
const tabId = source.tabId;
if (!tabId) return;
try {
await sendDebuggerCommand({ tabId }, 'Page.handleJavaScriptDialog', { accept: true });
} catch {
// The dialog may already be gone
}
});
chrome.debugger.onEvent.addListener(async (source, method, params) => {
const tabId = source.tabId;
if (!tabId) return;
Expand Down
80 changes: 80 additions & 0 deletions src/browser/beforeunload-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { generateBeforeUnloadGuardJs } from './beforeunload-guard.js';

function createWindow(): JSDOM['window'] {
return new JSDOM('<!doctype html><body></body>', { runScripts: 'outside-only' }).window;
}

describe('beforeunload guard', () => {
it('stops a handler the page registers after the guard', () => {
const window = createWindow();
window.eval(generateBeforeUnloadGuardJs());
let handled = false;
window.addEventListener('beforeunload', () => { handled = true; });

window.dispatchEvent(new window.Event('beforeunload', { cancelable: true }));

expect(handled).toBe(false);
});

it('stops an onbeforeunload assigned after the guard', () => {
const window = createWindow();
window.eval(generateBeforeUnloadGuardJs());
let handled = false;
window.onbeforeunload = () => {
handled = true;
return 'stay';
};

window.dispatchEvent(new window.Event('beforeunload', { cancelable: true }));

expect(handled).toBe(false);
});

it('installs once per document, without an enumerable window flag', () => {
const window = createWindow();

expect(window.eval(generateBeforeUnloadGuardJs())).toBe('installed');
expect(window.eval(generateBeforeUnloadGuardJs())).toBe('skipped');
expect(Object.keys(window)).not.toContain('__lsnUnload');
});

it('leaves other events alone', () => {
const window = createWindow();
window.eval(generateBeforeUnloadGuardJs());
let seen = false;
window.addEventListener('unload', () => { seen = true; });

window.dispatchEvent(new window.Event('unload'));

expect(seen).toBe(true);
});

it('stays in step with the copy the extension injects', () => {
const extensionSource = readFileSync(
new URL('../../extension/src/cdp.ts', import.meta.url),
'utf-8',
);
const extensionGuard = /const BEFORE_UNLOAD_GUARD_JS = `\n([\s\S]*?)\n`;/.exec(extensionSource)?.[1] ?? '';
// The extension cannot import from this package, so it carries the script
// inline; only the test-facing return values differ.
const normalize = (source: string) => source
.replace(/return\s*(?:'installed'|'skipped')?\s*;/g, '')
.replace(/\s+/g, ' ')
.trim();

expect(normalize(extensionGuard)).toBe(normalize(generateBeforeUnloadGuardJs()));
});

it('keeps the calls that a post-load injection cannot substitute for', () => {
const source = generateBeforeUnloadGuardJs();

// jsdom dispatches at-target listeners in capture-then-bubble order while
// Blink uses registration order, so the ordering that matters in Chrome is
// pinned on the source instead of on a jsdom dispatch.
expect(source).toContain('stopImmediatePropagation');
expect(source).toContain('window.onbeforeunload = null');
});
});
35 changes: 35 additions & 0 deletions src/browser/beforeunload-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Keep a page from wedging the session on its own "Leave site?" prompt.
*
* A site that stages unsaved state arms `beforeunload`, and the next navigation
* opens a native dialog. Nothing in the CLI can answer it: the daemon owns the
* CDP session, so the dialog events never reach us, and while it is open every
* evaluate returns an empty document, which reads as a broken adapter rather
* than a blocked tab.
*
* The script must run before the page's own scripts. Blink dispatches listeners
* on a target in registration order, so a guard injected after load is queued
* behind the site's handler and cannot stop it; injected first, it wins.
*
* The flag hides on a built-in prototype for the reason stealth.ts documents: a
* bare window property is an automation fingerprint. The extension carries the
* same script inline, since it cannot import from this package.
*/
export function generateBeforeUnloadGuardJs(): string {
return `
(() => {
const holder = EventTarget.prototype;
const key = '__lsnUnload'; // looks like an internal listener cache
if (Object.getOwnPropertyDescriptor(holder, key)) return 'skipped';
try {
Object.defineProperty(holder, key, { value: true, enumerable: false, configurable: true });
} catch {}
window.addEventListener('beforeunload', (event) => {
event.stopImmediatePropagation();
event.returnValue = '';
}, true);
window.onbeforeunload = null;
return 'installed';
})()
`;
}
Loading