pi-chrome 0.15.37 → 0.15.39

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.
@@ -702,6 +702,7 @@ export default function (pi: ExtensionAPI): void {
702
702
  };
703
703
  let chromeToolsRegistered = false;
704
704
  let authExpiryTimer: NodeJS.Timeout | undefined;
705
+ let countdownInterval: NodeJS.Timeout | undefined;
705
706
  // Remembered so bridge sends can tag tabs with this session's group even when ctx isn't handy.
706
707
  let sessionCtx: ExtensionContext | undefined;
707
708
 
@@ -711,6 +712,12 @@ export default function (pi: ExtensionAPI): void {
711
712
  authExpiryTimer = undefined;
712
713
  };
713
714
 
715
+ const clearCountdownInterval = (): void => {
716
+ if (!countdownInterval) return;
717
+ clearInterval(countdownInterval);
718
+ countdownInterval = undefined;
719
+ };
720
+
714
721
  const activeToolNamesWithoutChrome = (): string[] => pi.getActiveTools().filter((name) => !CHROME_TOOL_NAME_SET.has(name));
715
722
 
716
723
  const activateChromeTools = (): void => {
@@ -722,11 +729,23 @@ export default function (pi: ExtensionAPI): void {
722
729
  pi.setActiveTools(activeToolNamesWithoutChrome());
723
730
  };
724
731
 
732
+ // Close THIS session's dedicated automation window/tab. Fire-and-forget and best-effort: it
733
+ // must never block /quit, /reload, revoke, or session end, and the service-worker side only
734
+ // ever closes targets this session created itself (never user tabs/windows, never another
735
+ // session's target). Errors (bridge down, target already closed) are intentionally swallowed.
736
+ const cleanupAutomationTargetBestEffort = (): void => {
737
+ const sessionKey = sessionKeyFor(sessionCtx);
738
+ void bridge.send("automation.cleanup", sessionKey !== undefined ? { sessionKey } : {}, 3_000).catch(() => undefined);
739
+ };
740
+
725
741
  const lockChromeControl = (): void => {
726
742
  clearAuthExpiryTimer();
743
+ clearCountdownInterval();
727
744
  chromeAuthorizedUntil = undefined;
728
745
  persistAuth();
729
746
  deactivateChromeTools();
747
+ // Revoking control ends pi-chrome's automation for this session; tidy up the target we own.
748
+ cleanupAutomationTargetBestEffort();
730
749
  };
731
750
 
732
751
  const authSummary = (): string => {
@@ -759,16 +778,51 @@ export default function (pi: ExtensionAPI): void {
759
778
  return `Pi Session: ${name || id || "unknown"}`;
760
779
  };
761
780
 
781
+ const authCountdownLabel = (): string => {
782
+ if (chromeAuthorizedUntil === "indefinite") return " (indefinite)";
783
+ if (typeof chromeAuthorizedUntil === "number") {
784
+ const remainingMs = chromeAuthorizedUntil - Date.now();
785
+ if (remainingMs > 0) {
786
+ const mins = Math.ceil(remainingMs / 60_000);
787
+ return mins >= 1 ? ` (${mins}m)` : " (<1m)";
788
+ }
789
+ }
790
+ return "";
791
+ };
792
+
793
+ // Stable per-session key the service worker uses to scope its dedicated automation tab/window
794
+ // to *this* session (one extension brokers all sessions). The session id is stable across
795
+ // /reload, so the automation target is reused rather than orphaned. Returns undefined only
796
+ // before session_start, in which case the worker uses its default bucket.
797
+ const sessionKeyFor = (ctx: ExtensionContext | undefined): string | undefined => {
798
+ const id = ctx?.sessionManager?.getSessionId?.();
799
+ return typeof id === "string" && id ? `session:${id}` : undefined;
800
+ };
801
+
762
802
  const updateChromeStatus = (ctx: ExtensionContext): void => {
763
803
  if (chromeControlAuthorized()) {
764
- ctx.ui.setStatus("chrome", ctx.ui.theme.fg("success", "●") + " Chrome Bridge");
804
+ ctx.ui.setStatus("chrome", ctx.ui.theme.fg("success", "●") + " Chrome Bridge" + authCountdownLabel());
765
805
  } else {
766
806
  ctx.ui.setStatus("chrome", undefined);
767
807
  }
768
808
  };
769
809
 
810
+ // Ticks every 60 s while a timed authorization is active to keep the countdown current.
811
+ const startCountdownTicker = (ctx: ExtensionContext): void => {
812
+ clearCountdownInterval();
813
+ if (chromeAuthorizedUntil === "indefinite" || typeof chromeAuthorizedUntil !== "number") return;
814
+ countdownInterval = setInterval(() => {
815
+ if (!chromeControlAuthorized()) {
816
+ clearCountdownInterval();
817
+ return;
818
+ }
819
+ updateChromeStatus(ctx);
820
+ }, 60_000);
821
+ };
822
+
770
823
  const scheduleAuthExpiry = (ctx: ExtensionContext, until: number | "indefinite"): void => {
771
824
  clearAuthExpiryTimer();
825
+ startCountdownTicker(ctx);
772
826
  if (until === "indefinite") return;
773
827
  authExpiryTimer = setTimeout(() => {
774
828
  if (chromeAuthorizedUntil !== until) return;
@@ -784,14 +838,27 @@ export default function (pi: ExtensionAPI): void {
784
838
 
785
839
  const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> => {
786
840
  requireChromeControlAuthorized();
841
+ // Scope the service worker's dedicated automation tab/window to this session. Forwarded on
842
+ // every action so tab resolution, navigation, and cleanup all agree on which target is ours.
843
+ const sessionKey = sessionKeyFor(sessionCtx);
844
+ let wireParams: Record<string, unknown> = sessionKey !== undefined && params.sessionKey === undefined
845
+ ? { ...params, sessionKey }
846
+ : params;
847
+ const sessionTitle = sessionCtx !== undefined ? sessionGroupTitle(sessionCtx) : undefined;
848
+ // Any tab Pi opens through tab.new/tab.group must use THIS session's group, even if a caller
849
+ // passes group:false or a custom groupTitle. This central guard covers chrome_tab plus internal
850
+ // callers such as chrome_launch(url).
851
+ if ((action === "tab.new" || action === "tab.group") && sessionTitle !== undefined) {
852
+ wireParams = { ...wireParams, groupTitle: sessionTitle };
853
+ }
787
854
  // Any tab Pi *uses* (page.* interactions) should join this session's group, mirroring the
788
855
  // auto-grouping that tab.new already does. Tagging the wire params lets getTabByParams pull
789
- // the resolved (e.g. active) tab into the session group on the service-worker side. We skip
790
- // tab.* actions: tab.new/group group explicitly, and activate/close/ungroup/list must not.
791
- const shouldJoinGroup = action.startsWith("page.") && sessionCtx !== undefined && params.sessionGroupTitle === undefined;
792
- const wireParams = shouldJoinGroup
793
- ? { ...params, sessionGroupTitle: sessionGroupTitle(sessionCtx as ExtensionContext), joinSessionGroup: true }
794
- : params;
856
+ // the resolved tab into the session group on the service-worker side. We skip tab.* actions:
857
+ // tab.new/group are forced above, and activate/close/ungroup/list must not group tabs.
858
+ const shouldJoinGroup = action.startsWith("page.") && sessionTitle !== undefined && params.sessionGroupTitle === undefined;
859
+ if (shouldJoinGroup) {
860
+ wireParams = { ...wireParams, sessionGroupTitle: sessionTitle, joinSessionGroup: true };
861
+ }
795
862
  return bridge.send(action, wireParams, timeoutMs, signal);
796
863
  };
797
864
 
@@ -816,12 +883,22 @@ export default function (pi: ExtensionAPI): void {
816
883
  if (chromeControlAuthorized()) {
817
884
  activateChromeTools();
818
885
  if (typeof chromeAuthorizedUntil === "number") scheduleAuthExpiry(ctx, chromeAuthorizedUntil);
886
+ else if (chromeAuthorizedUntil === "indefinite") startCountdownTicker(ctx);
819
887
  }
820
888
  updateChromeStatus(ctx);
821
889
  });
822
890
 
823
- pi.on("session_shutdown", () => {
891
+ pi.on("session_shutdown", (event) => {
824
892
  clearAuthExpiryTimer();
893
+ clearCountdownInterval();
894
+ // Tidy up this session's dedicated automation window on real session end, but NOT on
895
+ // "reload": /reload tears down and re-evaluates this module while the *same* session
896
+ // (same sessionKey) continues, so we keep the window so it is reused, not churned. The
897
+ // call is fire-and-forget and runs before bridge.stop() so it never blocks shutdown.
898
+ // (Owner-session quit may not deliver in time since stop() closes the bridge server;
899
+ // that only ever leaves a clearly pi-chrome window for the user to close — never a user
900
+ // tab — and /chrome revoke remains the reliable, bridge-alive cleanup path.)
901
+ if (event?.reason !== "reload") cleanupAutomationTargetBestEffort();
825
902
  bridge.stop();
826
903
  if (globalState[PI_CHROME_GLOBAL_KEY]?.token === instanceToken) {
827
904
  delete globalState[PI_CHROME_GLOBAL_KEY];
@@ -836,6 +913,11 @@ export default function (pi: ExtensionAPI): void {
836
913
  <chrome-profile-bridge>
837
914
  Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
838
915
 
916
+ Tab/window isolation (important):
917
+ - pi-chrome owns a dedicated automation window/tab. When a chrome_* tool runs with no explicit target, it acts on that pi-chrome-owned target — it never reuses or overwrites the user's currently active tab. The dedicated target is created on first use and reused afterward.
918
+ - To act on a specific *existing* tab (e.g. one the user asks you to use), pass targetId/urlIncludes/titleIncludes. Without one of those, assume you are working in pi-chrome's own automation target.
919
+ - pi-chrome's automation target may be closed automatically when Chrome control is revoked; user tabs/windows are never closed by pi-chrome.
920
+
839
921
  Capability model (important):
840
922
  - Interactive controls (click/type/fill/key/hover/drag/scroll/tap) use Chrome's real input layer via chrome.debugger / CDP. Events satisfy normal user-activation gates.
841
923
  - Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
@@ -1204,7 +1286,7 @@ Usage rules:
1204
1286
  pi.registerTool({
1205
1287
  name: "chrome_tab",
1206
1288
  label: "Chrome Tab",
1207
- description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension.",
1289
+ description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension. New/grouped tabs always use this session's Pi tab group. activate/close/group/ungroup require a target (targetId/urlIncludes/titleIncludes); with no target they act on this session's pi-chrome automation tab if one exists, and otherwise error rather than touching the user's active tab.",
1208
1290
  promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.",
1209
1291
  parameters: Type.Object({
1210
1292
  action: StringEnum(tabActionValues),
@@ -1212,18 +1294,18 @@ Usage rules:
1212
1294
  targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close/group/ungroup." })),
1213
1295
  urlIncludes: Type.Optional(Type.String({ description: "Match the target tab by URL substring for activate/close/group/ungroup." })),
1214
1296
  titleIncludes: Type.Optional(Type.String({ description: "Match the target tab by title substring for activate/close/group/ungroup." })),
1215
- group: Type.Optional(Type.Boolean({ description: "action=new only: pass false to open an ungrouped tab. By default every Pi-opened tab joins this session's own tab group." })),
1216
- groupTitle: Type.Optional(Type.String({ description: "Tab group title for action=group/new. Defaults to this Pi session's group ('Pi Session: <name-or-id>'). Pass an empty string on action=new to opt out of grouping." })),
1297
+ group: Type.Optional(Type.Boolean({ description: "Deprecated; ignored. Pi-created tabs always join this session's own tab group." })),
1298
+ groupTitle: Type.Optional(Type.String({ description: "Deprecated for action=new/group; ignored so one Pi session uses one tab group ('Pi Session: <name-or-id>')." })),
1217
1299
  groupColor: Type.Optional(Type.String({ description: "Tab group color for action=group/new: grey, blue, red, yellow, green, pink, purple, cyan, or orange. Defaults to blue." })),
1218
1300
  host: Type.Optional(Type.String()),
1219
1301
  port: Type.Optional(Type.Number()),
1220
1302
  }),
1221
1303
  async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
1222
1304
  const forwarded = { ...params } as typeof params & { groupTitle?: string };
1223
- // Default every Pi-opened/explicitly-grouped tab into this session's own group,
1224
- // named after the session display name (falling back to the session id), unless
1225
- // the caller specified a group title or opted out with group:false.
1226
- if ((params.action === "new" || params.action === "group") && params.groupTitle === undefined && params.group !== false) {
1305
+ // Force every Pi-opened/explicitly-grouped tab into this session's own group,
1306
+ // named after the session display name (falling back to the session id). There is
1307
+ // intentionally no opt-out: one Pi session should create/use one tab group.
1308
+ if (params.action === "new" || params.action === "group") {
1227
1309
  forwarded.groupTitle = sessionGroupTitle(ctx);
1228
1310
  }
1229
1311
  const result = await authorizedBridgeSend(`tab.${params.action}`, forwarded, DEFAULT_TIMEOUT_MS, signal);
@@ -1351,7 +1433,7 @@ Usage rules:
1351
1433
  name: "chrome_navigate",
1352
1434
  label: "Chrome Navigate",
1353
1435
  description:
1354
- "Navigate an existing Chrome tab to a URL via the companion extension. Runs in the background by default; pass background=false to focus Chrome and activate the tab so the user can watch. Optionally waits for load completion.",
1436
+ "Navigate a Chrome tab to a URL via the companion extension. With no target, navigation goes to pi-chrome's own dedicated automation window/tab — it never replaces the user's active tab. Pass targetId/urlIncludes/titleIncludes only to act on a specific existing tab. Runs in the background by default; pass background=false to focus Chrome and activate the tab so the user can watch. Optionally waits for load completion.",
1355
1437
  promptSnippet: "Navigate a Chrome tab in the user's existing profile.",
1356
1438
  parameters: Type.Object({
1357
1439
  url: Type.String(),
@@ -1413,6 +1495,7 @@ Usage rules:
1413
1495
  selector: Type.Optional(Type.String({ description: "CSS selector to click. Prefer uid from chrome_snapshot when available." })),
1414
1496
  x: Type.Optional(Type.Number({ description: "Viewport x coordinate if uid/selector is omitted." })),
1415
1497
  y: Type.Optional(Type.Number({ description: "Viewport y coordinate if uid/selector is omitted." })),
1498
+ domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
1416
1499
  includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the click." })),
1417
1500
  maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
1418
1501
  targetId: Type.Optional(Type.String()),
@@ -1478,6 +1561,7 @@ Usage rules:
1478
1561
  uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
1479
1562
  selector: Type.Optional(Type.String({ description: "CSS selector to fill if uid is omitted." })),
1480
1563
  submit: Type.Optional(Type.Boolean({ description: "If true, press Enter after filling." })),
1564
+ domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
1481
1565
  includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after filling." })),
1482
1566
  maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
1483
1567
  targetId: Type.Optional(Type.String()),
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.37",
3
+ "version": "0.15.39",
4
4
  "scripts": {
5
- "test": "node test-suite/unit/csp-eval.test.mjs",
5
+ "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs",
6
6
  "version": "node scripts/sync-manifest-version.js",
7
7
  "prepublishOnly": "node scripts/sync-manifest-version.js"
8
8
  },
@@ -0,0 +1,387 @@
1
+ // Unit harness for pi-chrome's dedicated automation tab/window isolation in service_worker.js.
2
+ //
3
+ // Feature under test: pi-chrome must never navigate or replace the user's active tab. Page and
4
+ // navigation actions without an explicit target are routed to a dedicated automation target that
5
+ // the *calling Pi session* created and owns. Ownership is session-scoped (one extension brokers
6
+ // every session) and mirrored to chrome.storage.session so a service-worker restart re-hydrates
7
+ // it instead of orphaning the window. Cleanup closes only the calling session's owned target.
8
+ //
9
+ // Like csp-eval.test.mjs we load the *real* worker into a vm sandbox with a stateful chrome.*
10
+ // mock, then exercise the real helpers and the real dispatch() paths. Chrome state (tabs/windows/
11
+ // storage.session) can be shared across two sandbox loads to simulate a service-worker restart.
12
+
13
+ import vm from "node:vm";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
20
+ const src = fs.readFileSync(workerPath, "utf8");
21
+
22
+ let failures = 0;
23
+ let passes = 0;
24
+ function ok(cond, msg) {
25
+ if (cond) { passes++; }
26
+ else { failures++; console.error(` ✗ ${msg}`); }
27
+ }
28
+ async function throwsWith(fn, re, msg) {
29
+ try { await fn(); ok(false, `${msg} (expected throw)`); }
30
+ catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
31
+ }
32
+
33
+ // ---- stateful Chrome mock. `state` (tabs/windows/storage) can be shared to simulate a
34
+ // service-worker restart: the browser keeps its tabs/windows/session-storage, the worker memory
35
+ // is wiped (a fresh sandbox).
36
+ function makeChromeState() {
37
+ const tabs = new Map(); // id -> { id, windowId, url, active, groupId }
38
+ const windows = new Map(); // id -> { id }
39
+ const groups = new Map(); // groupId -> { id, title, color, collapsed, windowId }
40
+ const storage = {}; // chrome.storage.session backing
41
+ let nextTabId = 1;
42
+ let nextWindowId = 1;
43
+ let nextGroupId = 1;
44
+ const alloc = { tab: () => nextTabId++, window: () => nextWindowId++, group: () => nextGroupId++ };
45
+
46
+ // Seed a user window with two real user tabs (Gmail + a research article, the active one).
47
+ const userWindowId = alloc.window();
48
+ windows.set(userWindowId, { id: userWindowId });
49
+ const userGmail = { id: alloc.tab(), windowId: userWindowId, url: "https://mail.google.com/", active: false, groupId: -1 };
50
+ const userArticle = { id: alloc.tab(), windowId: userWindowId, url: "https://example.com/research-article", active: true, groupId: -1 };
51
+ tabs.set(userGmail.id, userGmail);
52
+ tabs.set(userArticle.id, userArticle);
53
+
54
+ return { tabs, windows, groups, storage, alloc, userWindowId, userGmail, userArticle };
55
+ }
56
+
57
+ function makeChrome(state, { withWindows = true, withStorage = true, withTabGroups = false } = {}) {
58
+ const { tabs, windows, groups, storage, alloc, userWindowId } = state;
59
+ const noop = () => {};
60
+ const listener = { addListener: noop, removeListener: noop };
61
+
62
+ const chrome = {
63
+ runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
64
+ alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
65
+ action: { onClicked: listener },
66
+ debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]), onDetach: listener },
67
+ scripting: { executeScript: async () => [{ result: undefined }], registerContentScripts: async () => {}, unregisterContentScripts: async () => {} },
68
+ webNavigation: { onCommitted: listener },
69
+ tabs: {
70
+ onUpdated: listener,
71
+ query: async (q = {}) => {
72
+ let list = [...tabs.values()];
73
+ if (q.active === true) list = list.filter((t) => t.active);
74
+ if (typeof q.windowId === "number") list = list.filter((t) => t.windowId === q.windowId);
75
+ return list.map((t) => ({ ...t }));
76
+ },
77
+ get: async (id) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); return { ...t }; },
78
+ create: async ({ url = "about:blank", active = false, windowId = userWindowId } = {}) => {
79
+ const tab = { id: alloc.tab(), windowId, url, active, groupId: -1 };
80
+ tabs.set(tab.id, tab);
81
+ return { ...tab };
82
+ },
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); },
85
+ group: async ({ groupId, tabIds = [] } = {}) => {
86
+ let gid = groupId;
87
+ if (typeof gid !== "number") {
88
+ gid = alloc.group();
89
+ const firstTab = tabs.get(tabIds[0]);
90
+ groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId: firstTab ? firstTab.windowId : userWindowId });
91
+ }
92
+ for (const tid of tabIds) { const t = tabs.get(tid); if (t) t.groupId = gid; }
93
+ return gid;
94
+ },
95
+ 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; } },
96
+ },
97
+ storage: withStorage ? {
98
+ session: {
99
+ get: async (key) => (key in storage ? { [key]: storage[key] } : {}),
100
+ set: async (obj) => { Object.assign(storage, obj); },
101
+ },
102
+ } : undefined,
103
+ };
104
+
105
+ if (withTabGroups) {
106
+ chrome.tabGroups = {
107
+ query: async ({ windowId } = {}) => [...groups.values()].filter((g) => windowId === undefined || g.windowId === windowId).map((g) => ({ ...g })),
108
+ get: async (id) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); return { ...g }; },
109
+ update: async (id, props = {}) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); Object.assign(g, props); return { ...g }; },
110
+ };
111
+ }
112
+
113
+ if (withWindows) {
114
+ chrome.windows = {
115
+ create: async ({ url = "about:blank", focused = false } = {}) => {
116
+ const id = alloc.window();
117
+ windows.set(id, { id });
118
+ const tab = { id: alloc.tab(), windowId: id, url, active: true, groupId: -1 };
119
+ tabs.set(tab.id, tab);
120
+ return { id, focused, tabs: [{ ...tab }] };
121
+ },
122
+ get: async (id) => { const w = windows.get(id); if (!w) throw new Error(`No window with id ${id}`); return { ...w }; },
123
+ remove: async (id) => { windows.delete(id); for (const [tid, t] of [...tabs]) if (t.windowId === id) tabs.delete(tid); },
124
+ update: async () => {},
125
+ };
126
+ } else {
127
+ chrome.windows = { update: async () => {} }; // no create/get/remove -> tab fallback path
128
+ }
129
+
130
+ return chrome;
131
+ }
132
+
133
+ function loadWorker(chrome) {
134
+ const noop = () => {};
135
+ const sandbox = {
136
+ console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
137
+ Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
138
+ setTimeout, clearTimeout, setInterval: () => 0, clearInterval: noop,
139
+ fetch: async () => { throw new Error("no network in unit test"); },
140
+ navigator: { userAgent: "unit-test" },
141
+ WebSocket: function () {},
142
+ chrome,
143
+ };
144
+ sandbox.globalThis = sandbox;
145
+ sandbox.self = sandbox;
146
+ vm.createContext(sandbox);
147
+ vm.runInContext(src, sandbox);
148
+ return sandbox;
149
+ }
150
+
151
+ const SK = "session:alpha"; // a representative sessionKey
152
+
153
+ async function run() {
154
+ // ===== Isolation: navigation does not touch the user's active/other tabs. =====
155
+ {
156
+ const state = makeChromeState();
157
+ const w = loadWorker(makeChrome(state));
158
+ const userActiveUrl = state.userArticle.url;
159
+
160
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/task", waitUntilLoad: false, sessionKey: SK });
161
+ ok(state.userArticle.url === userActiveUrl, "navigate: active user tab (research article) is not overwritten");
162
+ ok(state.userGmail.url === "https://mail.google.com/", "navigate: other user tab (Gmail) untouched");
163
+ ok(nav.url === "https://pi.test/task", "navigate: automation target navigated to requested URL");
164
+ ok(nav.id !== state.userArticle.id && nav.id !== state.userGmail.id, "navigate: did not reuse any user tab");
165
+ ok(nav.windowId !== state.userWindowId, "navigate: automation target lives in a dedicated window");
166
+
167
+ const status = await w.dispatch("automation.status", { sessionKey: SK });
168
+ ok(status.tabId === nav.id && status.windowId === nav.windowId, "ownership: target ids tracked for the session");
169
+ ok(w.isPiChromeOwnedTarget(nav.id, SK) === true, "ownership: isPiChromeOwnedTarget(owned, session) === true");
170
+ ok(w.isPiChromeOwnedTarget(state.userArticle.id) === false, "ownership: user tab is never owned (any session)");
171
+
172
+ // Reuse: a later navigation reuses the same owned target.
173
+ const nav2 = await w.dispatch("page.navigate", { url: "https://pi.test/step-2", waitUntilLoad: false, sessionKey: SK });
174
+ ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "reuse: second navigation reuses the same automation window/tab");
175
+ ok(state.userArticle.url === userActiveUrl, "reuse: user tab still untouched after second navigation");
176
+
177
+ // Cleanup closes only the owned window; user tabs/windows survive.
178
+ const cleanup = await w.dispatch("automation.cleanup", { sessionKey: SK });
179
+ ok(cleanup.closedWindowId === nav.windowId, "cleanup: closed the owned window");
180
+ ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "cleanup: user tabs never closed");
181
+ ok(state.windows.has(state.userWindowId), "cleanup: user window never closed");
182
+ ok(!state.tabs.has(nav.id), "cleanup: the owned automation tab is gone");
183
+ const status2 = await w.dispatch("automation.status", { sessionKey: SK });
184
+ ok(status2.tabId === null && status2.windowId === null, "cleanup: ownership cleared");
185
+ }
186
+
187
+ // ===== Session-group integration: the dedicated-window tab joins this session's group. =====
188
+ {
189
+ const state = makeChromeState();
190
+ const w = loadWorker(makeChrome(state, { withTabGroups: true }));
191
+ // index.ts tags page.* actions with joinSessionGroup + sessionGroupTitle; replicate that here.
192
+ const groupTitle = "Pi Session: alpha";
193
+ const nav = await w.dispatch("page.navigate", {
194
+ url: "https://pi.test/grouped", waitUntilLoad: false,
195
+ sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
196
+ });
197
+ const navTab = state.tabs.get(nav.id);
198
+ ok(navTab.windowId !== state.userWindowId, "group: automation tab is in its dedicated window");
199
+ ok(typeof navTab.groupId === "number" && navTab.groupId >= 0, "group: automation tab joined a tab group");
200
+ const grp = state.groups.get(navTab.groupId);
201
+ ok(grp && grp.title === groupTitle, "group: the group is titled with this session's title");
202
+ ok(grp.windowId === navTab.windowId, "group: the session group lives inside the dedicated automation window (not the user window)");
203
+
204
+ // A second page action reuses the same tab and does not spawn a second group.
205
+ const groupsBefore = state.groups.size;
206
+ await w.dispatch("page.navigate", { url: "https://pi.test/grouped-2", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle });
207
+ ok(state.groups.size === groupsBefore, "group: reusing the automation tab does not create a second group");
208
+ }
209
+
210
+ // ===== tab.new joins the existing session group instead of creating one group per window. =====
211
+ {
212
+ const state = makeChromeState();
213
+ const w = loadWorker(makeChrome(state, { withTabGroups: true }));
214
+ const groupTitle = "Pi Session: alpha";
215
+ const nav = await w.dispatch("page.navigate", {
216
+ url: "https://pi.test/group-owner", waitUntilLoad: false,
217
+ sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
218
+ });
219
+ const navTab = state.tabs.get(nav.id);
220
+ const groupId = navTab.groupId;
221
+ const groupsBefore = state.groups.size;
222
+
223
+ const opened = await w.dispatch("tab.new", { url: "https://pi.test/new-tab", groupTitle, sessionKey: SK });
224
+ ok(state.groups.size === groupsBefore, "tab.new-group: did not create another same-session group");
225
+ ok(opened.tab.groupId === groupId, "tab.new-group: opened tab joined the existing session group");
226
+ ok(opened.tab.windowId === nav.windowId, "tab.new-group: opened tab was created in the existing group's window");
227
+
228
+ const forced = await w.dispatch("tab.new", { url: "https://pi.test/no-opt-out", groupTitle, group: false, sessionKey: SK });
229
+ ok(forced.tab.groupId === groupId, "tab.new-group: group:false is ignored; tab still joins the session group");
230
+ ok(state.groups.size === groupsBefore, "tab.new-group: group:false does not create another group");
231
+
232
+ const blankTitle = await w.dispatch("tab.new", { url: "https://pi.test/blank-title", groupTitle: "", group: false, sessionKey: SK });
233
+ ok(typeof blankTitle.tab.groupId === "number" && blankTitle.tab.groupId >= 0, "tab.new-group: groupTitle:'' still creates a grouped tab");
234
+ ok(blankTitle.group.title === "Pi", "tab.new-group: blank groupTitle falls back to a group instead of opting out");
235
+ }
236
+
237
+ // ===== tab.new never leaves an ungrouped tab behind when grouping fails. =====
238
+ {
239
+ const state = makeChromeState();
240
+ const chrome = makeChrome(state, { withTabGroups: true });
241
+ const w = loadWorker(chrome);
242
+ const tabsBefore = state.tabs.size;
243
+ chrome.tabs.group = async () => { throw new Error("group blew up"); };
244
+
245
+ await throwsWith(
246
+ () => w.dispatch("tab.new", { url: "https://pi.test/group-fail", groupTitle: "Pi Session: alpha", sessionKey: SK }),
247
+ /group blew up/,
248
+ "tab.new-group-fail: surfaces grouping error",
249
+ );
250
+ ok(state.tabs.size === tabsBefore, "tab.new-group-fail: closes the created tab instead of leaving it ungrouped");
251
+ }
252
+
253
+ // ===== Grouping is best-effort: a tabGroups failure must not break navigation. =====
254
+ {
255
+ const state = makeChromeState();
256
+ const chrome = makeChrome(state, { withTabGroups: true });
257
+ chrome.tabs.group = async () => { throw new Error("group blew up"); };
258
+ const w = loadWorker(chrome);
259
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/group-fail", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: "Pi Session: alpha" });
260
+ ok(nav.url === "https://pi.test/group-fail", "group-fail: navigation still succeeds when grouping throws");
261
+ ok(state.tabs.get(nav.id).windowId !== state.userWindowId, "group-fail: still used the dedicated automation window");
262
+ }
263
+
264
+ // ===== Concurrency: two sessions get separate windows; cleanup is per-session. =====
265
+ {
266
+ const state = makeChromeState();
267
+ const w = loadWorker(makeChrome(state));
268
+ const a = await w.dispatch("page.navigate", { url: "https://pi.test/a", waitUntilLoad: false, sessionKey: "session:A" });
269
+ const b = await w.dispatch("page.navigate", { url: "https://pi.test/b", waitUntilLoad: false, sessionKey: "session:B" });
270
+ ok(a.id !== b.id && a.windowId !== b.windowId, "concurrency: each session gets its own dedicated window/tab");
271
+ ok(w.isPiChromeOwnedTarget(a.id, "session:A") && !w.isPiChromeOwnedTarget(a.id, "session:B"), "concurrency: ownership is scoped to the creating session");
272
+
273
+ // Cleaning up session A must not touch session B's target.
274
+ await w.dispatch("automation.cleanup", { sessionKey: "session:A" });
275
+ ok(!state.tabs.has(a.id), "concurrency: cleanup closed session A's tab");
276
+ ok(state.tabs.has(b.id), "concurrency: cleanup left session B's tab open");
277
+ const bStatus = await w.dispatch("automation.status", { sessionKey: "session:B" });
278
+ ok(bStatus.tabId === b.id, "concurrency: session B still owns its target after A cleanup");
279
+ }
280
+
281
+ // ===== Service-worker restart / reconnect: persisted ownership re-hydrates from storage. =====
282
+ {
283
+ const state = makeChromeState();
284
+ const w1 = loadWorker(makeChrome(state));
285
+ const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/persist", waitUntilLoad: false, sessionKey: SK });
286
+ ok(typeof state.storage.piChromeAutomationTargets === "object", "restart: ownership was persisted to storage.session");
287
+
288
+ // Simulate the MV3 service worker being suspended and restarted: fresh sandbox (memory wiped),
289
+ // same browser tabs/windows + same session storage.
290
+ const w2 = loadWorker(makeChrome(state));
291
+ const statusAfterRestart = await w2.dispatch("automation.status", { sessionKey: SK });
292
+ ok(statusAfterRestart.tabId === nav.id && statusAfterRestart.windowId === nav.windowId, "restart: re-hydrated the owned target from storage");
293
+
294
+ // A navigation after restart must REUSE the existing window, not orphan it with a new one.
295
+ const windowsBefore = state.windows.size;
296
+ const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/persist-2", waitUntilLoad: false, sessionKey: SK });
297
+ ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "restart: navigation after restart reuses the persisted window (no orphan)");
298
+ ok(state.windows.size === windowsBefore, "restart: no new window created after restart");
299
+
300
+ // Cleanup after restart works and clears persisted state.
301
+ await w2.dispatch("automation.cleanup", { sessionKey: SK });
302
+ const persisted = state.storage.piChromeAutomationTargets || {};
303
+ ok(!(SK in persisted), "restart: cleanup removed the session from persisted storage");
304
+ }
305
+
306
+ // ===== Restart after the user manually closed the window: no orphan, fresh target. =====
307
+ {
308
+ const state = makeChromeState();
309
+ const w1 = loadWorker(makeChrome(state));
310
+ const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/closed", waitUntilLoad: false, sessionKey: SK });
311
+ await state.windows.delete(nav.windowId); // user closed pi-chrome's window
312
+ for (const [tid, t] of [...state.tabs]) if (t.windowId === nav.windowId) state.tabs.delete(tid);
313
+
314
+ const w2 = loadWorker(makeChrome(state)); // SW restart
315
+ const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/reopened", waitUntilLoad: false, sessionKey: SK });
316
+ ok(nav2.id !== nav.id, "restart-after-close: a fresh automation target is created when the persisted one is gone");
317
+ ok(state.tabs.has(nav2.id), "restart-after-close: new target exists");
318
+ }
319
+
320
+ // ===== tab.* management never auto-creates / never falls back to the user's active tab. =====
321
+ {
322
+ const state = makeChromeState();
323
+ const w = loadWorker(makeChrome(state));
324
+ const windowsBefore = state.windows.size;
325
+ const tabsBefore = state.tabs.size;
326
+
327
+ await throwsWith(
328
+ () => w.dispatch("tab.close", { sessionKey: SK }),
329
+ /no automation tab yet|Pass targetId/,
330
+ "tab.close: with no target and no owned target, errors instead of closing the user's active tab",
331
+ );
332
+ ok(state.tabs.has(state.userArticle.id), "tab.close: user's active tab was NOT closed");
333
+ ok(state.windows.size === windowsBefore && state.tabs.size === tabsBefore, "tab.close: did not spawn a throwaway tab/window");
334
+
335
+ await throwsWith(() => w.dispatch("tab.activate", { sessionKey: SK }), /no automation tab yet|Pass targetId/, "tab.activate: errors with no target/owned target");
336
+
337
+ // Once an automation target exists, management actions operate on it (not on the user tab).
338
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/manage", waitUntilLoad: false, sessionKey: SK });
339
+ const closed = await w.dispatch("tab.close", { sessionKey: SK });
340
+ ok(closed.closed === nav.id, "tab.close: with an owned target, closes that target");
341
+ ok(state.tabs.has(state.userArticle.id), "tab.close: user tab still safe after closing the owned target");
342
+ }
343
+
344
+ // ===== Explicit targeting still works on any existing tab (no regression). =====
345
+ {
346
+ const state = makeChromeState();
347
+ const w = loadWorker(makeChrome(state));
348
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/explicit", targetId: String(state.userGmail.id), waitUntilLoad: false, sessionKey: SK });
349
+ ok(nav.id === state.userGmail.id, "explicit: targetId routes to the requested existing tab");
350
+ ok(state.userGmail.url === "https://pi.test/explicit", "explicit: explicitly targeted tab is navigated");
351
+ const status = await w.dispatch("automation.status", { sessionKey: SK });
352
+ ok(status.tabId === null, "explicit: explicit targeting does not create/claim an automation target");
353
+ }
354
+
355
+ // ===== Window-unavailable fallback: a dedicated TAB is used, and the user's window is safe. =====
356
+ {
357
+ const state = makeChromeState();
358
+ const w = loadWorker(makeChrome(state, { withWindows: false }));
359
+ const target = await w.getOrCreateAutomationTarget(SK);
360
+ ok(target.id !== state.userArticle.id && target.id !== state.userGmail.id, "fallback: created a dedicated tab, not a user tab");
361
+ ok(w.isPiChromeOwnedTarget(target.id, SK) === true, "fallback: dedicated tab is owned");
362
+ const cleanup = await w.cleanupAutomationTarget(SK);
363
+ ok(cleanup.closedTabId === target.id && cleanup.closedWindowId === null, "fallback: cleanup closes only the owned tab (never the shared window)");
364
+ ok(state.windows.has(state.userWindowId), "fallback: cleanup never closes the user/shared window");
365
+ ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "fallback: cleanup leaves user tabs intact");
366
+ }
367
+
368
+ // ===== Robust cleanup: no-op when nothing created, and when target already closed manually. =====
369
+ {
370
+ const state = makeChromeState();
371
+ const w = loadWorker(makeChrome(state));
372
+ const empty = await w.cleanupAutomationTarget(SK);
373
+ ok(empty.closedWindowId === null && empty.closedTabId === null, "cleanup: no-op when nothing was ever created");
374
+
375
+ const t = await w.getOrCreateAutomationTarget(SK);
376
+ // User closed pi-chrome's window manually (Chrome closes its tabs too).
377
+ state.windows.delete(t.windowId);
378
+ for (const [tid, tab] of [...state.tabs]) if (tab.windowId === t.windowId) state.tabs.delete(tid);
379
+ const stale = await w.cleanupAutomationTarget(SK);
380
+ ok(stale.closedWindowId === null && stale.closedTabId === null, "cleanup: robust when owned window was already closed");
381
+ }
382
+
383
+ console.log(`\n${passes} passed, ${failures} failed`);
384
+ if (failures) process.exit(1);
385
+ }
386
+
387
+ run().catch((e) => { console.error(e); process.exit(1); });