diff options
Diffstat (limited to 'test-suite/unit/bridge-resilience.test.mjs')
| -rw-r--r-- | test-suite/unit/bridge-resilience.test.mjs | 138 |
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); +}); |
