diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-08 11:58:33 +0000 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-08 11:58:33 +0000 |
| commit | e538c3e53e203fabb8a9fed94b6810c345ee6f80 (patch) | |
| tree | 394fd12fa9bb6829034e606c2bf0781c088af33d /test-suite/unit | |
| parent | ff99a4432a3feedfac10d9514ce661a9cb95004c (diff) | |
navigate: answer a page's Leave-site prompt instead of hanging
A page with a beforeunload handler shows 'Leave site?' when the extension
navigates away. Nothing answered it, so chrome_navigate waited out its timeout
and the agent never learned why. The worker now hears Page.javascriptDialogOpening
and dismisses the dialog (stay/cancel); page.navigate fails at once and says so.
leaveIfAsked: true, for that call only, accepts a beforeunload prompt. Tool
description and primer tell agents when to use it and to ask the user when unsure.
Diffstat (limited to 'test-suite/unit')
| -rw-r--r-- | test-suite/unit/background-policy.test.mjs | 2 | ||||
| -rw-r--r-- | test-suite/unit/leave-site-prompt.test.mjs | 101 |
2 files changed, 102 insertions, 1 deletions
diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs index 2388d78..efbca78 100644 --- a/test-suite/unit/background-policy.test.mjs +++ b/test-suite/unit/background-policy.test.mjs @@ -477,7 +477,7 @@ test("full-page tiles remain CDP-only, pin the resolved target, and restore both scrolls.push(clone(args)); }; let captures = 0; - h.cdpResult = () => { if (++captures === 2 && fail) throw new Error("tile failed"); return { data: "dGlsZQ==" }; }; + h.cdpResult = (method) => { if (method === "Page.captureScreenshot" && ++captures === 2 && fail) throw new Error("tile failed"); return { data: "dGlsZQ==" }; }; const shot = h.w.dispatch("page.screenshot", { urlIncludes: "target.test", fullPage: true, background: true, foreground: true }); if (fail) await assert.rejects(shot, /tile failed/); else { diff --git a/test-suite/unit/leave-site-prompt.test.mjs b/test-suite/unit/leave-site-prompt.test.mjs new file mode 100644 index 0000000..417d0b0 --- /dev/null +++ b/test-suite/unit/leave-site-prompt.test.mjs @@ -0,0 +1,101 @@ +// A page's "Leave site?" prompt (beforeunload) and other JavaScript dialogs block the tab and the +// agent cannot see them. page.navigate answers them: stay by default, leave with leaveIfAsked. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import { test } from "node:test"; + +const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8"); +const indexSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8"); + +// dialog: what the page opens after the navigation starts, or undefined for a clean load. +function worker(dialog) { + const calls = []; + const listener = { addListener() {}, removeListener() {} }; + let onUpdated; + const tab = { id: 2, windowId: 1, active: false, url: "https://app.test/form", status: "complete" }; + const w = { + console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); }, + chrome: { + runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener }, + alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener }, + debugger: { onDetach: listener, onEvent: listener, getTargets: (cb) => cb([]) }, + storage: { session: { get: async () => ({}), set: async () => {} } }, + tabs: { + get: async () => ({ ...tab }), + query: async () => [{ ...tab }], + onUpdated: { addListener: (fn) => { onUpdated = fn; }, removeListener() {} }, + update: async (_id, props) => { + calls.push({ method: "tabs.update", props }); + setTimeout(() => { + if (dialog) w.handleDebuggerEvent({ tabId: 2 }, "Page.javascriptDialogOpening", dialog); + else onUpdated?.(2, { status: "complete" }); + }, 5); + return { ...tab }; + }, + }, + }, + }; + w.self = w; + vm.runInNewContext(workerSource, w); + w.getTabByParams = async () => ({ ...tab }); + w.bringToFront = async () => {}; + w.attachDebugger = async () => {}; + w.formatTab = async (t) => ({ id: t.id, url: t.url }); + w.cdp = async (_tabId, method, params = {}) => { + calls.push({ method, params }); + // Accepting the leave prompt lets the navigation finish. + if (method === "Page.handleJavaScriptDialog" && params.accept) setTimeout(() => onUpdated?.(2, { status: "complete" }), 5); + }; + return { w, calls }; +} + +const leavePrompt = { type: "beforeunload", message: "", url: "https://app.test/form" }; +const answers = (calls) => calls.filter((c) => c.method === "Page.handleJavaScriptDialog").map((c) => c.params.accept); + +test("by default a Leave-site prompt is dismissed and the call says so at once", async () => { + const { w, calls } = worker(leavePrompt); + await assert.rejects( + () => w.dispatch("page.navigate", { url: "https://other.test/", timeoutMs: 5000 }), + (error) => /did not happen/.test(error.message) && /Leave site/.test(error.message) && /leaveIfAsked: true/.test(error.message) && /ask the user/.test(error.message), + ); + assert.deepEqual(answers(calls), [false]); +}); + +test("leaveIfAsked accepts the prompt and the navigation completes", async () => { + const { w, calls } = worker(leavePrompt); + const result = await w.dispatch("page.navigate", { url: "https://other.test/", leaveIfAsked: true, timeoutMs: 5000 }); + assert.equal(result.id, 2); + assert.deepEqual(answers(calls), [true]); +}); + +test("leaveIfAsked does not accept an alert or confirm", async () => { + const { w, calls } = worker({ type: "confirm", message: "Delete everything?", url: "https://app.test/form" }); + await assert.rejects( + () => w.dispatch("page.navigate", { url: "https://other.test/", leaveIfAsked: true, timeoutMs: 5000 }), + (error) => /confirm dialog saying "Delete everything\?"/.test(error.message), + ); + assert.deepEqual(answers(calls), [false]); +}); + +test("a page without a prompt navigates as before and no dialog is answered", async () => { + const { w, calls } = worker(undefined); + const result = await w.dispatch("page.navigate", { url: "https://other.test/", timeoutMs: 5000 }); + assert.equal(result.id, 2); + assert.deepEqual(answers(calls), []); +}); + +test("leaveIfAsked from one call does not carry over to the next", async () => { + const { w, calls } = worker(leavePrompt); + await w.dispatch("page.navigate", { url: "https://other.test/", leaveIfAsked: true, timeoutMs: 5000 }); + await assert.rejects(() => w.dispatch("page.navigate", { url: "https://other.test/", timeoutMs: 5000 }), /did not happen/); + assert.deepEqual(answers(calls), [true, false]); +}); + +test("the tool and the primer tell the agent what the prompt is and when to leave", () => { + const src = indexSource; + assert.match(src, /leaveIfAsked: Type\.Optional/); + assert.match(src, /Leave site\? Changes you made may not be saved/); + assert.match(src, /ask the user, because leaving discards unsaved work for good/); + assert.match(src, /9\. A page can open dialogs you cannot see/); +}); |
