aboutsummaryrefslogtreecommitdiff
path: root/test-suite/unit
diff options
context:
space:
mode:
Diffstat (limited to 'test-suite/unit')
-rw-r--r--test-suite/unit/background-policy.test.mjs14
-rw-r--r--test-suite/unit/grant-scope.test.mjs143
-rw-r--r--test-suite/unit/leave-site-prompt.test.mjs108
-rw-r--r--test-suite/unit/overlay.test.mjs95
-rw-r--r--test-suite/unit/sandboxed-snapshot.test.mjs108
-rw-r--r--test-suite/unit/session-cleanup.test.mjs14
-rw-r--r--test-suite/unit/shared-window.test.mjs61
7 files changed, 537 insertions, 6 deletions
diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs
index 2388d78..ea08be0 100644
--- a/test-suite/unit/background-policy.test.mjs
+++ b/test-suite/unit/background-policy.test.mjs
@@ -297,6 +297,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);
@@ -325,7 +333,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 +347,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);
@@ -477,7 +485,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/grant-scope.test.mjs b/test-suite/unit/grant-scope.test.mjs
new file mode 100644
index 0000000..7717131
--- /dev/null
+++ b/test-suite/unit/grant-scope.test.mjs
@@ -0,0 +1,143 @@
+// Which grant a session starts with, and whether the "● Chrome Bridge" footer announces it, across
+// /reload and session switches. Pi re-evaluates the extension for each runtime but keeps globalThis
+// for the life of the process, so each runtime here is a fresh evaluation of the shipped module in
+// one shared context. The bridge binds an ephemeral loopback port; no Chrome is involved.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { createRequire, stripTypeScriptTypes } from "node:module";
+import { test } from "node:test";
+
+const indexUrl = new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url);
+const stubbed = stripTypeScriptTypes(fs.readFileSync(indexUrl, "utf8"), { mode: "transform" })
+ .replace(/^import \{ Text \} from "@earendil-works\/pi-tui";$/m, "const Text = class {};")
+ .replace(/^import \{ Type \} from "typebox";$/m, "const Type = new Proxy({}, { get: () => (value = {}) => value });")
+ .replace(/^import \{([^}]*)\} from "(node:[^"]+)";$/gm, (_m, names, mod) => `const {${names.replace(/\btype\s+\w+\s*,?/g, "")}} = require("${mod}");`)
+ .replace(/^export default function/m, "module.exports.default = function");
+assert.doesNotMatch(stubbed, /^import /m, "every import must be stubbed");
+
+// One pi process: a shared context, so globalThis outlives each runtime.
+function piProcess(env) {
+ const fakeProcess = Object.create(process, { env: { value: { ...env, PI_CHROME_BRIDGE_PORT: "0" } } });
+ const context = vm.createContext({
+ process: fakeProcess, console, Buffer, URL, AbortController, fetch,
+ 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 = () => {
+ const module = { exports: {} };
+ vm.runInContext(`(function (module, exports, require, __dirname) {\n${stubbed}\n})`, context)(
+ module, module.exports, createRequire(indexUrl), new URL(".", indexUrl).pathname,
+ );
+ const handlers = {}, commands = {}, status = {};
+ let active = [];
+ const pi = {
+ on: (name, fn) => { handlers[name] = fn; },
+ registerTool() {}, registerEntryRenderer() {}, appendEntry() {},
+ registerCommand: (name, command) => { commands[name] = command; },
+ getActiveTools: () => active,
+ setActiveTools: (tools) => { active = tools; },
+ };
+ module.exports.default(pi);
+ const ctx = {
+ cwd: process.cwd(),
+ ui: {
+ setStatus: (key, value) => { status[key] = value; },
+ notify() {}, confirm: async () => true, theme: { fg: (_color, text) => text },
+ },
+ };
+ runtime = {
+ footer: () => status.chrome,
+ chromeTools: () => active.some((name) => name.startsWith("chrome_")),
+ start: () => handlers.session_start({ type: "session_start" }, ctx),
+ shutdown: (reason) => handlers.session_shutdown({ type: "session_shutdown", reason }),
+ chrome: (args) => commands.chrome.handler(args, ctx),
+ };
+ return runtime;
+ };
+ // End the current runtime for `reason`, then start the next one.
+ const replace = async (reason) => {
+ await runtime.shutdown(reason);
+ const next = load();
+ await next.start();
+ return next;
+ };
+ // Stops the bridge and timers of the live runtime, so a failed assertion cannot hang the run.
+ return { load, replace, seedGrant, end: () => runtime?.shutdown("quit") };
+}
+
+test("PI_CHROME_AUTHORIZE grants every runtime without announcing it", async (t) => {
+ const pi = piProcess({ PI_CHROME_AUTHORIZE: "indefinite" });
+ t.after(() => pi.end());
+ const first = pi.load();
+ await first.start();
+ assert.equal(first.chromeTools(), true);
+ assert.equal(first.footer(), undefined);
+ for (const reason of ["reload", "new", "resume", "fork"]) {
+ const next = await pi.replace(reason);
+ assert.equal(next.chromeTools(), true, `tools after ${reason}`);
+ assert.equal(next.footer(), undefined, `footer after ${reason}`);
+ }
+});
+
+test("/chrome authorize survives /reload and ends with its session", async (t) => {
+ const pi = piProcess({});
+ t.after(() => pi.end());
+ const first = pi.load();
+ await first.start();
+ assert.equal(first.chromeTools(), false);
+ await first.chrome("authorize indefinite");
+ assert.equal(first.chromeTools(), true);
+ assert.equal(first.footer(), "● Chrome Bridge (indefinite)");
+
+ const reloaded = await pi.replace("reload");
+ assert.equal(reloaded.chromeTools(), true);
+ assert.equal(reloaded.footer(), "● Chrome Bridge (indefinite)");
+
+ for (const reason of ["new", "resume", "fork"]) {
+ await (await pi.replace("reload")).chrome("authorize indefinite");
+ const next = await pi.replace(reason);
+ assert.equal(next.chromeTools(), false, `tools after ${reason}`);
+ assert.equal(next.footer(), undefined, `footer after ${reason}`);
+ }
+});
+
+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.equal(first.chromeTools(), true);
+ assert.equal(first.footer(), undefined);
+
+ const reloaded = await pi.replace("reload");
+ 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/leave-site-prompt.test.mjs b/test-suite/unit/leave-site-prompt.test.mjs
new file mode 100644
index 0000000..63d844a
--- /dev/null
+++ b/test-suite/unit/leave-site-prompt.test.mjs
@@ -0,0 +1,108 @@
+// 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/);
+});
+
+test("a tab the debugger cannot attach to (another extension's frame) still navigates", async () => {
+ const { w } = worker(undefined);
+ w.attachDebugger = async () => { throw new Error("Cannot access a chrome-extension:// URL of different extension"); };
+ const result = await w.dispatch("page.navigate", { url: "https://other.test/", timeoutMs: 5000 });
+ assert.equal(result.id, 2);
+});
diff --git a/test-suite/unit/overlay.test.mjs b/test-suite/unit/overlay.test.mjs
new file mode 100644
index 0000000..b533ff8
--- /dev/null
+++ b/test-suite/unit/overlay.test.mjs
@@ -0,0 +1,95 @@
+// Another extension's prompt (1Password) blocks the debugger on a tab. Nothing closes it
+// automatically, because the user may be using it; the agent is told what is blocking and decides:
+// chrome_clear_overlay when it is only in the way, leave it when it is waiting for the user.
+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");
+
+const BLOCK = "Cannot access a chrome-extension:// URL of different extension";
+const frame = { id: "F", tabId: 2, type: "other", url: "chrome-extension://aeblf/inline/universal-sign-on/x.html?tabId=2#t", extensionId: undefined };
+
+function worker({ targets = [frame], clears = true, scriptResult = { removed: ["chrome-extension://aeblf/inline/universal-sign-on/x.html"] } } = {}) {
+ const calls = [];
+ const listener = { addListener() {}, removeListener() {} };
+ let cleared = false;
+ const w = {
+ console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); },
+ chrome: {
+ runtime: { id: "ours", 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(cleared ? [] : targets) },
+ storage: { session: { get: async () => ({}), set: async () => {} } },
+ scripting: { executeScript: async (options) => { calls.push({ method: "scripting", options }); if (clears) cleared = true; return [{ result: scriptResult }]; } },
+ tabs: { get: async () => ({ id: 2, url: "https://news.ycombinator.com/login", title: "login" }), query: async () => [{ id: 2, url: "https://news.ycombinator.com/login", title: "login" }] },
+ },
+ };
+ w.self = w;
+ vm.runInNewContext(workerSource, w);
+ w.sleep = async () => {};
+ w.getTabByParams = async () => ({ id: 2, url: "https://news.ycombinator.com/login", title: "login" });
+ w.attachDebugger = async () => { if (!cleared) throw new Error(BLOCK); };
+ return { w, calls, isCleared: () => cleared };
+}
+
+test("a blocked input is not dismissed with Escape: the agent is told and decides", async () => {
+ const { w, calls } = worker();
+ w.cdpRaw = async (_tab, method) => { calls.push({ method }); throw new Error(`${method}: ${BLOCK}`); };
+ await assert.rejects(
+ () => w.cdp(2, "Input.dispatchMouseEvent", {}),
+ (e) => /only in your way/.test(e.message) && /waiting for the user to sign in or approve/.test(e.message) && /leave it alone/.test(e.message) && /chrome_clear_overlay/.test(e.message) && /aeblf/.test(e.message),
+ );
+ assert.deepEqual(calls.map((c) => c.method), ["Input.dispatchMouseEvent"]);
+});
+
+test("overlay.clear removes the frame and reports the tab usable", async () => {
+ const { w, calls } = worker();
+ const result = await w.dispatch("overlay.clear", { targetId: "2" });
+ assert.equal(result.cleared, true);
+ assert.deepEqual(result.removedFrames, ["chrome-extension://aeblf/inline/universal-sign-on/x.html"]);
+ assert.equal(result.debuggerAttachable, true);
+ assert.equal(calls[0].options.args[0], "ours");
+ assert.equal(calls[0].options.world, undefined);
+});
+
+test("overlay.clear says so when the tab is still blocked (toolbar popup, desktop app)", async () => {
+ const { w } = worker({ clears: false, scriptResult: { removed: [] } });
+ const result = await w.dispatch("overlay.clear", { targetId: "2" });
+ assert.equal(result.cleared, false);
+ assert.equal(result.debuggerAttachable, false);
+ assert.deepEqual(result.remainingForeignTargets, ["chrome-extension://aeblf/inline/universal-sign-on/x.html"]);
+});
+
+test("the injected clean-up removes a host whose closed shadow root holds a foreign frame, and loose foreign frames, but not ours or the page's", () => {
+ const { w } = worker();
+ const frame = (src) => ({ tagName: "IFRAME", src, isConnected: true, removed: false, remove() { this.removed = true; } });
+ const foreign = frame("chrome-extension://aeblf/inline/a.html?x=1");
+ const ours = frame("chrome-extension://ours/panel.html");
+ const page = frame("https://example.com/embed");
+ const inClosedRoot = frame("chrome-extension://aeblf/inline/universal-sign-on.html?tabId=2");
+ const host = { tagName: "COM-1PASSWORD-USO", isConnected: true, removed: false, remove() { this.removed = true; }, shadowRoot: null, closedRoot: { querySelectorAll: () => [inClosedRoot] } };
+ const plain = { tagName: "DIV", isConnected: true, shadowRoot: null };
+ let blurred = false, escaped = false;
+ const sandbox = {
+ document: { querySelectorAll: (sel) => (sel === "iframe" ? [foreign, ours, page] : [foreign, ours, page, host, plain]), activeElement: { blur: () => { blurred = true; } }, body: {}, dispatchEvent: (e) => { escaped = e.key === "Escape"; } },
+ chrome: { dom: { openOrClosedShadowRoot: (el) => el.closedRoot ?? null } },
+ KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } },
+ Array, Set,
+ };
+ const result = vm.runInNewContext(`(${w.clearForeignOverlays.toString()})("ours")`, sandbox);
+ assert.deepEqual([...result.removed], ["chrome-extension://aeblf/inline/a.html", "<com-1password-uso> holding chrome-extension://aeblf/inline/universal-sign-on.html"]);
+ assert.ok(foreign.removed && host.removed);
+ assert.equal(ours.removed || page.removed || inClosedRoot.removed, false);
+ assert.ok(blurred && escaped);
+});
+
+test("the tool and the primer let the agent decide: clear it when in the way, leave it when waiting for the user", () => {
+ assert.match(indexSource, /name: "chrome_clear_overlay"/);
+ assert.match(indexSource, /Decide for yourself whether to use it/);
+ assert.match(indexSource, /do not clear it: tell the user what you are waiting for/);
+ assert.match(indexSource, /11\. If a tool fails saying another extension[^`]*decide whether it is in your way or part of the task/);
+ assert.match(indexSource, /"chrome_clear_overlay",\n\] as const/);
+});
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/session-cleanup.test.mjs b/test-suite/unit/session-cleanup.test.mjs
index 7e3285f..604e691 100644
--- a/test-suite/unit/session-cleanup.test.mjs
+++ b/test-suite/unit/session-cleanup.test.mjs
@@ -19,7 +19,7 @@ function harness(send, sessionKey = "session:test") {
const events = [];
let shutdown;
const token = Symbol();
- const globalState = { loaded: { token } };
+ const globalState = { loaded: { token }, auth: { until: "indefinite" } };
const context = {
AbortController, Promise,
setTimeout(fn, ms) { const key = Symbol(); timers.set(key, { fn, ms }); return key; },
@@ -27,7 +27,7 @@ function harness(send, sessionKey = "session:test") {
bridge: { send(...args) { events.push("send"); return send(...args); }, stop() { events.push("stop"); } },
sessionCtx: {}, sessionKeyFor: () => sessionKey ?? undefined,
clearAuthExpiryTimer() {}, clearCountdownInterval() {},
- globalState, PI_CHROME_GLOBAL_KEY: "loaded", instanceToken: token,
+ globalState, PI_CHROME_GLOBAL_KEY: "loaded", PI_CHROME_AUTH_KEY: "auth", instanceToken: token,
pi: { on(name, handler) { assert.equal(name, "session_shutdown"); shutdown = handler; } },
};
vm.runInNewContext(`${helper}\n${listener}`, context);
@@ -78,6 +78,14 @@ for (const reason of ["quit", "new", "resume", "fork"]) {
const h = harness(() => Promise.resolve({}));
await h.shutdown({ reason });
assert.deepEqual(h.events, ["send", "stop"]);
+ // A /chrome authorize grant stays with its session: a session switch drops it, quit leaves it
+ // for a parent session sharing this process.
+ assert.equal("auth" in h.globalState, reason === "quit", `grant after ${reason}`);
+}
+{
+ const h = harness(() => Promise.resolve({}));
+ await h.shutdown({ reason: "reload" });
+ assert.deepEqual(h.globalState.auth, { until: "indefinite" }, "grant after reload");
}
for (const send of [() => Promise.reject(new Error("offline")), () => { throw new Error("offline"); }]) {
const h = harness(send);
@@ -90,4 +98,4 @@ for (const send of [() => Promise.reject(new Error("offline")), () => { throw ne
await h.shutdown({ reason: "exit" });
assert.deepEqual(h.events, ["stop"]);
}
-console.log("session-cleanup: shutdown ordering, reload, deadline, failures, and missing identity passed");
+console.log("session-cleanup: shutdown ordering, reload, deadline, failures, missing identity, and grant scope passed");
diff --git a/test-suite/unit/shared-window.test.mjs b/test-suite/unit/shared-window.test.mjs
new file mode 100644
index 0000000..66041f4
--- /dev/null
+++ b/test-suite/unit/shared-window.test.mjs
@@ -0,0 +1,61 @@
+// Each session has its own window: a window shows only one tab, so sessions sharing one hid each
+// other's tabs (no real input, blank screenshots). A session's extra tabs go into its own window.
+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 });
+const ops = (calls) => calls.filter((c) => c.method !== "storage.set").map((c) => c.method);
+
+test("a new session gets its own window even when another session's window exists", 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), ["windows.create"]);
+});
+
+test("a session that already has a group adds its tab to that group's window", async () => {
+ const { w, calls } = worker({ groups: [piGroup(5, "Pi Session: claude-code 2 mine")] });
+ 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, 5);
+});
+
+test("chrome_tab new for a session with no group goes to the session's own 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: "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.url, "https://x.test/");
+ assert.equal(calls[0].props.focused, false);
+});