aboutsummaryrefslogtreecommitdiff
path: root/test-suite/unit/console-capture.test.mjs
blob: ba0410bcf68db9750419d11f04295f366cb44dc8 (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
// Console capture must not trip anti-DevTools checks (issue #9) and must only touch Pi's tabs.
// Sites detect DevTools by logging an object whose getter/toString fires when something inspects
// it. Closed DevTools never does; our capture used to serialize every argument immediately.
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
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 snapshotSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js", import.meta.url), "utf8");

function loadWorker() {
  const navListeners = [];
  const injected = [];
  const noop = { addListener() {}, removeListener() {} };
  const chrome = {
    runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: noop, onStartup: noop },
    alarms: { create() {}, onAlarm: noop }, action: { onClicked: noop },
    webNavigation: { onCommitted: { addListener: (fn) => navListeners.push(fn) } },
    debugger: { onDetach: noop, onEvent: noop },
    storage: { session: { get: async () => ({}), set: async () => {} } },
    scripting: { executeScript: async (options) => { injected.push(options); return []; } },
    tabs: { get: async () => null },
  };
  const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } };
  w.self = w;
  vm.runInNewContext(workerSource, w);
  const commit = async (tabId, frameId = 0) => {
    for (const fn of navListeners) await fn({ tabId, frameId, url: "https://site.test/" });
  };
  return { w, injected, commit };
}

// A page world whose console records native calls and a DevTools trap object.
function pageWorld() {
  const nativeCalls = [];
  const trap = { tripped: 0 };
  const page = vm.createContext({
    console: Object.fromEntries(["debug", "log", "info", "warn", "error"].map((l) => [l, (...a) => nativeCalls.push([l, ...a])])),
    location: { href: "https://site.test/" },
    addEventListener() {},
    fetch: undefined, XMLHttpRequest: undefined,
  });
  page.window = page;
  page.trip = () => { trap.tripped++; };
  vm.runInContext(`
    var bait = { name: "bait" };
    Object.defineProperty(bait, "id", { enumerable: true, get() { trip(); return "x"; } });
    bait.toString = function toString() { trip(); return "bait"; };
    var trapError = new Error("trap");
    Object.defineProperty(trapError, "message", { get() { trip(); return "m"; } });
    var trapRegex = /a+/g;
    trapRegex.toString = function () { trip(); return "re"; };
    Object.defineProperty(trapRegex, "source", { get() { trip(); return "x"; } });
    var trapDate = new Date(0);
    trapDate.toString = function () { trip(); return "d"; };
  `, page);
  return { page, trap, nativeCalls };
}

const fnSource = (name) => {
  const start = workerSource.indexOf(`function ${name}(`);
  let depth = 0;
  for (let i = workerSource.indexOf("{", start); i < workerSource.length; i++) {
    if (workerSource[i] === "{") depth++;
    else if (workerSource[i] === "}" && --depth === 0) return workerSource.slice(start, i + 1);
  }
  throw new Error(`missing ${name}`);
};

for (const [label, install] of [
  ["early capture", (page) => vm.runInContext(`(${fnSource("installEarlyCapture")})()`, page)],
  ["post-hoc instrumentation", (page) => vm.runInContext(`${fnSource("getPiChromeState")}\n(${fnSource("installPiChromeInstrumentation")})()`, page)],
  ["snapshot_injected instrumentation", (page) => { vm.runInContext(snapshotSource, page); }],
]) {
  test(`${label}: logging an object does not read it until Pi lists messages`, () => {
    const { page, trap, nativeCalls } = pageWorld();
    install(page);
    if (label.startsWith("snapshot")) {
      // snapshot_injected installs lazily through its snapshot entry point; force the install.
      assert.equal(typeof page.__piChromeSnapshotPage, "function");
      try { page.__piChromeSnapshotPage({}); } catch {}
    }
    vm.runInContext("console.log('hello', bait); console.error(new Error('boom')); console.log(trapError, trapRegex, trapDate)", page);
    assert.equal(trap.tripped, 0, "no getter/toString fired while the page runs");
    assert.equal(nativeCalls.length, 3, "native console still receives the calls");
    const listed = vm.runInContext(`${fnSource("getPiChromeState")}\n${fnSource("installPiChromeInstrumentation")}\n(${fnSource("listConsoleMessages")})(false)`, page);
    assert.equal(trap.tripped, 0, "listing does not fire page getters or toString either");
    assert.equal(listed.count, 3);
    assert.equal(listed.messages[0].args[0], "hello");
    assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[0].args[1])), { name: "bait", id: "[getter]", toString: "[function toString]" });
    assert.equal(listed.messages[1].args[0].message, "boom");
    assert.match(listed.messages[1].args[0].stack, /boom/);
    const [err, re, date] = listed.messages[2].args;
    assert.equal(err.message, "[getter]");
    assert.equal(re, "/a+/g", "regex described through native getters");
    assert.equal(date, "1970-01-01T00:00:00.000Z");
  });
}

test("listing survives values that cannot be serialized", () => {
  const { page } = pageWorld();
  vm.runInContext(`(${fnSource("installEarlyCapture")})()`, page);
  vm.runInContext("const loop = { a: [1, 2n, undefined, Symbol('s')] }; loop.self = loop; console.log(loop); console.log(new Proxy({}, { ownKeys() { throw new Error('no'); } }), new Map([[1, 2]]), function named() {})", page);
  const listed = vm.runInContext(`${fnSource("getPiChromeState")}\n${fnSource("installPiChromeInstrumentation")}\n(${fnSource("listConsoleMessages")})(true)`, page);
  assert.equal(listed.count, 2);
  assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[0].args[0])), { a: [1, "2n", "undefined", "Symbol(s)"], self: "[circular]" });
  assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[1].args)), ["[unserializable]", "[Map(1)]", "[function named]"]);
  assert.equal(vm.runInContext("window.__PI_CHROME_STATE__.console.length", page), 0, "clear still empties the buffer");
});

test("early capture is injected only into tabs Pi owns or tracks", async () => {
  const h = loadWorker();
  await h.commit(10);
  assert.equal(h.injected.length, 0, "a user tab is never patched");
  vm.runInContext("automationTargets.set('s', { tabId: 11 }); sessionTabs.set('s', new Map([[12, { created: true }]]));", h.w);
  await h.commit(11);
  await h.commit(12);
  await h.commit(11, 3); // subframe
  assert.deepEqual(h.injected.map((o) => o.target.tabId), [11, 12]);
  assert.ok(h.injected.every((o) => o.func.name === "installEarlyCapture" && o.injectImmediately === true));
});