pi-chrome 0.15.37 → 0.15.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -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 +460 -92
- package/extensions/chrome-profile-bridge/index.ts +100 -16
- package/package.json +2 -2
- package/test-suite/unit/automation-target.test.mjs +387 -0
|
@@ -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(),
|
|
@@ -1413,6 +1495,7 @@ Usage rules:
|
|
|
1413
1495
|
selector: Type.Optional(Type.String({ description: "CSS selector to click. Prefer uid from chrome_snapshot when available." })),
|
|
1414
1496
|
x: Type.Optional(Type.Number({ description: "Viewport x coordinate if uid/selector is omitted." })),
|
|
1415
1497
|
y: Type.Optional(Type.Number({ description: "Viewport y coordinate if uid/selector is omitted." })),
|
|
1498
|
+
domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
|
|
1416
1499
|
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the click." })),
|
|
1417
1500
|
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
|
1418
1501
|
targetId: Type.Optional(Type.String()),
|
|
@@ -1478,6 +1561,7 @@ Usage rules:
|
|
|
1478
1561
|
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
|
|
1479
1562
|
selector: Type.Optional(Type.String({ description: "CSS selector to fill if uid is omitted." })),
|
|
1480
1563
|
submit: Type.Optional(Type.Boolean({ description: "If true, press Enter after filling." })),
|
|
1564
|
+
domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
|
|
1481
1565
|
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after filling." })),
|
|
1482
1566
|
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
|
1483
1567
|
targetId: Type.Optional(Type.String()),
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-chrome",
|
|
3
|
-
"version": "0.15.
|
|
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); });
|