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
184
185
186
187
188
189
190
191
192
|
// Unit harness for the CSP-bypass layer in service_worker.js.
//
// The real CSP bypass (CDP Runtime.evaluate not being subject to page CSP) can only be
// proven in a browser — see challenge 39-strict-csp-fallback. These tests instead validate
// the JS *logic* of the refactor that the bypass depends on:
// - evaluateInTab: wrapper-string construction, expression/statement fallback, value
// marker round-trip (undefined/function/symbol/bigint/Error/DOMRect), error propagation.
// - executeInTab: 2-phase define-then-invoke, envelope unwrap, error propagation, and that
// all real HELPER_FUNCS serialize+assign without a parse error.
// - page.waitFor: service-worker-side polling via evaluateInTab (selector + expression).
//
// We load the worker into a vm sandbox with mocked chrome.* APIs, then replace `cdp` with a
// shim that evaluates the expression in a separate "page world" vm context (simulating CDP
// Runtime.evaluate returnByValue). No browser, no network, no deps.
import vm from "node:vm";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
const src = fs.readFileSync(workerPath, "utf8");
let failures = 0;
let passes = 0;
function ok(cond, msg) {
if (cond) { passes++; }
else { failures++; console.error(` ✗ ${msg}`); }
}
async function throwsWith(fn, re, msg) {
try { await fn(); ok(false, `${msg} (expected throw)`); }
catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
}
// ---- page world: simulates the page's MAIN world for Runtime.evaluate ----
const pageGlobals = {
console, JSON, Date, Math, Promise, Object, Array, String, Number, Boolean,
Error, TypeError, SyntaxError, RangeError, BigInt, Symbol, structuredClone,
setTimeout, parseInt, parseFloat, isNaN,
document: {
title: "page title",
_present: new Set(),
querySelector(sel) { return this._present.has(sel) ? { sel } : null; },
},
};
pageGlobals.window = pageGlobals;
pageGlobals.globalThis = pageGlobals;
const pageWorld = vm.createContext(pageGlobals);
// Simulate CDP Runtime.evaluate returnByValue serialization.
function toCdpResult(v) {
if (v === undefined) return { result: { type: "undefined" } };
if (v === null) return { result: { type: "object", subtype: "null", value: null } };
const t = typeof v;
if (t === "number" || t === "string" || t === "boolean")
return { result: { type: t, value: v } };
// object/array: returnByValue deep-clones JSON-able structures
return { result: { type: "object", value: JSON.parse(JSON.stringify(v)) } };
}
// ---- worker sandbox ----
const noop = () => {};
const listener = { addListener: noop, removeListener: noop };
const sandbox = {
console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
setTimeout, clearTimeout,
setInterval: () => 0,
clearInterval: noop,
fetch: async () => { throw new Error("no network in unit test"); },
navigator: { userAgent: "unit-test" },
WebSocket: function () {},
chrome: {
runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
action: { onClicked: listener },
debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]) },
scripting: { executeScript: async () => [{ result: undefined }] },
tabs: { query: async () => [], get: async () => ({}), create: async () => ({}), update: async () => ({}), remove: async () => {} },
windows: { update: async () => {} },
webNavigation: { onCommitted: listener },
},
};
sandbox.globalThis = sandbox;
sandbox.self = sandbox;
vm.createContext(sandbox);
vm.runInContext(src, sandbox);
// ---- override the page-touching primitives with the page-world shim ----
sandbox.attachDebugger = async () => ({});
sandbox.bringToFront = async () => {};
sandbox.getTabByParams = async (p) => ({ id: (p && p.targetId) || 1, windowId: 1 });
sandbox.cdp = async (_tabId, method, params) => {
if (method !== "Runtime.evaluate") return {};
try {
const value = await vm.runInContext(params.expression, pageWorld);
return toCdpResult(value);
} catch (e) {
return { exceptionDetails: { exception: { className: e.name, description: String(e.stack || e.message) }, text: "Uncaught " + String(e) } };
}
};
// Phase-2 of executeInTab: run the injected wrapper func against the page world,
// where Phase-1 (via cdp shim above) already defined window.__piAction + helpers.
sandbox.chrome.scripting.executeScript = async ({ func, args }) => {
const fn = vm.runInContext("(" + func.toString() + ")", pageWorld);
const result = await fn(...(args || []));
return [{ result }];
};
const { evaluateInTab, executeInTab, dispatch } = sandbox;
async function run() {
// ===== evaluateInTab: primitives & objects =====
ok((await evaluateInTab({ expression: "2 + 2" })) === 4, "evaluate: arithmetic expression");
ok((await evaluateInTab({ expression: "document.title" })) === "page title", "evaluate: expression without return");
ok((await evaluateInTab({ expression: "'a' + 'b'" })) === "ab", "evaluate: string concat");
const obj = await evaluateInTab({ expression: "({a:1, b:[2,3]})" });
ok(obj && obj.a === 1 && obj.b[1] === 3, "evaluate: object literal round-trips");
// ===== value markers =====
ok((await evaluateInTab({ expression: "void 0" })) === undefined, "evaluate: undefined marker -> undefined");
ok((await evaluateInTab({ expression: "10n" })) === "10", "evaluate: bigint marker -> string");
ok(/^\[Function:/.test(await evaluateInTab({ expression: "(function foo(){})" })), "evaluate: function marker");
ok((await evaluateInTab({ expression: "Promise.resolve(42)" })) === 42, "evaluate: promise is awaited");
// DOMRect-like (toJSON + width/height/top) is expanded, not flattened to {}
const rect = await evaluateInTab({ expression: "({ x:1,y:2,width:3,height:4,top:2,right:4,bottom:6,left:1, toJSON(){return {}} })" });
ok(rect && rect.width === 3 && rect.bottom === 6, "evaluate: DOMRect-like expanded");
// ===== statement-form fallback (expression form is a SyntaxError) =====
// `let x=...; x` is not a valid expression, so the wrapper must retry as a statement body.
ok((await evaluateInTab({ expression: "let x = 5; x" })) === undefined, "evaluate: statement form falls back (no return -> undefined)");
ok((await evaluateInTab({ expression: "let y = 7; return y" })) === 7, "evaluate: statement form with explicit return");
// ===== error propagation =====
await throwsWith(() => evaluateInTab({ expression: "throw new Error('boom')" }), /chrome_evaluate failed[\s\S]*boom/, "evaluate: runtime error propagates");
// ===== executeInTab: 2-phase define + invoke =====
// Real HELPER_FUNCS get serialized + assigned in Phase 1; a parse error there would throw here.
const sum = await executeInTab({ targetId: 1 }, function add(a, b) { return a + b; }, [3, 4]);
ok(sum === 7, "executeInTab: action runs with args after helper injection");
const asyncResult = await executeInTab({ targetId: 1 }, async function asyncEcho(v) { return v * 2; }, [21]);
ok(asyncResult === 42, "executeInTab: async action awaited");
await throwsWith(
() => executeInTab({ targetId: 1 }, function boom() { throw new Error("action failed"); }, []),
/action failed/,
"executeInTab: thrown action error propagates via envelope",
);
// ===== page.waitFor (service-worker-side polling) =====
pageGlobals.document._present.add("#ready");
const wf = await dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#ready", timeoutMs: 1000, intervalMs: 20 });
ok(wf && typeof wf.elapsedMs === "number", "waitFor: selector present resolves");
const wfExpr = await dispatch("page.waitFor", { targetId: 1, kind: "expression", value: "1 === 1", timeoutMs: 1000, intervalMs: 20 });
ok(wfExpr && typeof wfExpr.elapsedMs === "number", "waitFor: truthy expression resolves");
await throwsWith(
() => dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#never", timeoutMs: 120, intervalMs: 30 }),
/Timed out after 120ms/,
"waitFor: missing selector times out",
);
// ===== usKeyLayoutForChar / cdpKeyInfo: US-layout key codes =====
// Regression: punctuation must NOT use charCodeAt() (".":46 collides with VK_DELETE,
// "-":45 with VK_INSERT), which made apps drop the char on keydown.
const { usKeyLayoutForChar, cdpKeyInfo } = sandbox;
const period = usKeyLayoutForChar(".");
ok(period.code === "Period" && period.keyCode === 190 && !period.needShift, "keylayout: '.' -> Period/190 (not 46)");
const dash = usKeyLayoutForChar("-");
ok(dash.code === "Minus" && dash.keyCode === 189, "keylayout: '-' -> Minus/189 (not 45)");
const slash = usKeyLayoutForChar("/");
ok(slash.code === "Slash" && slash.keyCode === 191, "keylayout: '/' -> Slash/191");
const at = usKeyLayoutForChar("@");
ok(at.code === "Digit2" && at.keyCode === 50 && at.needShift, "keylayout: '@' -> Digit2/50 + shift");
const A = usKeyLayoutForChar("A");
ok(A.code === "KeyA" && A.keyCode === 65 && A.needShift, "keylayout: 'A' -> KeyA/65 + shift");
const a = usKeyLayoutForChar("a");
ok(a.code === "KeyA" && a.keyCode === 65 && !a.needShift, "keylayout: 'a' -> KeyA/65 no shift");
const dot = cdpKeyInfo(".");
ok(dot.code === "Period" && dot.windowsVirtualKeyCode === 190 && dot.text === ".", "cdpKeyInfo: '.' -> Period/190 with text");
const ent = cdpKeyInfo("Enter");
ok(ent.code === "Enter" && ent.windowsVirtualKeyCode === 13, "cdpKeyInfo: named key 'Enter' unaffected");
console.log(`\n${passes} passed, ${failures} failed`);
if (failures) process.exit(1);
}
run().catch((e) => { console.error(e); process.exit(1); });
|