aboutsummaryrefslogtreecommitdiff
path: root/test-suite/unit/ownership-persistence.test.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'test-suite/unit/ownership-persistence.test.mjs')
-rw-r--r--test-suite/unit/ownership-persistence.test.mjs124
1 files changed, 124 insertions, 0 deletions
diff --git a/test-suite/unit/ownership-persistence.test.mjs b/test-suite/unit/ownership-persistence.test.mjs
new file mode 100644
index 0000000..988dd78
--- /dev/null
+++ b/test-suite/unit/ownership-persistence.test.mjs
@@ -0,0 +1,124 @@
+// Tab ownership must survive an extension reload (storage.session is wiped; the extension reloads
+// itself on every version bump) without ever claiming a tab that is no longer provably Pi's.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { test } from "node:test";
+
+const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
+const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
+
+// One browser (tabs/windows/groups) shared by successive extension instances.
+function makeBrowser() {
+ let nextId = 100;
+ const b = { tabs: new Map(), windows: new Set([1]), groups: new Map(), local: {}, removed: [], ungrouped: [] };
+ 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; };
+ b.newWindow = () => { const id = nextId++; b.windows.add(id); return id; };
+ return b;
+}
+
+function loadExtension(b, { session = {} } = {}) {
+ const listeners = { startup: [] };
+ const noop = { addListener() {}, removeListener() {} };
+ const area = (store) => ({
+ get: async (key) => (key in store ? { [key]: clone(store[key]) } : {}),
+ set: async (obj) => { for (const [k, v] of Object.entries(obj)) store[k] = clone(v); },
+ remove: async (keys) => { for (const k of [].concat(keys)) delete store[k]; },
+ });
+ const chrome = {
+ runtime: { id: "t", getManifest: () => ({ version: "0.0.0" }), onInstalled: noop, onStartup: { addListener: (fn) => listeners.startup.push(fn) } },
+ alarms: { create() {}, onAlarm: noop }, action: { onClicked: noop, setBadgeText() {}, setBadgeBackgroundColor() {} },
+ webNavigation: { onCommitted: noop }, debugger: { onDetach: noop, onEvent: noop },
+ scripting: { executeScript: async () => [] },
+ storage: { session: area(session), local: area(b.local) },
+ tabs: {
+ get: async (id) => { const t = b.tabs.get(id); if (!t) throw new Error(`No tab with id: ${id}`); return clone(t); },
+ query: async () => [...b.tabs.values()].map(clone),
+ create: async (props) => clone(b.addTab({ url: props.url, windowId: props.windowId ?? 1, active: !!props.active })),
+ remove: async (id) => { b.tabs.delete(id); b.removed.push(id); },
+ ungroup: async (id) => { b.tabs.get(id).groupId = -1; b.ungrouped.push(id); },
+ group: async ({ groupId, tabIds, createProperties }) => {
+ const gid = groupId ?? 900 + b.groups.size;
+ if (!b.groups.has(gid)) b.groups.set(gid, { id: gid, windowId: createProperties?.windowId ?? 1, title: "" });
+ for (const id of tabIds) Object.assign(b.tabs.get(id), { groupId: gid, windowId: b.groups.get(gid).windowId });
+ return gid;
+ },
+ },
+ tabGroups: {
+ query: async ({ windowId } = {}) => [...b.groups.values()].filter((g) => windowId === undefined || g.windowId === windowId).map(clone),
+ get: async (id) => clone(b.groups.get(id)),
+ update: async (id, props) => Object.assign(b.groups.get(id), props),
+ },
+ windows: {
+ getLastFocused: async () => ({ id: 1, state: "normal" }),
+ get: async (id) => { if (!b.windows.has(id)) throw new Error("no window"); return { id }; },
+ create: async ({ url }) => { const id = b.newWindow(); const tab = b.addTab({ windowId: id, url, active: true }); return { id, tabs: [clone(tab)] }; },
+ },
+ };
+ const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } };
+ w.self = w;
+ vm.runInNewContext(workerSource, w);
+ w.formatTab = async (t) => t;
+ w.waitForTabSettled = async () => true;
+ return { w, session, startup: () => Promise.all(listeners.startup.map((fn) => fn())) };
+}
+
+async function setUpOwnership(b) {
+ const ext = loadExtension(b);
+ const target = await ext.w.getOrCreateAutomationTarget("s", undefined); // own window
+ const created = await ext.w.dispatch("tab.new", { url: "https://a.test/", background: true, sessionKey: "s", groupTitle: "Pi Session: s" });
+ const userTab = b.addTab({ url: "https://user.test/" });
+ await ext.w.dispatch("tab.group", { targetId: String(userTab.id), sessionKey: "s", groupTitle: "Pi Session: s" });
+ return { ext, targetTab: target.id, createdTab: created.tab.id, adoptedTab: userTab.id };
+}
+
+test("after an extension reload, cleanup still closes Pi's tabs and ungroups the adopted one", async () => {
+ const b = makeBrowser();
+ const ids = await setUpOwnership(b);
+ const reloaded = loadExtension(b); // fresh worker, empty storage.session, same storage.local
+ const result = await reloaded.w.dispatch("automation.cleanup", { sessionKey: "s" });
+ assert.equal(result.closedTabId, ids.targetTab);
+ assert.equal(result.closedCreatedTabs, 1);
+ assert.equal(result.ungroupedAdoptedTabs, 1);
+ assert.deepEqual(b.removed.sort(), [ids.targetTab, ids.createdTab].sort());
+ assert.deepEqual(b.ungrouped, [ids.adoptedTab]);
+ assert.ok(b.tabs.has(ids.adoptedTab), "the user's tab is never closed");
+});
+
+test("a reloaded extension reuses its automation tab instead of opening another window", async () => {
+ const b = makeBrowser();
+ const { targetTab } = await setUpOwnership(b);
+ const windowsBefore = b.windows.size;
+ const reloaded = loadExtension(b);
+ const again = await reloaded.w.getOrCreateAutomationTarget("s", undefined);
+ assert.equal(again.id, targetTab);
+ assert.equal(b.windows.size, windowsBefore);
+});
+
+test("restored records are dropped when the tab moved windows, left Pi's group, or no longer exists", async () => {
+ const b = makeBrowser();
+ const ids = await setUpOwnership(b);
+ b.tabs.get(ids.targetTab).windowId = b.newWindow(); // user dragged it out
+ b.tabs.get(ids.adoptedTab).groupId = -1; // user removed it from Pi's group
+ b.tabs.delete(ids.createdTab); // closed; its id could later belong to another tab
+ const reloaded = loadExtension(b);
+ const result = await reloaded.w.dispatch("automation.cleanup", { sessionKey: "s" });
+ assert.equal(result.closedTabId, null);
+ assert.equal(result.closedCreatedTabs, 0);
+ assert.equal(result.ungroupedAdoptedTabs, 0);
+ assert.deepEqual(b.removed, []);
+ assert.deepEqual(b.ungrouped, []);
+});
+
+test("a browser restart drops the local copy; a service-worker restart keeps using storage.session", async () => {
+ const b = makeBrowser();
+ const { ext, targetTab } = await setUpOwnership(b);
+ // Service-worker restart: session storage survives, and it wins over local.
+ const swRestart = loadExtension(b, { session: ext.session });
+ assert.equal((await swRestart.w.dispatch("automation.status", { sessionKey: "s" })).tabId, targetTab);
+ // Browser restart: onStartup clears the local copy before anything reads it.
+ const restarted = loadExtension(b);
+ await restarted.startup();
+ assert.equal(Object.keys(b.local).length, 0);
+ assert.equal((await restarted.w.dispatch("automation.status", { sessionKey: "s" })).tabId, null);
+});