pi-chrome 0.15.56 → 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,12 @@ 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
+
7
13
  ## 0.15.56 — 2026-09-28
8
14
 
9
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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.56",
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
 
@@ -1537,6 +1578,8 @@ chrome.runtime.onInstalled.addListener(() => {
1537
1578
  });
1538
1579
 
1539
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(() => {});
1540
1583
  armKeepaliveAlarm();
1541
1584
  void pollLoop();
1542
1585
  });
@@ -1931,7 +1974,24 @@ async function dispatch(action, params) {
1931
1974
  // Report this session's owned automation target (ids only). Used for diagnostics/tests.
1932
1975
  await hydrateAutomationTargets();
1933
1976
  const t = automationTargets.get(sessionKeyOf(params));
1934
- 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
+ };
1935
1995
  }
1936
1996
  case "automation.cleanup":
1937
1997
  // Close recorded creations, and only ungroup user tabs still in their adopted group.
@@ -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.56",
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",
@@ -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
+ });
@@ -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
+ });
@@ -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
+ });