aboutsummaryrefslogtreecommitdiff
path: root/test-suite/unit
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-08 11:58:33 +0000
committerIgor Soarez <igor@soarez.org>2026-11-08 11:58:33 +0000
commite538c3e53e203fabb8a9fed94b6810c345ee6f80 (patch)
tree394fd12fa9bb6829034e606c2bf0781c088af33d /test-suite/unit
parentff99a4432a3feedfac10d9514ce661a9cb95004c (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.mjs2
-rw-r--r--test-suite/unit/leave-site-prompt.test.mjs101
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/);
+});