pi-chrome 0.15.46 → 0.15.47

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
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable user-facing changes to `pi-chrome`.
4
4
 
5
+ ## 0.15.47 — 2026-09-09
6
+
7
+ - **Bounded session cleanup.** On exit, Pi waits up to two seconds for cleanup before stopping the bridge. Reload preserves browser resources; revoke remains non-blocking.
8
+ - **Track every created tab.** Cleanup closes session-created tabs and ungroups adopted user tabs only if they remain in the group Pi assigned. Ownership survives service-worker restarts; failed removals remain tracked for retry.
9
+ - **Mixed-window safety.** Cleanup removes individual owned tabs, never whole windows. User tabs moved into a Pi window, and other sessions' tabs sharing that window, remain open.
10
+
5
11
  ## 0.15.40 — 2026-06-22
6
12
 
7
13
  - **Automation targets reuse the session tab group.** When `chrome_navigate` / implicit page actions create a new pi-chrome automation tab, it is now created in this session's existing tab-group window when possible and joins that same group, avoiding duplicate same-title `Pi Session: ...` groups.
package/CONTRIBUTING.md CHANGED
@@ -15,6 +15,10 @@ Thanks for considering a contribution. pi-chrome aims to be the **de-facto brows
15
15
  # Link from a checkout
16
16
  pi install ./pi-chrome
17
17
 
18
+ # Run unit regressions (Node.js 22.13+; no live Chrome required)
19
+ # Lifecycle tests use Node's built-in TypeScript stripping.
20
+ npm test
21
+
18
22
  # Run the benchmark dashboard
19
23
  cd test-suite
20
24
  python3 -m http.server 8765
@@ -30,7 +30,10 @@ Each Pi session owns its own automation target:
30
30
  - If separate window cannot be created, pi-chrome falls back to dedicated tab.
31
31
  - Target survives `/reload` and Chrome service-worker restarts.
32
32
  - Ownership is tracked by id and mirrored to `chrome.storage.session`.
33
- - Cleanup closes only calling session's own target, never user tabs/windows or other sessions' targets.
33
+ - Cleanup closes calling session's automation target and every tab it created through `tab.new`.
34
+ - Existing user tabs adopted into a session group are preserved, and ungrouped only if still in that group.
35
+ - Cleanup removes individual owned tabs, never whole windows. User/other-session tabs moved into a Pi window remain open; Chrome closes a window automatically when its final tab is removed.
36
+ - Shutdown waits at most two seconds for cleanup before stopping the bridge. `/reload` preserves resources; revoke starts cleanup without blocking. Hard process termination or unavailable Chrome can still leave owned tabs open.
34
37
 
35
38
  To point pi-chrome at an existing tab, pass `targetId`, `urlIncludes`, or `titleIncludes`.
36
39
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.46",
4
+ "version": "0.15.47",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -29,7 +29,11 @@ let polling = false;
29
29
  const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number }
30
30
  const DEFAULT_SESSION_KEY = "__default__";
31
31
  const AUTOMATION_STORAGE_KEY = "piChromeAutomationTargets";
32
- let automationHydrated = false;
32
+ let automationHydrated;
33
+ const sessionTabs = new Map(); // sessionKey -> Map<tabId, { created: boolean, groupId?: number }>
34
+ const SESSION_TABS_STORAGE_KEY = "piChromeSessionTabs";
35
+ let sessionTabsReady;
36
+ let sessionTabsWrite = Promise.resolve();
33
37
 
34
38
  function sessionKeyOf(params) {
35
39
  return params && typeof params.sessionKey === "string" && params.sessionKey
@@ -41,24 +45,99 @@ function sessionKeyOf(params) {
41
45
  // effort: storage may be unavailable on old Chrome, and a failure just means we may create a
42
46
  // fresh window (a harmless orphan) rather than reusing one.
43
47
  async function hydrateAutomationTargets() {
44
- if (automationHydrated) return;
45
- automationHydrated = true;
46
- try {
47
- const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY);
48
- const saved = stored && stored[AUTOMATION_STORAGE_KEY];
49
- if (saved && typeof saved === "object") {
50
- for (const [key, value] of Object.entries(saved)) {
51
- if (value && typeof value.tabId === "number") {
52
- automationTargets.set(key, {
53
- windowId: typeof value.windowId === "number" ? value.windowId : undefined,
54
- tabId: value.tabId,
55
- });
48
+ if (automationHydrated) return automationHydrated;
49
+ automationHydrated = (async () => {
50
+ try {
51
+ const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY);
52
+ const saved = stored && stored[AUTOMATION_STORAGE_KEY];
53
+ if (saved && typeof saved === "object") {
54
+ for (const [key, value] of Object.entries(saved)) {
55
+ if (value && typeof value.tabId === "number") {
56
+ automationTargets.set(key, {
57
+ windowId: typeof value.windowId === "number" ? value.windowId : undefined,
58
+ tabId: value.tabId,
59
+ });
60
+ }
56
61
  }
57
62
  }
63
+ } catch {
64
+ // Ignore: treat as "no persisted state".
65
+ }
66
+ })();
67
+ return automationHydrated;
68
+ }
69
+
70
+ async function hydrateSessionTabs() {
71
+ if (!sessionTabsReady) sessionTabsReady = (async () => {
72
+ try {
73
+ const stored = await chrome.storage?.session?.get?.(SESSION_TABS_STORAGE_KEY);
74
+ for (const [key, entries] of Object.entries(stored?.[SESSION_TABS_STORAGE_KEY] || {})) {
75
+ if (!Array.isArray(entries)) continue;
76
+ const tabs = new Map();
77
+ for (const entry of entries) {
78
+ if (!entry || !Number.isInteger(entry.tabId) || entry.tabId < 0) continue;
79
+ if (entry.created === true) tabs.set(entry.tabId, { created: true });
80
+ else if (entry.created === false && Number.isInteger(entry.groupId) && entry.groupId >= 0) {
81
+ tabs.set(entry.tabId, { created: false, groupId: entry.groupId });
82
+ }
83
+ }
84
+ if (tabs.size) sessionTabs.set(key, tabs);
85
+ }
86
+ } catch {
87
+ // Missing ownership must leave tabs alone, not guess ownership from group names.
88
+ }
89
+ })();
90
+ return sessionTabsReady;
91
+ }
92
+
93
+ function persistSessionTabs() {
94
+ // Serialize writes and construct each snapshot when its turn starts.
95
+ sessionTabsWrite = sessionTabsWrite.then(async () => {
96
+ const saved = Object.fromEntries([...sessionTabs].map(([key, tabs]) => [
97
+ key, [...tabs].map(([tabId, record]) => ({ tabId, ...record })),
98
+ ]));
99
+ await chrome.storage?.session?.set?.({ [SESSION_TABS_STORAGE_KEY]: saved });
100
+ }).catch(() => {});
101
+ return sessionTabsWrite;
102
+ }
103
+
104
+ async function trackSessionTab(sessionKey, tabId, created, groupId) {
105
+ await Promise.all([hydrateSessionTabs(), hydrateAutomationTargets()]);
106
+ if (!Number.isInteger(tabId)) return;
107
+ if (!created) {
108
+ if (!Number.isInteger(groupId) || groupId < 0 || isPiChromeOwnedTarget(tabId)) return;
109
+ if ([...sessionTabs.values()].some((tabs) => tabs.get(tabId)?.created)) return;
110
+ }
111
+ let tabs = sessionTabs.get(sessionKey);
112
+ if (!tabs) sessionTabs.set(sessionKey, tabs = new Map());
113
+ tabs.set(tabId, created ? { created: true } : { created: false, groupId });
114
+ await persistSessionTabs();
115
+ }
116
+
117
+ async function cleanupSessionTabs(sessionKey) {
118
+ await hydrateSessionTabs();
119
+ const automation = await cleanupAutomationTarget(sessionKey);
120
+ const tabs = sessionTabs.get(sessionKey);
121
+ let closedCreatedTabs = 0;
122
+ let ungroupedAdoptedTabs = 0;
123
+ for (const [tabId, record] of [...(tabs || [])]) {
124
+ try {
125
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
126
+ if (tab && record.created) {
127
+ await chrome.tabs.remove(tabId);
128
+ closedCreatedTabs++;
129
+ } else if (tab && tab.groupId === record.groupId) {
130
+ await chrome.tabs.ungroup(tabId);
131
+ ungroupedAdoptedTabs++;
132
+ }
133
+ tabs.delete(tabId);
134
+ } catch {
135
+ // Retain failed operations for a later cleanup; never report them as completed.
58
136
  }
59
- } catch {
60
- // Ignore: treat as "no persisted state".
61
137
  }
138
+ if (tabs && !tabs.size) sessionTabs.delete(sessionKey);
139
+ await persistSessionTabs();
140
+ return { ...automation, closedCreatedTabs, ungroupedAdoptedTabs };
62
141
  }
63
142
 
64
143
  async function persistAutomationTargets() {
@@ -144,25 +223,26 @@ async function getOrCreateAutomationTarget(sessionKey, groupTitle) {
144
223
  async function cleanupAutomationTarget(sessionKey) {
145
224
  await hydrateAutomationTargets();
146
225
  const t = automationTargets.get(sessionKey);
147
- automationTargets.delete(sessionKey);
148
- await persistAutomationTargets();
149
- if (!t) return { closedWindowId: null, closedTabId: null };
150
- const { windowId, tabId } = t;
151
- if (typeof windowId === "number" && chrome.windows && typeof chrome.windows.remove === "function") {
152
- const win = await chrome.windows.get(windowId).catch(() => null);
153
- if (win) {
154
- await chrome.windows.remove(windowId).catch(() => {});
155
- return { closedWindowId: windowId, closedTabId: typeof tabId === "number" ? tabId : null };
226
+ const result = { closedWindowId: null, closedTabId: null };
227
+ if (!t) return result;
228
+ const tab = await chrome.tabs.get(t.tabId).catch(() => null);
229
+ if (tab) {
230
+ try {
231
+ // Never remove a whole window: users/other sessions can add tabs even between a
232
+ // contents check and removal. Chrome closes empty windows when their last tab closes.
233
+ await chrome.tabs.remove(t.tabId);
234
+ result.closedTabId = t.tabId;
235
+ } catch {
236
+ return result; // Keep ownership so cleanup can retry.
156
237
  }
157
- }
158
- if (typeof tabId === "number") {
159
- const tab = await chrome.tabs.get(tabId).catch(() => null);
160
- if (tab) {
161
- await chrome.tabs.remove(tabId).catch(() => {});
162
- return { closedWindowId: null, closedTabId: tabId };
238
+ if (tab.windowId === t.windowId && typeof chrome.windows?.get === "function") {
239
+ const remaining = await chrome.windows.get(t.windowId).catch(() => null);
240
+ if (!remaining) result.closedWindowId = t.windowId;
163
241
  }
164
242
  }
165
- return { closedWindowId: null, closedTabId: null };
243
+ automationTargets.delete(sessionKey);
244
+ await persistAutomationTargets();
245
+ return result;
166
246
  }
167
247
 
168
248
  function withTimeout(promise, ms, label, onTimeout) {
@@ -1142,6 +1222,7 @@ async function dispatch(action, params) {
1142
1222
  const createParams = { url: params.url || "about:blank", active: true };
1143
1223
  if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
1144
1224
  const tab = await chrome.tabs.create(createParams);
1225
+ await trackSessionTab(sessionKeyOf(params), tab.id, true);
1145
1226
  try {
1146
1227
  return await groupTab(tab, groupTitle, params.groupColor);
1147
1228
  } catch (error) {
@@ -1159,7 +1240,9 @@ async function dispatch(action, params) {
1159
1240
  }
1160
1241
  case "tab.group": {
1161
1242
  const tab = await getTabByParams(params, { createOwnedTarget: false });
1162
- return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
1243
+ const grouped = await groupTab(tab, params.groupTitle || "Pi", params.groupColor);
1244
+ if (!(tab.groupId >= 0)) await trackSessionTab(sessionKeyOf(params), tab.id, false, grouped.group?.id);
1245
+ return grouped;
1163
1246
  }
1164
1247
  case "tab.ungroup": {
1165
1248
  const tab = await getTabByParams(params, { createOwnedTarget: false });
@@ -1256,9 +1339,9 @@ async function dispatch(action, params) {
1256
1339
  return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null };
1257
1340
  }
1258
1341
  case "automation.cleanup":
1259
- // Close only THIS session's pi-chrome-owned window/tab. Never touches user tabs/windows or
1260
- // another Pi session's target.
1261
- return cleanupAutomationTarget(sessionKeyOf(params));
1342
+ // Close recorded creations, and only ungroup user tabs still in their adopted group.
1343
+ // Group titles are not ownership evidence.
1344
+ return cleanupSessionTabs(sessionKeyOf(params));
1262
1345
  default:
1263
1346
  throw new Error(`Unknown action: ${action}`);
1264
1347
  }
@@ -1345,18 +1428,19 @@ async function getTabByParams(params, { createOwnedTarget = true } = {}) {
1345
1428
  // which tabs Pi is driving. We only adopt *ungrouped* tabs — never hijack a tab the user (or
1346
1429
  // another Pi session) already grouped, since groupTab would otherwise rename that group.
1347
1430
  if (params.joinSessionGroup && params.sessionGroupTitle) {
1348
- await joinSessionGroup(tab, params.sessionGroupTitle);
1431
+ await joinSessionGroup(tab, params.sessionGroupTitle, sessionKeyOf(params));
1349
1432
  }
1350
1433
  return tab;
1351
1434
  }
1352
1435
 
1353
1436
  // Add an ungrouped tab to the session's tab group (reusing it by title, else creating it).
1354
1437
  // No-op when the tab is already grouped or tabGroups is unavailable.
1355
- async function joinSessionGroup(tab, title) {
1438
+ async function joinSessionGroup(tab, title, sessionKey) {
1356
1439
  if (!chrome.tabGroups || typeof tab.id !== "number") return;
1357
1440
  if (typeof tab.groupId === "number" && tab.groupId >= 0) return;
1358
1441
  try {
1359
- await groupTab(tab, title);
1442
+ const grouped = await groupTab(tab, title);
1443
+ await trackSessionTab(sessionKey, tab.id, false, grouped.group?.id);
1360
1444
  } catch {
1361
1445
  // Grouping is best-effort; never block the actual page action on a grouping failure.
1362
1446
  }
@@ -759,13 +759,25 @@ export default function (pi: ExtensionAPI): void {
759
759
  }, { triggerTurn: false });
760
760
  };
761
761
 
762
- // Close THIS session's dedicated automation window/tab. Fire-and-forget and best-effort: it
763
- // must never block /quit, /reload, revoke, or session end, and the service-worker side only
764
- // ever closes targets this session created itself (never user tabs/windows, never another
765
- // session's target). Errors (bridge down, target already closed) are intentionally swallowed.
766
- const cleanupAutomationTargetBestEffort = (): void => {
762
+ // Bound the entire request, including shared-owner forwarding/takeover. Revoke can launch
763
+ // this in the background; shutdown must wait before tearing down the bridge.
764
+ const cleanupAutomationTargetBestEffort = async (timeoutMs = 2_000): Promise<void> => {
767
765
  const sessionKey = sessionKeyFor(sessionCtx);
768
- void bridge.send("automation.cleanup", sessionKey !== undefined ? { sessionKey } : {}, 3_000).catch(() => undefined);
766
+ if (sessionKey === undefined) return; // Never clean up an unscoped/default session.
767
+ const controller = new AbortController();
768
+ let timer: ReturnType<typeof setTimeout> | undefined;
769
+ try {
770
+ await Promise.race([
771
+ bridge.send("automation.cleanup", { sessionKey }, timeoutMs, controller.signal).catch(() => undefined),
772
+ new Promise<void>((resolveCleanup) => {
773
+ timer = setTimeout(() => { controller.abort(); resolveCleanup(); }, timeoutMs);
774
+ }),
775
+ ]);
776
+ } catch {
777
+ // Shutdown/revoke remains best-effort when Chrome or its bridge is unavailable.
778
+ } finally {
779
+ clearTimeout(timer);
780
+ }
769
781
  };
770
782
 
771
783
  const lockChromeControl = (logAction?: "revoked" | "expired"): void => {
@@ -778,7 +790,7 @@ export default function (pi: ExtensionAPI): void {
778
790
  chromeAuthorizedUntil = undefined;
779
791
  persistAuth();
780
792
  // Revoking control ends pi-chrome's automation for this session; tidy up the target we own.
781
- cleanupAutomationTargetBestEffort();
793
+ void cleanupAutomationTargetBestEffort();
782
794
  };
783
795
 
784
796
  const authSummary = (): string => {
@@ -925,17 +937,12 @@ export default function (pi: ExtensionAPI): void {
925
937
  updateChromeStatus(ctx);
926
938
  });
927
939
 
928
- pi.on("session_shutdown", (event) => {
940
+ pi.on("session_shutdown", async (event) => {
929
941
  clearAuthExpiryTimer();
930
942
  clearCountdownInterval();
931
- // Tidy up this session's dedicated automation window on real session end, but NOT on
932
- // "reload": /reload tears down and re-evaluates this module while the *same* session
933
- // (same sessionKey) continues, so we keep the window so it is reused, not churned. The
934
- // call is fire-and-forget and runs before bridge.stop() so it never blocks shutdown.
935
- // (Owner-session quit may not deliver in time since stop() closes the bridge server;
936
- // that only ever leaves a clearly pi-chrome window for the user to close — never a user
937
- // tab — and /chrome revoke remains the reliable, bridge-alive cleanup path.)
938
- if (event?.reason !== "reload") cleanupAutomationTargetBestEffort();
943
+ // /reload continues the same session. On exit, give Chrome a bounded opportunity to
944
+ // close this session's created tabs and ungroup adopted tabs before stopping the broker.
945
+ if (event?.reason !== "reload") await cleanupAutomationTargetBestEffort();
939
946
  bridge.stop();
940
947
  if (globalState[PI_CHROME_GLOBAL_KEY]?.token === instanceToken) {
941
948
  delete globalState[PI_CHROME_GLOBAL_KEY];
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.46",
3
+ "version": "0.15.47",
4
4
  "scripts": {
5
- "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs",
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",
6
6
  "version": "node scripts/sync-manifest-version.js",
7
7
  "prepublishOnly": "node scripts/sync-manifest-version.js"
8
8
  },
@@ -81,7 +81,12 @@ function makeChrome(state, { withWindows = true, withStorage = true, withTabGrou
81
81
  return { ...tab };
82
82
  },
83
83
  update: async (id, props = {}) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); Object.assign(t, props); return { ...t }; },
84
- remove: async (id) => { tabs.delete(id); },
84
+ remove: async (id) => {
85
+ const tab = tabs.get(id);
86
+ tabs.delete(id);
87
+ // Chrome closes a window automatically when its final tab is removed.
88
+ if (tab && ![...tabs.values()].some((other) => other.windowId === tab.windowId)) windows.delete(tab.windowId);
89
+ },
85
90
  group: async ({ groupId, tabIds = [] } = {}) => {
86
91
  let gid = groupId;
87
92
  if (typeof gid !== "number") {
@@ -388,6 +393,108 @@ async function run() {
388
393
  ok(stale.closedWindowId === null && stale.closedTabId === null, "cleanup: robust when owned window was already closed");
389
394
  }
390
395
 
396
+ // Cleanup must never remove a window wholesale, even if tabs move during removal.
397
+ for (const moveOwnedTab of [false, true]) {
398
+ const state = makeChromeState();
399
+ const chrome = makeChrome(state);
400
+ chrome.windows.remove = async () => { throw new Error("whole-window removal is forbidden"); };
401
+ const w = loadWorker(chrome);
402
+ const owned = await w.getOrCreateAutomationTarget(SK);
403
+ const remove = chrome.tabs.remove;
404
+ chrome.tabs.remove = async (id) => {
405
+ // User adds a tab just as cleanup starts; a pre-removal window check is not enough.
406
+ state.userArticle.windowId = owned.windowId;
407
+ if (moveOwnedTab) state.tabs.get(owned.id).windowId = state.userWindowId;
408
+ await remove(id);
409
+ };
410
+ const result = await w.dispatch("automation.cleanup", { sessionKey: SK });
411
+ ok(result.closedTabId === owned.id && result.closedWindowId === null, "mixed window: only owned tab reported closed");
412
+ ok(state.tabs.has(state.userArticle.id) && state.windows.has(owned.windowId), "mixed window: user tab and its window survive");
413
+ ok(!state.tabs.has(owned.id), "mixed window: owned tab removed even after moving to another window");
414
+ }
415
+
416
+ // A populated automation window is not disposable merely because Pi created it.
417
+ {
418
+ const state = makeChromeState();
419
+ const w = loadWorker(makeChrome(state));
420
+ const owned = await w.getOrCreateAutomationTarget(SK);
421
+ state.userArticle.windowId = owned.windowId;
422
+ await w.dispatch("automation.cleanup", { sessionKey: SK });
423
+ ok(state.tabs.has(state.userArticle.id) && state.windows.has(owned.windowId), "populated window: user's moved-in tab survives cleanup");
424
+ }
425
+
426
+ // Created vs adopted ownership survives restart; matching titles do not grant ownership.
427
+ for (const restart of [false, true]) {
428
+ const state = makeChromeState();
429
+ const chrome = makeChrome(state, { withTabGroups: true });
430
+ let w = loadWorker(chrome);
431
+ const groupTitle = "Pi Session: shared-name";
432
+ await w.dispatch("page.navigate", {
433
+ sessionKey: SK, targetId: String(state.userGmail.id), url: "https://mail.google.com/",
434
+ waitUntilLoad: false, joinSessionGroup: true, sessionGroupTitle: groupTitle,
435
+ });
436
+ await w.dispatch("tab.group", { sessionKey: SK, targetId: String(state.userArticle.id), groupTitle });
437
+ const created = await w.dispatch("tab.new", { sessionKey: SK, groupTitle });
438
+ const other = await w.dispatch("tab.new", { sessionKey: "session:other", groupTitle });
439
+ // User changes the article's group after Pi adopted it. Cleanup must respect that change.
440
+ const replacementGroup = state.alloc.group();
441
+ state.userArticle.groupId = replacementGroup;
442
+ if (restart) w = loadWorker(makeChrome(state, { withTabGroups: true }));
443
+ const result = await w.dispatch("automation.cleanup", { sessionKey: SK });
444
+ ok(!state.tabs.has(created.tab.id) && state.tabs.has(other.tab.id), "resources: close only this session's created tabs");
445
+ ok(state.tabs.has(state.userGmail.id) && state.userGmail.groupId === -1, "resources: adopted user tab ungrouped, not closed");
446
+ ok(state.userArticle.groupId === replacementGroup, "resources: user's replacement group untouched");
447
+ ok(result.closedCreatedTabs === 1 && result.ungroupedAdoptedTabs === 1, "resources: counts reflect successful operations");
448
+ ok(!(SK in state.storage.piChromeSessionTabs), "resources: completed ownership removed from persistence");
449
+ const again = await w.dispatch("automation.cleanup", { sessionKey: SK });
450
+ ok(again.closedCreatedTabs === 0 && again.ungroupedAdoptedTabs === 0, "resources: repeated cleanup is idempotent");
451
+ }
452
+
453
+ // A failed close keeps ownership for retry, without claiming success.
454
+ {
455
+ const state = makeChromeState();
456
+ const chrome = makeChrome(state, { withTabGroups: true });
457
+ const w = loadWorker(chrome);
458
+ const owned = await w.getOrCreateAutomationTarget(SK);
459
+ const opened = await w.dispatch("tab.new", { sessionKey: SK });
460
+ const remove = chrome.tabs.remove;
461
+ chrome.tabs.remove = async () => { throw new Error("temporary close failure"); };
462
+ const failed = await w.dispatch("automation.cleanup", { sessionKey: SK });
463
+ ok(failed.closedCreatedTabs === 0 && failed.closedTabId === null, "retry: failed closes not reported as success");
464
+ chrome.tabs.remove = remove;
465
+ const restarted = loadWorker(chrome);
466
+ await restarted.dispatch("automation.cleanup", { sessionKey: SK });
467
+ ok(!state.tabs.has(owned.id) && !state.tabs.has(opened.tab.id), "retry: persisted ownership allows retry after restart");
468
+ }
469
+
470
+ // Failed ungrouping also remains retryable; a full browser restart abandons ownership.
471
+ {
472
+ const state = makeChromeState();
473
+ const chrome = makeChrome(state, { withTabGroups: true });
474
+ const w = loadWorker(chrome);
475
+ await w.dispatch("tab.group", { sessionKey: SK, targetId: String(state.userGmail.id) });
476
+ const ungroup = chrome.tabs.ungroup;
477
+ chrome.tabs.ungroup = async () => { throw new Error("temporary group failure"); };
478
+ const failed = await w.dispatch("automation.cleanup", { sessionKey: SK });
479
+ ok(failed.ungroupedAdoptedTabs === 0 && state.userGmail.groupId >= 0, "ungroup retry: failure leaves user tab and ownership intact");
480
+ chrome.tabs.ungroup = ungroup;
481
+ await loadWorker(chrome).dispatch("automation.cleanup", { sessionKey: SK });
482
+ ok(state.userGmail.groupId === -1, "ungroup retry: successful after worker restart");
483
+ const created = await w.dispatch("tab.new", { sessionKey: SK });
484
+ for (const key of Object.keys(state.storage)) delete state.storage[key];
485
+ await loadWorker(chrome).dispatch("automation.cleanup", { sessionKey: SK });
486
+ ok(state.tabs.has(created.tab.id), "browser restart: cleared storage never reclaims restored tabs by group name");
487
+ }
488
+
489
+ // Runtime tracking still works when storage.session is unavailable.
490
+ {
491
+ const state = makeChromeState();
492
+ const w = loadWorker(makeChrome(state, { withTabGroups: true, withStorage: false }));
493
+ const opened = await w.dispatch("tab.new", { sessionKey: SK });
494
+ await w.dispatch("automation.cleanup", { sessionKey: SK });
495
+ ok(!state.tabs.has(opened.tab.id) && state.tabs.has(state.userGmail.id), "no storage: created tab cleaned up safely");
496
+ }
497
+
391
498
  console.log(`\n${passes} passed, ${failures} failed`);
392
499
  if (failures) process.exit(1);
393
500
  }
@@ -0,0 +1,93 @@
1
+ // Exercise the shipped cleanup helper and shutdown listener, without opening a bridge or Chrome.
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
+
7
+ const source = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8");
8
+ function section(start, end) {
9
+ const from = source.indexOf(start);
10
+ const to = source.indexOf(end, from);
11
+ assert.ok(from >= 0 && to > from, `Missing source section: ${start}`);
12
+ return source.slice(from, to);
13
+ }
14
+ const helper = stripTypeScriptTypes(section("const cleanupAutomationTargetBestEffort =", "const lockChromeControl ="));
15
+ const listener = section('pi.on("session_shutdown",', 'pi.on("before_agent_start",');
16
+
17
+ function harness(send, sessionKey = "session:test") {
18
+ const timers = new Map();
19
+ const events = [];
20
+ let shutdown;
21
+ const token = Symbol();
22
+ const globalState = { loaded: { token } };
23
+ const context = {
24
+ AbortController, Promise,
25
+ setTimeout(fn, ms) { const key = Symbol(); timers.set(key, { fn, ms }); return key; },
26
+ clearTimeout(key) { timers.delete(key); },
27
+ bridge: { send(...args) { events.push("send"); return send(...args); }, stop() { events.push("stop"); } },
28
+ sessionCtx: {}, sessionKeyFor: () => sessionKey ?? undefined,
29
+ clearAuthExpiryTimer() {}, clearCountdownInterval() {},
30
+ globalState, PI_CHROME_GLOBAL_KEY: "loaded", instanceToken: token,
31
+ pi: { on(name, handler) { assert.equal(name, "session_shutdown"); shutdown = handler; } },
32
+ };
33
+ vm.runInNewContext(`${helper}\n${listener}`, context);
34
+ return { shutdown, timers, events, globalState };
35
+ }
36
+
37
+ // Delivery must settle before bridge.stop; reload does not issue cleanup at all.
38
+ {
39
+ let complete;
40
+ let request;
41
+ const h = harness((...args) => {
42
+ request = args;
43
+ return new Promise((resolve) => { complete = resolve; });
44
+ });
45
+ const done = h.shutdown({ reason: "quit" });
46
+ assert.deepEqual(h.events, ["send"]);
47
+ assert.equal(request[0], "automation.cleanup");
48
+ assert.equal(request[1].sessionKey, "session:test");
49
+ assert.equal(request[2], 2000);
50
+ assert.equal(request[3].aborted, false);
51
+ complete();
52
+ await done;
53
+ assert.deepEqual(h.events, ["send", "stop"]);
54
+ assert.equal(h.timers.size, 0);
55
+ assert.equal(h.globalState.loaded, undefined);
56
+ }
57
+ {
58
+ const h = harness(() => { throw new Error("reload must not send"); });
59
+ await h.shutdown({ reason: "reload" });
60
+ assert.deepEqual(h.events, ["stop"]);
61
+ assert.equal(h.timers.size, 0);
62
+ }
63
+
64
+ // Even an owner request that never settles cannot hold up shutdown past its budget.
65
+ {
66
+ let signal;
67
+ const h = harness((_action, _params, _timeout, s) => { signal = s; return new Promise(() => {}); });
68
+ const done = h.shutdown({});
69
+ const [timer] = h.timers.values();
70
+ assert.equal(timer.ms, 2000);
71
+ timer.fn();
72
+ await done;
73
+ assert.equal(signal.aborted, true);
74
+ assert.deepEqual(h.events, ["send", "stop"]);
75
+ assert.equal(h.timers.size, 0);
76
+ }
77
+ for (const reason of ["quit", "new", "resume", "fork"]) {
78
+ const h = harness(() => Promise.resolve({}));
79
+ await h.shutdown({ reason });
80
+ assert.deepEqual(h.events, ["send", "stop"]);
81
+ }
82
+ for (const send of [() => Promise.reject(new Error("offline")), () => { throw new Error("offline"); }]) {
83
+ const h = harness(send);
84
+ await h.shutdown({ reason: "exit" });
85
+ assert.deepEqual(h.events, ["send", "stop"]);
86
+ assert.equal(h.timers.size, 0);
87
+ }
88
+ {
89
+ const h = harness(() => { throw new Error("unscoped cleanup must not send"); }, null);
90
+ await h.shutdown({ reason: "exit" });
91
+ assert.deepEqual(h.events, ["stop"]);
92
+ }
93
+ console.log("session-cleanup: shutdown ordering, reload, deadline, failures, and missing identity passed");