aboutsummaryrefslogtreecommitdiff
path: root/test-suite/unit
diff options
context:
space:
mode:
Diffstat (limited to 'test-suite/unit')
-rw-r--r--test-suite/unit/automation-target.test.mjs4
-rw-r--r--test-suite/unit/background-policy.test.mjs26
-rw-r--r--test-suite/unit/chrome-command.test.mjs3
-rw-r--r--test-suite/unit/grant-scope.test.mjs30
-rw-r--r--test-suite/unit/hidden-input.test.mjs98
-rw-r--r--test-suite/unit/sandboxed-snapshot.test.mjs108
-rw-r--r--test-suite/unit/shared-window.test.mjs78
-rw-r--r--test-suite/unit/user-window.test.mjs119
8 files changed, 337 insertions, 129 deletions
diff --git a/test-suite/unit/automation-target.test.mjs b/test-suite/unit/automation-target.test.mjs
index 73fbc77..1ac3fd6 100644
--- a/test-suite/unit/automation-target.test.mjs
+++ b/test-suite/unit/automation-target.test.mjs
@@ -9,6 +9,10 @@
// Like csp-eval.test.mjs we load the *real* worker into a vm sandbox with a stateful chrome.*
// mock, then exercise the real helpers and the real dispatch() paths. Chrome state (tabs/windows/
// storage.session) can be shared across two sandbox loads to simulate a service-worker restart.
+//
+// Its chrome.windows mock has no getLastFocused, so the worker takes the path for a browser with no
+// window to share and gives each target a window of its own. Placement in the user's window is
+// covered by user-window.test.mjs.
import vm from "node:vm";
import fs from "node:fs";
diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs
index efbca78..98eaa7c 100644
--- a/test-suite/unit/background-policy.test.mjs
+++ b/test-suite/unit/background-policy.test.mjs
@@ -71,7 +71,7 @@ function piHarness({ session = "alpha", send } = {}) {
}
const flagCases = [
- [{}, false], [{ background: false }, false], [{ background: true }, true],
+ [{}, true], [{ background: false }, false], [{ background: true }, true],
[{ foreground: true }, false], [{ foreground: false }, true],
[{ background: false, foreground: false }, false], [{ background: true, foreground: true }, true],
];
@@ -96,6 +96,7 @@ test("session background on overrides all per-call/legacy foreground flags; off
test("every registered page tool, tab.new, and chrome_launch(url) use the central policy", async () => {
const h = piHarness();
+ await h.background("on");
const cases = [
["chrome_launch", { url: "https://example.test" }, "tab.new"],
["chrome_tab", { action: "new", group: false, groupTitle: "other" }, "tab.new"],
@@ -143,7 +144,11 @@ test("typing options and verification pass through real tool registrations witho
test("activation is rejected before dispatch; existing on/off/toggle/status commands suffice", async () => {
const h = piHarness();
- await assert.rejects(h.tool("chrome_tab", { action: "activate", targetId: "2", background: false }), /background mode.*\/chrome background off/);
+ await h.tool("chrome_tab", { action: "activate", targetId: "2" });
+ assert.equal(h.calls.at(-1).params.foreground, true, "activation is allowed by default");
+ h.calls.length = 0;
+ await h.background("on");
+ await assert.rejects(h.tool("chrome_tab", { action: "activate", targetId: "2", background: false }), /background mode on/);
assert.equal(h.calls.length, 0);
await h.background("off");
await h.tool("chrome_tab", { action: "activate", targetId: "2" });
@@ -199,9 +204,9 @@ test("shared-owner sessions retain independent policies; per-call background sti
const sharedCalls = [];
const send = async (action, params) => { sharedCalls.push({ action, params }); return {}; };
const a = piHarness({ session: "A", send }), b = piHarness({ session: "B", send });
- await b.background("off");
+ await a.background("on");
await a.send("tab.new", { background: false });
- await b.send("tab.new", {});
+ await b.send("tab.new", { background: false });
assert.equal(sharedCalls[0].params.sessionKey, "session:A");
assert.equal(sharedCalls[0].params.foreground, false);
assert.equal(sharedCalls[0].action, "tab.new.background");
@@ -297,6 +302,14 @@ function workerHarness({ withWindows = true } = {}) {
};
return h;
}
+// A new Pi tab must not take over the user's window: it is either inactive, or the only tab of a
+// new, unfocused window of the session's own (so it is visible to the agent without stealing focus).
+function assertNewTabStaysOutOfTheWay(h, tab) {
+ if (tab.active === false) return;
+ assert.notEqual(tab.windowId, 1, "an active new tab must not be in the user's window");
+ assert.ok(h.calls.some((c) => c.action === "windows.create" && c.params.focused === false), "its window was created unfocused");
+}
+
function assertNoFocus(h) {
assert.equal(h.focusedWindow(), 1);
assert.equal(h.tabs.get(1).active, true);
@@ -306,6 +319,7 @@ function assertNoFocus(h) {
test("Pi-to-worker background aliases preserve grouping, screenshot output, and ownership-aware cleanup", async () => {
const h = workerHarness();
const pi = piHarness({ send: (action, params) => h.w.dispatch(action, params) });
+ await pi.background("on");
const opened = await pi.tool("chrome_tab", { action: "new", group: false, groupTitle: "wrong", background: false });
assert.equal(opened.details.result.group.title, "Pi Session: alpha");
const launched = await pi.tool("chrome_launch", { url: "https://fixture.test" });
@@ -325,7 +339,7 @@ test("worker advertises support, keeps new tabs inactive, and rejects activation
assert.equal((await h.w.dispatch("tab.version", {})).capabilities.hardBackground, true);
for (const flags of [{}, { foreground: false }, { background: true, foreground: true }]) {
const result = await h.w.dispatch("tab.new", { ...flags, sessionKey: "alpha", groupTitle: "Pi Session: alpha" });
- assert.equal(result.tab.active, false);
+ assertNewTabStaysOutOfTheWay(h, result.tab);
await assert.rejects(h.w.dispatch("tab.activate", { ...flags, targetId: "missing" }), /background mode/);
}
assertNoFocus(h);
@@ -339,7 +353,7 @@ test("worker advertises support, keeps new tabs inactive, and rejects activation
test("background-only worker aliases force safe behavior even with foreground flags", async () => {
const h = workerHarness();
const opened = await h.w.dispatch("tab.new.background", { foreground: true, background: false });
- assert.equal(opened.tab.active, false);
+ assertNewTabStaysOutOfTheWay(h, opened.tab);
const shot = await h.w.dispatch("page.screenshot.background", { targetId: "2", foreground: true, background: false });
assert.equal(shot.method, "cdp");
assertNoFocus(h);
diff --git a/test-suite/unit/chrome-command.test.mjs b/test-suite/unit/chrome-command.test.mjs
index dac719a..28bfe8a 100644
--- a/test-suite/unit/chrome-command.test.mjs
+++ b/test-suite/unit/chrome-command.test.mjs
@@ -174,8 +174,7 @@ test("Doctor warns when this session's Pi tab is hidden, and stays quiet when it
await hidden.run("doctor");
const report = hidden.notices.at(-1)[0];
assert.match(report, /⚠ Pi's tab is hidden because its window is covered by another window/);
- assert.match(report, /Chrome ignores clicks and typing in hidden tabs/);
- assert.match(report, /\/chrome background off/);
+ assert.match(report, /Clicks, typing and screenshots still work there/);
const none = harness({ send: withStatus({ windowId: null, tabId: null }) });
await none.run("doctor");
diff --git a/test-suite/unit/grant-scope.test.mjs b/test-suite/unit/grant-scope.test.mjs
index e7ce044..7717131 100644
--- a/test-suite/unit/grant-scope.test.mjs
+++ b/test-suite/unit/grant-scope.test.mjs
@@ -24,6 +24,8 @@ function piProcess(env) {
setTimeout, clearTimeout, setInterval, clearInterval,
});
let runtime;
+ // A grant an earlier runtime left on globalThis, e.g. a build that saved the env grant there.
+ const seedGrant = (until) => vm.runInContext(`globalThis.__piChromeProfileBridgeAuth__ = ${JSON.stringify({ until })}`, context);
// Evaluate the module again and run its factory, as pi does at startup, on /reload and on a
// session switch.
const load = () => {
@@ -65,7 +67,7 @@ function piProcess(env) {
return next;
};
// Stops the bridge and timers of the live runtime, so a failed assertion cannot hang the run.
- return { load, replace, end: () => runtime?.shutdown("quit") };
+ return { load, replace, seedGrant, end: () => runtime?.shutdown("quit") };
}
test("PI_CHROME_AUTHORIZE grants every runtime without announcing it", async (t) => {
@@ -104,18 +106,38 @@ test("/chrome authorize survives /reload and ends with its session", async (t) =
}
});
-test("/chrome authorize over PI_CHROME_AUTHORIZE is announced, and a switch falls back to the env grant", async (t) => {
+test("with PI_CHROME_AUTHORIZE set, /chrome authorize does not bring the footer, and a switch falls back to the env grant", async (t) => {
const pi = piProcess({ PI_CHROME_AUTHORIZE: "indefinite" });
t.after(() => pi.end());
const first = pi.load();
await first.start();
await first.chrome("authorize 15m");
- assert.match(first.footer(), /^● Chrome Bridge \(\d+m\)$/);
+ assert.equal(first.chromeTools(), true);
+ assert.equal(first.footer(), undefined);
const reloaded = await pi.replace("reload");
- assert.match(reloaded.footer(), /^● Chrome Bridge \(\d+m\)$/);
+ assert.equal(reloaded.footer(), undefined);
const next = await pi.replace("new");
assert.equal(next.chromeTools(), true);
assert.equal(next.footer(), undefined);
});
+
+test("with PI_CHROME_AUTHORIZE set, a grant left on globalThis by an older build never shows the footer", async (t) => {
+ for (const until of ["indefinite", Date.now() + 3_600_000]) {
+ const pi = piProcess({ PI_CHROME_AUTHORIZE: "indefinite" });
+ t.after(() => pi.end());
+ pi.seedGrant(until);
+ const first = pi.load();
+ await first.start();
+ assert.equal(first.chromeTools(), true);
+ assert.equal(first.footer(), undefined, `footer at start, saved until ${until}`);
+ for (const reason of ["reload", "new", "resume", "fork"]) {
+ pi.seedGrant(until);
+ const next = await pi.replace(reason);
+ assert.equal(next.chromeTools(), true, `tools after ${reason}`);
+ assert.equal(next.footer(), undefined, `footer after ${reason}, saved until ${until}`);
+ }
+ await pi.end();
+ }
+});
diff --git a/test-suite/unit/hidden-input.test.mjs b/test-suite/unit/hidden-input.test.mjs
index 874b5e5..b49229c 100644
--- a/test-suite/unit/hidden-input.test.mjs
+++ b/test-suite/unit/hidden-input.test.mjs
@@ -1,6 +1,8 @@
-// Hidden-tab input guard, full-screen automation targets, input.debug without a target, upload
-// targets (label/wrapper/iframe/file-chooser), and screenshot pruning. Chrome and pages are mocked;
-// the hidden-page behavior itself (Chrome drops trusted input) was measured live on Chrome/macOS.
+// Hidden-tab input, automation targets beside a full-screen window, input.debug without a target,
+// upload targets (label/wrapper/iframe/file-chooser), and screenshot pruning. Chrome and pages are
+// mocked. Measured live in Brave on 2026-11-14: an inactive tab takes trusted clicks, keys, text and
+// mouse moves and renders screenshots; a mouseWheel there hangs. Minimized windows are untested, so
+// input there still fails up front.
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
@@ -54,50 +56,68 @@ function worker({ visibility = ["visible"], tab = { id: 2, windowId: 1, active:
return { w, chrome, calls, inputs: () => calls.filter((c) => c.method.startsWith("Input.")) };
}
-// ---- hidden-tab guard ----
-test("typing into a hidden tab fails fast with the reason instead of silently dropping keys", async () => {
+// ---- hidden-tab input ----
+const minimized = { id: 1, state: "minimized" };
+
+test("typing into an inactive tab sends trusted input", async () => {
const h = worker({ visibility: ["hidden"] });
- await assert.rejects(h.w.dispatch("page.type", { selector: "#q", text: "hi", background: true }), (error) => {
- assert.match(error.message, /chrome\.type: tab 2 is hidden because it is an inactive tab in its window/);
- assert.match(error.message, /background:false/);
- return true;
- });
- assert.equal(h.inputs().length, 0, "no trusted input sent to a hidden page");
+ const result = await h.w.dispatch("page.type", { selector: "#q", text: "hi", background: true });
+ assert.ok(h.inputs().length > 0, "trusted input sent to the inactive tab");
+ assert.notEqual(result.input, "dom-fallback");
});
-for (const [action, params] of [["key", { key: "Enter" }], ["hover", { x: 1, y: 1 }], ["scroll", { deltaY: 10 }], ["tap", { x: 1, y: 1 }], ["drag", { fromX: 1, fromY: 1, toX: 2, toY: 2 }]]) {
- test(`${action} refuses hidden tabs before sending input`, async () => {
+for (const [action, params] of [["key", { key: "Enter" }], ["hover", { x: 1, y: 1 }], ["tap", { x: 1, y: 1 }], ["drag", { fromX: 1, fromY: 1, toX: 2, toY: 2 }]]) {
+ test(`${action} sends trusted input to an inactive tab and refuses a minimized window`, async () => {
const h = worker({ visibility: ["hidden"] });
- await assert.rejects(h.w.dispatch(`page.${action}`, { ...params, background: true }), new RegExp(`chrome\\.${action}: tab 2 is hidden`));
- assert.equal(h.inputs().length, 0);
+ let moves = 0;
+ h.w.cdpMoveTo = async () => { moves++; }; // hover is only a mouse move, stubbed by the harness
+ await h.w.dispatch(`page.${action}`, { ...params, background: true });
+ assert.ok(h.inputs().length + moves > 0);
+ const m = worker({ visibility: ["hidden"], win: minimized });
+ await assert.rejects(m.w.dispatch(`page.${action}`, { ...params, background: true }), new RegExp(`chrome\\.${action}: tab 2 is hidden because its window is minimized`));
+ assert.equal(m.inputs().length, 0);
});
}
-test("click and fill use their DOM fallback immediately for hidden tabs, or reject with domFallback:false", async () => {
- const click = worker({ visibility: ["hidden"] });
+test("scroll on an inactive tab scrolls by script, since Chrome never answers its wheel events", async () => {
+ const h = worker({ visibility: ["hidden"] });
+ const result = await h.w.dispatch("page.scroll", { deltaY: 300, background: true });
+ assert.equal(result.input, "script");
+ assert.equal(h.inputs().length, 0, "no mouseWheel sent");
+ assert.ok(h.calls.some((c) => c.method === "Runtime.evaluate" && /scrollBy/.test(c.params.expression)));
+ const visible = worker();
+ assert.equal((await visible.w.dispatch("page.scroll", { deltaY: 300, background: true })).input, "chrome");
+});
+
+test("click and fill send trusted input to an inactive tab; in a minimized window they use their DOM fallback, or reject with domFallback:false", async () => {
+ const inactive = worker({ visibility: ["hidden"] });
+ assert.equal((await inactive.w.dispatch("page.click", { selector: "button", background: true })).input, "chrome");
+ assert.ok(inactive.inputs().length > 0);
+
+ const click = worker({ visibility: ["hidden"], win: minimized });
const clicked = await click.w.dispatch("page.click", { selector: "button", background: true });
assert.equal(clicked.input, "dom-fallback");
- assert.match(clicked.reason, /hidden/);
+ assert.match(clicked.reason, /minimized/);
assert.equal(click.inputs().length, 0);
- await assert.rejects(worker({ visibility: ["hidden"] }).w.dispatch("page.click", { selector: "button", background: true, domFallback: false }), /is hidden/);
+ await assert.rejects(worker({ visibility: ["hidden"], win: minimized }).w.dispatch("page.click", { selector: "button", background: true, domFallback: false }), /is hidden/);
- const fill = worker({ visibility: ["hidden"] });
+ const fill = worker({ visibility: ["hidden"], win: minimized });
fill.w.domFillFallback = async (_tabId, _params, cause) => ({ input: "dom-fallback", reason: String(cause.message) });
const filled = await fill.w.dispatch("page.fill", { selector: "#q", text: "x", background: true });
assert.equal(filled.input, "dom-fallback");
- assert.match(filled.reason, /hidden/);
+ assert.match(filled.reason, /minimized/);
assert.equal(fill.inputs().length, 0);
});
-test("hidden reasons distinguish minimized and macOS full-screen windows", async () => {
- const minimized = worker({ visibility: ["hidden"], win: { id: 1, state: "minimized" } });
- await assert.rejects(minimized.w.dispatch("page.key", { key: "a", background: true }), /its window is minimized/);
- const fullscreen = worker({ visibility: ["hidden"], tab: { id: 2, windowId: 1, active: true, url: "https://fixture.test/" }, win: { id: 1, state: "fullscreen" } });
- await assert.rejects(fullscreen.w.dispatch("page.key", { key: "a", background: true }), (error) => {
- assert.match(error.message, /behind a full-screen window/);
- assert.equal(error.windowState, "fullscreen");
+test("a hidden page in a full-screen or covered window still takes input; only minimized is refused", async () => {
+ const m = worker({ visibility: ["hidden"], win: minimized });
+ await assert.rejects(m.w.dispatch("page.key", { key: "a", background: true }), (error) => {
+ assert.match(error.message, /its window is minimized/);
+ assert.equal(error.windowState, "minimized");
return true;
});
+ const fullscreen = worker({ visibility: ["hidden"], tab: { id: 2, windowId: 1, active: true, url: "https://fixture.test/" }, win: { id: 1, state: "fullscreen" } });
+ assert.equal((await fullscreen.w.dispatch("page.key", { key: "a", background: true })).input, "chrome");
});
test("visible pages, failed probes, and foreground tabs that become visible proceed with trusted input", async () => {
@@ -105,20 +125,18 @@ test("visible pages, failed probes, and foreground tabs that become visible proc
assert.equal((await visible.w.dispatch("page.key", { key: "a", background: true })).input, "chrome");
const unknown = worker({ visibility: [new Error("probe failed")] });
assert.equal((await unknown.w.dispatch("page.key", { key: "a", background: true })).input, "chrome");
- const foreground = worker({ visibility: ["hidden", "hidden", "visible"] });
+ const foreground = worker({ visibility: ["hidden", "hidden", "visible"], win: minimized });
foreground.w.bringToFront = async (tab) => tab;
assert.equal((await foreground.w.dispatch("page.key", { key: "a", foreground: true })).input, "chrome");
});
// ---- automation target placement ----
-test("automation targets get their own window even while the user's window is full screen", async () => {
- // Chrome disables macOS automatic window tabbing, so a new window is never merged into the user's
- // full-screen window; putting Pi's tab in the user's window would only lose isolation.
+test("automation targets go in the user's window, inactive, even while it is full screen", async () => {
for (const state of ["fullscreen", "maximized"]) {
const h = worker({ win: { id: 1, state } });
await h.w.createAutomationTarget(`s-${state}`, undefined);
- assert.deepEqual(clone(h.calls.find((c) => c.method === "windows.create").props), { url: "about:blank", focused: false }, state);
- assert.equal(h.calls.some((c) => c.method === "tabs.create"), false, state);
+ assert.deepEqual(clone(h.calls.find((c) => c.method === "tabs.create").props), { url: "about:blank", active: false, windowId: 1 }, state);
+ assert.equal(h.calls.some((c) => c.method === "windows.create"), false, state);
}
});
@@ -137,8 +155,8 @@ test("input.debug without a target does not call tabs.get(-1) and reports the se
});
// ---- upload targets ----
-function uploadWorker({ visibility = "visible", chooserMode = "selectMultiple", opensChooser = true } = {}) {
- const h = worker({ visibility: [visibility] });
+function uploadWorker({ visibility = "visible", chooserMode = "selectMultiple", opensChooser = true, win } = {}) {
+ const h = worker({ visibility: [visibility], ...(win ? { win } : {}) });
const fileInput = { tagName: "INPUT", type: "file", multiple: true, isConnected: true };
const frameInput = { tagName: "INPUT", type: "file", multiple: false, isConnected: true };
const button = { tagName: "BUTTON", isConnected: true, querySelectorAll: () => [] };
@@ -209,13 +227,15 @@ test("upload buttons open an intercepted file chooser with a trusted click on vi
assert.ok(h.inputs().some((c) => c.params.type === "mousePressed"));
});
-test("hidden pages use a user-gesture element.click() for upload buttons unless domFallback:false", async () => {
- const h = uploadWorker({ visibility: "hidden" });
+test("upload buttons in a minimized window use a user-gesture element.click() unless domFallback:false", async () => {
+ const inactive = uploadWorker({ visibility: "hidden" });
+ assert.equal((await inactive.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true })).trigger, "chrome", "an inactive tab gets the trusted click");
+ const h = uploadWorker({ visibility: "hidden", win: minimized });
const result = await h.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true });
assert.equal(result.trigger, "activation-click");
assert.match(result.reason, /hidden/);
assert.equal(h.inputs().length, 0);
- const strict = uploadWorker({ visibility: "hidden" });
+ const strict = uploadWorker({ visibility: "hidden", win: minimized });
await assert.rejects(strict.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true, domFallback: false }), /is hidden/);
assert.equal(setFiles(strict).length, 0);
});
diff --git a/test-suite/unit/sandboxed-snapshot.test.mjs b/test-suite/unit/sandboxed-snapshot.test.mjs
new file mode 100644
index 0000000..9774f21
--- /dev/null
+++ b/test-suite/unit/sandboxed-snapshot.test.mjs
@@ -0,0 +1,108 @@
+// chrome_snapshot and chrome_inspect on a page served with CSP `sandbox` (no allow-scripts), such as
+// raw.githubusercontent.com. There chrome.scripting.executeScript resolves with a null result
+// instead of throwing, so the access-denied CDP fallback never ran and the snapshot came back
+// undefined. The worker now redoes the injection over CDP Runtime.evaluate, which such pages allow.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { test } from "node:test";
+
+const workerUrl = new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url);
+const src = fs.readFileSync(workerUrl, "utf8");
+
+// Stands in for snapshot_injected.js when the worker reads it from the package for CDP injection.
+const INJECTED_STUB = `
+globalThis.__piChromeSnapshotPage = async (...args) => ({ mode: args[4], title: "raw page" });
+globalThis.__piChromeInspectTarget = async (uid) => ({ target: { uid } });
+`;
+
+function loadWorker({ scriptingRuns, cdpReturnsNothing = false }) {
+ const pageWorld = vm.createContext({ Promise, Error, String, JSON });
+ pageWorld.globalThis = pageWorld;
+ const calls = { scripting: 0, cdp: 0 };
+ const noop = () => {};
+ const listener = { addListener: noop, removeListener: noop };
+ const sandbox = {
+ console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
+ Error, TypeError, Map, Set, Symbol, structuredClone,
+ setTimeout, clearTimeout, setInterval: () => 0, clearInterval: noop,
+ fetch: async (url) => {
+ assert.match(String(url), /snapshot_injected\.js$/);
+ return { ok: true, text: async () => INJECTED_STUB };
+ },
+ navigator: { userAgent: "unit-test" },
+ WebSocket: function () {},
+ chrome: {
+ runtime: {
+ id: "unittestextension", getManifest: () => ({ version: "0.0.0" }),
+ getURL: (file) => `chrome-extension://unittestextension/${file}`,
+ onInstalled: listener, onStartup: listener, lastError: null,
+ },
+ alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
+ action: { onClicked: listener },
+ debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]) },
+ scripting: {
+ // A sandboxed document runs no extension script and reports a null result.
+ executeScript: async ({ files, func, args }) => {
+ calls.scripting++;
+ if (!scriptingRuns) return [{ frameId: 0, result: null }];
+ if (files) {
+ vm.runInContext(INJECTED_STUB, pageWorld);
+ return [{ frameId: 0, result: undefined }];
+ }
+ const fn = vm.runInContext(`(${func.toString()})`, pageWorld);
+ return [{ frameId: 0, result: await fn(...(args || [])) }];
+ },
+ },
+ tabs: { query: async () => [], get: async () => ({}), create: async () => ({}), update: async () => ({}), remove: async () => {} },
+ windows: { update: async () => {} },
+ webNavigation: { onCommitted: listener },
+ },
+ };
+ sandbox.globalThis = sandbox;
+ sandbox.self = sandbox;
+ vm.createContext(sandbox);
+ vm.runInContext(src, sandbox);
+ sandbox.attachDebugger = async () => ({});
+ sandbox.bringToFront = async () => {};
+ sandbox.getTabByParams = async () => ({ id: 7, windowId: 1, url: "https://raw.githubusercontent.com/o/r/main/f.swift" });
+ sandbox.cdp = async (_tabId, method, params) => {
+ assert.equal(method, "Runtime.evaluate");
+ calls.cdp++;
+ if (cdpReturnsNothing) return { result: { type: "undefined" } };
+ // returnByValue: JSON-able values come back as `value`; functions and undefined only as a type.
+ const value = await vm.runInContext(params.expression, pageWorld);
+ const json = JSON.stringify(value);
+ return json === undefined
+ ? { result: { type: typeof value } }
+ : { result: { type: "object", value: JSON.parse(json) } };
+ };
+ return { dispatch: sandbox.dispatch, calls };
+}
+
+test("snapshot of a sandboxed page falls back to CDP", async () => {
+ const { dispatch, calls } = loadWorker({ scriptingRuns: false });
+ const snapshot = await dispatch("page.snapshot", { mode: "text" });
+ assert.equal(snapshot?.title, "raw page");
+ assert.equal(snapshot?.mode, "text");
+ assert.equal(calls.scripting, 2, "scripting is tried first for both the injection and the call");
+ assert.equal(calls.cdp, 2, "both are redone over CDP");
+});
+
+test("inspect of a sandboxed page falls back to CDP", async () => {
+ const { dispatch } = loadWorker({ scriptingRuns: false });
+ const inspect = await dispatch("page.inspect", { uid: "e12" });
+ assert.equal(inspect?.target?.uid, "e12");
+});
+
+test("snapshot of an ordinary page does not touch CDP", async () => {
+ const { dispatch, calls } = loadWorker({ scriptingRuns: true });
+ const snapshot = await dispatch("page.snapshot", { mode: "auto" });
+ assert.equal(snapshot?.title, "raw page");
+ assert.equal(calls.cdp, 0);
+});
+
+test("a page that runs neither path reports an error instead of an empty snapshot", async () => {
+ const { dispatch } = loadWorker({ scriptingRuns: false, cdpReturnsNothing: true });
+ await assert.rejects(dispatch("page.snapshot", { mode: "text" }), /snapshot script returned no result in tab 7 \(https:\/\/raw\.githubusercontent\.com/);
+});
diff --git a/test-suite/unit/shared-window.test.mjs b/test-suite/unit/shared-window.test.mjs
deleted file mode 100644
index 7afeb77..0000000
--- a/test-suite/unit/shared-window.test.mjs
+++ /dev/null
@@ -1,78 +0,0 @@
-// Agent sessions share one window: a new session's tab goes into the window that already holds a
-// "Pi Session:" group, and a window is created only when there is none.
-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");
-
-function worker({ groups = [], windows = { 5: { id: 5, type: "normal" } } } = {}) {
- const calls = [];
- const listener = { addListener() {}, removeListener() {} };
- let nextTab = 100;
- 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 (v) => { calls.push({ method: 'storage.set', json: JSON.stringify(v) }); } } },
- tabGroups: { query: async () => groups },
- windows: {
- get: async (id) => windows[id] ?? Promise.reject(new Error("No window")),
- create: async (props) => { calls.push({ method: "windows.create", props }); return { id: 9, tabs: [{ id: 900, windowId: 9 }] }; },
- },
- tabs: {
- create: async (props) => { calls.push({ method: "tabs.create", props }); return { id: nextTab++, windowId: props.windowId }; },
- query: async () => [], get: async () => null, onUpdated: listener,
- },
- },
- };
- w.self = w;
- vm.runInNewContext(workerSource, w);
- return { w, calls };
-}
-
-const piGroup = (windowId, name = "Pi Session: claude-code 1 other") => ({ id: 1, windowId, title: name });
-
-test("a new session's tab goes into the window that already holds another Pi session", async () => {
- const { w, calls } = worker({ groups: [piGroup(5)] });
- await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
- assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["tabs.create"]);
- assert.equal(calls[0].props.windowId, 5);
-});
-
-test("with no Pi window yet, the first session creates one", async () => {
- const { w, calls } = worker({ groups: [] });
- await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
- assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["windows.create"]);
-});
-
-test("groups that are not Pi sessions do not attract agent tabs", async () => {
- const { w, calls } = worker({ groups: [{ id: 2, windowId: 5, title: "Work" }] });
- await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
- assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["windows.create"]);
-});
-
-test("a Pi group whose window is gone, or is not a normal window, is skipped", async () => {
- const { w, calls } = worker({ groups: [piGroup(7), piGroup(8)], windows: { 8: { id: 8, type: "popup" } } });
- await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
- assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["windows.create"]);
-});
-
-test("the shared window is never recorded as ours, so cleanup cannot close it", async () => {
- const { w, calls } = worker({ groups: [piGroup(5)] });
- await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
- const stored = calls.filter((c) => c.method === "storage.set").map((c) => c.json).join("");
- assert.doesNotMatch(stored, /"windowId":5/);
-});
-
-test("chrome_tab new for a session with no group uses the shared window, not the focused one", async () => {
- const { w, calls } = worker({ groups: [piGroup(5)] });
- w.groupTab = async (tab) => tab;
- w.bringToFront = async () => {};
- await w.dispatch("tab.new", { url: "about:blank", groupTitle: "Pi Session: claude-code 2 mine", sessionKey: "claude-code 2 mine" });
- const created = calls.find((c) => c.method === "tabs.create");
- assert.equal(created.props.windowId, 5);
-});
diff --git a/test-suite/unit/user-window.test.mjs b/test-suite/unit/user-window.test.mjs
new file mode 100644
index 0000000..af4e939
--- /dev/null
+++ b/test-suite/unit/user-window.test.mjs
@@ -0,0 +1,119 @@
+// Agents' tabs go in the user's window, inactive, in per-session tab groups that start collapsed.
+// An inactive tab takes trusted input and renders screenshots (measured 2026-11-14), so there is no
+// need for a window per session. A window is created only when Brave has none.
+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");
+
+function worker({ groups = [], windows = { 5: { id: 5, type: "normal" } }, lastFocused = 5, tabs = {} } = {}) {
+ const calls = [];
+ const listener = { addListener() {}, removeListener() {} };
+ let nextTab = 100;
+ 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 (v) => { calls.push({ method: 'storage.set', json: JSON.stringify(v) }); } } },
+ tabGroups: { query: async () => groups, update: async (id, props) => { calls.push({ method: "tabGroups.update", id, props }); return { id, ...props }; } },
+ windows: {
+ get: async (id) => windows[id] ?? Promise.reject(new Error("No window")),
+ getLastFocused: async () => (lastFocused === null ? Promise.reject(new Error("No window")) : windows[lastFocused]),
+ getAll: async () => Object.values(windows),
+ update: async (id, props) => { calls.push({ method: "windows.update", id, props }); return { id }; },
+ create: async (props) => { calls.push({ method: "windows.create", props }); return { id: 9, tabs: [{ id: 900, windowId: 9 }] }; },
+ },
+ tabs: {
+ create: async (props) => { calls.push({ method: "tabs.create", props }); return { id: nextTab++, windowId: props.windowId }; },
+ query: async () => [], get: async (id) => tabs[id] ?? null, onUpdated: listener,
+ group: async () => 77,
+ update: async (id, props) => { calls.push({ method: "tabs.update", id, props }); return { id, ...props }; },
+ },
+ },
+ };
+ w.self = w;
+ vm.runInNewContext(workerSource, w);
+ return { w, calls };
+}
+
+const piGroup = (windowId, name = "Pi Session: claude-code 1 other") => ({ id: 1, windowId, title: name });
+const ops = (calls) => calls.filter((c) => c.method !== "storage.set").map((c) => c.method);
+const noWindows = { windows: {}, lastFocused: null };
+
+test("a new session's tab goes in the user's window, inactive", async () => {
+ const { w, calls } = worker({ groups: [piGroup(5)] });
+ await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
+ assert.deepEqual(ops(calls), ["tabs.create"]);
+ const props = calls.find((c) => c.method === "tabs.create").props;
+ assert.equal(props.windowId, 5);
+ assert.equal(props.active, false);
+});
+
+test("a window is created only when Brave has none", async () => {
+ const { w, calls } = worker(noWindows);
+ await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
+ assert.deepEqual(ops(calls), ["windows.create"]);
+ assert.equal(calls[0].props.focused, false);
+});
+
+test("a session that already has a group adds its tab to that group's window", async () => {
+ const { w, calls } = worker({ groups: [piGroup(7, "Pi Session: claude-code 2 mine")], windows: { 5: { id: 5 }, 7: { id: 7 } } });
+ await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
+ assert.deepEqual(ops(calls), ["tabs.create"]);
+ assert.equal(calls.find((c) => c.method === "tabs.create").props.windowId, 7);
+});
+
+test("chrome_tab new for a session with no group goes in the user's window, inactive", async () => {
+ const { w, calls } = worker({ groups: [piGroup(5)] });
+ w.groupTab = async (tab) => tab;
+ w.bringToFront = async () => {};
+ await w.dispatch("tab.new", { url: "https://x.test/", groupTitle: "Pi Session: claude-code 2 mine", sessionKey: "claude-code 2 mine" });
+ assert.deepEqual(ops(calls), ["tabs.create"]);
+ assert.equal(calls[0].props.windowId, 5);
+ assert.equal(calls[0].props.active, false);
+});
+
+test("chrome_tab new with no window open creates an unfocused one", async () => {
+ const { w, calls } = worker(noWindows);
+ w.groupTab = async (tab) => tab;
+ w.bringToFront = async () => {};
+ await w.dispatch("tab.new", { url: "https://x.test/", groupTitle: "Pi Session: claude-code 2 mine", sessionKey: "claude-code 2 mine" });
+ assert.deepEqual(ops(calls), ["windows.create"]);
+ assert.equal(calls[0].props.focused, false);
+});
+
+test("a new group starts collapsed; an existing group keeps the user's setting", async () => {
+ const { w, calls } = worker({ tabs: { 300: { id: 300, windowId: 5, active: false, groupId: -1 }, 301: { id: 301, windowId: 5, active: false, groupId: 77 } } });
+ w.groupRecord = async (id) => ({ id });
+ w.formatTab = async (t) => t;
+ await w.groupTab({ id: 300, windowId: 5, groupId: -1 }, "Pi Session: x", "blue");
+ await w.groupTab({ id: 301, windowId: 5, groupId: 77 }, "Pi Session: x", "blue");
+ const updates = calls.filter((c) => c.method === "tabGroups.update").map((c) => c.props.collapsed);
+ assert.deepEqual(updates, [true, undefined]);
+});
+
+test("a new group holding the tab that is showing is not collapsed", async () => {
+ const { w, calls } = worker({ tabs: { 300: { id: 300, windowId: 5, active: true, groupId: -1 } } });
+ w.groupRecord = async (id) => ({ id });
+ w.formatTab = async (t) => t;
+ await w.groupTab({ id: 300, windowId: 5, groupId: -1 }, "Pi Session: x", "blue");
+ assert.equal(calls.find((c) => c.method === "tabGroups.update").props.collapsed, false);
+});
+
+test("bringing a tab forward expands its group", async () => {
+ const { w, calls } = worker();
+ await w.bringToFront({ id: 300, windowId: 5, groupId: 77 }, { foreground: true, background: false });
+ assert.deepEqual(ops(calls), ["windows.update", "tabGroups.update", "tabs.update"]);
+ assert.equal(calls[1].props.collapsed, false);
+});
+
+test("input is sent to an inactive tab; only a minimized window refuses it", async () => {
+ const { w } = worker({ windows: { 5: { id: 5, state: "normal" }, 6: { id: 6, state: "minimized" } }, tabs: { 300: { id: 300, windowId: 5, active: false }, 301: { id: 301, windowId: 6, active: true } } });
+ w.pageVisibilityState = async () => "hidden";
+ await w.assertTabVisibleForInput({ id: 300, windowId: 5 }, {}, "chrome.click");
+ await assert.rejects(w.assertTabVisibleForInput({ id: 301, windowId: 6 }, {}, "chrome.click"), /minimized/);
+});