diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 12:02:51 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 12:02:51 -0700 |
| commit | 86d9818abfc160dac031bb292abe28d6f28fc624 (patch) | |
| tree | a96983d60fb0f4dcb92a04c7aba50761da05cf9f /tools/puppeteer.js | |
| parent | b415b1fb31bd734619e1da3c88ca7e3822151c87 (diff) | |
| download | coinosv3-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.js | 126 |
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; + }, +}); |
