aboutsummaryrefslogtreecommitdiff
path: root/test-suite/unit/cdp-passthrough.test.mjs
blob: c14c4d7242f7d98cd59888fbaf5d8fb6467dc4d6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
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 } });
});