mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat(chrome): hand snapshots to context mode
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
// Unit harness for pi-chrome's dedicated automation tab/window isolation in service_worker.js.
|
||||
//
|
||||
// Feature under test: pi-chrome must never navigate or replace the user's active tab. Page and
|
||||
// navigation actions without an explicit target are routed to a dedicated automation target that
|
||||
// the *calling Pi session* created and owns. Ownership is session-scoped (one extension brokers
|
||||
// every session) and mirrored to chrome.storage.session so a service-worker restart re-hydrates
|
||||
// it instead of orphaning the window. Cleanup closes only the calling session's owned target.
|
||||
//
|
||||
// Like csp-eval.test.mjs we load the *real* worker into a vm sandbox with a stateful chrome.*
|
||||
// mock, then exercise the real helpers and the real dispatch() paths. Chrome state (tabs/windows/
|
||||
// storage.session) can be shared across two sandbox loads to simulate a service-worker restart.
|
||||
|
||||
import vm from "node:vm";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
|
||||
const src = fs.readFileSync(workerPath, "utf8");
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
function ok(cond, msg) {
|
||||
if (cond) { passes++; }
|
||||
else { failures++; console.error(` ✗ ${msg}`); }
|
||||
}
|
||||
async function throwsWith(fn, re, msg) {
|
||||
try { await fn(); ok(false, `${msg} (expected throw)`); }
|
||||
catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
|
||||
}
|
||||
|
||||
// ---- stateful Chrome mock. `state` (tabs/windows/storage) can be shared to simulate a
|
||||
// service-worker restart: the browser keeps its tabs/windows/session-storage, the worker memory
|
||||
// is wiped (a fresh sandbox).
|
||||
function makeChromeState() {
|
||||
const tabs = new Map(); // id -> { id, windowId, url, active, groupId }
|
||||
const windows = new Map(); // id -> { id }
|
||||
const groups = new Map(); // groupId -> { id, title, color, collapsed, windowId }
|
||||
const storage = {}; // chrome.storage.session backing
|
||||
let nextTabId = 1;
|
||||
let nextWindowId = 1;
|
||||
let nextGroupId = 1;
|
||||
const alloc = { tab: () => nextTabId++, window: () => nextWindowId++, group: () => nextGroupId++ };
|
||||
|
||||
// Seed a user window with two real user tabs (Gmail + a research article, the active one).
|
||||
const userWindowId = alloc.window();
|
||||
windows.set(userWindowId, { id: userWindowId });
|
||||
const userGmail = { id: alloc.tab(), windowId: userWindowId, url: "https://mail.google.com/", active: false, groupId: -1 };
|
||||
const userArticle = { id: alloc.tab(), windowId: userWindowId, url: "https://example.com/research-article", active: true, groupId: -1 };
|
||||
tabs.set(userGmail.id, userGmail);
|
||||
tabs.set(userArticle.id, userArticle);
|
||||
|
||||
return { tabs, windows, groups, storage, alloc, userWindowId, userGmail, userArticle };
|
||||
}
|
||||
|
||||
function makeChrome(state, { withWindows = true, withStorage = true, withTabGroups = false } = {}) {
|
||||
const { tabs, windows, groups, storage, alloc, userWindowId } = state;
|
||||
const noop = () => {};
|
||||
const listener = { addListener: noop, removeListener: noop };
|
||||
|
||||
const chrome = {
|
||||
runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
|
||||
alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
|
||||
action: { onClicked: listener },
|
||||
debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]), onDetach: listener },
|
||||
scripting: { executeScript: async () => [{ result: undefined }], registerContentScripts: async () => {}, unregisterContentScripts: async () => {} },
|
||||
webNavigation: { onCommitted: listener },
|
||||
tabs: {
|
||||
onUpdated: listener,
|
||||
query: async (q = {}) => {
|
||||
let list = [...tabs.values()];
|
||||
if (q.active === true) list = list.filter((t) => t.active);
|
||||
if (typeof q.windowId === "number") list = list.filter((t) => t.windowId === q.windowId);
|
||||
return list.map((t) => ({ ...t }));
|
||||
},
|
||||
get: async (id) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); return { ...t }; },
|
||||
create: async ({ url = "about:blank", active = false, windowId = userWindowId } = {}) => {
|
||||
const tab = { id: alloc.tab(), windowId, url, active, groupId: -1 };
|
||||
tabs.set(tab.id, tab);
|
||||
return { ...tab };
|
||||
},
|
||||
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 }; },
|
||||
remove: async (id) => { tabs.delete(id); },
|
||||
group: async ({ groupId, tabIds = [] } = {}) => {
|
||||
let gid = groupId;
|
||||
if (typeof gid !== "number") {
|
||||
gid = alloc.group();
|
||||
const firstTab = tabs.get(tabIds[0]);
|
||||
groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId: firstTab ? firstTab.windowId : userWindowId });
|
||||
}
|
||||
for (const tid of tabIds) { const t = tabs.get(tid); if (t) t.groupId = gid; }
|
||||
return gid;
|
||||
},
|
||||
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; } },
|
||||
},
|
||||
storage: withStorage ? {
|
||||
session: {
|
||||
get: async (key) => (key in storage ? { [key]: storage[key] } : {}),
|
||||
set: async (obj) => { Object.assign(storage, obj); },
|
||||
},
|
||||
} : undefined,
|
||||
};
|
||||
|
||||
if (withTabGroups) {
|
||||
chrome.tabGroups = {
|
||||
query: async ({ windowId } = {}) => [...groups.values()].filter((g) => windowId === undefined || g.windowId === windowId).map((g) => ({ ...g })),
|
||||
get: async (id) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); return { ...g }; },
|
||||
update: async (id, props = {}) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); Object.assign(g, props); return { ...g }; },
|
||||
};
|
||||
}
|
||||
|
||||
if (withWindows) {
|
||||
chrome.windows = {
|
||||
create: async ({ url = "about:blank", focused = false } = {}) => {
|
||||
const id = alloc.window();
|
||||
windows.set(id, { id });
|
||||
const tab = { id: alloc.tab(), windowId: id, url, active: true, groupId: -1 };
|
||||
tabs.set(tab.id, tab);
|
||||
return { id, focused, tabs: [{ ...tab }] };
|
||||
},
|
||||
get: async (id) => { const w = windows.get(id); if (!w) throw new Error(`No window with id ${id}`); return { ...w }; },
|
||||
remove: async (id) => { windows.delete(id); for (const [tid, t] of [...tabs]) if (t.windowId === id) tabs.delete(tid); },
|
||||
update: async () => {},
|
||||
};
|
||||
} else {
|
||||
chrome.windows = { update: async () => {} }; // no create/get/remove -> tab fallback path
|
||||
}
|
||||
|
||||
return chrome;
|
||||
}
|
||||
|
||||
function loadWorker(chrome) {
|
||||
const noop = () => {};
|
||||
const sandbox = {
|
||||
console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
|
||||
Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
|
||||
setTimeout, clearTimeout, setInterval: () => 0, clearInterval: noop,
|
||||
fetch: async () => { throw new Error("no network in unit test"); },
|
||||
navigator: { userAgent: "unit-test" },
|
||||
WebSocket: function () {},
|
||||
chrome,
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
const SK = "session:alpha"; // a representative sessionKey
|
||||
|
||||
async function run() {
|
||||
// ===== Isolation: navigation does not touch the user's active/other tabs. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state));
|
||||
const userActiveUrl = state.userArticle.url;
|
||||
|
||||
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/task", waitUntilLoad: false, sessionKey: SK });
|
||||
ok(state.userArticle.url === userActiveUrl, "navigate: active user tab (research article) is not overwritten");
|
||||
ok(state.userGmail.url === "https://mail.google.com/", "navigate: other user tab (Gmail) untouched");
|
||||
ok(nav.url === "https://pi.test/task", "navigate: automation target navigated to requested URL");
|
||||
ok(nav.id !== state.userArticle.id && nav.id !== state.userGmail.id, "navigate: did not reuse any user tab");
|
||||
ok(nav.windowId !== state.userWindowId, "navigate: automation target lives in a dedicated window");
|
||||
|
||||
const status = await w.dispatch("automation.status", { sessionKey: SK });
|
||||
ok(status.tabId === nav.id && status.windowId === nav.windowId, "ownership: target ids tracked for the session");
|
||||
ok(w.isPiChromeOwnedTarget(nav.id, SK) === true, "ownership: isPiChromeOwnedTarget(owned, session) === true");
|
||||
ok(w.isPiChromeOwnedTarget(state.userArticle.id) === false, "ownership: user tab is never owned (any session)");
|
||||
|
||||
// Reuse: a later navigation reuses the same owned target.
|
||||
const nav2 = await w.dispatch("page.navigate", { url: "https://pi.test/step-2", waitUntilLoad: false, sessionKey: SK });
|
||||
ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "reuse: second navigation reuses the same automation window/tab");
|
||||
ok(state.userArticle.url === userActiveUrl, "reuse: user tab still untouched after second navigation");
|
||||
|
||||
// Cleanup closes only the owned window; user tabs/windows survive.
|
||||
const cleanup = await w.dispatch("automation.cleanup", { sessionKey: SK });
|
||||
ok(cleanup.closedWindowId === nav.windowId, "cleanup: closed the owned window");
|
||||
ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "cleanup: user tabs never closed");
|
||||
ok(state.windows.has(state.userWindowId), "cleanup: user window never closed");
|
||||
ok(!state.tabs.has(nav.id), "cleanup: the owned automation tab is gone");
|
||||
const status2 = await w.dispatch("automation.status", { sessionKey: SK });
|
||||
ok(status2.tabId === null && status2.windowId === null, "cleanup: ownership cleared");
|
||||
}
|
||||
|
||||
// ===== Session-group integration: the dedicated-window tab joins this session's group. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state, { withTabGroups: true }));
|
||||
// index.ts tags page.* actions with joinSessionGroup + sessionGroupTitle; replicate that here.
|
||||
const groupTitle = "Pi Session: alpha";
|
||||
const nav = await w.dispatch("page.navigate", {
|
||||
url: "https://pi.test/grouped", waitUntilLoad: false,
|
||||
sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
|
||||
});
|
||||
const navTab = state.tabs.get(nav.id);
|
||||
ok(navTab.windowId !== state.userWindowId, "group: automation tab is in its dedicated window");
|
||||
ok(typeof navTab.groupId === "number" && navTab.groupId >= 0, "group: automation tab joined a tab group");
|
||||
const grp = state.groups.get(navTab.groupId);
|
||||
ok(grp && grp.title === groupTitle, "group: the group is titled with this session's title");
|
||||
ok(grp.windowId === navTab.windowId, "group: the session group lives inside the dedicated automation window (not the user window)");
|
||||
|
||||
// A second page action reuses the same tab and does not spawn a second group.
|
||||
const groupsBefore = state.groups.size;
|
||||
await w.dispatch("page.navigate", { url: "https://pi.test/grouped-2", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle });
|
||||
ok(state.groups.size === groupsBefore, "group: reusing the automation tab does not create a second group");
|
||||
}
|
||||
|
||||
// ===== tab.new joins the existing session group instead of creating one group per window. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state, { withTabGroups: true }));
|
||||
const groupTitle = "Pi Session: alpha";
|
||||
const nav = await w.dispatch("page.navigate", {
|
||||
url: "https://pi.test/group-owner", waitUntilLoad: false,
|
||||
sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
|
||||
});
|
||||
const navTab = state.tabs.get(nav.id);
|
||||
const groupId = navTab.groupId;
|
||||
const groupsBefore = state.groups.size;
|
||||
|
||||
const opened = await w.dispatch("tab.new", { url: "https://pi.test/new-tab", groupTitle, sessionKey: SK });
|
||||
ok(state.groups.size === groupsBefore, "tab.new-group: did not create another same-session group");
|
||||
ok(opened.tab.groupId === groupId, "tab.new-group: opened tab joined the existing session group");
|
||||
ok(opened.tab.windowId === nav.windowId, "tab.new-group: opened tab was created in the existing group's window");
|
||||
|
||||
const forced = await w.dispatch("tab.new", { url: "https://pi.test/no-opt-out", groupTitle, group: false, sessionKey: SK });
|
||||
ok(forced.tab.groupId === groupId, "tab.new-group: group:false is ignored; tab still joins the session group");
|
||||
ok(state.groups.size === groupsBefore, "tab.new-group: group:false does not create another group");
|
||||
|
||||
const blankTitle = await w.dispatch("tab.new", { url: "https://pi.test/blank-title", groupTitle: "", group: false, sessionKey: SK });
|
||||
ok(typeof blankTitle.tab.groupId === "number" && blankTitle.tab.groupId >= 0, "tab.new-group: groupTitle:'' still creates a grouped tab");
|
||||
ok(blankTitle.group.title === "Pi", "tab.new-group: blank groupTitle falls back to a group instead of opting out");
|
||||
|
||||
const nav2 = await w.dispatch("page.navigate", {
|
||||
url: "https://pi.test/new-automation-target", waitUntilLoad: false,
|
||||
sessionKey: "session:beta", joinSessionGroup: true, sessionGroupTitle: groupTitle,
|
||||
});
|
||||
ok(state.groups.size === groupsBefore + 1, "automation-target-group: reused the existing session group, only blank-title Pi group was extra");
|
||||
ok(nav2.groupId === groupId, "automation-target-group: new automation target joined the existing session group");
|
||||
ok(nav2.windowId === nav.windowId, "automation-target-group: new automation target was created in the existing group's window");
|
||||
}
|
||||
|
||||
// ===== tab.new never leaves an ungrouped tab behind when grouping fails. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const chrome = makeChrome(state, { withTabGroups: true });
|
||||
const w = loadWorker(chrome);
|
||||
const tabsBefore = state.tabs.size;
|
||||
chrome.tabs.group = async () => { throw new Error("group blew up"); };
|
||||
|
||||
await throwsWith(
|
||||
() => w.dispatch("tab.new", { url: "https://pi.test/group-fail", groupTitle: "Pi Session: alpha", sessionKey: SK }),
|
||||
/group blew up/,
|
||||
"tab.new-group-fail: surfaces grouping error",
|
||||
);
|
||||
ok(state.tabs.size === tabsBefore, "tab.new-group-fail: closes the created tab instead of leaving it ungrouped");
|
||||
}
|
||||
|
||||
// ===== Grouping is best-effort: a tabGroups failure must not break navigation. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const chrome = makeChrome(state, { withTabGroups: true });
|
||||
chrome.tabs.group = async () => { throw new Error("group blew up"); };
|
||||
const w = loadWorker(chrome);
|
||||
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/group-fail", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: "Pi Session: alpha" });
|
||||
ok(nav.url === "https://pi.test/group-fail", "group-fail: navigation still succeeds when grouping throws");
|
||||
ok(state.tabs.get(nav.id).windowId !== state.userWindowId, "group-fail: still used the dedicated automation window");
|
||||
}
|
||||
|
||||
// ===== Concurrency: two sessions get separate windows; cleanup is per-session. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state));
|
||||
const a = await w.dispatch("page.navigate", { url: "https://pi.test/a", waitUntilLoad: false, sessionKey: "session:A" });
|
||||
const b = await w.dispatch("page.navigate", { url: "https://pi.test/b", waitUntilLoad: false, sessionKey: "session:B" });
|
||||
ok(a.id !== b.id && a.windowId !== b.windowId, "concurrency: each session gets its own dedicated window/tab");
|
||||
ok(w.isPiChromeOwnedTarget(a.id, "session:A") && !w.isPiChromeOwnedTarget(a.id, "session:B"), "concurrency: ownership is scoped to the creating session");
|
||||
|
||||
// Cleaning up session A must not touch session B's target.
|
||||
await w.dispatch("automation.cleanup", { sessionKey: "session:A" });
|
||||
ok(!state.tabs.has(a.id), "concurrency: cleanup closed session A's tab");
|
||||
ok(state.tabs.has(b.id), "concurrency: cleanup left session B's tab open");
|
||||
const bStatus = await w.dispatch("automation.status", { sessionKey: "session:B" });
|
||||
ok(bStatus.tabId === b.id, "concurrency: session B still owns its target after A cleanup");
|
||||
}
|
||||
|
||||
// ===== Service-worker restart / reconnect: persisted ownership re-hydrates from storage. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w1 = loadWorker(makeChrome(state));
|
||||
const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/persist", waitUntilLoad: false, sessionKey: SK });
|
||||
ok(typeof state.storage.piChromeAutomationTargets === "object", "restart: ownership was persisted to storage.session");
|
||||
|
||||
// Simulate the MV3 service worker being suspended and restarted: fresh sandbox (memory wiped),
|
||||
// same browser tabs/windows + same session storage.
|
||||
const w2 = loadWorker(makeChrome(state));
|
||||
const statusAfterRestart = await w2.dispatch("automation.status", { sessionKey: SK });
|
||||
ok(statusAfterRestart.tabId === nav.id && statusAfterRestart.windowId === nav.windowId, "restart: re-hydrated the owned target from storage");
|
||||
|
||||
// A navigation after restart must REUSE the existing window, not orphan it with a new one.
|
||||
const windowsBefore = state.windows.size;
|
||||
const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/persist-2", waitUntilLoad: false, sessionKey: SK });
|
||||
ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "restart: navigation after restart reuses the persisted window (no orphan)");
|
||||
ok(state.windows.size === windowsBefore, "restart: no new window created after restart");
|
||||
|
||||
// Cleanup after restart works and clears persisted state.
|
||||
await w2.dispatch("automation.cleanup", { sessionKey: SK });
|
||||
const persisted = state.storage.piChromeAutomationTargets || {};
|
||||
ok(!(SK in persisted), "restart: cleanup removed the session from persisted storage");
|
||||
}
|
||||
|
||||
// ===== Restart after the user manually closed the window: no orphan, fresh target. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w1 = loadWorker(makeChrome(state));
|
||||
const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/closed", waitUntilLoad: false, sessionKey: SK });
|
||||
await state.windows.delete(nav.windowId); // user closed pi-chrome's window
|
||||
for (const [tid, t] of [...state.tabs]) if (t.windowId === nav.windowId) state.tabs.delete(tid);
|
||||
|
||||
const w2 = loadWorker(makeChrome(state)); // SW restart
|
||||
const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/reopened", waitUntilLoad: false, sessionKey: SK });
|
||||
ok(nav2.id !== nav.id, "restart-after-close: a fresh automation target is created when the persisted one is gone");
|
||||
ok(state.tabs.has(nav2.id), "restart-after-close: new target exists");
|
||||
}
|
||||
|
||||
// ===== tab.* management never auto-creates / never falls back to the user's active tab. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state));
|
||||
const windowsBefore = state.windows.size;
|
||||
const tabsBefore = state.tabs.size;
|
||||
|
||||
await throwsWith(
|
||||
() => w.dispatch("tab.close", { sessionKey: SK }),
|
||||
/no automation tab yet|Pass targetId/,
|
||||
"tab.close: with no target and no owned target, errors instead of closing the user's active tab",
|
||||
);
|
||||
ok(state.tabs.has(state.userArticle.id), "tab.close: user's active tab was NOT closed");
|
||||
ok(state.windows.size === windowsBefore && state.tabs.size === tabsBefore, "tab.close: did not spawn a throwaway tab/window");
|
||||
|
||||
await throwsWith(() => w.dispatch("tab.activate", { sessionKey: SK }), /no automation tab yet|Pass targetId/, "tab.activate: errors with no target/owned target");
|
||||
|
||||
// Once an automation target exists, management actions operate on it (not on the user tab).
|
||||
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/manage", waitUntilLoad: false, sessionKey: SK });
|
||||
const closed = await w.dispatch("tab.close", { sessionKey: SK });
|
||||
ok(closed.closed === nav.id, "tab.close: with an owned target, closes that target");
|
||||
ok(state.tabs.has(state.userArticle.id), "tab.close: user tab still safe after closing the owned target");
|
||||
}
|
||||
|
||||
// ===== Explicit targeting still works on any existing tab (no regression). =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state));
|
||||
const nav = await w.dispatch("page.navigate", { url: "https://pi.test/explicit", targetId: String(state.userGmail.id), waitUntilLoad: false, sessionKey: SK });
|
||||
ok(nav.id === state.userGmail.id, "explicit: targetId routes to the requested existing tab");
|
||||
ok(state.userGmail.url === "https://pi.test/explicit", "explicit: explicitly targeted tab is navigated");
|
||||
const status = await w.dispatch("automation.status", { sessionKey: SK });
|
||||
ok(status.tabId === null, "explicit: explicit targeting does not create/claim an automation target");
|
||||
}
|
||||
|
||||
// ===== Window-unavailable fallback: a dedicated TAB is used, and the user's window is safe. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state, { withWindows: false }));
|
||||
const target = await w.getOrCreateAutomationTarget(SK);
|
||||
ok(target.id !== state.userArticle.id && target.id !== state.userGmail.id, "fallback: created a dedicated tab, not a user tab");
|
||||
ok(w.isPiChromeOwnedTarget(target.id, SK) === true, "fallback: dedicated tab is owned");
|
||||
const cleanup = await w.cleanupAutomationTarget(SK);
|
||||
ok(cleanup.closedTabId === target.id && cleanup.closedWindowId === null, "fallback: cleanup closes only the owned tab (never the shared window)");
|
||||
ok(state.windows.has(state.userWindowId), "fallback: cleanup never closes the user/shared window");
|
||||
ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "fallback: cleanup leaves user tabs intact");
|
||||
}
|
||||
|
||||
// ===== Robust cleanup: no-op when nothing created, and when target already closed manually. =====
|
||||
{
|
||||
const state = makeChromeState();
|
||||
const w = loadWorker(makeChrome(state));
|
||||
const empty = await w.cleanupAutomationTarget(SK);
|
||||
ok(empty.closedWindowId === null && empty.closedTabId === null, "cleanup: no-op when nothing was ever created");
|
||||
|
||||
const t = await w.getOrCreateAutomationTarget(SK);
|
||||
// User closed pi-chrome's window manually (Chrome closes its tabs too).
|
||||
state.windows.delete(t.windowId);
|
||||
for (const [tid, tab] of [...state.tabs]) if (tab.windowId === t.windowId) state.tabs.delete(tid);
|
||||
const stale = await w.cleanupAutomationTarget(SK);
|
||||
ok(stale.closedWindowId === null && stale.closedTabId === null, "cleanup: robust when owned window was already closed");
|
||||
}
|
||||
|
||||
console.log(`\n${passes} passed, ${failures} failed`);
|
||||
if (failures) process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,192 @@
|
||||
// Unit harness for the CSP-bypass layer in service_worker.js.
|
||||
//
|
||||
// The real CSP bypass (CDP Runtime.evaluate not being subject to page CSP) can only be
|
||||
// proven in a browser — see challenge 39-strict-csp-fallback. These tests instead validate
|
||||
// the JS *logic* of the refactor that the bypass depends on:
|
||||
// - evaluateInTab: wrapper-string construction, expression/statement fallback, value
|
||||
// marker round-trip (undefined/function/symbol/bigint/Error/DOMRect), error propagation.
|
||||
// - executeInTab: 2-phase define-then-invoke, envelope unwrap, error propagation, and that
|
||||
// all real HELPER_FUNCS serialize+assign without a parse error.
|
||||
// - page.waitFor: service-worker-side polling via evaluateInTab (selector + expression).
|
||||
//
|
||||
// We load the worker into a vm sandbox with mocked chrome.* APIs, then replace `cdp` with a
|
||||
// shim that evaluates the expression in a separate "page world" vm context (simulating CDP
|
||||
// Runtime.evaluate returnByValue). No browser, no network, no deps.
|
||||
|
||||
import vm from "node:vm";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
|
||||
const src = fs.readFileSync(workerPath, "utf8");
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
function ok(cond, msg) {
|
||||
if (cond) { passes++; }
|
||||
else { failures++; console.error(` ✗ ${msg}`); }
|
||||
}
|
||||
async function throwsWith(fn, re, msg) {
|
||||
try { await fn(); ok(false, `${msg} (expected throw)`); }
|
||||
catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
|
||||
}
|
||||
|
||||
// ---- page world: simulates the page's MAIN world for Runtime.evaluate ----
|
||||
const pageGlobals = {
|
||||
console, JSON, Date, Math, Promise, Object, Array, String, Number, Boolean,
|
||||
Error, TypeError, SyntaxError, RangeError, BigInt, Symbol, structuredClone,
|
||||
setTimeout, parseInt, parseFloat, isNaN,
|
||||
document: {
|
||||
title: "page title",
|
||||
_present: new Set(),
|
||||
querySelector(sel) { return this._present.has(sel) ? { sel } : null; },
|
||||
},
|
||||
};
|
||||
pageGlobals.window = pageGlobals;
|
||||
pageGlobals.globalThis = pageGlobals;
|
||||
const pageWorld = vm.createContext(pageGlobals);
|
||||
|
||||
// Simulate CDP Runtime.evaluate returnByValue serialization.
|
||||
function toCdpResult(v) {
|
||||
if (v === undefined) return { result: { type: "undefined" } };
|
||||
if (v === null) return { result: { type: "object", subtype: "null", value: null } };
|
||||
const t = typeof v;
|
||||
if (t === "number" || t === "string" || t === "boolean")
|
||||
return { result: { type: t, value: v } };
|
||||
// object/array: returnByValue deep-clones JSON-able structures
|
||||
return { result: { type: "object", value: JSON.parse(JSON.stringify(v)) } };
|
||||
}
|
||||
|
||||
// ---- worker sandbox ----
|
||||
const noop = () => {};
|
||||
const listener = { addListener: noop, removeListener: noop };
|
||||
const sandbox = {
|
||||
console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
|
||||
Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
|
||||
setTimeout, clearTimeout,
|
||||
setInterval: () => 0,
|
||||
clearInterval: noop,
|
||||
fetch: async () => { throw new Error("no network in unit test"); },
|
||||
navigator: { userAgent: "unit-test" },
|
||||
WebSocket: function () {},
|
||||
chrome: {
|
||||
runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
|
||||
alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
|
||||
action: { onClicked: listener },
|
||||
debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]) },
|
||||
scripting: { executeScript: async () => [{ result: undefined }] },
|
||||
tabs: { query: async () => [], get: async () => ({}), create: async () => ({}), update: async () => ({}), remove: async () => {} },
|
||||
windows: { update: async () => {} },
|
||||
webNavigation: { onCommitted: listener },
|
||||
},
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox);
|
||||
|
||||
// ---- override the page-touching primitives with the page-world shim ----
|
||||
sandbox.attachDebugger = async () => ({});
|
||||
sandbox.bringToFront = async () => {};
|
||||
sandbox.getTabByParams = async (p) => ({ id: (p && p.targetId) || 1, windowId: 1 });
|
||||
sandbox.cdp = async (_tabId, method, params) => {
|
||||
if (method !== "Runtime.evaluate") return {};
|
||||
try {
|
||||
const value = await vm.runInContext(params.expression, pageWorld);
|
||||
return toCdpResult(value);
|
||||
} catch (e) {
|
||||
return { exceptionDetails: { exception: { className: e.name, description: String(e.stack || e.message) }, text: "Uncaught " + String(e) } };
|
||||
}
|
||||
};
|
||||
// Phase-2 of executeInTab: run the injected wrapper func against the page world,
|
||||
// where Phase-1 (via cdp shim above) already defined window.__piAction + helpers.
|
||||
sandbox.chrome.scripting.executeScript = async ({ func, args }) => {
|
||||
const fn = vm.runInContext("(" + func.toString() + ")", pageWorld);
|
||||
const result = await fn(...(args || []));
|
||||
return [{ result }];
|
||||
};
|
||||
|
||||
const { evaluateInTab, executeInTab, dispatch } = sandbox;
|
||||
|
||||
async function run() {
|
||||
// ===== evaluateInTab: primitives & objects =====
|
||||
ok((await evaluateInTab({ expression: "2 + 2" })) === 4, "evaluate: arithmetic expression");
|
||||
ok((await evaluateInTab({ expression: "document.title" })) === "page title", "evaluate: expression without return");
|
||||
ok((await evaluateInTab({ expression: "'a' + 'b'" })) === "ab", "evaluate: string concat");
|
||||
const obj = await evaluateInTab({ expression: "({a:1, b:[2,3]})" });
|
||||
ok(obj && obj.a === 1 && obj.b[1] === 3, "evaluate: object literal round-trips");
|
||||
|
||||
// ===== value markers =====
|
||||
ok((await evaluateInTab({ expression: "void 0" })) === undefined, "evaluate: undefined marker -> undefined");
|
||||
ok((await evaluateInTab({ expression: "10n" })) === "10", "evaluate: bigint marker -> string");
|
||||
ok(/^\[Function:/.test(await evaluateInTab({ expression: "(function foo(){})" })), "evaluate: function marker");
|
||||
ok((await evaluateInTab({ expression: "Promise.resolve(42)" })) === 42, "evaluate: promise is awaited");
|
||||
|
||||
// DOMRect-like (toJSON + width/height/top) is expanded, not flattened to {}
|
||||
const rect = await evaluateInTab({ expression: "({ x:1,y:2,width:3,height:4,top:2,right:4,bottom:6,left:1, toJSON(){return {}} })" });
|
||||
ok(rect && rect.width === 3 && rect.bottom === 6, "evaluate: DOMRect-like expanded");
|
||||
|
||||
// ===== statement-form fallback (expression form is a SyntaxError) =====
|
||||
// `let x=...; x` is not a valid expression, so the wrapper must retry as a statement body.
|
||||
ok((await evaluateInTab({ expression: "let x = 5; x" })) === undefined, "evaluate: statement form falls back (no return -> undefined)");
|
||||
ok((await evaluateInTab({ expression: "let y = 7; return y" })) === 7, "evaluate: statement form with explicit return");
|
||||
|
||||
// ===== error propagation =====
|
||||
await throwsWith(() => evaluateInTab({ expression: "throw new Error('boom')" }), /chrome_evaluate failed[\s\S]*boom/, "evaluate: runtime error propagates");
|
||||
|
||||
// ===== executeInTab: 2-phase define + invoke =====
|
||||
// Real HELPER_FUNCS get serialized + assigned in Phase 1; a parse error there would throw here.
|
||||
const sum = await executeInTab({ targetId: 1 }, function add(a, b) { return a + b; }, [3, 4]);
|
||||
ok(sum === 7, "executeInTab: action runs with args after helper injection");
|
||||
|
||||
const asyncResult = await executeInTab({ targetId: 1 }, async function asyncEcho(v) { return v * 2; }, [21]);
|
||||
ok(asyncResult === 42, "executeInTab: async action awaited");
|
||||
|
||||
await throwsWith(
|
||||
() => executeInTab({ targetId: 1 }, function boom() { throw new Error("action failed"); }, []),
|
||||
/action failed/,
|
||||
"executeInTab: thrown action error propagates via envelope",
|
||||
);
|
||||
|
||||
// ===== page.waitFor (service-worker-side polling) =====
|
||||
pageGlobals.document._present.add("#ready");
|
||||
const wf = await dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#ready", timeoutMs: 1000, intervalMs: 20 });
|
||||
ok(wf && typeof wf.elapsedMs === "number", "waitFor: selector present resolves");
|
||||
|
||||
const wfExpr = await dispatch("page.waitFor", { targetId: 1, kind: "expression", value: "1 === 1", timeoutMs: 1000, intervalMs: 20 });
|
||||
ok(wfExpr && typeof wfExpr.elapsedMs === "number", "waitFor: truthy expression resolves");
|
||||
|
||||
await throwsWith(
|
||||
() => dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#never", timeoutMs: 120, intervalMs: 30 }),
|
||||
/Timed out after 120ms/,
|
||||
"waitFor: missing selector times out",
|
||||
);
|
||||
|
||||
// ===== usKeyLayoutForChar / cdpKeyInfo: US-layout key codes =====
|
||||
// Regression: punctuation must NOT use charCodeAt() (".":46 collides with VK_DELETE,
|
||||
// "-":45 with VK_INSERT), which made apps drop the char on keydown.
|
||||
const { usKeyLayoutForChar, cdpKeyInfo } = sandbox;
|
||||
const period = usKeyLayoutForChar(".");
|
||||
ok(period.code === "Period" && period.keyCode === 190 && !period.needShift, "keylayout: '.' -> Period/190 (not 46)");
|
||||
const dash = usKeyLayoutForChar("-");
|
||||
ok(dash.code === "Minus" && dash.keyCode === 189, "keylayout: '-' -> Minus/189 (not 45)");
|
||||
const slash = usKeyLayoutForChar("/");
|
||||
ok(slash.code === "Slash" && slash.keyCode === 191, "keylayout: '/' -> Slash/191");
|
||||
const at = usKeyLayoutForChar("@");
|
||||
ok(at.code === "Digit2" && at.keyCode === 50 && at.needShift, "keylayout: '@' -> Digit2/50 + shift");
|
||||
const A = usKeyLayoutForChar("A");
|
||||
ok(A.code === "KeyA" && A.keyCode === 65 && A.needShift, "keylayout: 'A' -> KeyA/65 + shift");
|
||||
const a = usKeyLayoutForChar("a");
|
||||
ok(a.code === "KeyA" && a.keyCode === 65 && !a.needShift, "keylayout: 'a' -> KeyA/65 no shift");
|
||||
const dot = cdpKeyInfo(".");
|
||||
ok(dot.code === "Period" && dot.windowsVirtualKeyCode === 190 && dot.text === ".", "cdpKeyInfo: '.' -> Period/190 with text");
|
||||
const ent = cdpKeyInfo("Enter");
|
||||
ok(ent.code === "Enter" && ent.windowsVirtualKeyCode === 13, "cdpKeyInfo: named key 'Enter' unaffected");
|
||||
|
||||
console.log(`\n${passes} passed, ${failures} failed`);
|
||||
if (failures) process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user