aboutsummaryrefslogtreecommitdiff
path: root/test-suite/unit/ownership-persistence.test.mjs
blob: 988dd785fa1179a8539165664dcc7995e6670c63 (plain)
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
// 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);
});