pi-chrome 0.15.49 → 0.15.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ // Raw CDP passthrough (cdp.call / cdp.targets / chrome_cdp formatting) and the scripting→CDP fallback
2
+ // that keeps page actions working on about:blank automation tabs.
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import vm from "node:vm";
6
+ import { stripTypeScriptTypes } from "node:module";
7
+ import { test } from "node:test";
8
+
9
+ const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
10
+ const indexSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8");
11
+ const ABOUT_BLANK_DENIED = 'Cannot access contents of url "about:blank". Extension manifest must request permission to access this host.';
12
+
13
+ function harness({ targets = [], respond } = {}) {
14
+ const calls = [], attached = [], hung = [];
15
+ const listener = { addListener() {}, removeListener() {} };
16
+ const page = vm.createContext({ location: { href: "about:blank" } });
17
+ page.window = page;
18
+ page.globalThis = page;
19
+ const chrome = {
20
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.1" }), getURL: (f) => `chrome-extension://test/${f}`, onInstalled: listener, onStartup: listener, lastError: null },
21
+ alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener },
22
+ debugger: {
23
+ onDetach: listener,
24
+ getTargets: (cb) => cb(targets),
25
+ attach: async () => {},
26
+ // Real Chrome fails in-flight commands with "Detached while handling command" on detach.
27
+ detach: async () => {
28
+ for (const cb of hung.splice(0)) {
29
+ chrome.runtime.lastError = { message: "Detached while handling command." };
30
+ cb(undefined);
31
+ chrome.runtime.lastError = null;
32
+ }
33
+ },
34
+ sendCommand: (debuggee, method, params, cb) => {
35
+ calls.push({ method, params: JSON.parse(JSON.stringify(params)) });
36
+ const reply = respond ? respond(method, params) : undefined;
37
+ if (reply === "never") { hung.push(cb); return; } // simulate a hung CDP command
38
+ Promise.resolve(reply).then((value) => {
39
+ if (method === "Runtime.evaluate" && value === undefined) {
40
+ Promise.resolve().then(() => vm.runInContext(params.expression, page)).then(
41
+ (v) => { const json = JSON.stringify(v); cb({ result: json === undefined ? { type: typeof v } : { value: JSON.parse(json) } }); },
42
+ (error) => cb({ exceptionDetails: { text: error.message, exception: { description: error.message } } }),
43
+ );
44
+ return;
45
+ }
46
+ cb(value);
47
+ });
48
+ },
49
+ },
50
+ scripting: { executeScript: async () => { throw new Error(ABOUT_BLANK_DENIED); } },
51
+ tabs: { onUpdated: listener, get: async (id) => ({ id, windowId: 1, url: "about:blank", status: "complete" }) },
52
+ };
53
+ const sources = new Map();
54
+ const worker = {
55
+ chrome, console, AbortController, URL,
56
+ navigator: { userAgent: "unit-test" },
57
+ setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {},
58
+ fetch: async (url) => {
59
+ const file = String(url).replace("chrome-extension://test/", "");
60
+ if (!sources.has(file)) return { ok: false, status: 404, text: async () => "" };
61
+ return { ok: true, status: 200, text: async () => sources.get(file) };
62
+ },
63
+ };
64
+ worker.self = worker;
65
+ vm.runInNewContext(workerSource, worker);
66
+ worker.getTabByParams = async (params, opts = {}) => {
67
+ if (params.targetId === "missing") throw new Error("No Chrome tab with id 999");
68
+ if (opts.createOwnedTarget === false && params.targetId === undefined) return null;
69
+ return { id: Number(params.targetId ?? 5), windowId: 1, url: "https://fixture.test/", title: "Fixture" };
70
+ };
71
+ worker.bringToFront = async (tab) => tab;
72
+ const realAttach = worker.attachDebugger;
73
+ worker.attachDebugger = async (tabId) => { attached.push(tabId); return realAttach(tabId); };
74
+ return { worker, chrome, calls, attached, page, sources, cdpMethods: () => calls.map((c) => c.method) };
75
+ }
76
+
77
+ test("cdp.call validates method/params before touching the debugger", async () => {
78
+ const h = harness();
79
+ await assert.rejects(h.worker.dispatch("cdp.call", { method: " " }), /non-empty string "method"/);
80
+ await assert.rejects(h.worker.dispatch("cdp.call", { method: "Runtime.evaluate", params: [1] }), /must be a plain object/);
81
+ await assert.rejects(h.worker.dispatch("cdp.call", { method: "Runtime.evaluate", params: "x" }), /must be a plain object/);
82
+ assert.equal(h.attached.length, 0);
83
+ assert.equal(h.calls.length, 0);
84
+ });
85
+
86
+ test("cdp.call sends the exact method and params to the resolved tab and returns the raw result", async () => {
87
+ const h = harness({ respond: (method) => method === "Network.getCookies" ? { cookies: [{ name: "sid" }] } : {} });
88
+ const result = await h.worker.dispatch("cdp.call", { targetId: "7", method: " Network.getCookies ", params: { urls: ["https://fixture.test"] } });
89
+ assert.deepEqual(JSON.parse(JSON.stringify(result)), { cookies: [{ name: "sid" }] });
90
+ assert.deepEqual(h.attached, [7]);
91
+ assert.deepEqual(h.calls.at(-1), { method: "Network.getCookies", params: { urls: ["https://fixture.test"] } });
92
+ });
93
+
94
+ test("background mode blocks focus-stealing raw CDP methods; foreground allows them", async () => {
95
+ const h = harness();
96
+ for (const method of ["Page.bringToFront", "Target.activateTarget"]) {
97
+ await assert.rejects(h.worker.dispatch("cdp.call", { method, background: true, foreground: false }), /blocked by background mode/);
98
+ }
99
+ assert.equal(h.calls.length, 0);
100
+ await h.worker.dispatch("cdp.call", { method: "Page.bringToFront", background: false, foreground: true });
101
+ assert.equal(h.cdpMethods().at(-1), "Page.bringToFront");
102
+ });
103
+
104
+ test("cdp.call honours timeoutMs and detaches a hung session", async () => {
105
+ const h = harness({ respond: (method) => method === "Debugger.pause" ? "never" : undefined });
106
+ const started = Date.now();
107
+ await assert.rejects(h.worker.dispatch("cdp.call", { method: "Debugger.pause", timeoutMs: 60 }), /CDP Debugger\.pause timed out after 60ms/);
108
+ assert.ok(Date.now() - started < 2_000, "custom deadline used instead of a longer default");
109
+ assert.equal(h.cdpMethods().filter((m) => m === "Debugger.pause").length, 1, "a timed-out command is not re-sent after the cleanup detach");
110
+ assert.equal(h.worker.cdpCallTimeoutMs({}), 5_000);
111
+ assert.equal(h.worker.cdpCallTimeoutMs({ timeoutMs: 999_999 }), 120_000);
112
+ });
113
+
114
+ test("cdp.targets lists only the resolved tab's targets, never creates a target, and surfaces explicit misses", async () => {
115
+ const h = harness({ targets: [
116
+ { id: "a", tabId: 5, type: "page", url: "https://fixture.test/", attached: true },
117
+ { id: "b", tabId: 5, type: "other", url: "chrome-extension://pw/overlay.html", attached: false, extensionId: "pw" },
118
+ { id: "c", tabId: 9, type: "page", url: "https://private.example/secret", attached: false },
119
+ ] });
120
+ const listed = await h.worker.dispatch("cdp.targets", { targetId: "5" });
121
+ assert.deepEqual(listed.targets.map((t) => t.id), ["a", "b"]);
122
+ assert.equal(listed.otherTabTargetCount, 1);
123
+ assert.ok(!JSON.stringify(listed).includes("private.example"), "other tabs' URLs are not echoed");
124
+ const none = await h.worker.dispatch("cdp.targets", {});
125
+ assert.equal(none.tab, null);
126
+ assert.equal(none.targets.length, 0);
127
+ await assert.rejects(h.worker.dispatch("cdp.targets", { targetId: "missing" }), /No Chrome tab with id/);
128
+ assert.equal(h.attached.length, 0, "diagnostics never attach the debugger");
129
+ });
130
+
131
+ test("scripting denied on about:blank falls back to CDP for func and packaged-file injection", async () => {
132
+ const h = harness();
133
+ h.sources.set("snapshot_injected.js", "globalThis.__piChromeSnapshotPage = async (max, _c, _r, _n, mode) => ({ url: location.href, max, mode });");
134
+ const snapshot = await h.worker.dispatch("page.snapshot", { targetId: "5", maxElements: 12, mode: "forms" });
135
+ assert.deepEqual(JSON.parse(JSON.stringify(snapshot)), { url: "about:blank", max: 12, mode: "forms" });
136
+ assert.ok(h.cdpMethods().filter((m) => m === "Runtime.evaluate").length >= 2, "file and func both ran through Runtime.evaluate");
137
+
138
+ const clicked = await h.worker.resolveTargetInTab(5, { x: 10, y: 20 });
139
+ assert.equal(clicked.found, true, "input target resolution also survives denied scripting");
140
+ });
141
+
142
+ test("non-permission scripting failures are not masked by the CDP fallback", async () => {
143
+ const h = harness();
144
+ h.chrome.scripting.executeScript = async () => { throw new Error("Frame with ID 0 was removed."); };
145
+ await assert.rejects(h.worker.executeScriptWithFallback({ target: { tabId: 5 }, func: () => 1 }, "x"), /Frame with ID 0 was removed/);
146
+ assert.equal(h.calls.length, 0);
147
+ assert.equal(h.worker.isScriptingAccessDenied(new Error(ABOUT_BLANK_DENIED)), true);
148
+ });
149
+
150
+ test("CDP fallback reports page exceptions with the call-site label", async () => {
151
+ const h = harness();
152
+ await assert.rejects(
153
+ h.worker.executeScriptWithFallback({ target: { tabId: 5 }, func: () => { throw new Error("boom in page"); }, args: [] }, "probe page"),
154
+ /probe page: .*boom in page/,
155
+ );
156
+ });
157
+
158
+ // ---- Pi-side formatting for chrome_cdp results ----
159
+ function section(start, end) {
160
+ const from = indexSource.indexOf(start);
161
+ const to = indexSource.indexOf(end, from);
162
+ assert.ok(from >= 0 && to > from, `Missing source section: ${start}`);
163
+ return indexSource.slice(from, to);
164
+ }
165
+ const formatCdpResult = vm.runInNewContext(`${stripTypeScriptTypes(section("const CDP_OVERSIZE_JSON_CHARS", "\nfunction extensionRoot"))}; formatCdpResult`, {
166
+ safeJson: (v) => JSON.stringify(v, null, 2),
167
+ truncateText: (s) => (s.length > 30_000 ? `${s.slice(0, 30_000)}[truncated]` : s),
168
+ });
169
+
170
+ test("chrome_cdp never returns screenshot/binary payloads or oversized details verbatim", () => {
171
+ const data = "A".repeat(4000) + "==";
172
+ const shot = formatCdpResult("Page.captureScreenshot", { data });
173
+ assert.ok(!shot.content[0].text.includes("AAAA"));
174
+ assert.match(shot.content[0].text, /~2999 bytes/);
175
+ assert.equal(shot.details.value.omitted, "data-field");
176
+
177
+ const huge = formatCdpResult("DOM.getOuterHTML", { outerHTML: "x".repeat(300_000) });
178
+ assert.equal(huge.details.value.omitted, "oversized-result");
179
+ assert.ok(huge.content[0].text.length < 31_000);
180
+
181
+ const small = formatCdpResult("Runtime.evaluate", { result: { type: "number", value: 2 } });
182
+ assert.deepEqual(small.details.value, { result: { type: "number", value: 2 } });
183
+ });
@@ -0,0 +1,163 @@
1
+ // Exercise the shipped /chrome command registration and handlers without opening Chrome or a bridge.
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import vm from "node:vm";
5
+ import { stripTypeScriptTypes } from "node:module";
6
+ import { test } from "node:test";
7
+
8
+ const source = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8");
9
+ const { version } = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
10
+ const now = 1_000_000;
11
+ function section(start, end) {
12
+ const from = source.indexOf(start);
13
+ const to = source.indexOf(end, from);
14
+ assert.ok(from >= 0 && to > from, `Missing source section: ${start}`);
15
+ return source.slice(from, to);
16
+ }
17
+ const commandSource = stripTypeScriptTypes([
18
+ section("const authSummary =", "\n\tconst chromeControlAuthorized ="),
19
+ section("// Shared handlers,", "\n\tfunction registerChromeTools("),
20
+ ].join("\n"));
21
+
22
+ function healthyResponse(action) {
23
+ switch (action) {
24
+ case "tab.version": return { extensionVersion: version };
25
+ case "page.evaluate": return 2;
26
+ case "page.probe": return { arithmetic: 2, location: "https://fixture.test/", webdriver: false };
27
+ default: throw new Error(`Unexpected bridge action: ${action}`);
28
+ }
29
+ }
30
+
31
+ function harness({ until, background = true, mode = "server", choices = [], send = healthyResponse } = {}) {
32
+ const calls = [], notices = [], menus = [];
33
+ let command;
34
+ const ctx = {
35
+ ui: {
36
+ notify: (...args) => notices.push(args),
37
+ async select(title, items) {
38
+ menus.push({ title, items: Array.from(items) });
39
+ return choices[menus.length - 1];
40
+ },
41
+ },
42
+ };
43
+ const sandbox = {
44
+ Date: { now: () => now }, PI_CHROME_VERSION: version,
45
+ chromeAuthorizedUntil: until, backgroundEnabled: background,
46
+ hostnameOf: (url) => new URL(url).hostname,
47
+ bridge: {
48
+ status: () => ({ mode }),
49
+ async send(action, params, timeout) {
50
+ calls.push({ action, params: JSON.parse(JSON.stringify(params)), timeout });
51
+ return send(action, params, timeout);
52
+ },
53
+ },
54
+ pi: { registerCommand(name, definition) { assert.equal(name, "chrome"); command = definition; } },
55
+ };
56
+ vm.runInNewContext(commandSource, sandbox);
57
+ return { command, calls, notices, menus, sandbox, run: (args = "") => command.handler(args, ctx) };
58
+ }
59
+
60
+ test("command help and root completion omit status; nested background status remains available", () => {
61
+ const h = harness();
62
+ assert.doesNotMatch(h.command.description, /\/chrome status\b/);
63
+ assert.match(h.command.description, /\/chrome doctor/);
64
+ assert.deepEqual(Array.from(h.command.getArgumentCompletions(""), (item) => item.value), [
65
+ "authorize", "revoke", "doctor", "onboard", "background",
66
+ ]);
67
+ assert.equal(h.command.getArgumentCompletions("sta"), null);
68
+ assert.equal(h.command.getArgumentCompletions("doctor")[0].value, "doctor");
69
+ assert.equal(h.command.getArgumentCompletions("background st")[0].value, "background status");
70
+ assert.equal(h.command.getArgumentCompletions("authorize 15")[0].value, "authorize 15m");
71
+ });
72
+
73
+ test("removed status command returns a warning without probing Chrome", async () => {
74
+ const h = harness();
75
+ await h.run("status");
76
+ assert.equal(h.calls.length, 0);
77
+ assert.equal(h.notices.length, 1);
78
+ assert.match(h.notices[0][0], /Unknown subcommand 'status'/);
79
+ assert.doesNotMatch(h.notices[0][0], /\| status \|/);
80
+ assert.equal(h.notices[0][1], "warning");
81
+ });
82
+
83
+ test("bare chrome shows loading immediately, then a lightweight dashboard without page probes", async () => {
84
+ let finish;
85
+ const h = harness({ send: () => new Promise((resolve) => { finish = resolve; }) });
86
+ const done = h.run();
87
+ assert.deepEqual(h.notices, [["Checking Chrome connection…", "info"]]);
88
+ assert.equal(h.menus.length, 0);
89
+ assert.deepEqual(h.calls, [{ action: "tab.version", params: {}, timeout: 5_000 }]);
90
+ finish({ extensionVersion: version });
91
+ await done;
92
+ assert.match(h.menus[0].title, /Chrome connected.*auth: locked.*background: on \(hard\)/);
93
+ assert.ok(h.menus[0].items.includes("Doctor / troubleshoot"));
94
+ assert.ok(!h.menus[0].items.some((item) => /status/i.test(item)));
95
+ assert.equal(h.calls.length, 1);
96
+ });
97
+
98
+ test("dashboard retains authorization/background state when Chrome is offline or outdated", async () => {
99
+ for (const [send, expected] of [
100
+ [() => { throw new Error("offline"); }, /Chrome not responding/],
101
+ [() => ({ extensionVersion: "0.0.0" }), /Chrome extension v0\.0\.0.*reload extension/],
102
+ ]) {
103
+ const h = harness({ until: "indefinite", send });
104
+ await h.run("background off");
105
+ await h.run("background status");
106
+ assert.match(h.notices.at(-1)[0], /background is off/);
107
+ await h.run();
108
+ assert.match(h.menus[0].title, expected);
109
+ assert.match(h.menus[0].title, /auth: authorized indefinitely.*background: off/);
110
+ assert.deepEqual(h.calls, [{ action: "tab.version", params: {}, timeout: 5_000 }]);
111
+ }
112
+ });
113
+
114
+ test("Doctor includes locked, timed, indefinite, and expired authorization plus background state", async () => {
115
+ for (const [until, expected] of [
116
+ [undefined, "locked"], [now + 15 * 60_000, "authorized for ~15m"],
117
+ ["indefinite", "authorized indefinitely"], [now, "locked"],
118
+ ]) {
119
+ for (const background of [true, false]) {
120
+ const h = harness({ until, background });
121
+ await h.run("doctor");
122
+ assert.equal(h.notices[0][0], "Checking pi-chrome…");
123
+ const report = h.notices.at(-1)[0];
124
+ assert.ok(report.includes(`pi-chrome v${version}`));
125
+ assert.ok(report.includes(`Authorization: ${expected}`));
126
+ assert.ok(report.includes(`Background: ${background ? "on (hard)" : "off"}`));
127
+ assert.match(report, /Chrome is connected/);
128
+ assert.match(report, /can run code/);
129
+ assert.match(report, /fixture\.test/);
130
+ assert.deepEqual(h.calls.map(({ action, timeout }) => [action, timeout]), [
131
+ ["tab.version", 35_000], ["page.evaluate", 10_000], ["page.probe", 10_000],
132
+ ]);
133
+ assert.ok(h.calls.filter((call) => call.action.startsWith("page.")).every((call) => call.params.foreground === false));
134
+ assert.equal(h.sandbox.chromeAuthorizedUntil, until, "diagnostics do not grant or change authorization");
135
+ assert.equal(h.sandbox.backgroundEnabled, background);
136
+ }
137
+ }
138
+ });
139
+
140
+ test("Doctor retains local state and repair hints when connection/version checks fail", async () => {
141
+ for (const [send, expected] of [
142
+ [() => { throw new Error("offline"); }, /Chrome isn't responding: offline/],
143
+ [() => ({ extensionVersion: "0.0.0" }), /old version \(0\.0\.0\)/],
144
+ ]) {
145
+ const h = harness({ until: "indefinite", background: false, mode: "client", send });
146
+ await h.run("doctor");
147
+ const report = h.notices.at(-1)[0];
148
+ assert.match(report, /Authorization: authorized indefinitely/);
149
+ assert.match(report, /Background: off/);
150
+ assert.match(report, /sharing another pi session's connection/);
151
+ assert.match(report, expected);
152
+ assert.match(report, /Fix:/);
153
+ assert.deepEqual(h.calls.map((call) => call.action), ["tab.version"]);
154
+ }
155
+ });
156
+
157
+ test("choosing Doctor explicitly from the dashboard runs full diagnostics", async () => {
158
+ const h = harness({ choices: ["Doctor / troubleshoot"] });
159
+ await h.run();
160
+ assert.deepEqual(h.calls.map((call) => call.action), ["tab.version", "tab.version", "page.evaluate", "page.probe"]);
161
+ assert.match(h.notices.at(-1)[0], /Authorization: locked/);
162
+ assert.match(h.notices.at(-1)[0], /Background: on \(hard\)/);
163
+ });
@@ -0,0 +1,208 @@
1
+ // chrome_type before/after evidence, replace=true, navigation-aware includeSnapshot, and tab.new load
2
+ // settling. Chrome and the page are mocked; live caret behavior is covered by challenge 44.
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import vm from "node:vm";
6
+ import { stripTypeScriptTypes } from "node:module";
7
+ import { test } from "node:test";
8
+
9
+ const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
10
+ const indexSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8");
11
+ const clone = (value) => JSON.parse(JSON.stringify(value));
12
+
13
+ function makeInput({ value = "", caret = value.length, type = "text", name = "q" } = {}) {
14
+ return {
15
+ tagName: "INPUT", type, name, id: name, isConnected: true, isContentEditable: false,
16
+ value, selectionStart: caret, selectionEnd: caret,
17
+ getAttribute(attr) { return attr === "type" ? this.type : attr === "name" ? this.name : null; },
18
+ scrollIntoView() {}, getBoundingClientRect: () => ({ left: 10, top: 10, width: 300, height: 30 }),
19
+ focus() {},
20
+ };
21
+ }
22
+
23
+ function harness(element, { platform = "MacIntel", tabs = {} } = {}) {
24
+ const calls = [];
25
+ const listeners = new Set();
26
+ const page = vm.createContext({
27
+ document: { activeElement: element, querySelector: (s) => (s === "#q" ? element : null), createRange: () => ({}) },
28
+ getSelection: () => null,
29
+ location: { href: "https://fixture.test/" },
30
+ __PI_CHROME_STATE__: { elements: { "el-1": element } },
31
+ });
32
+ page.window = page;
33
+ const listener = { addListener() {}, removeListener() {} };
34
+ const chrome = {
35
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.1" }), onInstalled: listener, onStartup: listener },
36
+ alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener },
37
+ debugger: { onDetach: listener },
38
+ scripting: { executeScript: async ({ func, args = [] }) => [{ result: await vm.runInContext(`(${func.toString()})`, page)(...args) }] },
39
+ tabs: {
40
+ onUpdated: { addListener: (fn) => listeners.add(fn), removeListener: (fn) => listeners.delete(fn) },
41
+ get: async (id) => clone(tabs[id] ?? { id, windowId: 1, url: "https://fixture.test/", status: "complete" }),
42
+ },
43
+ };
44
+ const worker = { chrome, console, AbortController, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test", platform }, fetch: async () => { throw new Error("offline"); } };
45
+ worker.self = worker;
46
+ vm.runInNewContext(workerSource, worker);
47
+ worker.sleep = async () => {};
48
+ worker.getTabByParams = async (params) => clone(tabs[Number(params.targetId ?? 2)] ?? { id: Number(params.targetId ?? 2), windowId: 1, url: "https://fixture.test/", status: "complete" });
49
+ worker.bringToFront = async (tab) => tab;
50
+ worker.attachDebugger = async () => {};
51
+ worker.cdpMoveTo = async () => {};
52
+ const splice = (text) => {
53
+ if (typeof element.value !== "string") return;
54
+ const { value, selectionStart: s, selectionEnd: e } = element;
55
+ element.value = value.slice(0, s) + text + value.slice(e);
56
+ element.selectionStart = element.selectionEnd = s + text.length;
57
+ };
58
+ worker.cdp = async (_tabId, method, params = {}) => {
59
+ calls.push({ method, params: clone(params) });
60
+ if (method === "Input.dispatchKeyEvent") {
61
+ if ((params.commands || []).includes("selectAll")) { element.selectionStart = 0; element.selectionEnd = element.value.length; }
62
+ else if (params.type === "keyDown" && params.key === "Delete") {
63
+ if (element.selectionEnd > element.selectionStart) splice("");
64
+ } else if (params.type === "keyDown" && params.text && params.key !== "Enter") splice(params.text);
65
+ }
66
+ return {};
67
+ };
68
+ return { worker, calls, element, listeners, fire: (tabId, info) => { for (const fn of [...listeners]) fn(tabId, info); } };
69
+ }
70
+
71
+ test("typing into the middle of a prefilled field reports the splice instead of a bare count", async () => {
72
+ const h = harness(makeInput({ value: "why do flamingos stand", caret: 7 }));
73
+ const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "cats " });
74
+ assert.equal(h.element.value, "why do cats flamingos stand");
75
+ assert.equal(result.valueBefore, "why do flamingos stand");
76
+ assert.equal(result.valueAfter, "why do cats flamingos stand");
77
+ assert.equal(result.existingTextLengthBefore, 22);
78
+ assert.equal(result.insertedAt, "caret-middle");
79
+ assert.equal(result.replaced, undefined);
80
+ });
81
+
82
+ test("empty and caret-end targets are classified without warnings", async () => {
83
+ const empty = harness(makeInput());
84
+ assert.equal((await empty.worker.dispatch("page.type", { targetId: "2", uid: "el-1", text: "hi" })).insertedAt, "empty");
85
+ const end = harness(makeInput({ value: "hello" }));
86
+ const result = await end.worker.dispatch("page.type", { targetId: "2", uid: "el-1", text: "!" });
87
+ assert.equal(result.insertedAt, "caret-end");
88
+ assert.equal(result.valueAfter, "hello!");
89
+ });
90
+
91
+ for (const [platform, modifiers] of [["MacIntel", 4], ["Win32", 2]]) {
92
+ test(`replace=true selects all with the platform shortcut + selectAll command, then deletes (${platform})`, async () => {
93
+ const h = harness(makeInput({ value: "old query text", caret: 3 }), { platform });
94
+ const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "new", replace: true });
95
+ assert.equal(h.element.value, "new");
96
+ assert.equal(result.replaced, true);
97
+ assert.equal(result.insertedAt, "replaced-all");
98
+ assert.equal(result.valueBefore, "old query text", "before is read prior to select-all/delete");
99
+ const selectAll = h.calls.find((c) => (c.params.commands || []).includes("selectAll"));
100
+ assert.equal(selectAll.params.modifiers, modifiers);
101
+ });
102
+ }
103
+
104
+ test("sensitive fields never expose contents in type evidence", async () => {
105
+ const h = harness(makeInput({ value: "hunter2", type: "password", name: "password" }));
106
+ const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "x" });
107
+ assert.equal(result.valueRedacted, true);
108
+ assert.equal(result.valueBefore, undefined);
109
+ assert.equal(result.valueAfter, undefined);
110
+ assert.ok(!JSON.stringify(result).includes("hunter2"));
111
+ assert.equal(result.existingTextLengthBefore, 7);
112
+ });
113
+
114
+ test("a non-text focus target reports no value evidence rather than a false empty field", async () => {
115
+ const h = harness({ tagName: "DIV", isConnected: true, isContentEditable: false, scrollIntoView() {}, getBoundingClientRect: () => ({ left: 0, top: 0, width: 10, height: 10 }) });
116
+ const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "x" });
117
+ assert.equal(result.valueBefore, undefined);
118
+ assert.equal(result.insertedAt, undefined);
119
+ });
120
+
121
+ test("includeSnapshot waits for a navigation the action started and snapshots the new page", async () => {
122
+ const tabs = { 2: { id: 2, windowId: 1, url: "https://fixture.test/", status: "complete" } };
123
+ const h = harness(makeInput(), { tabs });
124
+ h.worker.chromeInputKey = async () => {
125
+ tabs[2] = { ...tabs[2], url: "https://fixture.test/search?q=x", status: "loading" };
126
+ setTimeout(() => { tabs[2].status = "complete"; h.fire(2, { status: "complete" }); }, 20);
127
+ return { input: "chrome", key: "Enter" };
128
+ };
129
+ h.worker.snapshotInTab = async (params) => ({ url: tabs[Number(params.targetId)].url, status: tabs[Number(params.targetId)].status });
130
+ const out = await h.worker.dispatch("page.key", { targetId: "2", key: "Enter", includeSnapshot: true });
131
+ assert.equal(out.navigation.from, "https://fixture.test/");
132
+ assert.equal(out.navigation.to, "https://fixture.test/search?q=x");
133
+ assert.equal(out.navigation.settled, true);
134
+ assert.deepEqual(clone(out.snapshot), { url: "https://fixture.test/search?q=x", status: "complete" });
135
+ assert.equal(h.listeners.size, 0, "listener removed");
136
+ });
137
+
138
+ test("a non-navigating action snapshots immediately; an already-loading page is not waited on", async () => {
139
+ const tabs = { 2: { id: 2, windowId: 1, url: "https://fixture.test/", status: "complete" } };
140
+ const h = harness(makeInput(), { tabs });
141
+ h.worker.chromeInputClick = async () => ({ input: "chrome" });
142
+ h.worker.snapshotInTab = async () => ({ ok: true });
143
+ const plain = await h.worker.dispatch("page.click", { targetId: "2", selector: "#q", includeSnapshot: true });
144
+ assert.equal(plain.navigation, undefined);
145
+
146
+ tabs[2].status = "loading"; // e.g. a hanging subresource, same URL
147
+ const started = Date.now();
148
+ const busy = await h.worker.dispatch("page.click", { targetId: "2", selector: "#q", includeSnapshot: true });
149
+ assert.ok(Date.now() - started < 1_000, "no bounded wait for a load the action did not start");
150
+ assert.equal(busy.navigation.settled, false);
151
+ assert.equal(busy.navigation.waitedMs < 1_000, true);
152
+ });
153
+
154
+ test("tab.new reports the settled tab and loadStatus instead of the t=0 loading stub", async () => {
155
+ const tabs = {};
156
+ const h = harness(makeInput(), { tabs });
157
+ h.worker.findGroupRecordByTitle = async () => null;
158
+ h.worker.trackSessionTab = async () => {};
159
+ h.worker.groupTab = async (tab) => ({ tab: { id: tab.id, url: tab.url, status: tab.status }, group: { title: "Pi Session: t" } });
160
+ h.worker.formatTab = async (tab) => ({ id: tab.id, url: tab.url, status: tab.status, title: tab.title });
161
+ h.worker.chrome.tabs.create = async ({ url }) => {
162
+ tabs[9] = { id: 9, windowId: 1, url: "", title: "", status: "loading" };
163
+ setTimeout(() => { tabs[9] = { ...tabs[9], url, title: "Loaded", status: "complete" }; h.fire(9, { status: "complete" }); }, 20);
164
+ return clone(tabs[9]);
165
+ };
166
+ const out = await h.worker.dispatch("tab.new", { url: "https://fixture.test/page", background: true });
167
+ assert.equal(out.loadStatus, "complete");
168
+ assert.deepEqual(clone(out.tab), { id: 9, url: "https://fixture.test/page", status: "complete", title: "Loaded" });
169
+ assert.equal(out.group.title, "Pi Session: t");
170
+ });
171
+
172
+ // ---- Pi-side text ----
173
+ function section(start, end) {
174
+ const from = indexSource.indexOf(start);
175
+ const to = indexSource.indexOf(end, from);
176
+ assert.ok(from >= 0 && to > from, `Missing source section: ${start}`);
177
+ return indexSource.slice(from, to);
178
+ }
179
+ const pi = vm.runInNewContext(`${stripTypeScriptTypes(section("function formatIncludedSnapshotText(", "\n// Keep raw CDP"))}; ({ formatIncludedSnapshotText, describeTypeEvidence })`, {
180
+ formatChromeSnapshot: (s) => `SNAPSHOT ${s.url}`,
181
+ });
182
+
183
+ test("chrome_type text warns on splices, mentions spliced submits, and redacts secrets", () => {
184
+ const spliced = pi.describeTypeEvidence({ valueBefore: "abc", valueAfter: "aXbc", insertedAt: "caret-middle" }, 1, true).join("\n");
185
+ assert.match(spliced, /Field went from "abc" to "aXbc"/);
186
+ assert.match(spliced, /spliced into the middle/);
187
+ assert.match(spliced, /submitted the spliced value/);
188
+ const appended = pi.describeTypeEvidence({ valueBefore: "abc", valueAfter: "abcX", insertedAt: "caret-end" }, 1, false).join("\n");
189
+ assert.match(appended, /appended to existing content/);
190
+ assert.doesNotMatch(appended, /⚠/);
191
+ const secret = pi.describeTypeEvidence({ valueRedacted: true, existingTextLengthBefore: 7, valueLengthAfter: 8, insertedAt: "caret-end" }, 1, false).join("\n");
192
+ assert.match(secret, /\[redacted\] \(7 → 8 chars/);
193
+ assert.equal(pi.describeTypeEvidence({ input: "chrome" }, 1, false).length, 0);
194
+ const lost = pi.describeTypeEvidence({ valueBefore: "abc", valueAfter: "abc", existingTextLengthBefore: 3, valueLengthAfter: 3, insertedAt: "caret-middle" }, 5, true).join("\n");
195
+ assert.match(lost, /did not change/);
196
+ assert.doesNotMatch(lost, /spliced/, "an unchanged field is not reported as a splice");
197
+ const lostSecret = pi.describeTypeEvidence({ valueRedacted: true, existingTextLengthBefore: 3, valueLengthAfter: 3, insertedAt: "caret-end" }, 2, false).join("\n");
198
+ assert.match(lostSecret, /did not change/);
199
+ });
200
+
201
+ test("included snapshots state whether a navigation settled", () => {
202
+ const settled = pi.formatIncludedSnapshotText({ snapshot: { url: "https://b.test/" }, navigation: { from: "https://a.test/", to: "https://b.test/", settled: true, waitedMs: 120 } }, "Pressed Enter.");
203
+ assert.match(settled, /Navigated https:\/\/a\.test\/ → https:\/\/b\.test\/ \(waited 120ms/);
204
+ assert.match(settled, /SNAPSHOT https:\/\/b\.test\//);
205
+ const stale = pi.formatIncludedSnapshotText({ snapshot: { url: "https://a.test/" }, navigation: { from: "https://a.test/", to: "https://b.test/", settled: false, waitedMs: 5000 } }, "Pressed Enter.");
206
+ assert.match(stale, /⚠ The action started a navigation that had not finished after 5000ms/);
207
+ assert.equal(pi.formatIncludedSnapshotText({ result: {} }, "Clicked."), "Clicked.");
208
+ });