pi-chrome 0.15.38 → 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.
- package/CHANGELOG.md +9 -0
- package/README.md +11 -0
- package/docs/FAQ.md +9 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +218 -14
- package/extensions/chrome-profile-bridge/index.ts +98 -16
- package/package.json +2 -2
- package/test-suite/unit/automation-target.test.mjs +387 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-facing changes to `pi-chrome`.
|
|
4
4
|
|
|
5
|
+
## 0.15.39 — 2026-06-07
|
|
6
|
+
|
|
7
|
+
- **Dedicated automation tab/window (no more hijacking your active tab).** When a chrome_* action runs without an explicit target, pi-chrome now opens and reuses a dedicated automation window it owns (falling back to a dedicated tab if a separate window can't be created) instead of navigating whatever tab you currently have open. Your existing tabs/windows are left untouched. Pass `targetId`/`urlIncludes`/`titleIncludes` to act on a specific existing tab.
|
|
8
|
+
- **Per session.** Ownership is scoped to the calling Pi session, so concurrent sessions sharing one companion extension each get their own automation window instead of fighting over a tab.
|
|
9
|
+
- **Survives `/reload` and Chrome service-worker restarts.** Ownership is tracked by id and mirrored to `chrome.storage.session`, so the window is reused rather than orphaned.
|
|
10
|
+
- **Guarded tab management.** `chrome_tab` `activate`/`close`/`group`/`ungroup` with no target now act on the session's automation tab if present, otherwise error — they no longer fall back to (or spawn a throwaway tab to touch) your active tab.
|
|
11
|
+
- **Safe, non-blocking cleanup.** The owned target is closed on `/chrome revoke` and on real session end (never on `/reload`); cleanup only ever closes the calling session's own window/tab — never user tabs/windows or another session's target — and is fire-and-forget so it never blocks `/quit`, `/reload`, or session end.
|
|
12
|
+
- **One session, one tab group.** `chrome_tab new` now reuses this session's existing Pi tab group even when another Chrome window is focused, creating the tab in that group's window because Chrome tab groups cannot span windows. The old `group:false` / `groupTitle:""` opt-out is ignored; Pi-created tabs are never intentionally left ungrouped, and a grouping failure closes the just-created tab before returning an error.
|
|
13
|
+
|
|
5
14
|
## 0.15.38 — 2026-06-07
|
|
6
15
|
|
|
7
16
|
- **Overlay-safe click/fill fallbacks.** `chrome_click` and `chrome_fill` now fall back to DOM-dispatched click/value events when Chrome's debugger input path is blocked by another extension overlay (for example password-manager/autofill UI), unless `domFallback:false` is passed.
|
package/README.md
CHANGED
|
@@ -173,6 +173,17 @@ Agents can verify page state immediately instead of blindly retrying.
|
|
|
173
173
|
|
|
174
174
|
Each tool is documented inline in Pi — agents see the parameters and gotchas (Chrome input, CSP limits, file upload behavior) without trial-and-error.
|
|
175
175
|
|
|
176
|
+
### Tab/window isolation
|
|
177
|
+
|
|
178
|
+
pi-chrome never overwrites the tab you're currently looking at. The first time a chrome_* action runs without an explicit target, pi-chrome opens a **dedicated automation window** (falling back to a dedicated tab if a separate window can't be created) and reuses it for the rest of the session. Your existing tabs and windows are left untouched. To point pi-chrome at a specific tab you already have open, pass `targetId`/`urlIncludes`/`titleIncludes`.
|
|
179
|
+
|
|
180
|
+
Details:
|
|
181
|
+
|
|
182
|
+
- **Per session.** Each Pi session owns its *own* automation window, so concurrent sessions (which all share one companion extension) never fight over a tab.
|
|
183
|
+
- **Survives `/reload` and Chrome service-worker restarts.** Ownership is tracked by id and mirrored to `chrome.storage.session`, so the window is reused rather than orphaned.
|
|
184
|
+
- **Cleanup is safe.** The dedicated target is closed when you revoke Chrome control (`/chrome revoke`) and on real session end — never on `/reload`. Cleanup only ever closes the calling session's own automation window/tab; user tabs/windows and other sessions' targets are never closed. Cleanup is fire-and-forget so it never blocks `/quit`, `/reload`, or session end.
|
|
185
|
+
- **Management actions are guarded.** `chrome_tab` `activate`/`close`/`group`/`ungroup` with no explicit target act on the session's automation tab if it exists, otherwise they error instead of touching your active tab.
|
|
186
|
+
|
|
176
187
|
### Known limits vs. human Chrome use
|
|
177
188
|
|
|
178
189
|
pi-chrome is strongest on web-page workflows exposed through DOM, screenshots, tabs, and Chrome input. It is not a full human/OS substitute. Current limitations include native Chrome/OS surfaces (print/save dialogs, permission bubbles, password-manager prompts), cross-origin iframe DOM access, rich multitouch/pinch/stylus gestures, visual CAPTCHA/bot challenges, hardware-backed auth (passkeys/security keys/biometrics), and arbitrary OS app interaction. For strict-CSP pages, use screenshot + coordinate input when `chrome_snapshot`/`chrome_evaluate` are blocked.
|
package/docs/FAQ.md
CHANGED
|
@@ -30,7 +30,15 @@ Chrome control is also locked per Pi session until you run `/chrome authorize`;
|
|
|
30
30
|
|
|
31
31
|
## Can multiple Pi sessions use it at once?
|
|
32
32
|
|
|
33
|
-
Yes. The first session opens the local bridge; later sessions detect it and pipe their commands through the same bridge. Each Pi session must be authorized with `/chrome authorize` before its chrome_* tools work.
|
|
33
|
+
Yes. The first session opens the local bridge; later sessions detect it and pipe their commands through the same bridge. Each Pi session must be authorized with `/chrome authorize` before its chrome_* tools work. Each session also owns its **own** dedicated automation window (ownership is keyed by session id inside the one extension), so concurrent sessions never navigate into or close each other's tabs.
|
|
34
|
+
|
|
35
|
+
## Does pi-chrome navigate my current tab?
|
|
36
|
+
|
|
37
|
+
No. The first chrome_* action that has no explicit target opens a **dedicated automation window** that pi-chrome owns (falling back to a dedicated tab only if a separate window can't be created), and reuses it for the rest of the session. Your existing tabs and windows are never reused or overwritten. Pass `targetId`/`urlIncludes`/`titleIncludes` to deliberately act on a tab you already have open.
|
|
38
|
+
|
|
39
|
+
The window survives `/reload` and Chrome service-worker restarts because ownership is tracked by id and mirrored to `chrome.storage.session`. It is closed when you run `/chrome revoke` and on real session end (not on `/reload`); cleanup only ever closes that session's own window/tab.
|
|
40
|
+
|
|
41
|
+
**After a full browser restart**, `chrome.storage.session` is cleared by Chrome. If Chrome's session-restore reopens the old automation window, pi-chrome no longer recognizes it (its tracking is gone), so it is left alone as an ordinary window — pi-chrome will open a fresh dedicated window for the new run rather than reclaim or close the restored one. pi-chrome never closes a window it can't positively identify as its own, so a user window is never at risk.
|
|
34
42
|
|
|
35
43
|
## Why ship as an unpacked extension?
|
|
36
44
|
|
|
@@ -10,6 +10,151 @@ const SCRIPTING_TIMEOUT_MS = 8_000;
|
|
|
10
10
|
const ATTACH_TIMEOUT_MS = 3_000;
|
|
11
11
|
let polling = false;
|
|
12
12
|
|
|
13
|
+
// =================== pi-chrome automation target ownership ===================
|
|
14
|
+
// pi-chrome must never hijack the user's active tab. When a page/navigation action runs without
|
|
15
|
+
// an explicit target (targetId/urlIncludes/titleIncludes), we route it to a dedicated automation
|
|
16
|
+
// target that pi-chrome created and owns. We prefer a separate Chrome window so the user's
|
|
17
|
+
// windows are left untouched; if the windows API is unavailable we fall back to a dedicated tab.
|
|
18
|
+
//
|
|
19
|
+
// Ownership is SESSION-SCOPED, keyed by the calling Pi session's `sessionKey` (forwarded on the
|
|
20
|
+
// wire). One Chrome extension / service worker brokers commands for *all* Pi sessions (see the
|
|
21
|
+
// client/server bridge in index.ts), so a single global target would make concurrent sessions
|
|
22
|
+
// fight over one window. A per-session map gives each session its own isolated window and lets
|
|
23
|
+
// cleanup close exactly that session's target — never another session's, never a user's.
|
|
24
|
+
//
|
|
25
|
+
// State is mirrored to chrome.storage.session so a service-worker restart (MV3 can suspend the
|
|
26
|
+
// worker at any time) re-hydrates ownership instead of orphaning the window it already created.
|
|
27
|
+
// storage.session is cleared on browser restart; any window restored by Chrome's session-restore
|
|
28
|
+
// is then untracked and simply left alone (we only ever close ids we still recognize as ours).
|
|
29
|
+
const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number }
|
|
30
|
+
const DEFAULT_SESSION_KEY = "__default__";
|
|
31
|
+
const AUTOMATION_STORAGE_KEY = "piChromeAutomationTargets";
|
|
32
|
+
let automationHydrated = false;
|
|
33
|
+
|
|
34
|
+
function sessionKeyOf(params) {
|
|
35
|
+
return params && typeof params.sessionKey === "string" && params.sessionKey
|
|
36
|
+
? params.sessionKey
|
|
37
|
+
: DEFAULT_SESSION_KEY;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Re-hydrate the in-memory ownership map from storage.session once per worker lifetime. Best
|
|
41
|
+
// effort: storage may be unavailable on old Chrome, and a failure just means we may create a
|
|
42
|
+
// fresh window (a harmless orphan) rather than reusing one.
|
|
43
|
+
async function hydrateAutomationTargets() {
|
|
44
|
+
if (automationHydrated) return;
|
|
45
|
+
automationHydrated = true;
|
|
46
|
+
try {
|
|
47
|
+
const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY);
|
|
48
|
+
const saved = stored && stored[AUTOMATION_STORAGE_KEY];
|
|
49
|
+
if (saved && typeof saved === "object") {
|
|
50
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
51
|
+
if (value && typeof value.tabId === "number") {
|
|
52
|
+
automationTargets.set(key, {
|
|
53
|
+
windowId: typeof value.windowId === "number" ? value.windowId : undefined,
|
|
54
|
+
tabId: value.tabId,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Ignore: treat as "no persisted state".
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function persistAutomationTargets() {
|
|
65
|
+
try {
|
|
66
|
+
const obj = {};
|
|
67
|
+
for (const [key, value] of automationTargets) {
|
|
68
|
+
obj[key] = { windowId: typeof value.windowId === "number" ? value.windowId : null, tabId: value.tabId };
|
|
69
|
+
}
|
|
70
|
+
await chrome.storage?.session?.set?.({ [AUTOMATION_STORAGE_KEY]: obj });
|
|
71
|
+
} catch {
|
|
72
|
+
// Ignore: persistence is an optimization, not a correctness requirement.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// True if `tabId` is a pi-chrome-owned automation tab. Pass `sessionKey` to check a specific
|
|
77
|
+
// session; omit it to check ownership across *any* session (used as a safety predicate so we
|
|
78
|
+
// never operate on a user-created tab). Never infers ownership from "active".
|
|
79
|
+
function isPiChromeOwnedTarget(tabId, sessionKey) {
|
|
80
|
+
if (typeof tabId !== "number") return false;
|
|
81
|
+
if (sessionKey !== undefined) {
|
|
82
|
+
const t = automationTargets.get(sessionKey);
|
|
83
|
+
return !!t && t.tabId === tabId;
|
|
84
|
+
}
|
|
85
|
+
for (const t of automationTargets.values()) if (t.tabId === tabId) return true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create a fresh dedicated automation target for `sessionKey`. Prefer an isolated window; fall
|
|
90
|
+
// back to a tab. Structured so a window can be the default while tab-fallback stays a one-liner.
|
|
91
|
+
async function createAutomationTarget(sessionKey) {
|
|
92
|
+
if (chrome.windows && typeof chrome.windows.create === "function") {
|
|
93
|
+
try {
|
|
94
|
+
const win = await chrome.windows.create({ url: "about:blank", focused: false });
|
|
95
|
+
const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined;
|
|
96
|
+
if (created && typeof created.id === "number") {
|
|
97
|
+
automationTargets.set(sessionKey, { windowId: typeof win.id === "number" ? win.id : undefined, tabId: created.id });
|
|
98
|
+
await persistAutomationTargets();
|
|
99
|
+
return created;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Window creation can fail (policy, headless, etc.); fall back to a dedicated tab below.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Tab fallback: the tab lives in a pre-existing (user/shared) window we did NOT create, so we
|
|
106
|
+
// must leave windowId unset — cleanup then closes only our tab, never the user's window.
|
|
107
|
+
const tab = await chrome.tabs.create({ url: "about:blank", active: false });
|
|
108
|
+
automationTargets.set(sessionKey, { windowId: undefined, tabId: typeof tab.id === "number" ? tab.id : undefined });
|
|
109
|
+
await persistAutomationTargets();
|
|
110
|
+
return tab;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Return the session's owned automation target if it still exists, else null. Robust to the user
|
|
114
|
+
// (or Chrome) having closed it: a stale entry is forgotten so callers can recreate cleanly.
|
|
115
|
+
async function resolveOwnedAutomationTarget(sessionKey) {
|
|
116
|
+
await hydrateAutomationTargets();
|
|
117
|
+
const t = automationTargets.get(sessionKey);
|
|
118
|
+
if (!t || typeof t.tabId !== "number") return null;
|
|
119
|
+
const existing = await chrome.tabs.get(t.tabId).catch(() => null);
|
|
120
|
+
if (existing && typeof existing.id === "number") return existing;
|
|
121
|
+
automationTargets.delete(sessionKey);
|
|
122
|
+
await persistAutomationTargets();
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Return the session's dedicated automation target, creating it on first use (or after the user
|
|
127
|
+
// closed it). Used by page/navigation actions that need a live surface to drive.
|
|
128
|
+
async function getOrCreateAutomationTarget(sessionKey) {
|
|
129
|
+
return (await resolveOwnedAutomationTarget(sessionKey)) || createAutomationTarget(sessionKey);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Close only the session's pi-chrome-owned window/tab, and only if it still exists. Never touches
|
|
133
|
+
// user tabs/windows or other sessions' targets. Safe to call repeatedly and when nothing exists.
|
|
134
|
+
async function cleanupAutomationTarget(sessionKey) {
|
|
135
|
+
await hydrateAutomationTargets();
|
|
136
|
+
const t = automationTargets.get(sessionKey);
|
|
137
|
+
automationTargets.delete(sessionKey);
|
|
138
|
+
await persistAutomationTargets();
|
|
139
|
+
if (!t) return { closedWindowId: null, closedTabId: null };
|
|
140
|
+
const { windowId, tabId } = t;
|
|
141
|
+
if (typeof windowId === "number" && chrome.windows && typeof chrome.windows.remove === "function") {
|
|
142
|
+
const win = await chrome.windows.get(windowId).catch(() => null);
|
|
143
|
+
if (win) {
|
|
144
|
+
await chrome.windows.remove(windowId).catch(() => {});
|
|
145
|
+
return { closedWindowId: windowId, closedTabId: typeof tabId === "number" ? tabId : null };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (typeof tabId === "number") {
|
|
149
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
150
|
+
if (tab) {
|
|
151
|
+
await chrome.tabs.remove(tabId).catch(() => {});
|
|
152
|
+
return { closedWindowId: null, closedTabId: tabId };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { closedWindowId: null, closedTabId: null };
|
|
156
|
+
}
|
|
157
|
+
|
|
13
158
|
function withTimeout(promise, ms, label, onTimeout) {
|
|
14
159
|
let timer;
|
|
15
160
|
return Promise.race([
|
|
@@ -928,8 +1073,10 @@ async function groupRecord(groupId) {
|
|
|
928
1073
|
};
|
|
929
1074
|
}
|
|
930
1075
|
|
|
931
|
-
// Find
|
|
932
|
-
//
|
|
1076
|
+
// Find existing tab groups whose title matches `title` (case-insensitive).
|
|
1077
|
+
// Same-window lookup is used when grouping an already-created tab. Any-window lookup is used before
|
|
1078
|
+
// creating a new Pi tab so one Pi session keeps one tab group and new tabs are created in that
|
|
1079
|
+
// group's window (Chrome tab groups cannot span windows).
|
|
933
1080
|
async function findGroupByTitle(windowId, title) {
|
|
934
1081
|
if (!chrome.tabGroups) return null;
|
|
935
1082
|
const wanted = cleanGroupTitle(title).toLowerCase();
|
|
@@ -938,6 +1085,13 @@ async function findGroupByTitle(windowId, title) {
|
|
|
938
1085
|
return match ? match.id : null;
|
|
939
1086
|
}
|
|
940
1087
|
|
|
1088
|
+
async function findGroupRecordByTitle(title) {
|
|
1089
|
+
if (!chrome.tabGroups) return null;
|
|
1090
|
+
const wanted = cleanGroupTitle(title).toLowerCase();
|
|
1091
|
+
const groups = await chrome.tabGroups.query({}).catch(() => []);
|
|
1092
|
+
return groups.find((g) => (g.title || "").trim().toLowerCase() === wanted) || null;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
941
1095
|
// Add `tab` to a tab group, then set title/color. If the tab is ungrouped, reuse an
|
|
942
1096
|
// existing same-title group in its window when present, otherwise create a new group.
|
|
943
1097
|
async function groupTab(tab, title, color) {
|
|
@@ -970,28 +1124,40 @@ async function dispatch(action, params) {
|
|
|
970
1124
|
return Promise.all(tabs.map(formatTab));
|
|
971
1125
|
}
|
|
972
1126
|
case "tab.new": {
|
|
973
|
-
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1127
|
+
// Every Pi-opened tab must join a tab group. There is intentionally no opt-out: an ungrouped
|
|
1128
|
+
// Pi-created tab is easy to lose among user tabs. If grouping fails after creation, close the
|
|
1129
|
+
// tab best-effort before surfacing the error so tab.new never leaves an ungrouped Pi tab.
|
|
1130
|
+
const groupTitle = params.groupTitle || "Pi";
|
|
1131
|
+
const existingGroup = await findGroupRecordByTitle(groupTitle);
|
|
1132
|
+
const createParams = { url: params.url || "about:blank", active: true };
|
|
1133
|
+
if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
|
|
1134
|
+
const tab = await chrome.tabs.create(createParams);
|
|
1135
|
+
try {
|
|
1136
|
+
return await groupTab(tab, groupTitle, params.groupColor);
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {});
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
978
1141
|
}
|
|
979
1142
|
case "tab.activate": {
|
|
980
|
-
|
|
1143
|
+
// Management actions never auto-create an automation target (createOwnedTarget:false): with
|
|
1144
|
+
// no explicit target they act on an owned target if one exists, else error — they must never
|
|
1145
|
+
// fall back to (or spawn a tab just to touch) the user's active tab.
|
|
1146
|
+
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
981
1147
|
await chrome.windows.update(tab.windowId, { focused: true });
|
|
982
1148
|
return formatTab(await chrome.tabs.update(tab.id, { active: true }));
|
|
983
1149
|
}
|
|
984
1150
|
case "tab.group": {
|
|
985
|
-
const tab = await getTabByParams(params);
|
|
1151
|
+
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
986
1152
|
return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
|
|
987
1153
|
}
|
|
988
1154
|
case "tab.ungroup": {
|
|
989
|
-
const tab = await getTabByParams(params);
|
|
1155
|
+
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
990
1156
|
if (typeof tab.groupId === "number" && tab.groupId >= 0) await chrome.tabs.ungroup(tab.id);
|
|
991
1157
|
return formatTab(await chrome.tabs.get(tab.id));
|
|
992
1158
|
}
|
|
993
1159
|
case "tab.close": {
|
|
994
|
-
const tab = await getTabByParams(params);
|
|
1160
|
+
const tab = await getTabByParams(params, { createOwnedTarget: false });
|
|
995
1161
|
await chrome.tabs.remove(tab.id);
|
|
996
1162
|
return { closed: tab.id };
|
|
997
1163
|
}
|
|
@@ -1073,6 +1239,16 @@ async function dispatch(action, params) {
|
|
|
1073
1239
|
}
|
|
1074
1240
|
case "page.screenshot":
|
|
1075
1241
|
return takeScreenshot(params);
|
|
1242
|
+
case "automation.status": {
|
|
1243
|
+
// Report this session's owned automation target (ids only). Used for diagnostics/tests.
|
|
1244
|
+
await hydrateAutomationTargets();
|
|
1245
|
+
const t = automationTargets.get(sessionKeyOf(params));
|
|
1246
|
+
return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null };
|
|
1247
|
+
}
|
|
1248
|
+
case "automation.cleanup":
|
|
1249
|
+
// Close only THIS session's pi-chrome-owned window/tab. Never touches user tabs/windows or
|
|
1250
|
+
// another Pi session's target.
|
|
1251
|
+
return cleanupAutomationTarget(sessionKeyOf(params));
|
|
1076
1252
|
default:
|
|
1077
1253
|
throw new Error(`Unknown action: ${action}`);
|
|
1078
1254
|
}
|
|
@@ -1094,7 +1270,23 @@ async function formatTab(tab) {
|
|
|
1094
1270
|
};
|
|
1095
1271
|
}
|
|
1096
1272
|
|
|
1097
|
-
|
|
1273
|
+
// Resolve which Chrome tab an action targets.
|
|
1274
|
+
//
|
|
1275
|
+
// Explicit targeting (targetId / urlIncludes / titleIncludes) is unchanged: callers can still act
|
|
1276
|
+
// on any existing tab, including a user tab, when they ask for it by name. Only the implicit
|
|
1277
|
+
// "no target given" case changed — it used to grab the user's *active* tab (and page.navigate
|
|
1278
|
+
// would then overwrite it); it now resolves to this Pi session's dedicated automation target.
|
|
1279
|
+
//
|
|
1280
|
+
// `createOwnedTarget` controls the implicit case:
|
|
1281
|
+
// - true (default): create the automation target on first use. Used by every page/content
|
|
1282
|
+
// action — page.navigate, click/type/fill/key/hover/drag/scroll/tap/upload, snapshot,
|
|
1283
|
+
// inspect, evaluate, screenshot, waitFor, console/network list, probe. These need a live
|
|
1284
|
+
// surface to drive, so auto-creating is correct and they no longer touch the user's tab.
|
|
1285
|
+
// - false: do NOT create. Used by tab.activate/close/group/ungroup (tab *management*): with no
|
|
1286
|
+
// explicit target they operate on an already-owned automation target if one exists, else
|
|
1287
|
+
// throw asking for an explicit target — so e.g. `chrome_tab close` can never silently close
|
|
1288
|
+
// the user's active tab the way it used to, and never spawns a throwaway tab just to close it.
|
|
1289
|
+
async function getTabByParams(params, { createOwnedTarget = true } = {}) {
|
|
1098
1290
|
const tabs = await chrome.tabs.query({});
|
|
1099
1291
|
let tab;
|
|
1100
1292
|
if (params.targetId !== undefined) {
|
|
@@ -1119,8 +1311,20 @@ async function getTabByParams(params) {
|
|
|
1119
1311
|
} else if (params.titleIncludes) {
|
|
1120
1312
|
tab = tabs.find((candidate) => (candidate.title || "").includes(params.titleIncludes));
|
|
1121
1313
|
} else {
|
|
1122
|
-
|
|
1123
|
-
|
|
1314
|
+
// No explicit target: use this session's dedicated automation target instead of hijacking the
|
|
1315
|
+
// user's active tab. This keeps human browsing and Pi automation separated — navigating here
|
|
1316
|
+
// never replaces whatever the user currently has open. Callers that *want* a specific
|
|
1317
|
+
// existing tab pass targetId/urlIncludes/titleIncludes above.
|
|
1318
|
+
const sessionKey = sessionKeyOf(params);
|
|
1319
|
+
tab = createOwnedTarget
|
|
1320
|
+
? await getOrCreateAutomationTarget(sessionKey)
|
|
1321
|
+
: await resolveOwnedAutomationTarget(sessionKey);
|
|
1322
|
+
if (!tab) {
|
|
1323
|
+
throw new Error(
|
|
1324
|
+
"No target tab specified and this Pi session has no automation tab yet. " +
|
|
1325
|
+
"Pass targetId/urlIncludes/titleIncludes, or run chrome_navigate first.",
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1124
1328
|
}
|
|
1125
1329
|
if (!tab?.id) throw new Error("No matching Chrome tab found");
|
|
1126
1330
|
const url = tab.url || "";
|
|
@@ -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
|
|
790
|
-
// tab
|
|
791
|
-
const shouldJoinGroup = action.startsWith("page.") &&
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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: "
|
|
1216
|
-
groupTitle: Type.Optional(Type.String({ description: "
|
|
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
|
-
//
|
|
1224
|
-
// named after the session display name (falling back to the session id)
|
|
1225
|
-
//
|
|
1226
|
-
if (
|
|
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
|
|
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(),
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-chrome",
|
|
3
|
-
"version": "0.15.
|
|
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); });
|