pi-chrome 0.15.55 → 0.15.56

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable user-facing changes to `pi-chrome`.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.15.56 — 2026-09-28
8
+
9
+ - **Pi's automation tab stays in its own window.** Joining the session tab group called `chrome.tabs.group` without a window. Chrome then creates the group in the current window (yours) and moves the tab there, where it sits as an inactive, hidden tab and trusted input is refused. This happened on a session's first page action and is the main source of the hidden-tab errors. New groups are now created in the tab's own window.
10
+ - **`chrome_fill` replaces all of a multi-line textarea.** Fill selected the old value with a triple-click, which in a textarea selects only the clicked line. The rest stayed, and the tool still reported success. Inputs and textareas now select the whole value with Chrome's `selectAll` editing command before deleting. Contenteditables are unchanged.
11
+ - **Early capture covers a new tab's first page.** `chrome_tab new` could commit its first page before the tab was recorded as Pi's, so early console/network capture skipped that load. Early capture now waits for in-flight Pi tab creations before deciding.
12
+
7
13
  ## 0.15.55 — 2026-09-26
8
14
 
9
15
  - **Console capture no longer breaks sites with DevTools detection (#9).** Some sites (BOSS Zhipin) log an object with a getter or custom `toString` and close the tab if anything reads it, since only open DevTools would. The console capture serialized every logged argument immediately, so it tripped the check. Captured arguments are now stored as-is, and `chrome_list_console_messages` serializes them without running page code: data properties only, accessors shown as `[getter]`, and DOM nodes, regexes, dates and errors described through Chrome's native getters.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.55",
4
+ "version": "0.15.56",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -1232,11 +1232,16 @@ async function chromeInputFill(params) {
1232
1232
  await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
1233
1233
  await sleep(rng(20, 60));
1234
1234
  }
1235
- await contentEditableInTab(tab.id, params);
1236
- // Delete selection.
1237
- await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
1238
- await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
1239
- await sleep(rng(20, 60));
1235
+ if (await contentEditableInTab(tab.id, params)) {
1236
+ // The requested editor's whole contents are already selected; delete the selection.
1237
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
1238
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
1239
+ await sleep(rng(20, 60));
1240
+ } else {
1241
+ // A triple-click selects only one line of a multi-line textarea. Select the whole value with
1242
+ // Chrome's selectAll editing command before deleting.
1243
+ await selectAllAndDelete(tab.id);
1244
+ }
1240
1245
  const text = String(params.text || "");
1241
1246
  const typing = await typeTextInTab(tab.id, text, params.perCharacter);
1242
1247
  if (params.submit) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" });
@@ -1706,10 +1711,14 @@ async function groupTab(tab, title, color) {
1706
1711
  const groupTitle = cleanGroupTitle(title);
1707
1712
  let groupId = tab.groupId;
1708
1713
  if (typeof groupId !== "number" || groupId < 0) {
1709
- const existing = await findGroupByTitle(tab.windowId, groupTitle);
1714
+ // Some browsers omit windowId on freshly created tabs; read it back instead of guessing.
1715
+ const windowId = typeof tab.windowId === "number" ? tab.windowId : (await chrome.tabs.get(tab.id).catch(() => null))?.windowId;
1716
+ const existing = await findGroupByTitle(windowId, groupTitle);
1717
+ // Without createProperties.windowId Chrome creates the group in the *current* (user's) window
1718
+ // and moves the tab there, pulling Pi's automation tab out of its own window as a hidden tab.
1710
1719
  groupId = existing !== null
1711
1720
  ? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] })
1712
- : await chrome.tabs.group({ tabIds: [tab.id] });
1721
+ : await chrome.tabs.group(typeof windowId === "number" ? { tabIds: [tab.id], createProperties: { windowId } } : { tabIds: [tab.id] });
1713
1722
  }
1714
1723
  await chrome.tabGroups.update(groupId, { title: groupTitle, color: cleanGroupColor(color), collapsed: false });
1715
1724
  const grouped = await chrome.tabs.get(tab.id);
@@ -1743,8 +1752,20 @@ async function dispatch(action, params) {
1743
1752
  const existingGroup = await findGroupRecordByTitle(groupTitle);
1744
1753
  const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) };
1745
1754
  if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
1746
- const tab = await chrome.tabs.create(createParams);
1747
- await trackSessionTab(sessionKeyOf(params), tab.id, true);
1755
+ // Register the creation as in flight until the tab is tracked, so early capture (which only
1756
+ // runs in tracked tabs) waits for it instead of skipping the new tab's first page load.
1757
+ const creation = (async () => {
1758
+ const created = await chrome.tabs.create(createParams);
1759
+ await trackSessionTab(sessionKeyOf(params), created.id, true);
1760
+ return created;
1761
+ })();
1762
+ pendingTabCreations.add(creation);
1763
+ let tab;
1764
+ try {
1765
+ tab = await creation;
1766
+ } finally {
1767
+ pendingTabCreations.delete(creation);
1768
+ }
1748
1769
  try {
1749
1770
  await bringToFront(tab, params);
1750
1771
  const grouped = await groupTab(tab, groupTitle, params.groupColor);
@@ -2298,8 +2319,12 @@ async function unregisterInitScript(tabId) {
2298
2319
  // console messages, errors, and network requests that fire during page load, before chrome_snapshot
2299
2320
  // or chrome_evaluate install the instrumentation normally. installEarlyCapture sets __piChromeWrapped
2300
2321
  // flags so the post-hoc installPiChromeInstrumentation() call is idempotent.
2322
+ const pendingTabCreations = new Set(); // in-flight Pi tab creations, resolved once tracked
2323
+
2301
2324
  async function isPiTrackedTab(tabId) {
2302
2325
  await Promise.all([hydrateAutomationTargets(), hydrateSessionTabs()]);
2326
+ // A tab Pi is creating right now can commit its first page before tab.new has tracked it.
2327
+ if (pendingTabCreations.size) await Promise.allSettled([...pendingTabCreations]);
2303
2328
  if (isPiChromeOwnedTarget(tabId)) return true;
2304
2329
  for (const tabs of sessionTabs.values()) if (tabs.has(tabId)) return true;
2305
2330
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.55",
3
+ "version": "0.15.56",
4
4
  "scripts": {
5
5
  "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs",
6
6
  "version": "node scripts/sync-manifest-version.js",
@@ -87,14 +87,17 @@ function makeChrome(state, { withWindows = true, withStorage = true, withTabGrou
87
87
  // Chrome closes a window automatically when its final tab is removed.
88
88
  if (tab && ![...tabs.values()].some((other) => other.windowId === tab.windowId)) windows.delete(tab.windowId);
89
89
  },
90
- group: async ({ groupId, tabIds = [] } = {}) => {
90
+ // Like Chrome: a new group goes into createProperties.windowId, else the *current* window
91
+ // (the user's), and grouped tabs move into the group's window.
92
+ group: async ({ groupId, tabIds = [], createProperties } = {}) => {
91
93
  let gid = groupId;
92
94
  if (typeof gid !== "number") {
93
95
  gid = alloc.group();
94
- const firstTab = tabs.get(tabIds[0]);
95
- groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId: firstTab ? firstTab.windowId : userWindowId });
96
+ const windowId = typeof createProperties?.windowId === "number" ? createProperties.windowId : userWindowId;
97
+ groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId });
96
98
  }
97
- for (const tid of tabIds) { const t = tabs.get(tid); if (t) t.groupId = gid; }
99
+ const groupWindow = groups.get(gid).windowId;
100
+ for (const tid of tabIds) { const t = tabs.get(tid); if (t) { t.groupId = gid; t.windowId = groupWindow; } }
98
101
  return gid;
99
102
  },
100
103
  ungroup: async (id) => { const ids = Array.isArray(id) ? id : [id]; for (const tid of ids) { const t = tabs.get(tid); if (t) t.groupId = -1; } },
@@ -20,7 +20,8 @@ function loadWorker() {
20
20
  debugger: { onDetach: noop, onEvent: noop },
21
21
  storage: { session: { get: async () => ({}), set: async () => {} } },
22
22
  scripting: { executeScript: async (options) => { injected.push(options); return []; } },
23
- tabs: { get: async () => null },
23
+ tabs: { get: async () => null, create: async () => { throw new Error("not mocked"); } },
24
+ tabGroups: { query: async () => [] },
24
25
  };
25
26
  const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } };
26
27
  w.self = w;
@@ -120,3 +121,21 @@ test("early capture is injected only into tabs Pi owns or tracks", async () => {
120
121
  assert.deepEqual(h.injected.map((o) => o.target.tabId), [11, 12]);
121
122
  assert.ok(h.injected.every((o) => o.func.name === "installEarlyCapture" && o.injectImmediately === true));
122
123
  });
124
+
125
+ test("a Pi tab whose first page commits before tab.new tracks it still gets early capture", async () => {
126
+ const h = loadWorker();
127
+ let commit;
128
+ h.w.chrome.tabs.create = async (props) => {
129
+ // Chrome can commit the first document while tab.new is still recording ownership.
130
+ commit = h.commit(21);
131
+ return { id: 21, windowId: 1, url: props.url, status: "loading" };
132
+ };
133
+ h.w.groupTab = async (tab) => ({ tab: { id: tab.id }, group: { title: "Pi" } });
134
+ h.w.waitForTabSettled = async () => true;
135
+ h.w.formatTab = async (tab) => tab;
136
+ await h.w.dispatch("tab.new", { url: "https://site.test/", background: true, sessionKey: "s" });
137
+ await commit;
138
+ assert.deepEqual(h.injected.map((o) => o.target.tabId), [21]);
139
+ await h.commit(22);
140
+ assert.deepEqual(h.injected.map((o) => o.target.tabId), [21], "unrelated tabs stay untouched");
141
+ });
@@ -215,6 +215,18 @@ for (const perCharacter of [false, true]) {
215
215
  });
216
216
  }
217
217
 
218
+ test("fill selects a textarea's whole value with the selectAll command, not just the clicked line", async () => {
219
+ const h = harness({ tag: "TEXTAREA", editable: false });
220
+ const result = await h.call("fill", { selector: "#target", text: "NEW", domFallback: false });
221
+ assert.equal(result.input, "chrome");
222
+ const keys = h.commands("Input.dispatchKeyEvent");
223
+ const selectAll = keys.findIndex((c) => (c.params.commands || []).includes("selectAll"));
224
+ const del = keys.findIndex((c) => c.params.key === "Delete" && c.params.type === "keyDown");
225
+ assert.ok(selectAll >= 0, "selectAll editing command sent");
226
+ assert.ok(del > selectAll, "delete follows select-all");
227
+ assert.equal(h.selected.length, 0, "no DOM range selection for value fields");
228
+ });
229
+
218
230
  test("fill refuses to select an unrelated focused editor", async () => {
219
231
  const h = harness({ initial: "keep this" });
220
232
  h.page.document.activeElement = { isContentEditable: true, contains: () => false };