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 +6 -0
- package/CONTRIBUTING.md +4 -0
- package/docs/ARCHITECTURE.md +4 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +122 -38
- package/extensions/chrome-profile-bridge/index.ts +23 -16
- package/package.json +2 -2
- package/test-suite/unit/automation-target.test.mjs +108 -1
- package/test-suite/unit/session-cleanup.test.mjs +93 -0
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
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
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
|
|
|
@@ -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
|
|
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 =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
await chrome.
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1260
|
-
//
|
|
1261
|
-
return
|
|
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
|
-
//
|
|
763
|
-
//
|
|
764
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
932
|
-
//
|
|
933
|
-
|
|
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.
|
|
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) => {
|
|
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");
|