aboutsummaryrefslogtreecommitdiff
path: root/test-suite/unit/bridge-resilience.test.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'test-suite/unit/bridge-resilience.test.mjs')
-rw-r--r--test-suite/unit/bridge-resilience.test.mjs138
1 files changed, 138 insertions, 0 deletions
diff --git a/test-suite/unit/bridge-resilience.test.mjs b/test-suite/unit/bridge-resilience.test.mjs
new file mode 100644
index 0000000..47495fc
--- /dev/null
+++ b/test-suite/unit/bridge-resilience.test.mjs
@@ -0,0 +1,138 @@
+// Bridge transport resilience in the shipped worker, plus same-process (subagent) loading in index.ts.
+// Worker timers are scaled 1000x down so the real 45s /next deadline elapses in ~45ms.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { stripTypeScriptTypes } from "node:module";
+import { test } from "node:test";
+
+const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
+const indexSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8");
+
+function loadWorker(fetchImpl) {
+ const listener = { addListener() {}, removeListener() {} };
+ const chrome = {
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.1" }), getURL: (f) => `chrome-extension://test/${f}`, onInstalled: listener, onStartup: listener, reload() {} },
+ alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener },
+ debugger: { onDetach: listener }, scripting: {}, tabs: { onUpdated: listener },
+ };
+ const sandbox = {
+ chrome, console, AbortController, URL,
+ navigator: { userAgent: "unit-test" },
+ setTimeout: (fn, ms, ...args) => setTimeout(fn, Math.ceil((ms || 0) / 1000), ...args),
+ clearTimeout, setInterval: () => 0, clearInterval() {},
+ fetch: fetchImpl,
+ };
+ sandbox.self = sandbox;
+ vm.runInNewContext(workerSource, sandbox);
+ return sandbox;
+}
+
+function response(status, body, headers = {}) {
+ return { ok: status >= 200 && status < 300, status, headers: { get: (name) => headers[name.toLowerCase()] ?? null }, json: async () => body };
+}
+
+test("a half-open /next long poll is aborted at its deadline and polling resumes", async () => {
+ const requests = [];
+ let aborted = 0;
+ const worker = loadWorker((url, options = {}) => {
+ requests.push({ url, options });
+ if (requests.length === 1) {
+ // Zombie socket: never settles unless aborted.
+ return new Promise((_resolve, reject) => {
+ options.signal.addEventListener("abort", () => {
+ aborted++;
+ const error = new Error("The operation was aborted");
+ error.name = "AbortError";
+ reject(error);
+ });
+ });
+ }
+ return Promise.reject(new Error("bridge down"));
+ });
+ const first = worker.pollLoop();
+ assert.ok(requests[0]?.options.signal, "the /next fetch carries an abort signal");
+ // Resolves only after the scaled 45s deadline fires and the 2s backoff elapses.
+ await first;
+ assert.equal(aborted, 1, "the stalled /next fetch was aborted");
+ await worker.pollLoop();
+ assert.equal(requests.length, 2, "a later poll opens a fresh /next request instead of staying parked");
+});
+
+test("a command result is posted exactly once, retried on transport failure, and not retried on 4xx", async () => {
+ const posts = [];
+ let postStatus = [500, 200];
+ const worker = loadWorker(async (url, options = {}) => {
+ if (url.includes("/result")) {
+ posts.push(JSON.parse(options.body));
+ const status = postStatus.shift() ?? 200;
+ if (status === 0) throw new Error("fetch failed");
+ return response(status, {});
+ }
+ throw new Error("not polling in this test");
+ });
+ await worker.handleCommand({ id: "cmd-1", action: "tab.version", params: {} });
+ assert.equal(posts.length, 2, "one retry after HTTP 500");
+ assert.ok(posts.every((p) => p.id === "cmd-1" && p.ok === true), "the retry re-sends the success payload, not an error payload");
+
+ posts.length = 0;
+ postStatus = [0, 0, 0];
+ await worker.handleCommand({ id: "cmd-2", action: "tab.version", params: {} });
+ assert.equal(posts.length, 3, "network failures retry up to the attempt limit");
+ assert.ok(posts.every((p) => p.ok === true), "a failed success-post never turns into a second, error result");
+
+ posts.length = 0;
+ postStatus = [404];
+ await worker.handleCommand({ id: "cmd-3", action: "no.such.action", params: {} });
+ assert.equal(posts.length, 1, "4xx (unknown command id) is final");
+ assert.equal(posts[0].ok, false);
+ assert.match(posts[0].error, /Unknown action/);
+});
+
+test("cdp.call widens only its own command deadline", () => {
+ const worker = loadWorker(async () => { throw new Error("offline"); });
+ assert.equal(worker.commandTimeoutMs("page.click", { timeoutMs: 90_000 }), 25_000);
+ assert.equal(worker.commandTimeoutMs("cdp.call", {}), 25_000);
+ assert.equal(worker.commandTimeoutMs("cdp.call", { timeoutMs: 60_000 }), 65_000);
+ assert.equal(worker.commandTimeoutMs("cdp.call", { timeoutMs: 10_000_000 }), 125_000, "capped below the MV3 worker lifetime");
+});
+
+// ---- index.ts: a subagent session loads pi-chrome again in the same process. ----
+function loadFactoryPrelude(globalState, root) {
+ const from = indexSource.indexOf("\tconst alreadyLoaded = globalState[PI_CHROME_GLOBAL_KEY];");
+ const to = indexSource.indexOf("\tconst bridge = new ChromeProfileBridge(", from);
+ assert.ok(from > 0 && to > from, "factory prelude section");
+ const body = stripTypeScriptTypes(`(() => {\n${indexSource.slice(from, to)}\nreturn true;\n})()`);
+ const warnings = [];
+ const token = Symbol(root);
+ const sandbox = {
+ globalState, currentRoot: root, instanceToken: token, PI_CHROME_GLOBAL_KEY: "loaded", PI_CHROME_VERSION: "9.9.9",
+ console: { warn: (msg) => warnings.push(msg) },
+ };
+ const loaded = vm.runInNewContext(body, sandbox);
+ return { loaded: loaded === true, token, warnings };
+}
+
+test("a same-root second load (subagent session) is not skipped, and does not steal the singleton flag", () => {
+ const globalState = {};
+ const parent = loadFactoryPrelude(globalState, "/pkg/pi-chrome");
+ assert.equal(parent.loaded, true);
+ assert.equal(globalState.loaded.token, parent.token);
+ const subagent = loadFactoryPrelude(globalState, "/pkg/pi-chrome");
+ assert.equal(subagent.loaded, true, "subagent gets its own pi-chrome instance and chrome_* tools");
+ assert.equal(subagent.warnings.length, 0);
+ assert.equal(globalState.loaded.token, parent.token, "only the first instance owns (and later clears) the flag");
+});
+
+test("a second install root is still treated as a duplicate", () => {
+ const globalState = {};
+ loadFactoryPrelude(globalState, "/npm/pi-chrome");
+ const other = loadFactoryPrelude(globalState, "/checkout/pi-chrome");
+ assert.equal(other.loaded, false);
+ assert.match(other.warnings[0], /already loaded from \/npm\/pi-chrome/);
+});
+
+test("a stale same-root flag from an older release does not block loading", () => {
+ const globalState = { loaded: { version: "0.15.19", root: "/pkg/pi-chrome" } };
+ assert.equal(loadFactoryPrelude(globalState, "/pkg/pi-chrome").loaded, true);
+});