aboutsummaryrefslogtreecommitdiff
path: root/test-suite
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-09-26 16:37:25 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-09-26 16:37:25 -0400
commitc49dc15fa5c33d5b1d210b0185677ce29640e2d5 (patch)
treeb1f6ddcf387edb51213a6f9f1beb011fbe5c0bd3 /test-suite
parent4811ee616d2bbd5ead669a7e2f33a0bb14bd43ef (diff)
Absorb fork fixes and features; release 0.15.53
Adapted from community forks (ardhiqii, kkunkunya, nihar-oracle, steimerbyte): - Abort stalled /next long polls after 45s; time out, retry, and single-post results. - Fall back to CDP Runtime.evaluate when chrome.scripting is refused (about:blank automation tabs). - Load pi-chrome in same-process subagent sessions as a bridge client; client chrome_launch reports owner status. - Add chrome_cdp (validated raw CDP passthrough, background-guarded, payload summarization) and chrome_cdp_targets. - chrome_type reports before/after values, insertedAt, splice/unchanged warnings; add replace=true. - includeSnapshot waits for navigations started by the action; tab.new reports the settled tab. - CDP timeouts surface as timeouts and are not re-sent after the cleanup detach. - Activate/deactivate chrome_find and chrome_inspect with the rest of the chrome_* tools. - New unit suites: bridge-resilience, cdp-passthrough, type-evidence.
Diffstat (limited to 'test-suite')
-rw-r--r--test-suite/unit/background-policy.test.mjs4
-rw-r--r--test-suite/unit/bridge-resilience.test.mjs138
-rw-r--r--test-suite/unit/cdp-passthrough.test.mjs183
-rw-r--r--test-suite/unit/type-evidence.test.mjs208
4 files changed, 532 insertions, 1 deletions
diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs
index e78fa49..fe71634 100644
--- a/test-suite/unit/background-policy.test.mjs
+++ b/test-suite/unit/background-policy.test.mjs
@@ -24,7 +24,7 @@ function piHarness({ session = "alpha", send } = {}) {
let authorized = true;
const ctx = { key: `session:${session}`, title: `Pi Session: ${session}`, cwd: "/fixture", ui: { notify: (...args) => notices.push(args) } };
const bridge = {
- connected: true, status: () => ({}),
+ connected: true, status: () => ({}), connectionStatus: async () => ({ connected: true }),
async send(action, params, timeout, signal) {
calls.push({ action, params: clone(params), timeout, signal });
if (signal?.aborted) throw new Error("Chrome command aborted");
@@ -48,6 +48,7 @@ function piHarness({ session = "alpha", send } = {}) {
tabActionValues: [], snapshotModeValues: [], waitForValues: [], imageFormatValues: [],
safeJson: JSON.stringify, truncateText: (s) => s, formatChromeSnapshot: JSON.stringify,
formatChromeInspect: JSON.stringify, summarizeActionResult: () => "", formatIncludedSnapshotText: (_r, text) => text,
+ describeTypeEvidence: () => [], formatCdpResult: (_method, value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], details: { value } }),
workspaceCwd: () => ctx.cwd, ...path,
mkdir: async () => {}, writeFile: async (...args) => writes.push(args),
};
@@ -107,6 +108,7 @@ test("every registered page tool, tab.new, and chrome_launch(url) use the centra
["chrome_get_network_request", { requestId: "1" }, "page.network.get"], ["chrome_screenshot", {}, "page.screenshot"],
["chrome_hover", {}, "page.hover"], ["chrome_drag", {}, "page.drag"], ["chrome_tap", {}, "page.tap"],
["chrome_scroll", {}, "page.scroll"], ["chrome_upload_file", { paths: ["fixture.txt"] }, "page.upload"],
+ ["chrome_cdp", { method: "Runtime.evaluate", params: { expression: "1" } }, "cdp.call"],
];
for (const [name, params, action] of cases) {
await h.tool(name, { ...params, background: false, foreground: true });
diff --git a/test-suite/unit/bridge-resilience.test.mjs b/test-suite/unit/bridge-resilience.test.mjs
new file mode 100644
index 0000000..47495fc
--- /dev/null
+++ b/test-suite/unit/bridge-resilience.test.mjs
@@ -0,0 +1,138 @@
+// Bridge transport resilience in the shipped worker, plus same-process (subagent) loading in index.ts.
+// Worker timers are scaled 1000x down so the real 45s /next deadline elapses in ~45ms.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { stripTypeScriptTypes } from "node:module";
+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");
+
+function loadWorker(fetchImpl) {
+ const listener = { addListener() {}, removeListener() {} };
+ const chrome = {
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.1" }), getURL: (f) => `chrome-extension://test/${f}`, onInstalled: listener, onStartup: listener, reload() {} },
+ alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener },
+ debugger: { onDetach: listener }, scripting: {}, tabs: { onUpdated: listener },
+ };
+ const sandbox = {
+ chrome, console, AbortController, URL,
+ navigator: { userAgent: "unit-test" },
+ setTimeout: (fn, ms, ...args) => setTimeout(fn, Math.ceil((ms || 0) / 1000), ...args),
+ clearTimeout, setInterval: () => 0, clearInterval() {},
+ fetch: fetchImpl,
+ };
+ sandbox.self = sandbox;
+ vm.runInNewContext(workerSource, sandbox);
+ return sandbox;
+}
+
+function response(status, body, headers = {}) {
+ return { ok: status >= 200 && status < 300, status, headers: { get: (name) => headers[name.toLowerCase()] ?? null }, json: async () => body };
+}
+
+test("a half-open /next long poll is aborted at its deadline and polling resumes", async () => {
+ const requests = [];
+ let aborted = 0;
+ const worker = loadWorker((url, options = {}) => {
+ requests.push({ url, options });
+ if (requests.length === 1) {
+ // Zombie socket: never settles unless aborted.
+ return new Promise((_resolve, reject) => {
+ options.signal.addEventListener("abort", () => {
+ aborted++;
+ const error = new Error("The operation was aborted");
+ error.name = "AbortError";
+ reject(error);
+ });
+ });
+ }
+ return Promise.reject(new Error("bridge down"));
+ });
+ const first = worker.pollLoop();
+ assert.ok(requests[0]?.options.signal, "the /next fetch carries an abort signal");
+ // Resolves only after the scaled 45s deadline fires and the 2s backoff elapses.
+ await first;
+ assert.equal(aborted, 1, "the stalled /next fetch was aborted");
+ await worker.pollLoop();
+ assert.equal(requests.length, 2, "a later poll opens a fresh /next request instead of staying parked");
+});
+
+test("a command result is posted exactly once, retried on transport failure, and not retried on 4xx", async () => {
+ const posts = [];
+ let postStatus = [500, 200];
+ const worker = loadWorker(async (url, options = {}) => {
+ if (url.includes("/result")) {
+ posts.push(JSON.parse(options.body));
+ const status = postStatus.shift() ?? 200;
+ if (status === 0) throw new Error("fetch failed");
+ return response(status, {});
+ }
+ throw new Error("not polling in this test");
+ });
+ await worker.handleCommand({ id: "cmd-1", action: "tab.version", params: {} });
+ assert.equal(posts.length, 2, "one retry after HTTP 500");
+ assert.ok(posts.every((p) => p.id === "cmd-1" && p.ok === true), "the retry re-sends the success payload, not an error payload");
+
+ posts.length = 0;
+ postStatus = [0, 0, 0];
+ await worker.handleCommand({ id: "cmd-2", action: "tab.version", params: {} });
+ assert.equal(posts.length, 3, "network failures retry up to the attempt limit");
+ assert.ok(posts.every((p) => p.ok === true), "a failed success-post never turns into a second, error result");
+
+ posts.length = 0;
+ postStatus = [404];
+ await worker.handleCommand({ id: "cmd-3", action: "no.such.action", params: {} });
+ assert.equal(posts.length, 1, "4xx (unknown command id) is final");
+ assert.equal(posts[0].ok, false);
+ assert.match(posts[0].error, /Unknown action/);
+});
+
+test("cdp.call widens only its own command deadline", () => {
+ const worker = loadWorker(async () => { throw new Error("offline"); });
+ assert.equal(worker.commandTimeoutMs("page.click", { timeoutMs: 90_000 }), 25_000);
+ assert.equal(worker.commandTimeoutMs("cdp.call", {}), 25_000);
+ assert.equal(worker.commandTimeoutMs("cdp.call", { timeoutMs: 60_000 }), 65_000);
+ assert.equal(worker.commandTimeoutMs("cdp.call", { timeoutMs: 10_000_000 }), 125_000, "capped below the MV3 worker lifetime");
+});
+
+// ---- index.ts: a subagent session loads pi-chrome again in the same process. ----
+function loadFactoryPrelude(globalState, root) {
+ const from = indexSource.indexOf("\tconst alreadyLoaded = globalState[PI_CHROME_GLOBAL_KEY];");
+ const to = indexSource.indexOf("\tconst bridge = new ChromeProfileBridge(", from);
+ assert.ok(from > 0 && to > from, "factory prelude section");
+ const body = stripTypeScriptTypes(`(() => {\n${indexSource.slice(from, to)}\nreturn true;\n})()`);
+ const warnings = [];
+ const token = Symbol(root);
+ const sandbox = {
+ globalState, currentRoot: root, instanceToken: token, PI_CHROME_GLOBAL_KEY: "loaded", PI_CHROME_VERSION: "9.9.9",
+ console: { warn: (msg) => warnings.push(msg) },
+ };
+ const loaded = vm.runInNewContext(body, sandbox);
+ return { loaded: loaded === true, token, warnings };
+}
+
+test("a same-root second load (subagent session) is not skipped, and does not steal the singleton flag", () => {
+ const globalState = {};
+ const parent = loadFactoryPrelude(globalState, "/pkg/pi-chrome");
+ assert.equal(parent.loaded, true);
+ assert.equal(globalState.loaded.token, parent.token);
+ const subagent = loadFactoryPrelude(globalState, "/pkg/pi-chrome");
+ assert.equal(subagent.loaded, true, "subagent gets its own pi-chrome instance and chrome_* tools");
+ assert.equal(subagent.warnings.length, 0);
+ assert.equal(globalState.loaded.token, parent.token, "only the first instance owns (and later clears) the flag");
+});
+
+test("a second install root is still treated as a duplicate", () => {
+ const globalState = {};
+ loadFactoryPrelude(globalState, "/npm/pi-chrome");
+ const other = loadFactoryPrelude(globalState, "/checkout/pi-chrome");
+ assert.equal(other.loaded, false);
+ assert.match(other.warnings[0], /already loaded from \/npm\/pi-chrome/);
+});
+
+test("a stale same-root flag from an older release does not block loading", () => {
+ const globalState = { loaded: { version: "0.15.19", root: "/pkg/pi-chrome" } };
+ assert.equal(loadFactoryPrelude(globalState, "/pkg/pi-chrome").loaded, true);
+});
diff --git a/test-suite/unit/cdp-passthrough.test.mjs b/test-suite/unit/cdp-passthrough.test.mjs
new file mode 100644
index 0000000..c14c4d7
--- /dev/null
+++ b/test-suite/unit/cdp-passthrough.test.mjs
@@ -0,0 +1,183 @@
+// Raw CDP passthrough (cdp.call / cdp.targets / chrome_cdp formatting) and the scripting→CDP fallback
+// that keeps page actions working on about:blank automation tabs.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { stripTypeScriptTypes } from "node:module";
+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 ABOUT_BLANK_DENIED = 'Cannot access contents of url "about:blank". Extension manifest must request permission to access this host.';
+
+function harness({ targets = [], respond } = {}) {
+ const calls = [], attached = [], hung = [];
+ const listener = { addListener() {}, removeListener() {} };
+ const page = vm.createContext({ location: { href: "about:blank" } });
+ page.window = page;
+ page.globalThis = page;
+ const chrome = {
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.1" }), getURL: (f) => `chrome-extension://test/${f}`, onInstalled: listener, onStartup: listener, lastError: null },
+ alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener },
+ debugger: {
+ onDetach: listener,
+ getTargets: (cb) => cb(targets),
+ attach: async () => {},
+ // Real Chrome fails in-flight commands with "Detached while handling command" on detach.
+ detach: async () => {
+ for (const cb of hung.splice(0)) {
+ chrome.runtime.lastError = { message: "Detached while handling command." };
+ cb(undefined);
+ chrome.runtime.lastError = null;
+ }
+ },
+ sendCommand: (debuggee, method, params, cb) => {
+ calls.push({ method, params: JSON.parse(JSON.stringify(params)) });
+ const reply = respond ? respond(method, params) : undefined;
+ if (reply === "never") { hung.push(cb); return; } // simulate a hung CDP command
+ Promise.resolve(reply).then((value) => {
+ if (method === "Runtime.evaluate" && value === undefined) {
+ Promise.resolve().then(() => vm.runInContext(params.expression, page)).then(
+ (v) => { const json = JSON.stringify(v); cb({ result: json === undefined ? { type: typeof v } : { value: JSON.parse(json) } }); },
+ (error) => cb({ exceptionDetails: { text: error.message, exception: { description: error.message } } }),
+ );
+ return;
+ }
+ cb(value);
+ });
+ },
+ },
+ scripting: { executeScript: async () => { throw new Error(ABOUT_BLANK_DENIED); } },
+ tabs: { onUpdated: listener, get: async (id) => ({ id, windowId: 1, url: "about:blank", status: "complete" }) },
+ };
+ const sources = new Map();
+ const worker = {
+ chrome, console, AbortController, URL,
+ navigator: { userAgent: "unit-test" },
+ setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {},
+ fetch: async (url) => {
+ const file = String(url).replace("chrome-extension://test/", "");
+ if (!sources.has(file)) return { ok: false, status: 404, text: async () => "" };
+ return { ok: true, status: 200, text: async () => sources.get(file) };
+ },
+ };
+ worker.self = worker;
+ vm.runInNewContext(workerSource, worker);
+ worker.getTabByParams = async (params, opts = {}) => {
+ if (params.targetId === "missing") throw new Error("No Chrome tab with id 999");
+ if (opts.createOwnedTarget === false && params.targetId === undefined) return null;
+ return { id: Number(params.targetId ?? 5), windowId: 1, url: "https://fixture.test/", title: "Fixture" };
+ };
+ worker.bringToFront = async (tab) => tab;
+ const realAttach = worker.attachDebugger;
+ worker.attachDebugger = async (tabId) => { attached.push(tabId); return realAttach(tabId); };
+ return { worker, chrome, calls, attached, page, sources, cdpMethods: () => calls.map((c) => c.method) };
+}
+
+test("cdp.call validates method/params before touching the debugger", async () => {
+ const h = harness();
+ await assert.rejects(h.worker.dispatch("cdp.call", { method: " " }), /non-empty string "method"/);
+ await assert.rejects(h.worker.dispatch("cdp.call", { method: "Runtime.evaluate", params: [1] }), /must be a plain object/);
+ await assert.rejects(h.worker.dispatch("cdp.call", { method: "Runtime.evaluate", params: "x" }), /must be a plain object/);
+ assert.equal(h.attached.length, 0);
+ assert.equal(h.calls.length, 0);
+});
+
+test("cdp.call sends the exact method and params to the resolved tab and returns the raw result", async () => {
+ const h = harness({ respond: (method) => method === "Network.getCookies" ? { cookies: [{ name: "sid" }] } : {} });
+ const result = await h.worker.dispatch("cdp.call", { targetId: "7", method: " Network.getCookies ", params: { urls: ["https://fixture.test"] } });
+ assert.deepEqual(JSON.parse(JSON.stringify(result)), { cookies: [{ name: "sid" }] });
+ assert.deepEqual(h.attached, [7]);
+ assert.deepEqual(h.calls.at(-1), { method: "Network.getCookies", params: { urls: ["https://fixture.test"] } });
+});
+
+test("background mode blocks focus-stealing raw CDP methods; foreground allows them", async () => {
+ const h = harness();
+ for (const method of ["Page.bringToFront", "Target.activateTarget"]) {
+ await assert.rejects(h.worker.dispatch("cdp.call", { method, background: true, foreground: false }), /blocked by background mode/);
+ }
+ assert.equal(h.calls.length, 0);
+ await h.worker.dispatch("cdp.call", { method: "Page.bringToFront", background: false, foreground: true });
+ assert.equal(h.cdpMethods().at(-1), "Page.bringToFront");
+});
+
+test("cdp.call honours timeoutMs and detaches a hung session", async () => {
+ const h = harness({ respond: (method) => method === "Debugger.pause" ? "never" : undefined });
+ const started = Date.now();
+ await assert.rejects(h.worker.dispatch("cdp.call", { method: "Debugger.pause", timeoutMs: 60 }), /CDP Debugger\.pause timed out after 60ms/);
+ assert.ok(Date.now() - started < 2_000, "custom deadline used instead of a longer default");
+ assert.equal(h.cdpMethods().filter((m) => m === "Debugger.pause").length, 1, "a timed-out command is not re-sent after the cleanup detach");
+ assert.equal(h.worker.cdpCallTimeoutMs({}), 5_000);
+ assert.equal(h.worker.cdpCallTimeoutMs({ timeoutMs: 999_999 }), 120_000);
+});
+
+test("cdp.targets lists only the resolved tab's targets, never creates a target, and surfaces explicit misses", async () => {
+ const h = harness({ targets: [
+ { id: "a", tabId: 5, type: "page", url: "https://fixture.test/", attached: true },
+ { id: "b", tabId: 5, type: "other", url: "chrome-extension://pw/overlay.html", attached: false, extensionId: "pw" },
+ { id: "c", tabId: 9, type: "page", url: "https://private.example/secret", attached: false },
+ ] });
+ const listed = await h.worker.dispatch("cdp.targets", { targetId: "5" });
+ assert.deepEqual(listed.targets.map((t) => t.id), ["a", "b"]);
+ assert.equal(listed.otherTabTargetCount, 1);
+ assert.ok(!JSON.stringify(listed).includes("private.example"), "other tabs' URLs are not echoed");
+ const none = await h.worker.dispatch("cdp.targets", {});
+ assert.equal(none.tab, null);
+ assert.equal(none.targets.length, 0);
+ await assert.rejects(h.worker.dispatch("cdp.targets", { targetId: "missing" }), /No Chrome tab with id/);
+ assert.equal(h.attached.length, 0, "diagnostics never attach the debugger");
+});
+
+test("scripting denied on about:blank falls back to CDP for func and packaged-file injection", async () => {
+ const h = harness();
+ h.sources.set("snapshot_injected.js", "globalThis.__piChromeSnapshotPage = async (max, _c, _r, _n, mode) => ({ url: location.href, max, mode });");
+ const snapshot = await h.worker.dispatch("page.snapshot", { targetId: "5", maxElements: 12, mode: "forms" });
+ assert.deepEqual(JSON.parse(JSON.stringify(snapshot)), { url: "about:blank", max: 12, mode: "forms" });
+ assert.ok(h.cdpMethods().filter((m) => m === "Runtime.evaluate").length >= 2, "file and func both ran through Runtime.evaluate");
+
+ const clicked = await h.worker.resolveTargetInTab(5, { x: 10, y: 20 });
+ assert.equal(clicked.found, true, "input target resolution also survives denied scripting");
+});
+
+test("non-permission scripting failures are not masked by the CDP fallback", async () => {
+ const h = harness();
+ h.chrome.scripting.executeScript = async () => { throw new Error("Frame with ID 0 was removed."); };
+ await assert.rejects(h.worker.executeScriptWithFallback({ target: { tabId: 5 }, func: () => 1 }, "x"), /Frame with ID 0 was removed/);
+ assert.equal(h.calls.length, 0);
+ assert.equal(h.worker.isScriptingAccessDenied(new Error(ABOUT_BLANK_DENIED)), true);
+});
+
+test("CDP fallback reports page exceptions with the call-site label", async () => {
+ const h = harness();
+ await assert.rejects(
+ h.worker.executeScriptWithFallback({ target: { tabId: 5 }, func: () => { throw new Error("boom in page"); }, args: [] }, "probe page"),
+ /probe page: .*boom in page/,
+ );
+});
+
+// ---- Pi-side formatting for chrome_cdp results ----
+function section(start, end) {
+ const from = indexSource.indexOf(start);
+ const to = indexSource.indexOf(end, from);
+ assert.ok(from >= 0 && to > from, `Missing source section: ${start}`);
+ return indexSource.slice(from, to);
+}
+const formatCdpResult = vm.runInNewContext(`${stripTypeScriptTypes(section("const CDP_OVERSIZE_JSON_CHARS", "\nfunction extensionRoot"))}; formatCdpResult`, {
+ safeJson: (v) => JSON.stringify(v, null, 2),
+ truncateText: (s) => (s.length > 30_000 ? `${s.slice(0, 30_000)}[truncated]` : s),
+});
+
+test("chrome_cdp never returns screenshot/binary payloads or oversized details verbatim", () => {
+ const data = "A".repeat(4000) + "==";
+ const shot = formatCdpResult("Page.captureScreenshot", { data });
+ assert.ok(!shot.content[0].text.includes("AAAA"));
+ assert.match(shot.content[0].text, /~2999 bytes/);
+ assert.equal(shot.details.value.omitted, "data-field");
+
+ const huge = formatCdpResult("DOM.getOuterHTML", { outerHTML: "x".repeat(300_000) });
+ assert.equal(huge.details.value.omitted, "oversized-result");
+ assert.ok(huge.content[0].text.length < 31_000);
+
+ const small = formatCdpResult("Runtime.evaluate", { result: { type: "number", value: 2 } });
+ assert.deepEqual(small.details.value, { result: { type: "number", value: 2 } });
+});
diff --git a/test-suite/unit/type-evidence.test.mjs b/test-suite/unit/type-evidence.test.mjs
new file mode 100644
index 0000000..f0a51c1
--- /dev/null
+++ b/test-suite/unit/type-evidence.test.mjs
@@ -0,0 +1,208 @@
+// chrome_type before/after evidence, replace=true, navigation-aware includeSnapshot, and tab.new load
+// settling. Chrome and the page are mocked; live caret behavior is covered by challenge 44.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { stripTypeScriptTypes } from "node:module";
+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 clone = (value) => JSON.parse(JSON.stringify(value));
+
+function makeInput({ value = "", caret = value.length, type = "text", name = "q" } = {}) {
+ return {
+ tagName: "INPUT", type, name, id: name, isConnected: true, isContentEditable: false,
+ value, selectionStart: caret, selectionEnd: caret,
+ getAttribute(attr) { return attr === "type" ? this.type : attr === "name" ? this.name : null; },
+ scrollIntoView() {}, getBoundingClientRect: () => ({ left: 10, top: 10, width: 300, height: 30 }),
+ focus() {},
+ };
+}
+
+function harness(element, { platform = "MacIntel", tabs = {} } = {}) {
+ const calls = [];
+ const listeners = new Set();
+ const page = vm.createContext({
+ document: { activeElement: element, querySelector: (s) => (s === "#q" ? element : null), createRange: () => ({}) },
+ getSelection: () => null,
+ location: { href: "https://fixture.test/" },
+ __PI_CHROME_STATE__: { elements: { "el-1": element } },
+ });
+ page.window = page;
+ const listener = { addListener() {}, removeListener() {} };
+ const chrome = {
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.1" }), onInstalled: listener, onStartup: listener },
+ alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener },
+ debugger: { onDetach: listener },
+ scripting: { executeScript: async ({ func, args = [] }) => [{ result: await vm.runInContext(`(${func.toString()})`, page)(...args) }] },
+ tabs: {
+ onUpdated: { addListener: (fn) => listeners.add(fn), removeListener: (fn) => listeners.delete(fn) },
+ get: async (id) => clone(tabs[id] ?? { id, windowId: 1, url: "https://fixture.test/", status: "complete" }),
+ },
+ };
+ const worker = { chrome, console, AbortController, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test", platform }, fetch: async () => { throw new Error("offline"); } };
+ worker.self = worker;
+ vm.runInNewContext(workerSource, worker);
+ worker.sleep = async () => {};
+ worker.getTabByParams = async (params) => clone(tabs[Number(params.targetId ?? 2)] ?? { id: Number(params.targetId ?? 2), windowId: 1, url: "https://fixture.test/", status: "complete" });
+ worker.bringToFront = async (tab) => tab;
+ worker.attachDebugger = async () => {};
+ worker.cdpMoveTo = async () => {};
+ const splice = (text) => {
+ if (typeof element.value !== "string") return;
+ const { value, selectionStart: s, selectionEnd: e } = element;
+ element.value = value.slice(0, s) + text + value.slice(e);
+ element.selectionStart = element.selectionEnd = s + text.length;
+ };
+ worker.cdp = async (_tabId, method, params = {}) => {
+ calls.push({ method, params: clone(params) });
+ if (method === "Input.dispatchKeyEvent") {
+ if ((params.commands || []).includes("selectAll")) { element.selectionStart = 0; element.selectionEnd = element.value.length; }
+ else if (params.type === "keyDown" && params.key === "Delete") {
+ if (element.selectionEnd > element.selectionStart) splice("");
+ } else if (params.type === "keyDown" && params.text && params.key !== "Enter") splice(params.text);
+ }
+ return {};
+ };
+ return { worker, calls, element, listeners, fire: (tabId, info) => { for (const fn of [...listeners]) fn(tabId, info); } };
+}
+
+test("typing into the middle of a prefilled field reports the splice instead of a bare count", async () => {
+ const h = harness(makeInput({ value: "why do flamingos stand", caret: 7 }));
+ const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "cats " });
+ assert.equal(h.element.value, "why do cats flamingos stand");
+ assert.equal(result.valueBefore, "why do flamingos stand");
+ assert.equal(result.valueAfter, "why do cats flamingos stand");
+ assert.equal(result.existingTextLengthBefore, 22);
+ assert.equal(result.insertedAt, "caret-middle");
+ assert.equal(result.replaced, undefined);
+});
+
+test("empty and caret-end targets are classified without warnings", async () => {
+ const empty = harness(makeInput());
+ assert.equal((await empty.worker.dispatch("page.type", { targetId: "2", uid: "el-1", text: "hi" })).insertedAt, "empty");
+ const end = harness(makeInput({ value: "hello" }));
+ const result = await end.worker.dispatch("page.type", { targetId: "2", uid: "el-1", text: "!" });
+ assert.equal(result.insertedAt, "caret-end");
+ assert.equal(result.valueAfter, "hello!");
+});
+
+for (const [platform, modifiers] of [["MacIntel", 4], ["Win32", 2]]) {
+ test(`replace=true selects all with the platform shortcut + selectAll command, then deletes (${platform})`, async () => {
+ const h = harness(makeInput({ value: "old query text", caret: 3 }), { platform });
+ const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "new", replace: true });
+ assert.equal(h.element.value, "new");
+ assert.equal(result.replaced, true);
+ assert.equal(result.insertedAt, "replaced-all");
+ assert.equal(result.valueBefore, "old query text", "before is read prior to select-all/delete");
+ const selectAll = h.calls.find((c) => (c.params.commands || []).includes("selectAll"));
+ assert.equal(selectAll.params.modifiers, modifiers);
+ });
+}
+
+test("sensitive fields never expose contents in type evidence", async () => {
+ const h = harness(makeInput({ value: "hunter2", type: "password", name: "password" }));
+ const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "x" });
+ assert.equal(result.valueRedacted, true);
+ assert.equal(result.valueBefore, undefined);
+ assert.equal(result.valueAfter, undefined);
+ assert.ok(!JSON.stringify(result).includes("hunter2"));
+ assert.equal(result.existingTextLengthBefore, 7);
+});
+
+test("a non-text focus target reports no value evidence rather than a false empty field", async () => {
+ const h = harness({ tagName: "DIV", isConnected: true, isContentEditable: false, scrollIntoView() {}, getBoundingClientRect: () => ({ left: 0, top: 0, width: 10, height: 10 }) });
+ const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "x" });
+ assert.equal(result.valueBefore, undefined);
+ assert.equal(result.insertedAt, undefined);
+});
+
+test("includeSnapshot waits for a navigation the action started and snapshots the new page", async () => {
+ const tabs = { 2: { id: 2, windowId: 1, url: "https://fixture.test/", status: "complete" } };
+ const h = harness(makeInput(), { tabs });
+ h.worker.chromeInputKey = async () => {
+ tabs[2] = { ...tabs[2], url: "https://fixture.test/search?q=x", status: "loading" };
+ setTimeout(() => { tabs[2].status = "complete"; h.fire(2, { status: "complete" }); }, 20);
+ return { input: "chrome", key: "Enter" };
+ };
+ h.worker.snapshotInTab = async (params) => ({ url: tabs[Number(params.targetId)].url, status: tabs[Number(params.targetId)].status });
+ const out = await h.worker.dispatch("page.key", { targetId: "2", key: "Enter", includeSnapshot: true });
+ assert.equal(out.navigation.from, "https://fixture.test/");
+ assert.equal(out.navigation.to, "https://fixture.test/search?q=x");
+ assert.equal(out.navigation.settled, true);
+ assert.deepEqual(clone(out.snapshot), { url: "https://fixture.test/search?q=x", status: "complete" });
+ assert.equal(h.listeners.size, 0, "listener removed");
+});
+
+test("a non-navigating action snapshots immediately; an already-loading page is not waited on", async () => {
+ const tabs = { 2: { id: 2, windowId: 1, url: "https://fixture.test/", status: "complete" } };
+ const h = harness(makeInput(), { tabs });
+ h.worker.chromeInputClick = async () => ({ input: "chrome" });
+ h.worker.snapshotInTab = async () => ({ ok: true });
+ const plain = await h.worker.dispatch("page.click", { targetId: "2", selector: "#q", includeSnapshot: true });
+ assert.equal(plain.navigation, undefined);
+
+ tabs[2].status = "loading"; // e.g. a hanging subresource, same URL
+ const started = Date.now();
+ const busy = await h.worker.dispatch("page.click", { targetId: "2", selector: "#q", includeSnapshot: true });
+ assert.ok(Date.now() - started < 1_000, "no bounded wait for a load the action did not start");
+ assert.equal(busy.navigation.settled, false);
+ assert.equal(busy.navigation.waitedMs < 1_000, true);
+});
+
+test("tab.new reports the settled tab and loadStatus instead of the t=0 loading stub", async () => {
+ const tabs = {};
+ const h = harness(makeInput(), { tabs });
+ h.worker.findGroupRecordByTitle = async () => null;
+ h.worker.trackSessionTab = async () => {};
+ h.worker.groupTab = async (tab) => ({ tab: { id: tab.id, url: tab.url, status: tab.status }, group: { title: "Pi Session: t" } });
+ h.worker.formatTab = async (tab) => ({ id: tab.id, url: tab.url, status: tab.status, title: tab.title });
+ h.worker.chrome.tabs.create = async ({ url }) => {
+ tabs[9] = { id: 9, windowId: 1, url: "", title: "", status: "loading" };
+ setTimeout(() => { tabs[9] = { ...tabs[9], url, title: "Loaded", status: "complete" }; h.fire(9, { status: "complete" }); }, 20);
+ return clone(tabs[9]);
+ };
+ const out = await h.worker.dispatch("tab.new", { url: "https://fixture.test/page", background: true });
+ assert.equal(out.loadStatus, "complete");
+ assert.deepEqual(clone(out.tab), { id: 9, url: "https://fixture.test/page", status: "complete", title: "Loaded" });
+ assert.equal(out.group.title, "Pi Session: t");
+});
+
+// ---- Pi-side text ----
+function section(start, end) {
+ const from = indexSource.indexOf(start);
+ const to = indexSource.indexOf(end, from);
+ assert.ok(from >= 0 && to > from, `Missing source section: ${start}`);
+ return indexSource.slice(from, to);
+}
+const pi = vm.runInNewContext(`${stripTypeScriptTypes(section("function formatIncludedSnapshotText(", "\n// Keep raw CDP"))}; ({ formatIncludedSnapshotText, describeTypeEvidence })`, {
+ formatChromeSnapshot: (s) => `SNAPSHOT ${s.url}`,
+});
+
+test("chrome_type text warns on splices, mentions spliced submits, and redacts secrets", () => {
+ const spliced = pi.describeTypeEvidence({ valueBefore: "abc", valueAfter: "aXbc", insertedAt: "caret-middle" }, 1, true).join("\n");
+ assert.match(spliced, /Field went from "abc" to "aXbc"/);
+ assert.match(spliced, /spliced into the middle/);
+ assert.match(spliced, /submitted the spliced value/);
+ const appended = pi.describeTypeEvidence({ valueBefore: "abc", valueAfter: "abcX", insertedAt: "caret-end" }, 1, false).join("\n");
+ assert.match(appended, /appended to existing content/);
+ assert.doesNotMatch(appended, /⚠/);
+ const secret = pi.describeTypeEvidence({ valueRedacted: true, existingTextLengthBefore: 7, valueLengthAfter: 8, insertedAt: "caret-end" }, 1, false).join("\n");
+ assert.match(secret, /\[redacted\] \(7 → 8 chars/);
+ assert.equal(pi.describeTypeEvidence({ input: "chrome" }, 1, false).length, 0);
+ const lost = pi.describeTypeEvidence({ valueBefore: "abc", valueAfter: "abc", existingTextLengthBefore: 3, valueLengthAfter: 3, insertedAt: "caret-middle" }, 5, true).join("\n");
+ assert.match(lost, /did not change/);
+ assert.doesNotMatch(lost, /spliced/, "an unchanged field is not reported as a splice");
+ const lostSecret = pi.describeTypeEvidence({ valueRedacted: true, existingTextLengthBefore: 3, valueLengthAfter: 3, insertedAt: "caret-end" }, 2, false).join("\n");
+ assert.match(lostSecret, /did not change/);
+});
+
+test("included snapshots state whether a navigation settled", () => {
+ const settled = pi.formatIncludedSnapshotText({ snapshot: { url: "https://b.test/" }, navigation: { from: "https://a.test/", to: "https://b.test/", settled: true, waitedMs: 120 } }, "Pressed Enter.");
+ assert.match(settled, /Navigated https:\/\/a\.test\/ → https:\/\/b\.test\/ \(waited 120ms/);
+ assert.match(settled, /SNAPSHOT https:\/\/b\.test\//);
+ const stale = pi.formatIncludedSnapshotText({ snapshot: { url: "https://a.test/" }, navigation: { from: "https://a.test/", to: "https://b.test/", settled: false, waitedMs: 5000 } }, "Pressed Enter.");
+ assert.match(stale, /⚠ The action started a navigation that had not finished after 5000ms/);
+ assert.equal(pi.formatIncludedSnapshotText({ result: {} }, "Clicked."), "Clicked.");
+});