aboutsummaryrefslogtreecommitdiffstats
path: root/tools/puppeteer.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-07 12:02:51 -0700
committerAdam Soltys <[email protected]>2026-10-07 12:02:51 -0700
commit86d9818abfc160dac031bb292abe28d6f28fc624 (patch)
treea96983d60fb0f4dcb92a04c7aba50761da05cf9f /tools/puppeteer.js
parentb415b1fb31bd734619e1da3c88ca7e3822151c87 (diff)
downloadcoinosv3-86d9818abfc160dac031bb292abe28d6f28fc624.tar.gz
coinosv3-86d9818abfc160dac031bb292abe28d6f28fc624.zip
Browser tests: public relays answered in the page, never reached
A suite run let ~90 throwaway test wallets subscribe and publish on public relays from one home address, and nos.lol and relay.damus.io banned it. tools/puppeteer.js wraps puppeteer-core: every socket to a non-local host gets an in-page stand-in relay (EVENT -> OK, REQ -> EOSE). Tests that read real content launch with { relays: 'read' } (real reads, publishes answered in place); feed-live, chat-layout and cordn-ui launch with { relays: 'live' }. LIVE_RELAYS=read|1|host,... overrides per run. profile-key-qr: measure the QR again if a repaint swapped it out. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'tools/puppeteer.js')
-rw-r--r--tools/puppeteer.js126
1 files changed, 126 insertions, 0 deletions
diff --git a/tools/puppeteer.js b/tools/puppeteer.js
new file mode 100644
index 0000000..4e0f121
--- /dev/null
+++ b/tools/puppeteer.js
@@ -0,0 +1,126 @@
+// puppeteer-core for the browser tests, with one difference: a page's
+// sockets to public relays never leave the machine. Throwaway test wallets
+// boot, subscribe and publish like real ones, and a suite run from one home
+// address gets that address banned (nos.lol, relay.damus.io, 2026-10-07).
+// So every socket to a host that isn't local is answered in the page by a
+// stand-in relay that has nothing stored: EVENT gets OK, REQ gets EOSE,
+// COUNT gets 0. Local relays (ws://127.0.0.1:…, a test's own) are untouched,
+// and a test's own WebSocket stubs still see every send, as before.
+//
+// A test that reads real content off the public relays launches with
+// { relays: 'read' }: its sockets are real, but nothing it publishes leaves
+// the page (each EVENT is answered OK in place). LIVE_RELAYS=read does the
+// same for any test. { relays: 'live' } (a test of a public service, like
+// the Cordn coordinator) or LIVE_RELAYS=1 turns the guard off, and
+// LIVE_RELAYS=relay.example.com,other.example lets just those hosts through.
+// puppeteer.connect (driving a real Chrome) is never guarded.
+//
+// Use: import puppeteer from './puppeteer.js'; (in place of 'puppeteer-core')
+import real from 'puppeteer-core';
+
+const live = (process.env.LIVE_RELAYS || '').trim();
+const allow = live && live !== '1' && live !== 'read' ? live.split(',').map((h) => h.trim()).filter(Boolean) : [];
+
+// Runs in the page before anything else.
+function relayGuard(allow, readOnly) {
+ if (window.__relayGuard) return;
+ window.__relayGuard = { blocked: [], sent: 0, events: [] }; // events: what didn't go out
+ const Real = window.WebSocket;
+ const nativeSend = Real.prototype.send;
+ const nativeClose = Real.prototype.close;
+ const fakes = new WeakSet();
+ const reading = new WeakSet(); // real public sockets in read mode
+ const local = (h) => h === 'localhost' || h === '[::1]' || h === '0.0.0.0' || /^127\./.test(h) || h.endsWith('.localhost');
+ const answer = (ws, msg) => setTimeout(() => {
+ if (ws.readyState !== 1) return;
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(msg) }));
+ }, 5);
+ const relay = (ws, d) => {
+ let m;
+ try { m = JSON.parse(d); } catch { return; }
+ if (!Array.isArray(m)) return;
+ window.__relayGuard.sent++;
+ if (m[0] === 'EVENT' && m[1]?.id) { window.__relayGuard.events.push(m[1]); answer(ws, ['OK', m[1].id, true, '']); }
+ else if (m[0] === 'REQ') answer(ws, ['EOSE', m[1]]);
+ else if (m[0] === 'COUNT') answer(ws, ['COUNT', m[1], { count: 0 }]);
+ else if (m[0] === 'AUTH' && m[1]?.id) answer(ws, ['OK', m[1].id, true, '']);
+ };
+ // Every send goes through here, so a test that wraps
+ // WebSocket.prototype.send (before or after) still sees a stand-in's.
+ Real.prototype.send = function (d) {
+ if (fakes.has(this)) { if (this.readyState !== 1) throw new DOMException('not open', 'InvalidStateError'); return relay(this, d); }
+ if (reading.has(this) && typeof d === 'string' && /^\s*\[\s*"EVENT"/.test(d)) return relay(this, d);
+ return nativeSend.call(this, d);
+ };
+ Real.prototype.close = function (code, reason) {
+ if (!fakes.has(this)) return nativeClose.call(this, code, reason);
+ if (this.readyState >= 2) return;
+ this.readyState = 2;
+ setTimeout(() => { this.readyState = 3; this.dispatchEvent(new CloseEvent('close', { code: code || 1000, reason: reason || '', wasClean: true })); }, 0);
+ };
+ const handlerProp = (ws, type) => {
+ let fn = null;
+ const call = (e) => fn && fn.call(ws, e);
+ ws.addEventListener(type, call);
+ Object.defineProperty(ws, 'on' + type, { get: () => fn, set: (v) => { fn = typeof v === 'function' ? v : null; }, configurable: true });
+ };
+ function WebSocket(url, protocols) {
+ if (!new.target) throw new TypeError("Failed to construct 'WebSocket': Please use the 'new' operator");
+ let u;
+ try { u = new URL(url, location.href); } catch { return new Real(url, protocols); }
+ if (u.protocol !== 'wss:' && u.protocol !== 'ws:') return new Real(url, protocols);
+ if (local(u.hostname) || allow.includes(u.hostname)) return new Real(url, protocols);
+ if (readOnly) { const ws = new Real(url, protocols); reading.add(ws); return ws; }
+ window.__relayGuard.blocked.push(u.href);
+ // a genuine EventTarget (so dispatchEvent works) that passes instanceof
+ const ws = Object.setPrototypeOf(new EventTarget(), Real.prototype);
+ fakes.add(ws);
+ Object.defineProperties(ws, {
+ url: { value: u.href }, protocol: { value: '' }, extensions: { value: '' },
+ bufferedAmount: { value: 0 }, binaryType: { value: 'blob', writable: true },
+ readyState: { value: 0, writable: true },
+ });
+ for (const t of ['open', 'message', 'close', 'error']) handlerProp(ws, t);
+ setTimeout(() => { if (ws.readyState !== 0) return; ws.readyState = 1; ws.dispatchEvent(new Event('open')); }, 0);
+ return ws;
+ }
+ WebSocket.prototype = Real.prototype;
+ for (const k of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) WebSocket[k] = Real[k];
+ window.WebSocket = WebSocket;
+}
+
+const guarded = new WeakSet();
+const guardPage = async (page, ro) => {
+ if (!page || guarded.has(page)) return;
+ guarded.add(page);
+ await page.evaluateOnNewDocument(relayGuard, allow, ro).catch(() => {});
+};
+const guardContext = (ctx, ro) => {
+ const newPage = ctx.newPage.bind(ctx);
+ ctx.newPage = async (...a) => { const p = await newPage(...a); await guardPage(p, ro); return p; };
+};
+const guardBrowser = async (browser, ro) => {
+ guardContext(browser, ro);
+ for (const name of ['createBrowserContext', 'createIncognitoBrowserContext']) {
+ if (typeof browser[name] !== 'function') continue;
+ const make = browser[name].bind(browser);
+ browser[name] = async (...a) => { const c = await make(...a); guardContext(c, ro); return c; };
+ }
+ // pages it opened itself (popups, the first tab)
+ browser.on('targetcreated', async (t) => { if (t.type() === 'page') await guardPage(await t.page().catch(() => null), ro); });
+ for (const p of await browser.pages()) await guardPage(p, ro);
+};
+
+const launch = async ({ relays, ...opts } = {}) => {
+ const browser = await real.launch(opts);
+ if (live !== '1' && relays !== 'live') await guardBrowser(browser, live === 'read' || relays === 'read');
+ return browser;
+};
+
+export default new Proxy(real, {
+ get(t, k) {
+ if (k === 'launch') return launch;
+ const v = t[k];
+ return typeof v === 'function' ? v.bind(t) : v;
+ },
+});