diff options
Diffstat (limited to 'test-suite')
| -rw-r--r-- | test-suite/unit/background-policy.test.mjs | 12 | ||||
| -rw-r--r-- | test-suite/unit/grant-scope.test.mjs | 30 | ||||
| -rw-r--r-- | test-suite/unit/sandboxed-snapshot.test.mjs | 108 | ||||
| -rw-r--r-- | test-suite/unit/shared-window.test.mjs | 45 |
4 files changed, 158 insertions, 37 deletions
diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs index efbca78..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); 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/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 index 7afeb77..66041f4 100644 --- a/test-suite/unit/shared-window.test.mjs +++ b/test-suite/unit/shared-window.test.mjs @@ -1,5 +1,5 @@ -// 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. +// 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"; @@ -35,44 +35,27 @@ function worker({ groups = [], windows = { 5: { id: 5, type: "normal" } } } = {} } 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's tab goes into the window that already holds another Pi session", async () => { +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(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["tabs.create"]); - assert.equal(calls[0].props.windowId, 5); + assert.deepEqual(ops(calls), ["windows.create"]); }); -test("with no Pi window yet, the first session creates one", async () => { - const { w, calls } = worker({ groups: [] }); +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(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["windows.create"]); + assert.deepEqual(ops(calls), ["tabs.create"]); + assert.equal(calls.find((c) => c.method === "tabs.create").props.windowId, 5); }); -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 () => { +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: "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); + 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); }); |
