pi-chrome 0.15.55 → 0.15.57

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,18 @@ All notable user-facing changes to `pi-chrome`.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.15.57 — 2026-09-28
8
+
9
+ - **Pi gets its own window again while your Chrome is full screen.** 0.15.54 put Pi's tab in your window during full screen, assuming macOS would merge a new window into yours as a hidden window tab. Chrome disables macOS automatic window tabbing (`chrome/browser/app_controller_mac.mm`), so that never happens. The hidden tabs seen at the time came from the tab-group bug fixed in 0.15.56. A new window opened during full screen lands on another Space, and a tab in your window is just as hidden, so the special case only cost isolation.
10
+ - **`/chrome doctor` flags a hidden Pi tab.** Chrome ignores clicks and typing in hidden pages. Doctor now checks this session's own tab and says whether it is visible. If it's hidden, doctor gives the reason (inactive tab, minimized window, covered by another window) and the fix, instead of the first click or type failing. It never creates or activates a tab to check.
11
+ - **Pi keeps track of its tabs across extension reloads.** Ownership of Pi's automation window and session tabs lived only in `chrome.storage.session`, which Chrome wipes when the extension reloads. The extension reloads itself on every pi-chrome update, so afterwards Pi could not close or reuse the windows and tabs it had opened, and they were left behind. Ownership is now also kept in `chrome.storage.local` and restored after a reload. A restored tab is trusted only if it still exists in the window it was recorded in (adopted tabs must also still be in Pi's group). The copy is dropped on browser startup, since tab ids do not carry over between browser runs.
12
+
13
+ ## 0.15.56 — 2026-09-28
14
+
15
+ - **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.
16
+ - **`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.
17
+ - **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.
18
+
7
19
  ## 0.15.55 — 2026-09-26
8
20
 
9
21
  - **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.57",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -33,6 +33,11 @@ let polling = false;
33
33
  //
34
34
  // State is mirrored to chrome.storage.session so a service-worker restart (MV3 can suspend the
35
35
  // worker at any time) re-hydrates ownership instead of orphaning the window it already created.
36
+ // storage.session is also cleared when the extension reloads, which it does itself on every
37
+ // pi-chrome version bump, so ownership is mirrored to chrome.storage.local too (see
38
+ // readOwnership). The local copy is restored only when the session copy is missing, every restored
39
+ // tab is re-checked, and the local copy is dropped on browser startup because tab ids do not carry
40
+ // over between browser runs.
36
41
  // storage.session is cleared on browser restart; any window restored by Chrome's session-restore
37
42
  // is then untracked and simply left alone (we only ever close ids we still recognize as ours).
38
43
  const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number }
@@ -50,6 +55,36 @@ function sessionKeyOf(params) {
50
55
  : DEFAULT_SESSION_KEY;
51
56
  }
52
57
 
58
+ // Read an ownership record: the storage.session copy if present, else the storage.local copy
59
+ // (after an extension reload). Returns { value, restored } where restored marks a local copy.
60
+ async function readOwnership(key) {
61
+ const fromSession = await chrome.storage?.session?.get?.(key).catch(() => null);
62
+ if (fromSession && fromSession[key] && typeof fromSession[key] === "object") return { value: fromSession[key], restored: false };
63
+ const fromLocal = await chrome.storage?.local?.get?.(key).catch(() => null);
64
+ if (fromLocal && fromLocal[key] && typeof fromLocal[key] === "object") return { value: fromLocal[key], restored: true };
65
+ return { value: null, restored: false };
66
+ }
67
+
68
+ async function writeOwnership(key, value) {
69
+ await Promise.all([
70
+ Promise.resolve(chrome.storage?.session?.set?.({ [key]: value })).catch(() => {}),
71
+ Promise.resolve(chrome.storage?.local?.set?.({ [key]: value })).catch(() => {}),
72
+ ]);
73
+ }
74
+
75
+ // A restored (local) record is trusted only if its tab still exists in the window it was recorded
76
+ // in. Anything else is forgotten, so a reused id or a tab the user moved is never closed by Pi.
77
+ async function restoredTabStillOurs(tabId, windowId) {
78
+ if (!Number.isInteger(tabId) || tabId < 0 || !Number.isInteger(windowId)) return null;
79
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
80
+ return tab && tab.windowId === windowId ? tab : null;
81
+ }
82
+
83
+ async function tabWindowId(tabId) {
84
+ const tab = Number.isInteger(tabId) ? await chrome.tabs.get(tabId).catch(() => null) : null;
85
+ return Number.isInteger(tab?.windowId) ? tab.windowId : undefined;
86
+ }
87
+
53
88
  // Re-hydrate the in-memory ownership map from storage.session once per worker lifetime. Best
54
89
  // effort: storage may be unavailable on old Chrome, and a failure just means we may create a
55
90
  // fresh window (a harmless orphan) rather than reusing one.
@@ -57,17 +92,19 @@ async function hydrateAutomationTargets() {
57
92
  if (automationHydrated) return automationHydrated;
58
93
  automationHydrated = (async () => {
59
94
  try {
60
- const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY);
61
- const saved = stored && stored[AUTOMATION_STORAGE_KEY];
95
+ const { value: saved, restored } = await readOwnership(AUTOMATION_STORAGE_KEY);
62
96
  if (saved && typeof saved === "object") {
63
97
  for (const [key, value] of Object.entries(saved)) {
64
- if (value && typeof value.tabId === "number") {
65
- automationTargets.set(key, {
66
- windowId: typeof value.windowId === "number" ? value.windowId : undefined,
67
- tabId: value.tabId,
68
- });
69
- }
98
+ if (!value || typeof value.tabId !== "number") continue;
99
+ const tabWindow = typeof value.tabWindowId === "number" ? value.tabWindowId : undefined;
100
+ if (restored && !(await restoredTabStillOurs(value.tabId, tabWindow))) continue;
101
+ automationTargets.set(key, {
102
+ windowId: typeof value.windowId === "number" ? value.windowId : undefined,
103
+ tabId: value.tabId,
104
+ tabWindowId: tabWindow,
105
+ });
70
106
  }
107
+ if (restored) await persistAutomationTargets();
71
108
  }
72
109
  } catch {
73
110
  // Ignore: treat as "no persisted state".
@@ -79,19 +116,28 @@ async function hydrateAutomationTargets() {
79
116
  async function hydrateSessionTabs() {
80
117
  if (!sessionTabsReady) sessionTabsReady = (async () => {
81
118
  try {
82
- const stored = await chrome.storage?.session?.get?.(SESSION_TABS_STORAGE_KEY);
83
- for (const [key, entries] of Object.entries(stored?.[SESSION_TABS_STORAGE_KEY] || {})) {
119
+ const { value: saved, restored } = await readOwnership(SESSION_TABS_STORAGE_KEY);
120
+ for (const [key, entries] of Object.entries(saved || {})) {
84
121
  if (!Array.isArray(entries)) continue;
85
122
  const tabs = new Map();
86
123
  for (const entry of entries) {
87
124
  if (!entry || !Number.isInteger(entry.tabId) || entry.tabId < 0) continue;
88
- if (entry.created === true) tabs.set(entry.tabId, { created: true });
125
+ const windowId = Number.isInteger(entry.windowId) ? entry.windowId : undefined;
126
+ let tab = null;
127
+ if (restored) {
128
+ tab = await restoredTabStillOurs(entry.tabId, windowId);
129
+ if (!tab) continue;
130
+ }
131
+ if (entry.created === true) tabs.set(entry.tabId, { created: true, windowId });
89
132
  else if (entry.created === false && Number.isInteger(entry.groupId) && entry.groupId >= 0) {
90
- tabs.set(entry.tabId, { created: false, groupId: entry.groupId });
133
+ // An adopted tab must also still be in the group Pi put it in.
134
+ if (restored && tab.groupId !== entry.groupId) continue;
135
+ tabs.set(entry.tabId, { created: false, groupId: entry.groupId, windowId });
91
136
  }
92
137
  }
93
138
  if (tabs.size) sessionTabs.set(key, tabs);
94
139
  }
140
+ if (restored) await persistSessionTabs();
95
141
  } catch {
96
142
  // Missing ownership must leave tabs alone, not guess ownership from group names.
97
143
  }
@@ -105,7 +151,7 @@ function persistSessionTabs() {
105
151
  const saved = Object.fromEntries([...sessionTabs].map(([key, tabs]) => [
106
152
  key, [...tabs].map(([tabId, record]) => ({ tabId, ...record })),
107
153
  ]));
108
- await chrome.storage?.session?.set?.({ [SESSION_TABS_STORAGE_KEY]: saved });
154
+ await writeOwnership(SESSION_TABS_STORAGE_KEY, saved);
109
155
  }).catch(() => {});
110
156
  return sessionTabsWrite;
111
157
  }
@@ -119,7 +165,8 @@ async function trackSessionTab(sessionKey, tabId, created, groupId) {
119
165
  }
120
166
  let tabs = sessionTabs.get(sessionKey);
121
167
  if (!tabs) sessionTabs.set(sessionKey, tabs = new Map());
122
- tabs.set(tabId, created ? { created: true } : { created: false, groupId });
168
+ const windowId = await tabWindowId(tabId);
169
+ tabs.set(tabId, created ? { created: true, windowId } : { created: false, groupId, windowId });
123
170
  await persistSessionTabs();
124
171
  }
125
172
 
@@ -153,9 +200,14 @@ async function persistAutomationTargets() {
153
200
  try {
154
201
  const obj = {};
155
202
  for (const [key, value] of automationTargets) {
156
- obj[key] = { windowId: typeof value.windowId === "number" ? value.windowId : null, tabId: value.tabId };
203
+ if (typeof value.tabWindowId !== "number") value.tabWindowId = await tabWindowId(value.tabId);
204
+ obj[key] = {
205
+ windowId: typeof value.windowId === "number" ? value.windowId : null,
206
+ tabId: value.tabId,
207
+ tabWindowId: typeof value.tabWindowId === "number" ? value.tabWindowId : null,
208
+ };
157
209
  }
158
- await chrome.storage?.session?.set?.({ [AUTOMATION_STORAGE_KEY]: obj });
210
+ await writeOwnership(AUTOMATION_STORAGE_KEY, obj);
159
211
  } catch {
160
212
  // Ignore: persistence is an optimization, not a correctness requirement.
161
213
  }
@@ -174,17 +226,6 @@ function isPiChromeOwnedTarget(tabId, sessionKey) {
174
226
  return false;
175
227
  }
176
228
 
177
- // A new window created while the user's Chrome window is full screen does not become a visible
178
- // standalone window: macOS either merges it into the full-screen window as a hidden native window
179
- // tab ("Prefer tabs: in full screen") or places it on another Space. Its page is hidden, so trusted
180
- // input cannot reach it. Use a grouped background tab in the user's window instead, which is at least
181
- // visible in the tab strip and does not add a macOS tab bar to the user's full-screen window.
182
- async function userWindowIsFullscreen() {
183
- if (typeof chrome.windows?.getLastFocused !== "function") return false;
184
- const win = await chrome.windows.getLastFocused({ windowTypes: ["normal"] }).catch(() => null);
185
- return win?.state === "fullscreen";
186
- }
187
-
188
229
  // Create a fresh automation target for `sessionKey`. If this session already has a tab group,
189
230
  // create the tab inside that group's window so one Pi session keeps one Chrome tab group (Chrome
190
231
  // groups cannot span windows). If no group exists yet, prefer an isolated window; fall back to a
@@ -198,7 +239,7 @@ async function createAutomationTarget(sessionKey, groupTitle) {
198
239
  await persistAutomationTargets();
199
240
  return tab;
200
241
  }
201
- if (chrome.windows && typeof chrome.windows.create === "function" && !(await userWindowIsFullscreen())) {
242
+ if (chrome.windows && typeof chrome.windows.create === "function") {
202
243
  try {
203
244
  const win = await chrome.windows.create({ url: "about:blank", focused: false });
204
245
  const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined;
@@ -452,7 +493,7 @@ async function pageVisibilityState(tabId) {
452
493
  function hiddenTabReason(tab, win) {
453
494
  if (win?.state === "minimized") return "its window is minimized";
454
495
  if (tab && tab.active === false) return "it is an inactive tab in its window";
455
- if (win?.state === "fullscreen") return "its window is behind a full-screen window (on macOS, new windows can become hidden window tabs)";
496
+ if (win?.state === "fullscreen") return "its window is behind a full-screen window or on another Space";
456
497
  return "its window is covered by another window or is on another Space (Chrome treats fully covered windows as hidden)";
457
498
  }
458
499
 
@@ -1232,11 +1273,16 @@ async function chromeInputFill(params) {
1232
1273
  await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
1233
1274
  await sleep(rng(20, 60));
1234
1275
  }
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));
1276
+ if (await contentEditableInTab(tab.id, params)) {
1277
+ // The requested editor's whole contents are already selected; delete the selection.
1278
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
1279
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
1280
+ await sleep(rng(20, 60));
1281
+ } else {
1282
+ // A triple-click selects only one line of a multi-line textarea. Select the whole value with
1283
+ // Chrome's selectAll editing command before deleting.
1284
+ await selectAllAndDelete(tab.id);
1285
+ }
1240
1286
  const text = String(params.text || "");
1241
1287
  const typing = await typeTextInTab(tab.id, text, params.perCharacter);
1242
1288
  if (params.submit) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" });
@@ -1532,6 +1578,8 @@ chrome.runtime.onInstalled.addListener(() => {
1532
1578
  });
1533
1579
 
1534
1580
  chrome.runtime.onStartup.addListener(() => {
1581
+ // A new browser run: tab ids from the last run mean nothing now.
1582
+ Promise.resolve(chrome.storage?.local?.remove?.([AUTOMATION_STORAGE_KEY, SESSION_TABS_STORAGE_KEY])).catch(() => {});
1535
1583
  armKeepaliveAlarm();
1536
1584
  void pollLoop();
1537
1585
  });
@@ -1706,10 +1754,14 @@ async function groupTab(tab, title, color) {
1706
1754
  const groupTitle = cleanGroupTitle(title);
1707
1755
  let groupId = tab.groupId;
1708
1756
  if (typeof groupId !== "number" || groupId < 0) {
1709
- const existing = await findGroupByTitle(tab.windowId, groupTitle);
1757
+ // Some browsers omit windowId on freshly created tabs; read it back instead of guessing.
1758
+ const windowId = typeof tab.windowId === "number" ? tab.windowId : (await chrome.tabs.get(tab.id).catch(() => null))?.windowId;
1759
+ const existing = await findGroupByTitle(windowId, groupTitle);
1760
+ // Without createProperties.windowId Chrome creates the group in the *current* (user's) window
1761
+ // and moves the tab there, pulling Pi's automation tab out of its own window as a hidden tab.
1710
1762
  groupId = existing !== null
1711
1763
  ? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] })
1712
- : await chrome.tabs.group({ tabIds: [tab.id] });
1764
+ : await chrome.tabs.group(typeof windowId === "number" ? { tabIds: [tab.id], createProperties: { windowId } } : { tabIds: [tab.id] });
1713
1765
  }
1714
1766
  await chrome.tabGroups.update(groupId, { title: groupTitle, color: cleanGroupColor(color), collapsed: false });
1715
1767
  const grouped = await chrome.tabs.get(tab.id);
@@ -1743,8 +1795,20 @@ async function dispatch(action, params) {
1743
1795
  const existingGroup = await findGroupRecordByTitle(groupTitle);
1744
1796
  const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) };
1745
1797
  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);
1798
+ // Register the creation as in flight until the tab is tracked, so early capture (which only
1799
+ // runs in tracked tabs) waits for it instead of skipping the new tab's first page load.
1800
+ const creation = (async () => {
1801
+ const created = await chrome.tabs.create(createParams);
1802
+ await trackSessionTab(sessionKeyOf(params), created.id, true);
1803
+ return created;
1804
+ })();
1805
+ pendingTabCreations.add(creation);
1806
+ let tab;
1807
+ try {
1808
+ tab = await creation;
1809
+ } finally {
1810
+ pendingTabCreations.delete(creation);
1811
+ }
1748
1812
  try {
1749
1813
  await bringToFront(tab, params);
1750
1814
  const grouped = await groupTab(tab, groupTitle, params.groupColor);
@@ -1910,7 +1974,24 @@ async function dispatch(action, params) {
1910
1974
  // Report this session's owned automation target (ids only). Used for diagnostics/tests.
1911
1975
  await hydrateAutomationTargets();
1912
1976
  const t = automationTargets.get(sessionKeyOf(params));
1913
- return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null };
1977
+ const status = { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null };
1978
+ if (!params.includeVisibility || !t) return status;
1979
+ // For /chrome doctor: is this session's own tab visible? Never creates or activates anything.
1980
+ const tab = await chrome.tabs.get(t.tabId).catch(() => null);
1981
+ if (!tab) return { ...status, tabId: null };
1982
+ const results = await executeScriptWithFallback({
1983
+ target: { tabId: tab.id, frameIds: [0] },
1984
+ world: "MAIN",
1985
+ func: () => document.visibilityState,
1986
+ }, `read visibility of tab ${tab.id}`).catch(() => null);
1987
+ const visibility = typeof results?.[0]?.result === "string" ? results[0].result : "unknown";
1988
+ const win = typeof chrome.windows?.get === "function" ? await chrome.windows.get(tab.windowId).catch(() => null) : null;
1989
+ return {
1990
+ ...status,
1991
+ visibility,
1992
+ url: tab.url,
1993
+ hiddenReason: visibility === "hidden" ? hiddenTabReason(tab, win) : undefined,
1994
+ };
1914
1995
  }
1915
1996
  case "automation.cleanup":
1916
1997
  // Close recorded creations, and only ungroup user tabs still in their adopted group.
@@ -2298,8 +2379,12 @@ async function unregisterInitScript(tabId) {
2298
2379
  // console messages, errors, and network requests that fire during page load, before chrome_snapshot
2299
2380
  // or chrome_evaluate install the instrumentation normally. installEarlyCapture sets __piChromeWrapped
2300
2381
  // flags so the post-hoc installPiChromeInstrumentation() call is idempotent.
2382
+ const pendingTabCreations = new Set(); // in-flight Pi tab creations, resolved once tracked
2383
+
2301
2384
  async function isPiTrackedTab(tabId) {
2302
2385
  await Promise.all([hydrateAutomationTargets(), hydrateSessionTabs()]);
2386
+ // A tab Pi is creating right now can commit its first page before tab.new has tracked it.
2387
+ if (pendingTabCreations.size) await Promise.allSettled([...pendingTabCreations]);
2303
2388
  if (isPiChromeOwnedTarget(tabId)) return true;
2304
2389
  for (const tabs of sessionTabs.values()) if (tabs.has(tabId)) return true;
2305
2390
  return false;
@@ -1219,6 +1219,25 @@ Usage rules:
1219
1219
  } catch (error) {
1220
1220
  lines.push(`⚠ Couldn't inspect the active tab: ${(error as Error).message}`);
1221
1221
  }
1222
+
1223
+ // Pi's own tab for this session: hidden pages ignore clicks and typing, so say so up front.
1224
+ try {
1225
+ const own = (await bridge.send("automation.status", { sessionKey: sessionKeyFor(ctx), includeVisibility: true }, 10_000)) as
1226
+ | { tabId?: number | null; visibility?: string; hiddenReason?: string }
1227
+ | undefined;
1228
+ if (own?.tabId === null || own?.tabId === undefined) {
1229
+ lines.push(`• Pi hasn't opened its own tab in this session yet.`);
1230
+ } else if (own.visibility === "hidden") {
1231
+ lines.push(
1232
+ `⚠ Pi's tab is hidden because ${own.hiddenReason ?? "its window is not visible"}. Chrome ignores clicks and typing in hidden tabs, so those tools will fail until it's visible.`,
1233
+ ` Fix: bring Pi's window forward or uncover it, or run /chrome background off to let Pi bring it forward.`,
1234
+ );
1235
+ } else if (own.visibility === "visible") {
1236
+ lines.push(`✓ Pi's tab is visible, so clicks and typing will reach it.`);
1237
+ }
1238
+ } catch {
1239
+ // Older extensions or a closed tab: nothing useful to add.
1240
+ }
1222
1241
  } else if (versionMismatch) {
1223
1242
  lines.push(`… Skipped the remaining checks until you reload the Chrome extension.`);
1224
1243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.55",
3
+ "version": "0.15.57",
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; } },
@@ -24,6 +24,7 @@ function healthyResponse(action) {
24
24
  case "tab.version": return { extensionVersion: version };
25
25
  case "page.evaluate": return 2;
26
26
  case "page.probe": return { arithmetic: 2, location: "https://fixture.test/", webdriver: false };
27
+ case "automation.status": return { windowId: 5, tabId: 6, visibility: "visible" };
27
28
  default: throw new Error(`Unexpected bridge action: ${action}`);
28
29
  }
29
30
  }
@@ -44,6 +45,7 @@ function harness({ until, background = true, mode = "server", choices = [], send
44
45
  Date: { now: () => now }, PI_CHROME_VERSION: version,
45
46
  chromeAuthorizedUntil: until, backgroundEnabled: background,
46
47
  hostnameOf: (url) => new URL(url).hostname,
48
+ sessionKeyFor: () => "session:alpha",
47
49
  bridge: {
48
50
  status: () => ({ mode }),
49
51
  async send(action, params, timeout) {
@@ -128,8 +130,10 @@ test("Doctor includes locked, timed, indefinite, and expired authorization plus
128
130
  assert.match(report, /can run code/);
129
131
  assert.match(report, /fixture\.test/);
130
132
  assert.deepEqual(h.calls.map(({ action, timeout }) => [action, timeout]), [
131
- ["tab.version", 35_000], ["page.evaluate", 10_000], ["page.probe", 10_000],
133
+ ["tab.version", 35_000], ["page.evaluate", 10_000], ["page.probe", 10_000], ["automation.status", 10_000],
132
134
  ]);
135
+ assert.deepEqual(h.calls.at(-1).params, { sessionKey: "session:alpha", includeVisibility: true });
136
+ assert.match(report, /Pi's tab is visible/);
133
137
  assert.ok(h.calls.filter((call) => call.action.startsWith("page.")).every((call) => call.params.foreground === false));
134
138
  assert.equal(h.sandbox.chromeAuthorizedUntil, until, "diagnostics do not grant or change authorization");
135
139
  assert.equal(h.sandbox.backgroundEnabled, background);
@@ -157,7 +161,29 @@ test("Doctor retains local state and repair hints when connection/version checks
157
161
  test("choosing Doctor explicitly from the dashboard runs full diagnostics", async () => {
158
162
  const h = harness({ choices: ["Doctor / troubleshoot"] });
159
163
  await h.run();
160
- assert.deepEqual(h.calls.map((call) => call.action), ["tab.version", "tab.version", "page.evaluate", "page.probe"]);
164
+ assert.deepEqual(h.calls.map((call) => call.action), ["tab.version", "tab.version", "page.evaluate", "page.probe", "automation.status"]);
161
165
  assert.match(h.notices.at(-1)[0], /Authorization: locked/);
162
166
  assert.match(h.notices.at(-1)[0], /Background: on \(hard\)/);
163
167
  });
168
+
169
+ test("Doctor warns when this session's Pi tab is hidden, and stays quiet when it cannot tell", async () => {
170
+ const withStatus = (status) => (action, ...rest) => action === "automation.status"
171
+ ? (typeof status === "function" ? status() : status)
172
+ : healthyResponse(action, ...rest);
173
+ const hidden = harness({ send: withStatus({ tabId: 6, visibility: "hidden", hiddenReason: "its window is covered by another window" }) });
174
+ await hidden.run("doctor");
175
+ const report = hidden.notices.at(-1)[0];
176
+ assert.match(report, /⚠ Pi's tab is hidden because its window is covered by another window/);
177
+ assert.match(report, /Chrome ignores clicks and typing in hidden tabs/);
178
+ assert.match(report, /\/chrome background off/);
179
+
180
+ const none = harness({ send: withStatus({ windowId: null, tabId: null }) });
181
+ await none.run("doctor");
182
+ assert.match(none.notices.at(-1)[0], /hasn't opened its own tab in this session yet/);
183
+
184
+ for (const status of [{ windowId: 5, tabId: 6 }, () => { throw new Error("old extension"); }]) {
185
+ const quiet = harness({ send: withStatus(status) });
186
+ await quiet.run("doctor");
187
+ assert.doesNotMatch(quiet.notices.at(-1)[0], /Pi's tab|own tab/);
188
+ }
189
+ });
@@ -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
+ });
@@ -111,15 +111,15 @@ test("visible pages, failed probes, and foreground tabs that become visible proc
111
111
  });
112
112
 
113
113
  // ---- automation target placement ----
114
- test("automation targets use a background tab, not a new window, while the user's window is full screen", async () => {
115
- const full = worker({ win: { id: 1, state: "fullscreen" } });
116
- await full.w.createAutomationTarget("s1", undefined);
117
- assert.equal(full.calls.some((c) => c.method === "windows.create"), false);
118
- assert.deepEqual(clone(full.calls.find((c) => c.method === "tabs.create").props), { url: "about:blank", active: false });
119
-
120
- const normal = worker({ win: { id: 1, state: "maximized" } });
121
- await normal.w.createAutomationTarget("s2", undefined);
122
- assert.deepEqual(clone(normal.calls.find((c) => c.method === "windows.create").props), { url: "about:blank", focused: false });
114
+ test("automation targets get their own window even while the user's window is full screen", async () => {
115
+ // Chrome disables macOS automatic window tabbing, so a new window is never merged into the user's
116
+ // full-screen window; putting Pi's tab in the user's window would only lose isolation.
117
+ for (const state of ["fullscreen", "maximized"]) {
118
+ const h = worker({ win: { id: 1, state } });
119
+ await h.w.createAutomationTarget(`s-${state}`, undefined);
120
+ assert.deepEqual(clone(h.calls.find((c) => c.method === "windows.create").props), { url: "about:blank", focused: false }, state);
121
+ assert.equal(h.calls.some((c) => c.method === "tabs.create"), false, state);
122
+ }
123
123
  });
124
124
 
125
125
  // ---- input.debug ----
@@ -277,3 +277,23 @@ test("pruneScreenshotDir deletes only eligible files and never throws", async ()
277
277
  assert.equal(await prune.pruneScreenshotDir(path.join(dir, "missing")), 0);
278
278
  fs.rmSync(dir, { recursive: true, force: true });
279
279
  });
280
+
281
+ // ---- /chrome doctor visibility report ----
282
+ test("automation.status reports whether this session's own tab is visible, without creating one", async () => {
283
+ const h = worker({ tab: { id: 2, windowId: 1, active: false, url: "https://fixture.test/" }, win: { id: 1, state: "normal" } });
284
+ h.chrome.scripting.executeScript = async () => [{ result: "hidden" }];
285
+ const none = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
286
+ assert.deepEqual(clone(none), { windowId: null, tabId: null });
287
+ assert.equal(h.calls.some((c) => c.method === "tabs.create" || c.method === "windows.create"), false);
288
+
289
+ vm.runInContext("automationTargets.set('s', { tabId: 2, windowId: undefined })", h.w);
290
+ const hidden = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
291
+ assert.equal(hidden.visibility, "hidden");
292
+ assert.match(hidden.hiddenReason, /inactive tab/);
293
+
294
+ h.chrome.scripting.executeScript = async () => [{ result: "visible" }];
295
+ const visible = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
296
+ assert.equal(visible.visibility, "visible");
297
+ assert.equal(visible.hiddenReason, undefined);
298
+ assert.deepEqual(clone(await h.w.dispatch("automation.status", { sessionKey: "s" })), { windowId: null, tabId: 2 }, "plain status is unchanged");
299
+ });
@@ -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 };
@@ -0,0 +1,124 @@
1
+ // Tab ownership must survive an extension reload (storage.session is wiped; the extension reloads
2
+ // itself on every version bump) without ever claiming a tab that is no longer provably Pi's.
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import vm from "node:vm";
6
+ import { test } from "node:test";
7
+
8
+ const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
9
+ const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
10
+
11
+ // One browser (tabs/windows/groups) shared by successive extension instances.
12
+ function makeBrowser() {
13
+ let nextId = 100;
14
+ const b = { tabs: new Map(), windows: new Set([1]), groups: new Map(), local: {}, removed: [], ungrouped: [] };
15
+ b.addTab = (props) => { const tab = { id: nextId++, windowId: 1, active: false, url: "about:blank", groupId: -1, ...props }; b.tabs.set(tab.id, tab); return tab; };
16
+ b.newWindow = () => { const id = nextId++; b.windows.add(id); return id; };
17
+ return b;
18
+ }
19
+
20
+ function loadExtension(b, { session = {} } = {}) {
21
+ const listeners = { startup: [] };
22
+ const noop = { addListener() {}, removeListener() {} };
23
+ const area = (store) => ({
24
+ get: async (key) => (key in store ? { [key]: clone(store[key]) } : {}),
25
+ set: async (obj) => { for (const [k, v] of Object.entries(obj)) store[k] = clone(v); },
26
+ remove: async (keys) => { for (const k of [].concat(keys)) delete store[k]; },
27
+ });
28
+ const chrome = {
29
+ runtime: { id: "t", getManifest: () => ({ version: "0.0.0" }), onInstalled: noop, onStartup: { addListener: (fn) => listeners.startup.push(fn) } },
30
+ alarms: { create() {}, onAlarm: noop }, action: { onClicked: noop, setBadgeText() {}, setBadgeBackgroundColor() {} },
31
+ webNavigation: { onCommitted: noop }, debugger: { onDetach: noop, onEvent: noop },
32
+ scripting: { executeScript: async () => [] },
33
+ storage: { session: area(session), local: area(b.local) },
34
+ tabs: {
35
+ get: async (id) => { const t = b.tabs.get(id); if (!t) throw new Error(`No tab with id: ${id}`); return clone(t); },
36
+ query: async () => [...b.tabs.values()].map(clone),
37
+ create: async (props) => clone(b.addTab({ url: props.url, windowId: props.windowId ?? 1, active: !!props.active })),
38
+ remove: async (id) => { b.tabs.delete(id); b.removed.push(id); },
39
+ ungroup: async (id) => { b.tabs.get(id).groupId = -1; b.ungrouped.push(id); },
40
+ group: async ({ groupId, tabIds, createProperties }) => {
41
+ const gid = groupId ?? 900 + b.groups.size;
42
+ if (!b.groups.has(gid)) b.groups.set(gid, { id: gid, windowId: createProperties?.windowId ?? 1, title: "" });
43
+ for (const id of tabIds) Object.assign(b.tabs.get(id), { groupId: gid, windowId: b.groups.get(gid).windowId });
44
+ return gid;
45
+ },
46
+ },
47
+ tabGroups: {
48
+ query: async ({ windowId } = {}) => [...b.groups.values()].filter((g) => windowId === undefined || g.windowId === windowId).map(clone),
49
+ get: async (id) => clone(b.groups.get(id)),
50
+ update: async (id, props) => Object.assign(b.groups.get(id), props),
51
+ },
52
+ windows: {
53
+ getLastFocused: async () => ({ id: 1, state: "normal" }),
54
+ get: async (id) => { if (!b.windows.has(id)) throw new Error("no window"); return { id }; },
55
+ create: async ({ url }) => { const id = b.newWindow(); const tab = b.addTab({ windowId: id, url, active: true }); return { id, tabs: [clone(tab)] }; },
56
+ },
57
+ };
58
+ const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } };
59
+ w.self = w;
60
+ vm.runInNewContext(workerSource, w);
61
+ w.formatTab = async (t) => t;
62
+ w.waitForTabSettled = async () => true;
63
+ return { w, session, startup: () => Promise.all(listeners.startup.map((fn) => fn())) };
64
+ }
65
+
66
+ async function setUpOwnership(b) {
67
+ const ext = loadExtension(b);
68
+ const target = await ext.w.getOrCreateAutomationTarget("s", undefined); // own window
69
+ const created = await ext.w.dispatch("tab.new", { url: "https://a.test/", background: true, sessionKey: "s", groupTitle: "Pi Session: s" });
70
+ const userTab = b.addTab({ url: "https://user.test/" });
71
+ await ext.w.dispatch("tab.group", { targetId: String(userTab.id), sessionKey: "s", groupTitle: "Pi Session: s" });
72
+ return { ext, targetTab: target.id, createdTab: created.tab.id, adoptedTab: userTab.id };
73
+ }
74
+
75
+ test("after an extension reload, cleanup still closes Pi's tabs and ungroups the adopted one", async () => {
76
+ const b = makeBrowser();
77
+ const ids = await setUpOwnership(b);
78
+ const reloaded = loadExtension(b); // fresh worker, empty storage.session, same storage.local
79
+ const result = await reloaded.w.dispatch("automation.cleanup", { sessionKey: "s" });
80
+ assert.equal(result.closedTabId, ids.targetTab);
81
+ assert.equal(result.closedCreatedTabs, 1);
82
+ assert.equal(result.ungroupedAdoptedTabs, 1);
83
+ assert.deepEqual(b.removed.sort(), [ids.targetTab, ids.createdTab].sort());
84
+ assert.deepEqual(b.ungrouped, [ids.adoptedTab]);
85
+ assert.ok(b.tabs.has(ids.adoptedTab), "the user's tab is never closed");
86
+ });
87
+
88
+ test("a reloaded extension reuses its automation tab instead of opening another window", async () => {
89
+ const b = makeBrowser();
90
+ const { targetTab } = await setUpOwnership(b);
91
+ const windowsBefore = b.windows.size;
92
+ const reloaded = loadExtension(b);
93
+ const again = await reloaded.w.getOrCreateAutomationTarget("s", undefined);
94
+ assert.equal(again.id, targetTab);
95
+ assert.equal(b.windows.size, windowsBefore);
96
+ });
97
+
98
+ test("restored records are dropped when the tab moved windows, left Pi's group, or no longer exists", async () => {
99
+ const b = makeBrowser();
100
+ const ids = await setUpOwnership(b);
101
+ b.tabs.get(ids.targetTab).windowId = b.newWindow(); // user dragged it out
102
+ b.tabs.get(ids.adoptedTab).groupId = -1; // user removed it from Pi's group
103
+ b.tabs.delete(ids.createdTab); // closed; its id could later belong to another tab
104
+ const reloaded = loadExtension(b);
105
+ const result = await reloaded.w.dispatch("automation.cleanup", { sessionKey: "s" });
106
+ assert.equal(result.closedTabId, null);
107
+ assert.equal(result.closedCreatedTabs, 0);
108
+ assert.equal(result.ungroupedAdoptedTabs, 0);
109
+ assert.deepEqual(b.removed, []);
110
+ assert.deepEqual(b.ungrouped, []);
111
+ });
112
+
113
+ test("a browser restart drops the local copy; a service-worker restart keeps using storage.session", async () => {
114
+ const b = makeBrowser();
115
+ const { ext, targetTab } = await setUpOwnership(b);
116
+ // Service-worker restart: session storage survives, and it wins over local.
117
+ const swRestart = loadExtension(b, { session: ext.session });
118
+ assert.equal((await swRestart.w.dispatch("automation.status", { sessionKey: "s" })).tabId, targetTab);
119
+ // Browser restart: onStartup clears the local copy before anything reads it.
120
+ const restarted = loadExtension(b);
121
+ await restarted.startup();
122
+ assert.equal(Object.keys(b.local).length, 0);
123
+ assert.equal((await restarted.w.dispatch("automation.status", { sessionKey: "s" })).tabId, null);
124
+ });