diff options
Diffstat (limited to 'test-suite/unit/cdp-passthrough.test.mjs')
| -rw-r--r-- | test-suite/unit/cdp-passthrough.test.mjs | 183 |
1 files changed, 183 insertions, 0 deletions
diff --git a/test-suite/unit/cdp-passthrough.test.mjs b/test-suite/unit/cdp-passthrough.test.mjs new file mode 100644 index 0000000..c14c4d7 --- /dev/null +++ b/test-suite/unit/cdp-passthrough.test.mjs @@ -0,0 +1,183 @@ +// Raw CDP passthrough (cdp.call / cdp.targets / chrome_cdp formatting) and the scripting→CDP fallback +// that keeps page actions working on about:blank automation tabs. +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"); +const ABOUT_BLANK_DENIED = 'Cannot access contents of url "about:blank". Extension manifest must request permission to access this host.'; + +function harness({ targets = [], respond } = {}) { + const calls = [], attached = [], hung = []; + const listener = { addListener() {}, removeListener() {} }; + const page = vm.createContext({ location: { href: "about:blank" } }); + page.window = page; + page.globalThis = page; + const chrome = { + runtime: { id: "test", getManifest: () => ({ version: "0.0.1" }), getURL: (f) => `chrome-extension://test/${f}`, onInstalled: listener, onStartup: listener, lastError: null }, + alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener }, + debugger: { + onDetach: listener, + getTargets: (cb) => cb(targets), + attach: async () => {}, + // Real Chrome fails in-flight commands with "Detached while handling command" on detach. + detach: async () => { + for (const cb of hung.splice(0)) { + chrome.runtime.lastError = { message: "Detached while handling command." }; + cb(undefined); + chrome.runtime.lastError = null; + } + }, + sendCommand: (debuggee, method, params, cb) => { + calls.push({ method, params: JSON.parse(JSON.stringify(params)) }); + const reply = respond ? respond(method, params) : undefined; + if (reply === "never") { hung.push(cb); return; } // simulate a hung CDP command + Promise.resolve(reply).then((value) => { + if (method === "Runtime.evaluate" && value === undefined) { + Promise.resolve().then(() => vm.runInContext(params.expression, page)).then( + (v) => { const json = JSON.stringify(v); cb({ result: json === undefined ? { type: typeof v } : { value: JSON.parse(json) } }); }, + (error) => cb({ exceptionDetails: { text: error.message, exception: { description: error.message } } }), + ); + return; + } + cb(value); + }); + }, + }, + scripting: { executeScript: async () => { throw new Error(ABOUT_BLANK_DENIED); } }, + tabs: { onUpdated: listener, get: async (id) => ({ id, windowId: 1, url: "about:blank", status: "complete" }) }, + }; + const sources = new Map(); + const worker = { + chrome, console, AbortController, URL, + navigator: { userAgent: "unit-test" }, + setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {}, + fetch: async (url) => { + const file = String(url).replace("chrome-extension://test/", ""); + if (!sources.has(file)) return { ok: false, status: 404, text: async () => "" }; + return { ok: true, status: 200, text: async () => sources.get(file) }; + }, + }; + worker.self = worker; + vm.runInNewContext(workerSource, worker); + worker.getTabByParams = async (params, opts = {}) => { + if (params.targetId === "missing") throw new Error("No Chrome tab with id 999"); + if (opts.createOwnedTarget === false && params.targetId === undefined) return null; + return { id: Number(params.targetId ?? 5), windowId: 1, url: "https://fixture.test/", title: "Fixture" }; + }; + worker.bringToFront = async (tab) => tab; + const realAttach = worker.attachDebugger; + worker.attachDebugger = async (tabId) => { attached.push(tabId); return realAttach(tabId); }; + return { worker, chrome, calls, attached, page, sources, cdpMethods: () => calls.map((c) => c.method) }; +} + +test("cdp.call validates method/params before touching the debugger", async () => { + const h = harness(); + await assert.rejects(h.worker.dispatch("cdp.call", { method: " " }), /non-empty string "method"/); + await assert.rejects(h.worker.dispatch("cdp.call", { method: "Runtime.evaluate", params: [1] }), /must be a plain object/); + await assert.rejects(h.worker.dispatch("cdp.call", { method: "Runtime.evaluate", params: "x" }), /must be a plain object/); + assert.equal(h.attached.length, 0); + assert.equal(h.calls.length, 0); +}); + +test("cdp.call sends the exact method and params to the resolved tab and returns the raw result", async () => { + const h = harness({ respond: (method) => method === "Network.getCookies" ? { cookies: [{ name: "sid" }] } : {} }); + const result = await h.worker.dispatch("cdp.call", { targetId: "7", method: " Network.getCookies ", params: { urls: ["https://fixture.test"] } }); + assert.deepEqual(JSON.parse(JSON.stringify(result)), { cookies: [{ name: "sid" }] }); + assert.deepEqual(h.attached, [7]); + assert.deepEqual(h.calls.at(-1), { method: "Network.getCookies", params: { urls: ["https://fixture.test"] } }); +}); + +test("background mode blocks focus-stealing raw CDP methods; foreground allows them", async () => { + const h = harness(); + for (const method of ["Page.bringToFront", "Target.activateTarget"]) { + await assert.rejects(h.worker.dispatch("cdp.call", { method, background: true, foreground: false }), /blocked by background mode/); + } + assert.equal(h.calls.length, 0); + await h.worker.dispatch("cdp.call", { method: "Page.bringToFront", background: false, foreground: true }); + assert.equal(h.cdpMethods().at(-1), "Page.bringToFront"); +}); + +test("cdp.call honours timeoutMs and detaches a hung session", async () => { + const h = harness({ respond: (method) => method === "Debugger.pause" ? "never" : undefined }); + const started = Date.now(); + await assert.rejects(h.worker.dispatch("cdp.call", { method: "Debugger.pause", timeoutMs: 60 }), /CDP Debugger\.pause timed out after 60ms/); + assert.ok(Date.now() - started < 2_000, "custom deadline used instead of a longer default"); + assert.equal(h.cdpMethods().filter((m) => m === "Debugger.pause").length, 1, "a timed-out command is not re-sent after the cleanup detach"); + assert.equal(h.worker.cdpCallTimeoutMs({}), 5_000); + assert.equal(h.worker.cdpCallTimeoutMs({ timeoutMs: 999_999 }), 120_000); +}); + +test("cdp.targets lists only the resolved tab's targets, never creates a target, and surfaces explicit misses", async () => { + const h = harness({ targets: [ + { id: "a", tabId: 5, type: "page", url: "https://fixture.test/", attached: true }, + { id: "b", tabId: 5, type: "other", url: "chrome-extension://pw/overlay.html", attached: false, extensionId: "pw" }, + { id: "c", tabId: 9, type: "page", url: "https://private.example/secret", attached: false }, + ] }); + const listed = await h.worker.dispatch("cdp.targets", { targetId: "5" }); + assert.deepEqual(listed.targets.map((t) => t.id), ["a", "b"]); + assert.equal(listed.otherTabTargetCount, 1); + assert.ok(!JSON.stringify(listed).includes("private.example"), "other tabs' URLs are not echoed"); + const none = await h.worker.dispatch("cdp.targets", {}); + assert.equal(none.tab, null); + assert.equal(none.targets.length, 0); + await assert.rejects(h.worker.dispatch("cdp.targets", { targetId: "missing" }), /No Chrome tab with id/); + assert.equal(h.attached.length, 0, "diagnostics never attach the debugger"); +}); + +test("scripting denied on about:blank falls back to CDP for func and packaged-file injection", async () => { + const h = harness(); + h.sources.set("snapshot_injected.js", "globalThis.__piChromeSnapshotPage = async (max, _c, _r, _n, mode) => ({ url: location.href, max, mode });"); + const snapshot = await h.worker.dispatch("page.snapshot", { targetId: "5", maxElements: 12, mode: "forms" }); + assert.deepEqual(JSON.parse(JSON.stringify(snapshot)), { url: "about:blank", max: 12, mode: "forms" }); + assert.ok(h.cdpMethods().filter((m) => m === "Runtime.evaluate").length >= 2, "file and func both ran through Runtime.evaluate"); + + const clicked = await h.worker.resolveTargetInTab(5, { x: 10, y: 20 }); + assert.equal(clicked.found, true, "input target resolution also survives denied scripting"); +}); + +test("non-permission scripting failures are not masked by the CDP fallback", async () => { + const h = harness(); + h.chrome.scripting.executeScript = async () => { throw new Error("Frame with ID 0 was removed."); }; + await assert.rejects(h.worker.executeScriptWithFallback({ target: { tabId: 5 }, func: () => 1 }, "x"), /Frame with ID 0 was removed/); + assert.equal(h.calls.length, 0); + assert.equal(h.worker.isScriptingAccessDenied(new Error(ABOUT_BLANK_DENIED)), true); +}); + +test("CDP fallback reports page exceptions with the call-site label", async () => { + const h = harness(); + await assert.rejects( + h.worker.executeScriptWithFallback({ target: { tabId: 5 }, func: () => { throw new Error("boom in page"); }, args: [] }, "probe page"), + /probe page: .*boom in page/, + ); +}); + +// ---- Pi-side formatting for chrome_cdp results ---- +function section(start, end) { + const from = indexSource.indexOf(start); + const to = indexSource.indexOf(end, from); + assert.ok(from >= 0 && to > from, `Missing source section: ${start}`); + return indexSource.slice(from, to); +} +const formatCdpResult = vm.runInNewContext(`${stripTypeScriptTypes(section("const CDP_OVERSIZE_JSON_CHARS", "\nfunction extensionRoot"))}; formatCdpResult`, { + safeJson: (v) => JSON.stringify(v, null, 2), + truncateText: (s) => (s.length > 30_000 ? `${s.slice(0, 30_000)}[truncated]` : s), +}); + +test("chrome_cdp never returns screenshot/binary payloads or oversized details verbatim", () => { + const data = "A".repeat(4000) + "=="; + const shot = formatCdpResult("Page.captureScreenshot", { data }); + assert.ok(!shot.content[0].text.includes("AAAA")); + assert.match(shot.content[0].text, /~2999 bytes/); + assert.equal(shot.details.value.omitted, "data-field"); + + const huge = formatCdpResult("DOM.getOuterHTML", { outerHTML: "x".repeat(300_000) }); + assert.equal(huge.details.value.omitted, "oversized-result"); + assert.ok(huge.content[0].text.length < 31_000); + + const small = formatCdpResult("Runtime.evaluate", { result: { type: "number", value: 2 } }); + assert.deepEqual(small.details.value, { result: { type: "number", value: 2 } }); +}); |
