pi-chrome 0.15.55 → 0.15.57
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 +12 -0
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +124 -39
- package/extensions/chrome-profile-bridge/index.ts +19 -0
- package/package.json +1 -1
- package/test-suite/unit/automation-target.test.mjs +7 -4
- package/test-suite/unit/chrome-command.test.mjs +28 -2
- package/test-suite/unit/console-capture.test.mjs +20 -1
- package/test-suite/unit/hidden-input.test.mjs +29 -9
- package/test-suite/unit/input-reliability.test.mjs +12 -0
- package/test-suite/unit/ownership-persistence.test.mjs +124 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable user-facing changes to `pi-chrome`.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.15.57 — 2026-09-28
|
|
8
|
+
|
|
9
|
+
- **Pi gets its own window again while your Chrome is full screen.** 0.15.54 put Pi's tab in your window during full screen, assuming macOS would merge a new window into yours as a hidden window tab. Chrome disables macOS automatic window tabbing (`chrome/browser/app_controller_mac.mm`), so that never happens. The hidden tabs seen at the time came from the tab-group bug fixed in 0.15.56. A new window opened during full screen lands on another Space, and a tab in your window is just as hidden, so the special case only cost isolation.
|
|
10
|
+
- **`/chrome doctor` flags a hidden Pi tab.** Chrome ignores clicks and typing in hidden pages. Doctor now checks this session's own tab and says whether it is visible. If it's hidden, doctor gives the reason (inactive tab, minimized window, covered by another window) and the fix, instead of the first click or type failing. It never creates or activates a tab to check.
|
|
11
|
+
- **Pi keeps track of its tabs across extension reloads.** Ownership of Pi's automation window and session tabs lived only in `chrome.storage.session`, which Chrome wipes when the extension reloads. The extension reloads itself on every pi-chrome update, so afterwards Pi could not close or reuse the windows and tabs it had opened, and they were left behind. Ownership is now also kept in `chrome.storage.local` and restored after a reload. A restored tab is trusted only if it still exists in the window it was recorded in (adopted tabs must also still be in Pi's group). The copy is dropped on browser startup, since tab ids do not carry over between browser runs.
|
|
12
|
+
|
|
13
|
+
## 0.15.56 — 2026-09-28
|
|
14
|
+
|
|
15
|
+
- **Pi's automation tab stays in its own window.** Joining the session tab group called `chrome.tabs.group` without a window. Chrome then creates the group in the current window (yours) and moves the tab there, where it sits as an inactive, hidden tab and trusted input is refused. This happened on a session's first page action and is the main source of the hidden-tab errors. New groups are now created in the tab's own window.
|
|
16
|
+
- **`chrome_fill` replaces all of a multi-line textarea.** Fill selected the old value with a triple-click, which in a textarea selects only the clicked line. The rest stayed, and the tool still reported success. Inputs and textareas now select the whole value with Chrome's `selectAll` editing command before deleting. Contenteditables are unchanged.
|
|
17
|
+
- **Early capture covers a new tab's first page.** `chrome_tab new` could commit its first page before the tab was recorded as Pi's, so early console/network capture skipped that load. Early capture now waits for in-flight Pi tab creations before deciding.
|
|
18
|
+
|
|
7
19
|
## 0.15.55 — 2026-09-26
|
|
8
20
|
|
|
9
21
|
- **Console capture no longer breaks sites with DevTools detection (#9).** Some sites (BOSS Zhipin) log an object with a getter or custom `toString` and close the tab if anything reads it, since only open DevTools would. The console capture serialized every logged argument immediately, so it tripped the check. Captured arguments are now stored as-is, and `chrome_list_console_messages` serializes them without running page code: data properties only, accessors shown as `[getter]`, and DOM nodes, regexes, dates and errors described through Chrome's native getters.
|
|
@@ -33,6 +33,11 @@ let polling = false;
|
|
|
33
33
|
//
|
|
34
34
|
// State is mirrored to chrome.storage.session so a service-worker restart (MV3 can suspend the
|
|
35
35
|
// worker at any time) re-hydrates ownership instead of orphaning the window it already created.
|
|
36
|
+
// storage.session is also cleared when the extension reloads, which it does itself on every
|
|
37
|
+
// pi-chrome version bump, so ownership is mirrored to chrome.storage.local too (see
|
|
38
|
+
// readOwnership). The local copy is restored only when the session copy is missing, every restored
|
|
39
|
+
// tab is re-checked, and the local copy is dropped on browser startup because tab ids do not carry
|
|
40
|
+
// over between browser runs.
|
|
36
41
|
// storage.session is cleared on browser restart; any window restored by Chrome's session-restore
|
|
37
42
|
// is then untracked and simply left alone (we only ever close ids we still recognize as ours).
|
|
38
43
|
const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number }
|
|
@@ -50,6 +55,36 @@ function sessionKeyOf(params) {
|
|
|
50
55
|
: DEFAULT_SESSION_KEY;
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
// Read an ownership record: the storage.session copy if present, else the storage.local copy
|
|
59
|
+
// (after an extension reload). Returns { value, restored } where restored marks a local copy.
|
|
60
|
+
async function readOwnership(key) {
|
|
61
|
+
const fromSession = await chrome.storage?.session?.get?.(key).catch(() => null);
|
|
62
|
+
if (fromSession && fromSession[key] && typeof fromSession[key] === "object") return { value: fromSession[key], restored: false };
|
|
63
|
+
const fromLocal = await chrome.storage?.local?.get?.(key).catch(() => null);
|
|
64
|
+
if (fromLocal && fromLocal[key] && typeof fromLocal[key] === "object") return { value: fromLocal[key], restored: true };
|
|
65
|
+
return { value: null, restored: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writeOwnership(key, value) {
|
|
69
|
+
await Promise.all([
|
|
70
|
+
Promise.resolve(chrome.storage?.session?.set?.({ [key]: value })).catch(() => {}),
|
|
71
|
+
Promise.resolve(chrome.storage?.local?.set?.({ [key]: value })).catch(() => {}),
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A restored (local) record is trusted only if its tab still exists in the window it was recorded
|
|
76
|
+
// in. Anything else is forgotten, so a reused id or a tab the user moved is never closed by Pi.
|
|
77
|
+
async function restoredTabStillOurs(tabId, windowId) {
|
|
78
|
+
if (!Number.isInteger(tabId) || tabId < 0 || !Number.isInteger(windowId)) return null;
|
|
79
|
+
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
80
|
+
return tab && tab.windowId === windowId ? tab : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function tabWindowId(tabId) {
|
|
84
|
+
const tab = Number.isInteger(tabId) ? await chrome.tabs.get(tabId).catch(() => null) : null;
|
|
85
|
+
return Number.isInteger(tab?.windowId) ? tab.windowId : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
// Re-hydrate the in-memory ownership map from storage.session once per worker lifetime. Best
|
|
54
89
|
// effort: storage may be unavailable on old Chrome, and a failure just means we may create a
|
|
55
90
|
// fresh window (a harmless orphan) rather than reusing one.
|
|
@@ -57,17 +92,19 @@ async function hydrateAutomationTargets() {
|
|
|
57
92
|
if (automationHydrated) return automationHydrated;
|
|
58
93
|
automationHydrated = (async () => {
|
|
59
94
|
try {
|
|
60
|
-
const
|
|
61
|
-
const saved = stored && stored[AUTOMATION_STORAGE_KEY];
|
|
95
|
+
const { value: saved, restored } = await readOwnership(AUTOMATION_STORAGE_KEY);
|
|
62
96
|
if (saved && typeof saved === "object") {
|
|
63
97
|
for (const [key, value] of Object.entries(saved)) {
|
|
64
|
-
if (value
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
98
|
+
if (!value || typeof value.tabId !== "number") continue;
|
|
99
|
+
const tabWindow = typeof value.tabWindowId === "number" ? value.tabWindowId : undefined;
|
|
100
|
+
if (restored && !(await restoredTabStillOurs(value.tabId, tabWindow))) continue;
|
|
101
|
+
automationTargets.set(key, {
|
|
102
|
+
windowId: typeof value.windowId === "number" ? value.windowId : undefined,
|
|
103
|
+
tabId: value.tabId,
|
|
104
|
+
tabWindowId: tabWindow,
|
|
105
|
+
});
|
|
70
106
|
}
|
|
107
|
+
if (restored) await persistAutomationTargets();
|
|
71
108
|
}
|
|
72
109
|
} catch {
|
|
73
110
|
// Ignore: treat as "no persisted state".
|
|
@@ -79,19 +116,28 @@ async function hydrateAutomationTargets() {
|
|
|
79
116
|
async function hydrateSessionTabs() {
|
|
80
117
|
if (!sessionTabsReady) sessionTabsReady = (async () => {
|
|
81
118
|
try {
|
|
82
|
-
const
|
|
83
|
-
for (const [key, entries] of Object.entries(
|
|
119
|
+
const { value: saved, restored } = await readOwnership(SESSION_TABS_STORAGE_KEY);
|
|
120
|
+
for (const [key, entries] of Object.entries(saved || {})) {
|
|
84
121
|
if (!Array.isArray(entries)) continue;
|
|
85
122
|
const tabs = new Map();
|
|
86
123
|
for (const entry of entries) {
|
|
87
124
|
if (!entry || !Number.isInteger(entry.tabId) || entry.tabId < 0) continue;
|
|
88
|
-
|
|
125
|
+
const windowId = Number.isInteger(entry.windowId) ? entry.windowId : undefined;
|
|
126
|
+
let tab = null;
|
|
127
|
+
if (restored) {
|
|
128
|
+
tab = await restoredTabStillOurs(entry.tabId, windowId);
|
|
129
|
+
if (!tab) continue;
|
|
130
|
+
}
|
|
131
|
+
if (entry.created === true) tabs.set(entry.tabId, { created: true, windowId });
|
|
89
132
|
else if (entry.created === false && Number.isInteger(entry.groupId) && entry.groupId >= 0) {
|
|
90
|
-
|
|
133
|
+
// An adopted tab must also still be in the group Pi put it in.
|
|
134
|
+
if (restored && tab.groupId !== entry.groupId) continue;
|
|
135
|
+
tabs.set(entry.tabId, { created: false, groupId: entry.groupId, windowId });
|
|
91
136
|
}
|
|
92
137
|
}
|
|
93
138
|
if (tabs.size) sessionTabs.set(key, tabs);
|
|
94
139
|
}
|
|
140
|
+
if (restored) await persistSessionTabs();
|
|
95
141
|
} catch {
|
|
96
142
|
// Missing ownership must leave tabs alone, not guess ownership from group names.
|
|
97
143
|
}
|
|
@@ -105,7 +151,7 @@ function persistSessionTabs() {
|
|
|
105
151
|
const saved = Object.fromEntries([...sessionTabs].map(([key, tabs]) => [
|
|
106
152
|
key, [...tabs].map(([tabId, record]) => ({ tabId, ...record })),
|
|
107
153
|
]));
|
|
108
|
-
await
|
|
154
|
+
await writeOwnership(SESSION_TABS_STORAGE_KEY, saved);
|
|
109
155
|
}).catch(() => {});
|
|
110
156
|
return sessionTabsWrite;
|
|
111
157
|
}
|
|
@@ -119,7 +165,8 @@ async function trackSessionTab(sessionKey, tabId, created, groupId) {
|
|
|
119
165
|
}
|
|
120
166
|
let tabs = sessionTabs.get(sessionKey);
|
|
121
167
|
if (!tabs) sessionTabs.set(sessionKey, tabs = new Map());
|
|
122
|
-
|
|
168
|
+
const windowId = await tabWindowId(tabId);
|
|
169
|
+
tabs.set(tabId, created ? { created: true, windowId } : { created: false, groupId, windowId });
|
|
123
170
|
await persistSessionTabs();
|
|
124
171
|
}
|
|
125
172
|
|
|
@@ -153,9 +200,14 @@ async function persistAutomationTargets() {
|
|
|
153
200
|
try {
|
|
154
201
|
const obj = {};
|
|
155
202
|
for (const [key, value] of automationTargets) {
|
|
156
|
-
|
|
203
|
+
if (typeof value.tabWindowId !== "number") value.tabWindowId = await tabWindowId(value.tabId);
|
|
204
|
+
obj[key] = {
|
|
205
|
+
windowId: typeof value.windowId === "number" ? value.windowId : null,
|
|
206
|
+
tabId: value.tabId,
|
|
207
|
+
tabWindowId: typeof value.tabWindowId === "number" ? value.tabWindowId : null,
|
|
208
|
+
};
|
|
157
209
|
}
|
|
158
|
-
await
|
|
210
|
+
await writeOwnership(AUTOMATION_STORAGE_KEY, obj);
|
|
159
211
|
} catch {
|
|
160
212
|
// Ignore: persistence is an optimization, not a correctness requirement.
|
|
161
213
|
}
|
|
@@ -174,17 +226,6 @@ function isPiChromeOwnedTarget(tabId, sessionKey) {
|
|
|
174
226
|
return false;
|
|
175
227
|
}
|
|
176
228
|
|
|
177
|
-
// A new window created while the user's Chrome window is full screen does not become a visible
|
|
178
|
-
// standalone window: macOS either merges it into the full-screen window as a hidden native window
|
|
179
|
-
// tab ("Prefer tabs: in full screen") or places it on another Space. Its page is hidden, so trusted
|
|
180
|
-
// input cannot reach it. Use a grouped background tab in the user's window instead, which is at least
|
|
181
|
-
// visible in the tab strip and does not add a macOS tab bar to the user's full-screen window.
|
|
182
|
-
async function userWindowIsFullscreen() {
|
|
183
|
-
if (typeof chrome.windows?.getLastFocused !== "function") return false;
|
|
184
|
-
const win = await chrome.windows.getLastFocused({ windowTypes: ["normal"] }).catch(() => null);
|
|
185
|
-
return win?.state === "fullscreen";
|
|
186
|
-
}
|
|
187
|
-
|
|
188
229
|
// Create a fresh automation target for `sessionKey`. If this session already has a tab group,
|
|
189
230
|
// create the tab inside that group's window so one Pi session keeps one Chrome tab group (Chrome
|
|
190
231
|
// groups cannot span windows). If no group exists yet, prefer an isolated window; fall back to a
|
|
@@ -198,7 +239,7 @@ async function createAutomationTarget(sessionKey, groupTitle) {
|
|
|
198
239
|
await persistAutomationTargets();
|
|
199
240
|
return tab;
|
|
200
241
|
}
|
|
201
|
-
if (chrome.windows && typeof chrome.windows.create === "function"
|
|
242
|
+
if (chrome.windows && typeof chrome.windows.create === "function") {
|
|
202
243
|
try {
|
|
203
244
|
const win = await chrome.windows.create({ url: "about:blank", focused: false });
|
|
204
245
|
const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined;
|
|
@@ -452,7 +493,7 @@ async function pageVisibilityState(tabId) {
|
|
|
452
493
|
function hiddenTabReason(tab, win) {
|
|
453
494
|
if (win?.state === "minimized") return "its window is minimized";
|
|
454
495
|
if (tab && tab.active === false) return "it is an inactive tab in its window";
|
|
455
|
-
if (win?.state === "fullscreen") return "its window is behind a full-screen window
|
|
496
|
+
if (win?.state === "fullscreen") return "its window is behind a full-screen window or on another Space";
|
|
456
497
|
return "its window is covered by another window or is on another Space (Chrome treats fully covered windows as hidden)";
|
|
457
498
|
}
|
|
458
499
|
|
|
@@ -1232,11 +1273,16 @@ async function chromeInputFill(params) {
|
|
|
1232
1273
|
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
|
|
1233
1274
|
await sleep(rng(20, 60));
|
|
1234
1275
|
}
|
|
1235
|
-
await contentEditableInTab(tab.id, params)
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1276
|
+
if (await contentEditableInTab(tab.id, params)) {
|
|
1277
|
+
// The requested editor's whole contents are already selected; delete the selection.
|
|
1278
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
|
|
1279
|
+
await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
|
|
1280
|
+
await sleep(rng(20, 60));
|
|
1281
|
+
} else {
|
|
1282
|
+
// A triple-click selects only one line of a multi-line textarea. Select the whole value with
|
|
1283
|
+
// Chrome's selectAll editing command before deleting.
|
|
1284
|
+
await selectAllAndDelete(tab.id);
|
|
1285
|
+
}
|
|
1240
1286
|
const text = String(params.text || "");
|
|
1241
1287
|
const typing = await typeTextInTab(tab.id, text, params.perCharacter);
|
|
1242
1288
|
if (params.submit) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" });
|
|
@@ -1532,6 +1578,8 @@ chrome.runtime.onInstalled.addListener(() => {
|
|
|
1532
1578
|
});
|
|
1533
1579
|
|
|
1534
1580
|
chrome.runtime.onStartup.addListener(() => {
|
|
1581
|
+
// A new browser run: tab ids from the last run mean nothing now.
|
|
1582
|
+
Promise.resolve(chrome.storage?.local?.remove?.([AUTOMATION_STORAGE_KEY, SESSION_TABS_STORAGE_KEY])).catch(() => {});
|
|
1535
1583
|
armKeepaliveAlarm();
|
|
1536
1584
|
void pollLoop();
|
|
1537
1585
|
});
|
|
@@ -1706,10 +1754,14 @@ async function groupTab(tab, title, color) {
|
|
|
1706
1754
|
const groupTitle = cleanGroupTitle(title);
|
|
1707
1755
|
let groupId = tab.groupId;
|
|
1708
1756
|
if (typeof groupId !== "number" || groupId < 0) {
|
|
1709
|
-
|
|
1757
|
+
// Some browsers omit windowId on freshly created tabs; read it back instead of guessing.
|
|
1758
|
+
const windowId = typeof tab.windowId === "number" ? tab.windowId : (await chrome.tabs.get(tab.id).catch(() => null))?.windowId;
|
|
1759
|
+
const existing = await findGroupByTitle(windowId, groupTitle);
|
|
1760
|
+
// Without createProperties.windowId Chrome creates the group in the *current* (user's) window
|
|
1761
|
+
// and moves the tab there, pulling Pi's automation tab out of its own window as a hidden tab.
|
|
1710
1762
|
groupId = existing !== null
|
|
1711
1763
|
? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] })
|
|
1712
|
-
: await chrome.tabs.group({ tabIds: [tab.id] });
|
|
1764
|
+
: await chrome.tabs.group(typeof windowId === "number" ? { tabIds: [tab.id], createProperties: { windowId } } : { tabIds: [tab.id] });
|
|
1713
1765
|
}
|
|
1714
1766
|
await chrome.tabGroups.update(groupId, { title: groupTitle, color: cleanGroupColor(color), collapsed: false });
|
|
1715
1767
|
const grouped = await chrome.tabs.get(tab.id);
|
|
@@ -1743,8 +1795,20 @@ async function dispatch(action, params) {
|
|
|
1743
1795
|
const existingGroup = await findGroupRecordByTitle(groupTitle);
|
|
1744
1796
|
const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) };
|
|
1745
1797
|
if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
|
|
1746
|
-
|
|
1747
|
-
|
|
1798
|
+
// Register the creation as in flight until the tab is tracked, so early capture (which only
|
|
1799
|
+
// runs in tracked tabs) waits for it instead of skipping the new tab's first page load.
|
|
1800
|
+
const creation = (async () => {
|
|
1801
|
+
const created = await chrome.tabs.create(createParams);
|
|
1802
|
+
await trackSessionTab(sessionKeyOf(params), created.id, true);
|
|
1803
|
+
return created;
|
|
1804
|
+
})();
|
|
1805
|
+
pendingTabCreations.add(creation);
|
|
1806
|
+
let tab;
|
|
1807
|
+
try {
|
|
1808
|
+
tab = await creation;
|
|
1809
|
+
} finally {
|
|
1810
|
+
pendingTabCreations.delete(creation);
|
|
1811
|
+
}
|
|
1748
1812
|
try {
|
|
1749
1813
|
await bringToFront(tab, params);
|
|
1750
1814
|
const grouped = await groupTab(tab, groupTitle, params.groupColor);
|
|
@@ -1910,7 +1974,24 @@ async function dispatch(action, params) {
|
|
|
1910
1974
|
// Report this session's owned automation target (ids only). Used for diagnostics/tests.
|
|
1911
1975
|
await hydrateAutomationTargets();
|
|
1912
1976
|
const t = automationTargets.get(sessionKeyOf(params));
|
|
1913
|
-
|
|
1977
|
+
const status = { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null };
|
|
1978
|
+
if (!params.includeVisibility || !t) return status;
|
|
1979
|
+
// For /chrome doctor: is this session's own tab visible? Never creates or activates anything.
|
|
1980
|
+
const tab = await chrome.tabs.get(t.tabId).catch(() => null);
|
|
1981
|
+
if (!tab) return { ...status, tabId: null };
|
|
1982
|
+
const results = await executeScriptWithFallback({
|
|
1983
|
+
target: { tabId: tab.id, frameIds: [0] },
|
|
1984
|
+
world: "MAIN",
|
|
1985
|
+
func: () => document.visibilityState,
|
|
1986
|
+
}, `read visibility of tab ${tab.id}`).catch(() => null);
|
|
1987
|
+
const visibility = typeof results?.[0]?.result === "string" ? results[0].result : "unknown";
|
|
1988
|
+
const win = typeof chrome.windows?.get === "function" ? await chrome.windows.get(tab.windowId).catch(() => null) : null;
|
|
1989
|
+
return {
|
|
1990
|
+
...status,
|
|
1991
|
+
visibility,
|
|
1992
|
+
url: tab.url,
|
|
1993
|
+
hiddenReason: visibility === "hidden" ? hiddenTabReason(tab, win) : undefined,
|
|
1994
|
+
};
|
|
1914
1995
|
}
|
|
1915
1996
|
case "automation.cleanup":
|
|
1916
1997
|
// Close recorded creations, and only ungroup user tabs still in their adopted group.
|
|
@@ -2298,8 +2379,12 @@ async function unregisterInitScript(tabId) {
|
|
|
2298
2379
|
// console messages, errors, and network requests that fire during page load, before chrome_snapshot
|
|
2299
2380
|
// or chrome_evaluate install the instrumentation normally. installEarlyCapture sets __piChromeWrapped
|
|
2300
2381
|
// flags so the post-hoc installPiChromeInstrumentation() call is idempotent.
|
|
2382
|
+
const pendingTabCreations = new Set(); // in-flight Pi tab creations, resolved once tracked
|
|
2383
|
+
|
|
2301
2384
|
async function isPiTrackedTab(tabId) {
|
|
2302
2385
|
await Promise.all([hydrateAutomationTargets(), hydrateSessionTabs()]);
|
|
2386
|
+
// A tab Pi is creating right now can commit its first page before tab.new has tracked it.
|
|
2387
|
+
if (pendingTabCreations.size) await Promise.allSettled([...pendingTabCreations]);
|
|
2303
2388
|
if (isPiChromeOwnedTarget(tabId)) return true;
|
|
2304
2389
|
for (const tabs of sessionTabs.values()) if (tabs.has(tabId)) return true;
|
|
2305
2390
|
return false;
|
|
@@ -1219,6 +1219,25 @@ Usage rules:
|
|
|
1219
1219
|
} catch (error) {
|
|
1220
1220
|
lines.push(`⚠ Couldn't inspect the active tab: ${(error as Error).message}`);
|
|
1221
1221
|
}
|
|
1222
|
+
|
|
1223
|
+
// Pi's own tab for this session: hidden pages ignore clicks and typing, so say so up front.
|
|
1224
|
+
try {
|
|
1225
|
+
const own = (await bridge.send("automation.status", { sessionKey: sessionKeyFor(ctx), includeVisibility: true }, 10_000)) as
|
|
1226
|
+
| { tabId?: number | null; visibility?: string; hiddenReason?: string }
|
|
1227
|
+
| undefined;
|
|
1228
|
+
if (own?.tabId === null || own?.tabId === undefined) {
|
|
1229
|
+
lines.push(`• Pi hasn't opened its own tab in this session yet.`);
|
|
1230
|
+
} else if (own.visibility === "hidden") {
|
|
1231
|
+
lines.push(
|
|
1232
|
+
`⚠ Pi's tab is hidden because ${own.hiddenReason ?? "its window is not visible"}. Chrome ignores clicks and typing in hidden tabs, so those tools will fail until it's visible.`,
|
|
1233
|
+
` Fix: bring Pi's window forward or uncover it, or run /chrome background off to let Pi bring it forward.`,
|
|
1234
|
+
);
|
|
1235
|
+
} else if (own.visibility === "visible") {
|
|
1236
|
+
lines.push(`✓ Pi's tab is visible, so clicks and typing will reach it.`);
|
|
1237
|
+
}
|
|
1238
|
+
} catch {
|
|
1239
|
+
// Older extensions or a closed tab: nothing useful to add.
|
|
1240
|
+
}
|
|
1222
1241
|
} else if (versionMismatch) {
|
|
1223
1242
|
lines.push(`… Skipped the remaining checks until you reload the Chrome extension.`);
|
|
1224
1243
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-chrome",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.57",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs",
|
|
6
6
|
"version": "node scripts/sync-manifest-version.js",
|
|
@@ -87,14 +87,17 @@ function makeChrome(state, { withWindows = true, withStorage = true, withTabGrou
|
|
|
87
87
|
// Chrome closes a window automatically when its final tab is removed.
|
|
88
88
|
if (tab && ![...tabs.values()].some((other) => other.windowId === tab.windowId)) windows.delete(tab.windowId);
|
|
89
89
|
},
|
|
90
|
-
|
|
90
|
+
// Like Chrome: a new group goes into createProperties.windowId, else the *current* window
|
|
91
|
+
// (the user's), and grouped tabs move into the group's window.
|
|
92
|
+
group: async ({ groupId, tabIds = [], createProperties } = {}) => {
|
|
91
93
|
let gid = groupId;
|
|
92
94
|
if (typeof gid !== "number") {
|
|
93
95
|
gid = alloc.group();
|
|
94
|
-
const
|
|
95
|
-
groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId
|
|
96
|
+
const windowId = typeof createProperties?.windowId === "number" ? createProperties.windowId : userWindowId;
|
|
97
|
+
groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId });
|
|
96
98
|
}
|
|
97
|
-
|
|
99
|
+
const groupWindow = groups.get(gid).windowId;
|
|
100
|
+
for (const tid of tabIds) { const t = tabs.get(tid); if (t) { t.groupId = gid; t.windowId = groupWindow; } }
|
|
98
101
|
return gid;
|
|
99
102
|
},
|
|
100
103
|
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; } },
|
|
@@ -24,6 +24,7 @@ function healthyResponse(action) {
|
|
|
24
24
|
case "tab.version": return { extensionVersion: version };
|
|
25
25
|
case "page.evaluate": return 2;
|
|
26
26
|
case "page.probe": return { arithmetic: 2, location: "https://fixture.test/", webdriver: false };
|
|
27
|
+
case "automation.status": return { windowId: 5, tabId: 6, visibility: "visible" };
|
|
27
28
|
default: throw new Error(`Unexpected bridge action: ${action}`);
|
|
28
29
|
}
|
|
29
30
|
}
|
|
@@ -44,6 +45,7 @@ function harness({ until, background = true, mode = "server", choices = [], send
|
|
|
44
45
|
Date: { now: () => now }, PI_CHROME_VERSION: version,
|
|
45
46
|
chromeAuthorizedUntil: until, backgroundEnabled: background,
|
|
46
47
|
hostnameOf: (url) => new URL(url).hostname,
|
|
48
|
+
sessionKeyFor: () => "session:alpha",
|
|
47
49
|
bridge: {
|
|
48
50
|
status: () => ({ mode }),
|
|
49
51
|
async send(action, params, timeout) {
|
|
@@ -128,8 +130,10 @@ test("Doctor includes locked, timed, indefinite, and expired authorization plus
|
|
|
128
130
|
assert.match(report, /can run code/);
|
|
129
131
|
assert.match(report, /fixture\.test/);
|
|
130
132
|
assert.deepEqual(h.calls.map(({ action, timeout }) => [action, timeout]), [
|
|
131
|
-
["tab.version", 35_000], ["page.evaluate", 10_000], ["page.probe", 10_000],
|
|
133
|
+
["tab.version", 35_000], ["page.evaluate", 10_000], ["page.probe", 10_000], ["automation.status", 10_000],
|
|
132
134
|
]);
|
|
135
|
+
assert.deepEqual(h.calls.at(-1).params, { sessionKey: "session:alpha", includeVisibility: true });
|
|
136
|
+
assert.match(report, /Pi's tab is visible/);
|
|
133
137
|
assert.ok(h.calls.filter((call) => call.action.startsWith("page.")).every((call) => call.params.foreground === false));
|
|
134
138
|
assert.equal(h.sandbox.chromeAuthorizedUntil, until, "diagnostics do not grant or change authorization");
|
|
135
139
|
assert.equal(h.sandbox.backgroundEnabled, background);
|
|
@@ -157,7 +161,29 @@ test("Doctor retains local state and repair hints when connection/version checks
|
|
|
157
161
|
test("choosing Doctor explicitly from the dashboard runs full diagnostics", async () => {
|
|
158
162
|
const h = harness({ choices: ["Doctor / troubleshoot"] });
|
|
159
163
|
await h.run();
|
|
160
|
-
assert.deepEqual(h.calls.map((call) => call.action), ["tab.version", "tab.version", "page.evaluate", "page.probe"]);
|
|
164
|
+
assert.deepEqual(h.calls.map((call) => call.action), ["tab.version", "tab.version", "page.evaluate", "page.probe", "automation.status"]);
|
|
161
165
|
assert.match(h.notices.at(-1)[0], /Authorization: locked/);
|
|
162
166
|
assert.match(h.notices.at(-1)[0], /Background: on \(hard\)/);
|
|
163
167
|
});
|
|
168
|
+
|
|
169
|
+
test("Doctor warns when this session's Pi tab is hidden, and stays quiet when it cannot tell", async () => {
|
|
170
|
+
const withStatus = (status) => (action, ...rest) => action === "automation.status"
|
|
171
|
+
? (typeof status === "function" ? status() : status)
|
|
172
|
+
: healthyResponse(action, ...rest);
|
|
173
|
+
const hidden = harness({ send: withStatus({ tabId: 6, visibility: "hidden", hiddenReason: "its window is covered by another window" }) });
|
|
174
|
+
await hidden.run("doctor");
|
|
175
|
+
const report = hidden.notices.at(-1)[0];
|
|
176
|
+
assert.match(report, /⚠ Pi's tab is hidden because its window is covered by another window/);
|
|
177
|
+
assert.match(report, /Chrome ignores clicks and typing in hidden tabs/);
|
|
178
|
+
assert.match(report, /\/chrome background off/);
|
|
179
|
+
|
|
180
|
+
const none = harness({ send: withStatus({ windowId: null, tabId: null }) });
|
|
181
|
+
await none.run("doctor");
|
|
182
|
+
assert.match(none.notices.at(-1)[0], /hasn't opened its own tab in this session yet/);
|
|
183
|
+
|
|
184
|
+
for (const status of [{ windowId: 5, tabId: 6 }, () => { throw new Error("old extension"); }]) {
|
|
185
|
+
const quiet = harness({ send: withStatus(status) });
|
|
186
|
+
await quiet.run("doctor");
|
|
187
|
+
assert.doesNotMatch(quiet.notices.at(-1)[0], /Pi's tab|own tab/);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
@@ -20,7 +20,8 @@ function loadWorker() {
|
|
|
20
20
|
debugger: { onDetach: noop, onEvent: noop },
|
|
21
21
|
storage: { session: { get: async () => ({}), set: async () => {} } },
|
|
22
22
|
scripting: { executeScript: async (options) => { injected.push(options); return []; } },
|
|
23
|
-
tabs: { get: async () => null },
|
|
23
|
+
tabs: { get: async () => null, create: async () => { throw new Error("not mocked"); } },
|
|
24
|
+
tabGroups: { query: async () => [] },
|
|
24
25
|
};
|
|
25
26
|
const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } };
|
|
26
27
|
w.self = w;
|
|
@@ -120,3 +121,21 @@ test("early capture is injected only into tabs Pi owns or tracks", async () => {
|
|
|
120
121
|
assert.deepEqual(h.injected.map((o) => o.target.tabId), [11, 12]);
|
|
121
122
|
assert.ok(h.injected.every((o) => o.func.name === "installEarlyCapture" && o.injectImmediately === true));
|
|
122
123
|
});
|
|
124
|
+
|
|
125
|
+
test("a Pi tab whose first page commits before tab.new tracks it still gets early capture", async () => {
|
|
126
|
+
const h = loadWorker();
|
|
127
|
+
let commit;
|
|
128
|
+
h.w.chrome.tabs.create = async (props) => {
|
|
129
|
+
// Chrome can commit the first document while tab.new is still recording ownership.
|
|
130
|
+
commit = h.commit(21);
|
|
131
|
+
return { id: 21, windowId: 1, url: props.url, status: "loading" };
|
|
132
|
+
};
|
|
133
|
+
h.w.groupTab = async (tab) => ({ tab: { id: tab.id }, group: { title: "Pi" } });
|
|
134
|
+
h.w.waitForTabSettled = async () => true;
|
|
135
|
+
h.w.formatTab = async (tab) => tab;
|
|
136
|
+
await h.w.dispatch("tab.new", { url: "https://site.test/", background: true, sessionKey: "s" });
|
|
137
|
+
await commit;
|
|
138
|
+
assert.deepEqual(h.injected.map((o) => o.target.tabId), [21]);
|
|
139
|
+
await h.commit(22);
|
|
140
|
+
assert.deepEqual(h.injected.map((o) => o.target.tabId), [21], "unrelated tabs stay untouched");
|
|
141
|
+
});
|
|
@@ -111,15 +111,15 @@ test("visible pages, failed probes, and foreground tabs that become visible proc
|
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
// ---- automation target placement ----
|
|
114
|
-
test("automation targets
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
114
|
+
test("automation targets get their own window even while the user's window is full screen", async () => {
|
|
115
|
+
// Chrome disables macOS automatic window tabbing, so a new window is never merged into the user's
|
|
116
|
+
// full-screen window; putting Pi's tab in the user's window would only lose isolation.
|
|
117
|
+
for (const state of ["fullscreen", "maximized"]) {
|
|
118
|
+
const h = worker({ win: { id: 1, state } });
|
|
119
|
+
await h.w.createAutomationTarget(`s-${state}`, undefined);
|
|
120
|
+
assert.deepEqual(clone(h.calls.find((c) => c.method === "windows.create").props), { url: "about:blank", focused: false }, state);
|
|
121
|
+
assert.equal(h.calls.some((c) => c.method === "tabs.create"), false, state);
|
|
122
|
+
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
// ---- input.debug ----
|
|
@@ -277,3 +277,23 @@ test("pruneScreenshotDir deletes only eligible files and never throws", async ()
|
|
|
277
277
|
assert.equal(await prune.pruneScreenshotDir(path.join(dir, "missing")), 0);
|
|
278
278
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
279
279
|
});
|
|
280
|
+
|
|
281
|
+
// ---- /chrome doctor visibility report ----
|
|
282
|
+
test("automation.status reports whether this session's own tab is visible, without creating one", async () => {
|
|
283
|
+
const h = worker({ tab: { id: 2, windowId: 1, active: false, url: "https://fixture.test/" }, win: { id: 1, state: "normal" } });
|
|
284
|
+
h.chrome.scripting.executeScript = async () => [{ result: "hidden" }];
|
|
285
|
+
const none = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
|
|
286
|
+
assert.deepEqual(clone(none), { windowId: null, tabId: null });
|
|
287
|
+
assert.equal(h.calls.some((c) => c.method === "tabs.create" || c.method === "windows.create"), false);
|
|
288
|
+
|
|
289
|
+
vm.runInContext("automationTargets.set('s', { tabId: 2, windowId: undefined })", h.w);
|
|
290
|
+
const hidden = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
|
|
291
|
+
assert.equal(hidden.visibility, "hidden");
|
|
292
|
+
assert.match(hidden.hiddenReason, /inactive tab/);
|
|
293
|
+
|
|
294
|
+
h.chrome.scripting.executeScript = async () => [{ result: "visible" }];
|
|
295
|
+
const visible = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
|
|
296
|
+
assert.equal(visible.visibility, "visible");
|
|
297
|
+
assert.equal(visible.hiddenReason, undefined);
|
|
298
|
+
assert.deepEqual(clone(await h.w.dispatch("automation.status", { sessionKey: "s" })), { windowId: null, tabId: 2 }, "plain status is unchanged");
|
|
299
|
+
});
|
|
@@ -215,6 +215,18 @@ for (const perCharacter of [false, true]) {
|
|
|
215
215
|
});
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
test("fill selects a textarea's whole value with the selectAll command, not just the clicked line", async () => {
|
|
219
|
+
const h = harness({ tag: "TEXTAREA", editable: false });
|
|
220
|
+
const result = await h.call("fill", { selector: "#target", text: "NEW", domFallback: false });
|
|
221
|
+
assert.equal(result.input, "chrome");
|
|
222
|
+
const keys = h.commands("Input.dispatchKeyEvent");
|
|
223
|
+
const selectAll = keys.findIndex((c) => (c.params.commands || []).includes("selectAll"));
|
|
224
|
+
const del = keys.findIndex((c) => c.params.key === "Delete" && c.params.type === "keyDown");
|
|
225
|
+
assert.ok(selectAll >= 0, "selectAll editing command sent");
|
|
226
|
+
assert.ok(del > selectAll, "delete follows select-all");
|
|
227
|
+
assert.equal(h.selected.length, 0, "no DOM range selection for value fields");
|
|
228
|
+
});
|
|
229
|
+
|
|
218
230
|
test("fill refuses to select an unrelated focused editor", async () => {
|
|
219
231
|
const h = harness({ initial: "keep this" });
|
|
220
232
|
h.page.document.activeElement = { isContentEditable: true, contains: () => false };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Tab ownership must survive an extension reload (storage.session is wiped; the extension reloads
|
|
2
|
+
// itself on every version bump) without ever claiming a tab that is no longer provably Pi's.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import vm from "node:vm";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
|
|
8
|
+
const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
|
|
9
|
+
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
10
|
+
|
|
11
|
+
// One browser (tabs/windows/groups) shared by successive extension instances.
|
|
12
|
+
function makeBrowser() {
|
|
13
|
+
let nextId = 100;
|
|
14
|
+
const b = { tabs: new Map(), windows: new Set([1]), groups: new Map(), local: {}, removed: [], ungrouped: [] };
|
|
15
|
+
b.addTab = (props) => { const tab = { id: nextId++, windowId: 1, active: false, url: "about:blank", groupId: -1, ...props }; b.tabs.set(tab.id, tab); return tab; };
|
|
16
|
+
b.newWindow = () => { const id = nextId++; b.windows.add(id); return id; };
|
|
17
|
+
return b;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadExtension(b, { session = {} } = {}) {
|
|
21
|
+
const listeners = { startup: [] };
|
|
22
|
+
const noop = { addListener() {}, removeListener() {} };
|
|
23
|
+
const area = (store) => ({
|
|
24
|
+
get: async (key) => (key in store ? { [key]: clone(store[key]) } : {}),
|
|
25
|
+
set: async (obj) => { for (const [k, v] of Object.entries(obj)) store[k] = clone(v); },
|
|
26
|
+
remove: async (keys) => { for (const k of [].concat(keys)) delete store[k]; },
|
|
27
|
+
});
|
|
28
|
+
const chrome = {
|
|
29
|
+
runtime: { id: "t", getManifest: () => ({ version: "0.0.0" }), onInstalled: noop, onStartup: { addListener: (fn) => listeners.startup.push(fn) } },
|
|
30
|
+
alarms: { create() {}, onAlarm: noop }, action: { onClicked: noop, setBadgeText() {}, setBadgeBackgroundColor() {} },
|
|
31
|
+
webNavigation: { onCommitted: noop }, debugger: { onDetach: noop, onEvent: noop },
|
|
32
|
+
scripting: { executeScript: async () => [] },
|
|
33
|
+
storage: { session: area(session), local: area(b.local) },
|
|
34
|
+
tabs: {
|
|
35
|
+
get: async (id) => { const t = b.tabs.get(id); if (!t) throw new Error(`No tab with id: ${id}`); return clone(t); },
|
|
36
|
+
query: async () => [...b.tabs.values()].map(clone),
|
|
37
|
+
create: async (props) => clone(b.addTab({ url: props.url, windowId: props.windowId ?? 1, active: !!props.active })),
|
|
38
|
+
remove: async (id) => { b.tabs.delete(id); b.removed.push(id); },
|
|
39
|
+
ungroup: async (id) => { b.tabs.get(id).groupId = -1; b.ungrouped.push(id); },
|
|
40
|
+
group: async ({ groupId, tabIds, createProperties }) => {
|
|
41
|
+
const gid = groupId ?? 900 + b.groups.size;
|
|
42
|
+
if (!b.groups.has(gid)) b.groups.set(gid, { id: gid, windowId: createProperties?.windowId ?? 1, title: "" });
|
|
43
|
+
for (const id of tabIds) Object.assign(b.tabs.get(id), { groupId: gid, windowId: b.groups.get(gid).windowId });
|
|
44
|
+
return gid;
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
tabGroups: {
|
|
48
|
+
query: async ({ windowId } = {}) => [...b.groups.values()].filter((g) => windowId === undefined || g.windowId === windowId).map(clone),
|
|
49
|
+
get: async (id) => clone(b.groups.get(id)),
|
|
50
|
+
update: async (id, props) => Object.assign(b.groups.get(id), props),
|
|
51
|
+
},
|
|
52
|
+
windows: {
|
|
53
|
+
getLastFocused: async () => ({ id: 1, state: "normal" }),
|
|
54
|
+
get: async (id) => { if (!b.windows.has(id)) throw new Error("no window"); return { id }; },
|
|
55
|
+
create: async ({ url }) => { const id = b.newWindow(); const tab = b.addTab({ windowId: id, url, active: true }); return { id, tabs: [clone(tab)] }; },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } };
|
|
59
|
+
w.self = w;
|
|
60
|
+
vm.runInNewContext(workerSource, w);
|
|
61
|
+
w.formatTab = async (t) => t;
|
|
62
|
+
w.waitForTabSettled = async () => true;
|
|
63
|
+
return { w, session, startup: () => Promise.all(listeners.startup.map((fn) => fn())) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function setUpOwnership(b) {
|
|
67
|
+
const ext = loadExtension(b);
|
|
68
|
+
const target = await ext.w.getOrCreateAutomationTarget("s", undefined); // own window
|
|
69
|
+
const created = await ext.w.dispatch("tab.new", { url: "https://a.test/", background: true, sessionKey: "s", groupTitle: "Pi Session: s" });
|
|
70
|
+
const userTab = b.addTab({ url: "https://user.test/" });
|
|
71
|
+
await ext.w.dispatch("tab.group", { targetId: String(userTab.id), sessionKey: "s", groupTitle: "Pi Session: s" });
|
|
72
|
+
return { ext, targetTab: target.id, createdTab: created.tab.id, adoptedTab: userTab.id };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test("after an extension reload, cleanup still closes Pi's tabs and ungroups the adopted one", async () => {
|
|
76
|
+
const b = makeBrowser();
|
|
77
|
+
const ids = await setUpOwnership(b);
|
|
78
|
+
const reloaded = loadExtension(b); // fresh worker, empty storage.session, same storage.local
|
|
79
|
+
const result = await reloaded.w.dispatch("automation.cleanup", { sessionKey: "s" });
|
|
80
|
+
assert.equal(result.closedTabId, ids.targetTab);
|
|
81
|
+
assert.equal(result.closedCreatedTabs, 1);
|
|
82
|
+
assert.equal(result.ungroupedAdoptedTabs, 1);
|
|
83
|
+
assert.deepEqual(b.removed.sort(), [ids.targetTab, ids.createdTab].sort());
|
|
84
|
+
assert.deepEqual(b.ungrouped, [ids.adoptedTab]);
|
|
85
|
+
assert.ok(b.tabs.has(ids.adoptedTab), "the user's tab is never closed");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a reloaded extension reuses its automation tab instead of opening another window", async () => {
|
|
89
|
+
const b = makeBrowser();
|
|
90
|
+
const { targetTab } = await setUpOwnership(b);
|
|
91
|
+
const windowsBefore = b.windows.size;
|
|
92
|
+
const reloaded = loadExtension(b);
|
|
93
|
+
const again = await reloaded.w.getOrCreateAutomationTarget("s", undefined);
|
|
94
|
+
assert.equal(again.id, targetTab);
|
|
95
|
+
assert.equal(b.windows.size, windowsBefore);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("restored records are dropped when the tab moved windows, left Pi's group, or no longer exists", async () => {
|
|
99
|
+
const b = makeBrowser();
|
|
100
|
+
const ids = await setUpOwnership(b);
|
|
101
|
+
b.tabs.get(ids.targetTab).windowId = b.newWindow(); // user dragged it out
|
|
102
|
+
b.tabs.get(ids.adoptedTab).groupId = -1; // user removed it from Pi's group
|
|
103
|
+
b.tabs.delete(ids.createdTab); // closed; its id could later belong to another tab
|
|
104
|
+
const reloaded = loadExtension(b);
|
|
105
|
+
const result = await reloaded.w.dispatch("automation.cleanup", { sessionKey: "s" });
|
|
106
|
+
assert.equal(result.closedTabId, null);
|
|
107
|
+
assert.equal(result.closedCreatedTabs, 0);
|
|
108
|
+
assert.equal(result.ungroupedAdoptedTabs, 0);
|
|
109
|
+
assert.deepEqual(b.removed, []);
|
|
110
|
+
assert.deepEqual(b.ungrouped, []);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a browser restart drops the local copy; a service-worker restart keeps using storage.session", async () => {
|
|
114
|
+
const b = makeBrowser();
|
|
115
|
+
const { ext, targetTab } = await setUpOwnership(b);
|
|
116
|
+
// Service-worker restart: session storage survives, and it wins over local.
|
|
117
|
+
const swRestart = loadExtension(b, { session: ext.session });
|
|
118
|
+
assert.equal((await swRestart.w.dispatch("automation.status", { sessionKey: "s" })).tabId, targetTab);
|
|
119
|
+
// Browser restart: onStartup clears the local copy before anything reads it.
|
|
120
|
+
const restarted = loadExtension(b);
|
|
121
|
+
await restarted.startup();
|
|
122
|
+
assert.equal(Object.keys(b.local).length, 0);
|
|
123
|
+
assert.equal((await restarted.w.dispatch("automation.status", { sessionKey: "s" })).tabId, null);
|
|
124
|
+
});
|