diff options
Diffstat (limited to 'packages/meshbay-hub/tests/harness/local_network_harness.mjs')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/local_network_harness.mjs | 156 |
1 files changed, 156 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/local_network_harness.mjs b/packages/meshbay-hub/tests/harness/local_network_harness.mjs new file mode 100644 index 00000000..9afdf59e --- /dev/null +++ b/packages/meshbay-hub/tests/harness/local_network_harness.mjs @@ -0,0 +1,156 @@ +// Run the SHIPPED transport against a device that refuses the local network. +// +// Android 17 makes the local network a permission of the application, and an +// application that targets it (Chrome from 155) has none until the person +// grants it. The browser then drops the node's addresses on the phone's own +// subnet: they never show in getStats, no check goes out to them, ICE fails and +// the channel closes. Measured on a Pixel, Android 17, Chrome 155, 2026-10-16. +// +// transport.js is executed whole. What is modelled is the environment: a clock, +// a peer connection whose ICE fails (or connects and then fails) after the +// answer, the remote candidates its getStats reports, and a hub that answers the +// offer and the token. +// +// Usage: node local_network_harness.mjs <transport sources> <json> +// {"classify": [[offerSdp, answerSdp, remoteAddresses], ...]} +// → the verdict of localNetworkRefused for each case +// {"offer": sdp, "answer": sdp, "remote": [...], "iceConnects": bool, "statsThrow": bool} +// → how connect() ends +import { readFileSync } from 'fs'; +import { delimiter } from 'path'; + +const transportSrc = process.argv[2].split(delimiter) + .map((p) => readFileSync(p, 'utf8')).join('\n'); +const cfg = JSON.parse(process.argv[3] || '{}'); + +for (const level of ['log', 'warn', 'error', 'info', 'debug']) { + console[level] = (...args) => process.stderr.write(args.join(' ') + '\n'); +} +process.on('unhandledRejection', () => {}); + +// ── The clock ──────────────────────────────────────────────────────────────── + +let now = 0; +let nextId = 1; +let timers = []; +globalThis.setTimeout = (fn, ms) => { + const t = { at: now + (ms || 0), fn, id: nextId++ }; + timers.push(t); + return t.id; +}; +globalThis.setInterval = (fn, ms) => { + const t = { at: now + ms, fn, id: nextId++, every: ms }; + timers.push(t); + return t.id; +}; +globalThis.clearTimeout = (id) => { timers = timers.filter((t) => t.id !== id); }; +globalThis.clearInterval = globalThis.clearTimeout; +Date.now = () => now; +const flush = async () => { for (let i = 0; i < 50; i++) await Promise.resolve(); }; +async function run(until) { + await flush(); + while (timers.length) { + const due = timers.reduce((a, b) => (b.at < a.at ? b : a)); + if (due.at > until) break; + if (due.every) due.at += due.every; + else timers = timers.filter((t) => t !== due); + now = Math.max(now, due.every ? due.at - due.every : due.at); + due.fn(); + await flush(); + } + now = until; +} + +// ── The page around the transport ──────────────────────────────────────────── + +globalThis.window = globalThis; +globalThis.addEventListener = () => {}; +globalThis.removeEventListener = () => {}; +globalThis.location = { hash: '' }; +globalThis.document = { + addEventListener() {}, removeEventListener() {}, visibilityState: 'visible', +}; +globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; +window.MeshBayCrypto = {}; + +globalThis.fetch = async (url) => ({ + ok: true, status: 200, headers: { get: () => null }, + json: async () => (url.endsWith('/mnp-token') ? { mnp_token: 't' } : { sdp: cfg.answer }), +}); + +// ── The device ─────────────────────────────────────────────────────────────── + +class FakeChannel { + constructor() { this.readyState = 'connecting'; this.binaryType = 'arraybuffer'; } + send() { throw new Error('InvalidStateError'); } + close() { + if (this.readyState === 'closed') return; + this.readyState = 'closed'; + if (this.onclose) this.onclose({}); + } +} + +let statsCalls = 0; +class FakePC { + constructor() { + this.connectionState = 'new'; + this.iceConnectionState = 'new'; + this.iceGatheringState = 'complete'; + } + createDataChannel() { this.channel = new FakeChannel(); return this.channel; } + async createOffer() { return { type: 'offer', sdp: cfg.offer }; } + async setLocalDescription(d) { this.localDescription = d; } + async setRemoteDescription() { + this.iceConnectionState = 'checking'; + this.oniceconnectionstatechange(); + if (cfg.iceConnects) { + setTimeout(() => { + this.iceConnectionState = 'connected'; + this.oniceconnectionstatechange(); + }, 200); + } + // ICE gives up, and the channel goes with it, as Chrome reports it. + setTimeout(() => { + if (this.connectionState === 'closed') return; + this.iceConnectionState = 'failed'; + this.oniceconnectionstatechange(); + this.connectionState = 'failed'; + this.onconnectionstatechange(); + this.channel.close(); + }, 12000); + } + async getStats() { + statsCalls += 1; + if (this.connectionState === 'closed' || cfg.statsThrow) throw new Error('InvalidStateError'); + const reports = new Map(); + (cfg.remote || []).forEach((address, i) => { + reports.set(`R${i}`, { id: `R${i}`, type: 'remote-candidate', address }); + }); + return reports; + } + close() { + this.connectionState = 'closed'; + if (this.channel) this.channel.close(); + } +} +globalThis.RTCPeerConnection = FakePC; + +// ── The shipped transport ──────────────────────────────────────────────────── + +new Function(transportSrc)(); +const Transport = window.MeshBayTransport; + +if (cfg.classify) { + process.stdout.write(JSON.stringify( + cfg.classify.map(([offer, answer, remote]) => Transport.localNetworkRefused(offer, answer, remote)))); +} else { + const main = new Transport('https://hub.invalid', 'token'); + let outcome = 'pending'; + let reason = null; + main.connect('node', 'token', 'group', new Uint8Array(32), null, null, 'u', 'uid', + undefined, undefined, '', 'pk') + .then(() => { outcome = 'connected'; }, + (err) => { outcome = err.message; reason = err.reason || null; main.close(); }); + await run(60000); + process.stdout.write(JSON.stringify({ outcome, reason, statsCalls })); +} |