1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
|
// Unit harness for pi-chrome's dedicated automation tab/window isolation in service_worker.js.
//
// Feature under test: pi-chrome must never navigate or replace the user's active tab. Page and
// navigation actions without an explicit target are routed to a dedicated automation target that
// the *calling Pi session* created and owns. Ownership is session-scoped (one extension brokers
// every session) and mirrored to chrome.storage.session so a service-worker restart re-hydrates
// it instead of orphaning the window. Cleanup closes only the calling session's owned target.
//
// Like csp-eval.test.mjs we load the *real* worker into a vm sandbox with a stateful chrome.*
// mock, then exercise the real helpers and the real dispatch() paths. Chrome state (tabs/windows/
// storage.session) can be shared across two sandbox loads to simulate a service-worker restart.
import vm from "node:vm";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
const src = fs.readFileSync(workerPath, "utf8");
let failures = 0;
let passes = 0;
function ok(cond, msg) {
if (cond) { passes++; }
else { failures++; console.error(` ✗ ${msg}`); }
}
async function throwsWith(fn, re, msg) {
try { await fn(); ok(false, `${msg} (expected throw)`); }
catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
}
// ---- stateful Chrome mock. `state` (tabs/windows/storage) can be shared to simulate a
// service-worker restart: the browser keeps its tabs/windows/session-storage, the worker memory
// is wiped (a fresh sandbox).
function makeChromeState() {
const tabs = new Map(); // id -> { id, windowId, url, active, groupId }
const windows = new Map(); // id -> { id }
const groups = new Map(); // groupId -> { id, title, color, collapsed, windowId }
const storage = {}; // chrome.storage.session backing
let nextTabId = 1;
let nextWindowId = 1;
let nextGroupId = 1;
const alloc = { tab: () => nextTabId++, window: () => nextWindowId++, group: () => nextGroupId++ };
// Seed a user window with two real user tabs (Gmail + a research article, the active one).
const userWindowId = alloc.window();
windows.set(userWindowId, { id: userWindowId });
const userGmail = { id: alloc.tab(), windowId: userWindowId, url: "https://mail.google.com/", active: false, groupId: -1 };
const userArticle = { id: alloc.tab(), windowId: userWindowId, url: "https://example.com/research-article", active: true, groupId: -1 };
tabs.set(userGmail.id, userGmail);
tabs.set(userArticle.id, userArticle);
return { tabs, windows, groups, storage, alloc, userWindowId, userGmail, userArticle };
}
function makeChrome(state, { withWindows = true, withStorage = true, withTabGroups = false } = {}) {
const { tabs, windows, groups, storage, alloc, userWindowId } = state;
const noop = () => {};
const listener = { addListener: noop, removeListener: noop };
const chrome = {
runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
action: { onClicked: listener },
debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]), onDetach: listener },
scripting: { executeScript: async () => [{ result: undefined }], registerContentScripts: async () => {}, unregisterContentScripts: async () => {} },
webNavigation: { onCommitted: listener },
tabs: {
onUpdated: listener,
query: async (q = {}) => {
let list = [...tabs.values()];
if (q.active === true) list = list.filter((t) => t.active);
if (typeof q.windowId === "number") list = list.filter((t) => t.windowId === q.windowId);
return list.map((t) => ({ ...t }));
},
get: async (id) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); return { ...t }; },
create: async ({ url = "about:blank", active = false, windowId = userWindowId } = {}) => {
const tab = { id: alloc.tab(), windowId, url, active, groupId: -1 };
tabs.set(tab.id, tab);
return { ...tab };
},
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 }; },
remove: async (id) => {
const tab = tabs.get(id);
tabs.delete(id);
// Chrome closes a window automatically when its final tab is removed.
if (tab && ![...tabs.values()].some((other) => other.windowId === tab.windowId)) windows.delete(tab.windowId);
},
group: async ({ groupId, tabIds = [] } = {}) => {
let gid = groupId;
if (typeof gid !== "number") {
gid = alloc.group();
const firstTab = tabs.get(tabIds[0]);
groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId: firstTab ? firstTab.windowId : userWindowId });
}
for (const tid of tabIds) { const t = tabs.get(tid); if (t) t.groupId = gid; }
return gid;
},
ungroup: async (id) => { const ids = Array.isArray(id) ? id : [id]; for (const tid of ids) { const t = tabs.get(tid); if (t) t.groupId = -1; } },
},
storage: withStorage ? {
session: {
get: async (key) => (key in storage ? { [key]: storage[key] } : {}),
set: async (obj) => { Object.assign(storage, obj); },
},
} : undefined,
};
if (withTabGroups) {
chrome.tabGroups = {
query: async ({ windowId } = {}) => [...groups.values()].filter((g) => windowId === undefined || g.windowId === windowId).map((g) => ({ ...g })),
get: async (id) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); return { ...g }; },
update: async (id, props = {}) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); Object.assign(g, props); return { ...g }; },
};
}
if (withWindows) {
chrome.windows = {
create: async ({ url = "about:blank", focused = false } = {}) => {
const id = alloc.window();
windows.set(id, { id });
const tab = { id: alloc.tab(), windowId: id, url, active: true, groupId: -1 };
tabs.set(tab.id, tab);
return { id, focused, tabs: [{ ...tab }] };
},
get: async (id) => { const w = windows.get(id); if (!w) throw new Error(`No window with id ${id}`); return { ...w }; },
remove: async (id) => { windows.delete(id); for (const [tid, t] of [...tabs]) if (t.windowId === id) tabs.delete(tid); },
update: async () => {},
};
} else {
chrome.windows = { update: async () => {} }; // no create/get/remove -> tab fallback path
}
return chrome;
}
function loadWorker(chrome) {
const noop = () => {};
const sandbox = {
console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
setTimeout, clearTimeout, setInterval: () => 0, clearInterval: noop,
fetch: async () => { throw new Error("no network in unit test"); },
navigator: { userAgent: "unit-test" },
WebSocket: function () {},
chrome,
};
sandbox.globalThis = sandbox;
sandbox.self = sandbox;
vm.createContext(sandbox);
vm.runInContext(src, sandbox);
return sandbox;
}
const SK = "session:alpha"; // a representative sessionKey
async function run() {
// ===== Isolation: navigation does not touch the user's active/other tabs. =====
{
const state = makeChromeState();
const w = loadWorker(makeChrome(state));
const userActiveUrl = state.userArticle.url;
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/task", waitUntilLoad: false, sessionKey: SK });
ok(state.userArticle.url === userActiveUrl, "navigate: active user tab (research article) is not overwritten");
ok(state.userGmail.url === "https://mail.google.com/", "navigate: other user tab (Gmail) untouched");
ok(nav.url === "https://pi.test/task", "navigate: automation target navigated to requested URL");
ok(nav.id !== state.userArticle.id && nav.id !== state.userGmail.id, "navigate: did not reuse any user tab");
ok(nav.windowId !== state.userWindowId, "navigate: automation target lives in a dedicated window");
const status = await w.dispatch("automation.status", { sessionKey: SK });
ok(status.tabId === nav.id && status.windowId === nav.windowId, "ownership: target ids tracked for the session");
ok(w.isPiChromeOwnedTarget(nav.id, SK) === true, "ownership: isPiChromeOwnedTarget(owned, session) === true");
ok(w.isPiChromeOwnedTarget(state.userArticle.id) === false, "ownership: user tab is never owned (any session)");
// Reuse: a later navigation reuses the same owned target.
const nav2 = await w.dispatch("page.navigate", { url: "https://pi.test/step-2", waitUntilLoad: false, sessionKey: SK });
ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "reuse: second navigation reuses the same automation window/tab");
ok(state.userArticle.url === userActiveUrl, "reuse: user tab still untouched after second navigation");
// Cleanup closes only the owned window; user tabs/windows survive.
const cleanup = await w.dispatch("automation.cleanup", { sessionKey: SK });
ok(cleanup.closedWindowId === nav.windowId, "cleanup: closed the owned window");
ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "cleanup: user tabs never closed");
ok(state.windows.has(state.userWindowId), "cleanup: user window never closed");
ok(!state.tabs.has(nav.id), "cleanup: the owned automation tab is gone");
const status2 = await w.dispatch("automation.status", { sessionKey: SK });
ok(status2.tabId === null && status2.windowId === null, "cleanup: ownership cleared");
}
// ===== Session-group integration: the dedicated-window tab joins this session's group. =====
{
const state = makeChromeState();
const w = loadWorker(makeChrome(state, { withTabGroups: true }));
// index.ts tags page.* actions with joinSessionGroup + sessionGroupTitle; replicate that here.
const groupTitle = "Pi Session: alpha";
const nav = await w.dispatch("page.navigate", {
url: "https://pi.test/grouped", waitUntilLoad: false,
sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
});
const navTab = state.tabs.get(nav.id);
ok(navTab.windowId !== state.userWindowId, "group: automation tab is in its dedicated window");
ok(typeof navTab.groupId === "number" && navTab.groupId >= 0, "group: automation tab joined a tab group");
const grp = state.groups.get(navTab.groupId);
ok(grp && grp.title === groupTitle, "group: the group is titled with this session's title");
ok(grp.windowId === navTab.windowId, "group: the session group lives inside the dedicated automation window (not the user window)");
// A second page action reuses the same tab and does not spawn a second group.
const groupsBefore = state.groups.size;
await w.dispatch("page.navigate", { url: "https://pi.test/grouped-2", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle });
ok(state.groups.size === groupsBefore, "group: reusing the automation tab does not create a second group");
}
// ===== tab.new joins the existing session group instead of creating one group per window. =====
{
const state = makeChromeState();
const w = loadWorker(makeChrome(state, { withTabGroups: true }));
const groupTitle = "Pi Session: alpha";
const nav = await w.dispatch("page.navigate", {
url: "https://pi.test/group-owner", waitUntilLoad: false,
sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
});
const navTab = state.tabs.get(nav.id);
const groupId = navTab.groupId;
const groupsBefore = state.groups.size;
const opened = await w.dispatch("tab.new", { url: "https://pi.test/new-tab", groupTitle, sessionKey: SK });
ok(state.groups.size === groupsBefore, "tab.new-group: did not create another same-session group");
ok(opened.tab.groupId === groupId, "tab.new-group: opened tab joined the existing session group");
ok(opened.tab.windowId === nav.windowId, "tab.new-group: opened tab was created in the existing group's window");
const forced = await w.dispatch("tab.new", { url: "https://pi.test/no-opt-out", groupTitle, group: false, sessionKey: SK });
ok(forced.tab.groupId === groupId, "tab.new-group: group:false is ignored; tab still joins the session group");
ok(state.groups.size === groupsBefore, "tab.new-group: group:false does not create another group");
const blankTitle = await w.dispatch("tab.new", { url: "https://pi.test/blank-title", groupTitle: "", group: false, sessionKey: SK });
ok(typeof blankTitle.tab.groupId === "number" && blankTitle.tab.groupId >= 0, "tab.new-group: groupTitle:'' still creates a grouped tab");
ok(blankTitle.group.title === "Pi", "tab.new-group: blank groupTitle falls back to a group instead of opting out");
const nav2 = await w.dispatch("page.navigate", {
url: "https://pi.test/new-automation-target", waitUntilLoad: false,
sessionKey: "session:beta", joinSessionGroup: true, sessionGroupTitle: groupTitle,
});
ok(state.groups.size === groupsBefore + 1, "automation-target-group: reused the existing session group, only blank-title Pi group was extra");
ok(nav2.groupId === groupId, "automation-target-group: new automation target joined the existing session group");
ok(nav2.windowId === nav.windowId, "automation-target-group: new automation target was created in the existing group's window");
}
// ===== tab.new never leaves an ungrouped tab behind when grouping fails. =====
{
const state = makeChromeState();
const chrome = makeChrome(state, { withTabGroups: true });
const w = loadWorker(chrome);
const tabsBefore = state.tabs.size;
chrome.tabs.group = async () => { throw new Error("group blew up"); };
await throwsWith(
() => w.dispatch("tab.new", { url: "https://pi.test/group-fail", groupTitle: "Pi Session: alpha", sessionKey: SK }),
/group blew up/,
"tab.new-group-fail: surfaces grouping error",
);
ok(state.tabs.size === tabsBefore, "tab.new-group-fail: closes the created tab instead of leaving it ungrouped");
}
// ===== Grouping is best-effort: a tabGroups failure must not break navigation. =====
{
const state = makeChromeState();
const chrome = makeChrome(state, { withTabGroups: true });
chrome.tabs.group = async () => { throw new Error("group blew up"); };
const w = loadWorker(chrome);
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/group-fail", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: "Pi Session: alpha" });
ok(nav.url === "https://pi.test/group-fail", "group-fail: navigation still succeeds when grouping throws");
ok(state.tabs.get(nav.id).windowId !== state.userWindowId, "group-fail: still used the dedicated automation window");
}
// ===== Concurrency: two sessions get separate windows; cleanup is per-session. =====
{
const state = makeChromeState();
const w = loadWorker(makeChrome(state));
const a = await w.dispatch("page.navigate", { url: "https://pi.test/a", waitUntilLoad: false, sessionKey: "session:A" });
const b = await w.dispatch("page.navigate", { url: "https://pi.test/b", waitUntilLoad: false, sessionKey: "session:B" });
ok(a.id !== b.id && a.windowId !== b.windowId, "concurrency: each session gets its own dedicated window/tab");
ok(w.isPiChromeOwnedTarget(a.id, "session:A") && !w.isPiChromeOwnedTarget(a.id, "session:B"), "concurrency: ownership is scoped to the creating session");
// Cleaning up session A must not touch session B's target.
await w.dispatch("automation.cleanup", { sessionKey: "session:A" });
ok(!state.tabs.has(a.id), "concurrency: cleanup closed session A's tab");
ok(state.tabs.has(b.id), "concurrency: cleanup left session B's tab open");
const bStatus = await w.dispatch("automation.status", { sessionKey: "session:B" });
ok(bStatus.tabId === b.id, "concurrency: session B still owns its target after A cleanup");
}
// ===== Service-worker restart / reconnect: persisted ownership re-hydrates from storage. =====
{
const state = makeChromeState();
const w1 = loadWorker(makeChrome(state));
const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/persist", waitUntilLoad: false, sessionKey: SK });
ok(typeof state.storage.piChromeAutomationTargets === "object", "restart: ownership was persisted to storage.session");
// Simulate the MV3 service worker being suspended and restarted: fresh sandbox (memory wiped),
// same browser tabs/windows + same session storage.
const w2 = loadWorker(makeChrome(state));
const statusAfterRestart = await w2.dispatch("automation.status", { sessionKey: SK });
ok(statusAfterRestart.tabId === nav.id && statusAfterRestart.windowId === nav.windowId, "restart: re-hydrated the owned target from storage");
// A navigation after restart must REUSE the existing window, not orphan it with a new one.
const windowsBefore = state.windows.size;
const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/persist-2", waitUntilLoad: false, sessionKey: SK });
ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "restart: navigation after restart reuses the persisted window (no orphan)");
ok(state.windows.size === windowsBefore, "restart: no new window created after restart");
// Cleanup after restart works and clears persisted state.
await w2.dispatch("automation.cleanup", { sessionKey: SK });
const persisted = state.storage.piChromeAutomationTargets || {};
ok(!(SK in persisted), "restart: cleanup removed the session from persisted storage");
}
// ===== Restart after the user manually closed the window: no orphan, fresh target. =====
{
const state = makeChromeState();
const w1 = loadWorker(makeChrome(state));
const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/closed", waitUntilLoad: false, sessionKey: SK });
await state.windows.delete(nav.windowId); // user closed pi-chrome's window
for (const [tid, t] of [...state.tabs]) if (t.windowId === nav.windowId) state.tabs.delete(tid);
const w2 = loadWorker(makeChrome(state)); // SW restart
const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/reopened", waitUntilLoad: false, sessionKey: SK });
ok(nav2.id !== nav.id, "restart-after-close: a fresh automation target is created when the persisted one is gone");
ok(state.tabs.has(nav2.id), "restart-after-close: new target exists");
}
// ===== tab.* management never auto-creates / never falls back to the user's active tab. =====
{
const state = makeChromeState();
const w = loadWorker(makeChrome(state));
const windowsBefore = state.windows.size;
const tabsBefore = state.tabs.size;
await throwsWith(
() => w.dispatch("tab.close", { sessionKey: SK }),
/no automation tab yet|Pass targetId/,
"tab.close: with no target and no owned target, errors instead of closing the user's active tab",
);
ok(state.tabs.has(state.userArticle.id), "tab.close: user's active tab was NOT closed");
ok(state.windows.size === windowsBefore && state.tabs.size === tabsBefore, "tab.close: did not spawn a throwaway tab/window");
await throwsWith(() => w.dispatch("tab.activate", { sessionKey: SK }), /no automation tab yet|Pass targetId/, "tab.activate: errors with no target/owned target");
// Once an automation target exists, management actions operate on it (not on the user tab).
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/manage", waitUntilLoad: false, sessionKey: SK });
const closed = await w.dispatch("tab.close", { sessionKey: SK });
ok(closed.closed === nav.id, "tab.close: with an owned target, closes that target");
ok(state.tabs.has(state.userArticle.id), "tab.close: user tab still safe after closing the owned target");
}
// ===== Explicit targeting still works on any existing tab (no regression). =====
{
const state = makeChromeState();
const w = loadWorker(makeChrome(state));
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/explicit", targetId: String(state.userGmail.id), waitUntilLoad: false, sessionKey: SK });
ok(nav.id === state.userGmail.id, "explicit: targetId routes to the requested existing tab");
ok(state.userGmail.url === "https://pi.test/explicit", "explicit: explicitly targeted tab is navigated");
const status = await w.dispatch("automation.status", { sessionKey: SK });
ok(status.tabId === null, "explicit: explicit targeting does not create/claim an automation target");
}
// ===== Window-unavailable fallback: a dedicated TAB is used, and the user's window is safe. =====
{
const state = makeChromeState();
const w = loadWorker(makeChrome(state, { withWindows: false }));
const target = await w.getOrCreateAutomationTarget(SK);
ok(target.id !== state.userArticle.id && target.id !== state.userGmail.id, "fallback: created a dedicated tab, not a user tab");
ok(w.isPiChromeOwnedTarget(target.id, SK) === true, "fallback: dedicated tab is owned");
const cleanup = await w.cleanupAutomationTarget(SK);
ok(cleanup.closedTabId === target.id && cleanup.closedWindowId === null, "fallback: cleanup closes only the owned tab (never the shared window)");
ok(state.windows.has(state.userWindowId), "fallback: cleanup never closes the user/shared window");
ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "fallback: cleanup leaves user tabs intact");
}
// ===== Robust cleanup: no-op when nothing created, and when target already closed manually. =====
{
const state = makeChromeState();
const w = loadWorker(makeChrome(state));
const empty = await w.cleanupAutomationTarget(SK);
ok(empty.closedWindowId === null && empty.closedTabId === null, "cleanup: no-op when nothing was ever created");
const t = await w.getOrCreateAutomationTarget(SK);
// User closed pi-chrome's window manually (Chrome closes its tabs too).
state.windows.delete(t.windowId);
for (const [tid, tab] of [...state.tabs]) if (tab.windowId === t.windowId) state.tabs.delete(tid);
const stale = await w.cleanupAutomationTarget(SK);
ok(stale.closedWindowId === null && stale.closedTabId === null, "cleanup: robust when owned window was already closed");
}
// Cleanup must never remove a window wholesale, even if tabs move during removal.
for (const moveOwnedTab of [false, true]) {
const state = makeChromeState();
const chrome = makeChrome(state);
chrome.windows.remove = async () => { throw new Error("whole-window removal is forbidden"); };
const w = loadWorker(chrome);
const owned = await w.getOrCreateAutomationTarget(SK);
const remove = chrome.tabs.remove;
chrome.tabs.remove = async (id) => {
// User adds a tab just as cleanup starts; a pre-removal window check is not enough.
state.userArticle.windowId = owned.windowId;
if (moveOwnedTab) state.tabs.get(owned.id).windowId = state.userWindowId;
await remove(id);
};
const result = await w.dispatch("automation.cleanup", { sessionKey: SK });
ok(result.closedTabId === owned.id && result.closedWindowId === null, "mixed window: only owned tab reported closed");
ok(state.tabs.has(state.userArticle.id) && state.windows.has(owned.windowId), "mixed window: user tab and its window survive");
ok(!state.tabs.has(owned.id), "mixed window: owned tab removed even after moving to another window");
}
// A populated automation window is not disposable merely because Pi created it.
{
const state = makeChromeState();
const w = loadWorker(makeChrome(state));
const owned = await w.getOrCreateAutomationTarget(SK);
state.userArticle.windowId = owned.windowId;
await w.dispatch("automation.cleanup", { sessionKey: SK });
ok(state.tabs.has(state.userArticle.id) && state.windows.has(owned.windowId), "populated window: user's moved-in tab survives cleanup");
}
// Created vs adopted ownership survives restart; matching titles do not grant ownership.
for (const restart of [false, true]) {
const state = makeChromeState();
const chrome = makeChrome(state, { withTabGroups: true });
let w = loadWorker(chrome);
const groupTitle = "Pi Session: shared-name";
await w.dispatch("page.navigate", {
sessionKey: SK, targetId: String(state.userGmail.id), url: "https://mail.google.com/",
waitUntilLoad: false, joinSessionGroup: true, sessionGroupTitle: groupTitle,
});
await w.dispatch("tab.group", { sessionKey: SK, targetId: String(state.userArticle.id), groupTitle });
const created = await w.dispatch("tab.new", { sessionKey: SK, groupTitle });
const other = await w.dispatch("tab.new", { sessionKey: "session:other", groupTitle });
// User changes the article's group after Pi adopted it. Cleanup must respect that change.
const replacementGroup = state.alloc.group();
state.userArticle.groupId = replacementGroup;
if (restart) w = loadWorker(makeChrome(state, { withTabGroups: true }));
const result = await w.dispatch("automation.cleanup", { sessionKey: SK });
ok(!state.tabs.has(created.tab.id) && state.tabs.has(other.tab.id), "resources: close only this session's created tabs");
ok(state.tabs.has(state.userGmail.id) && state.userGmail.groupId === -1, "resources: adopted user tab ungrouped, not closed");
ok(state.userArticle.groupId === replacementGroup, "resources: user's replacement group untouched");
ok(result.closedCreatedTabs === 1 && result.ungroupedAdoptedTabs === 1, "resources: counts reflect successful operations");
ok(!(SK in state.storage.piChromeSessionTabs), "resources: completed ownership removed from persistence");
const again = await w.dispatch("automation.cleanup", { sessionKey: SK });
ok(again.closedCreatedTabs === 0 && again.ungroupedAdoptedTabs === 0, "resources: repeated cleanup is idempotent");
}
// A failed close keeps ownership for retry, without claiming success.
{
const state = makeChromeState();
const chrome = makeChrome(state, { withTabGroups: true });
const w = loadWorker(chrome);
const owned = await w.getOrCreateAutomationTarget(SK);
const opened = await w.dispatch("tab.new", { sessionKey: SK });
const remove = chrome.tabs.remove;
chrome.tabs.remove = async () => { throw new Error("temporary close failure"); };
const failed = await w.dispatch("automation.cleanup", { sessionKey: SK });
ok(failed.closedCreatedTabs === 0 && failed.closedTabId === null, "retry: failed closes not reported as success");
chrome.tabs.remove = remove;
const restarted = loadWorker(chrome);
await restarted.dispatch("automation.cleanup", { sessionKey: SK });
ok(!state.tabs.has(owned.id) && !state.tabs.has(opened.tab.id), "retry: persisted ownership allows retry after restart");
}
// Failed ungrouping also remains retryable; a full browser restart abandons ownership.
{
const state = makeChromeState();
const chrome = makeChrome(state, { withTabGroups: true });
const w = loadWorker(chrome);
await w.dispatch("tab.group", { sessionKey: SK, targetId: String(state.userGmail.id) });
const ungroup = chrome.tabs.ungroup;
chrome.tabs.ungroup = async () => { throw new Error("temporary group failure"); };
const failed = await w.dispatch("automation.cleanup", { sessionKey: SK });
ok(failed.ungroupedAdoptedTabs === 0 && state.userGmail.groupId >= 0, "ungroup retry: failure leaves user tab and ownership intact");
chrome.tabs.ungroup = ungroup;
await loadWorker(chrome).dispatch("automation.cleanup", { sessionKey: SK });
ok(state.userGmail.groupId === -1, "ungroup retry: successful after worker restart");
const created = await w.dispatch("tab.new", { sessionKey: SK });
for (const key of Object.keys(state.storage)) delete state.storage[key];
await loadWorker(chrome).dispatch("automation.cleanup", { sessionKey: SK });
ok(state.tabs.has(created.tab.id), "browser restart: cleared storage never reclaims restored tabs by group name");
}
// Runtime tracking still works when storage.session is unavailable.
{
const state = makeChromeState();
const w = loadWorker(makeChrome(state, { withTabGroups: true, withStorage: false }));
const opened = await w.dispatch("tab.new", { sessionKey: SK });
await w.dispatch("automation.cleanup", { sessionKey: SK });
ok(!state.tabs.has(opened.tab.id) && state.tabs.has(state.userGmail.id), "no storage: created tab cleaned up safely");
}
console.log(`\n${passes} passed, ${failures} failed`);
if (failures) process.exit(1);
}
run().catch((e) => { console.error(e); process.exit(1); });
|