aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/network_change_harness.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness/network_change_harness.mjs')
-rw-r--r--packages/meshbay-hub/tests/harness/network_change_harness.mjs267
1 files changed, 267 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/network_change_harness.mjs b/packages/meshbay-hub/tests/harness/network_change_harness.mjs
new file mode 100644
index 00000000..e0201125
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/network_change_harness.mjs
@@ -0,0 +1,267 @@
+// Run the SHIPPED transport through a change of network, and see whether the
+// connection follows.
+//
+// A phone moving between mobile data and the Wi-Fi the node is on keeps a
+// connection whose candidate pair runs over the network it left. Reported on
+// the Android application: the group stopped answering and the application had
+// to be killed. transport.js is executed whole; what is modelled is the
+// environment: a clock, the window's events, `navigator.connection`, a peer
+// connection that opens its channel after the answer, a hub that answers the
+// offer and the token (or does not, while the device is offline), and a node
+// that answers the handshake and pings until it is cut off.
+//
+// Usage: node network_change_harness.mjs <transport sources> <json>
+// {"scenario": "change-dead" | "change-alive" | "backoff" | "ice-dead" | "ice-alive"
+// | "events" | "closed"}
+import { readFileSync } from 'fs';
+import { delimiter } from 'path';
+
+const transportSrc = process.argv[2].split(delimiter)
+ .map((p) => readFileSync(p, 'utf8')).join('\n');
+const { scenario } = 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;
+globalThis.performance = { 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 ────────────────────────────────────────────
+
+function eventTarget(obj) {
+ const listeners = new Map();
+ obj.addEventListener = (type, fn) => {
+ if (!listeners.has(type)) listeners.set(type, new Set());
+ listeners.get(type).add(fn);
+ };
+ obj.removeEventListener = (type, fn) => { listeners.get(type)?.delete(fn); };
+ obj.dispatch = (type) => { for (const fn of [...(listeners.get(type) || [])]) fn({ type }); };
+ obj.listening = () => [...listeners.values()].reduce((n, s) => n + s.size, 0);
+ return obj;
+}
+
+globalThis.window = globalThis;
+eventTarget(globalThis);
+const connection = eventTarget({ type: 'cellular', effectiveType: '4g' });
+Object.defineProperty(globalThis, 'navigator',
+ { value: { userAgent: 'harness', connection }, configurable: true });
+globalThis.location = { hash: '' };
+globalThis.document = {
+ addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
+};
+globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
+
+const b64 = (u8) => Buffer.from(u8).toString('base64');
+window.MeshBayCrypto = {
+ b64encode: (u8) => b64(u8),
+ b64decode: (s) => new Uint8Array(Buffer.from(String(s), 'base64')),
+ verifyNodeSignature: async () => true,
+ challengeTranscript: () => new Uint8Array(1),
+ webrtcBinding: () => new Uint8Array(1),
+ handshakeProof: async () => new Uint8Array([7]),
+ handshakeTranscript: () => new Uint8Array(1),
+ constantTimeEqual: () => true,
+ openGroup: async () => window.__codec.msgpack_encode({ chat_epoch: 1 }),
+};
+
+const SDP = 'v=0\r\na=fingerprint:sha-256 AA:BB\r\n';
+let offline = false;
+globalThis.fetch = async (url) => {
+ if (offline) throw new TypeError('Failed to fetch');
+ return {
+ ok: true, status: 200, headers: { get: () => null },
+ json: async () => (url.endsWith('/mnp-token') ? { mnp_token: 't' } : { sdp: SDP }),
+ };
+};
+
+// ── The node ─────────────────────────────────────────────────────────────────
+
+let pings = 0;
+const pcs = [];
+
+class NodeSession {
+ constructor(channel) { this.channel = channel; this.cut = false; }
+ reply(obj, ms) {
+ setTimeout(() => {
+ if (this.cut || this.channel.readyState !== 'open') return;
+ const body = window.__codec.msgpack_encode(obj);
+ const frame = new Uint8Array(4 + body.byteLength);
+ new DataView(frame.buffer).setUint32(0, body.byteLength, false);
+ frame.set(body, 4);
+ this.channel.onmessage({ data: frame.buffer });
+ }, ms);
+ }
+ receive(frame) {
+ if (this.cut) return;
+ const msg = window.__codec.msgpack_decode(frame.slice(4));
+ const req_id = msg.req_id;
+ switch (msg.type) {
+ case 'handshake':
+ return this.reply({ type: 'handshake_challenge', v: '99.0', v_min: '0.0',
+ nonce: b64(new Uint8Array(32)), node_pk: 'pk', sig: 's', req_id }, 50);
+ case 'handshake_response':
+ return this.reply({ type: 'handshake_ack', proof: 'AA==', node_pk: 'pk', sig: 's',
+ nonce: 'n', ct: 'c', req_id }, 50);
+ case 'device_hello':
+ return this.reply({ type: 'device_hello_ack', req_id }, 50);
+ case 'ping':
+ pings += 1;
+ return this.reply({ type: 'pong', token: msg.token, req_id }, 30);
+ default:
+ return undefined;
+ }
+ }
+}
+
+class FakeChannel {
+ constructor() { this.readyState = 'connecting'; this.binaryType = 'arraybuffer'; }
+ send(frame) {
+ if (this.readyState !== 'open') throw new Error('InvalidStateError');
+ this.session.receive(frame);
+ }
+ close() {
+ if (this.readyState === 'closed') return;
+ this.readyState = 'closed';
+ if (this.onclose) this.onclose({});
+ }
+}
+
+class FakePC {
+ constructor() {
+ this.connectionState = 'new';
+ this.iceConnectionState = 'new';
+ this.iceGatheringState = 'complete';
+ pcs.push(this);
+ }
+ createDataChannel() { this.channel = new FakeChannel(); return this.channel; }
+ async createOffer() { return { type: 'offer', sdp: SDP }; }
+ async setLocalDescription(d) { this.localDescription = d; }
+ async getStats() { return new Map(); }
+ async setRemoteDescription() {
+ setTimeout(() => {
+ if (this.connectionState === 'closed') return;
+ this.channel.session = new NodeSession(this.channel);
+ this.channel.readyState = 'open';
+ this.connectionState = 'connected';
+ this.iceConnectionState = 'connected';
+ if (this.channel.onopen) this.channel.onopen();
+ }, 100);
+ }
+ close() {
+ this.connectionState = 'closed';
+ if (this.channel) this.channel.close();
+ }
+}
+globalThis.RTCPeerConnection = FakePC;
+
+// ── The shipped transport ────────────────────────────────────────────────────
+
+new Function(`${transportSrc}\nwindow.__codec = { msgpack_encode, msgpack_decode };`)();
+const Transport = window.MeshBayTransport;
+// What the transport's own module listens to, before any connection exists.
+const moduleListeners = window.listening() + connection.listening();
+const out = {};
+
+if (scenario === 'events') {
+ // The helper alone: which events count as another network, and how many.
+ let calls = 0;
+ const off = Transport.onNetworkChange(() => { calls += 1; });
+ const at = [];
+ const step = async (fire) => { fire(); await run(now + 5000); at.push(calls); };
+ await step(() => connection.dispatch('change')); // a bandwidth estimate
+ await step(() => { connection.type = 'wifi'; connection.dispatch('change'); });
+ await step(() => window.dispatch('online'));
+ await step(() => window.dispatch('meshbay-default-network'));
+ await step(() => { // one change, three events
+ connection.type = 'cellular'; connection.dispatch('change');
+ window.dispatch('online');
+ window.dispatch('meshbay-default-network');
+ });
+ off();
+ await step(() => window.dispatch('online'));
+ out.calls = at;
+ out.leftListening = window.listening() + connection.listening() - moduleListeners;
+} else {
+ const main = new Transport('https://hub.invalid', 'token');
+ let reconnectedAt = null;
+ main.addReconnectListener(() => { if (reconnectedAt === null) reconnectedAt = now; });
+ let connected = false;
+ const identity = { pkEdB64: 'ed', signAs: async () => 'sig', native: false };
+ main.connect('node', 'token', 'group', new Uint8Array(32), identity, null, 'u', 'uid',
+ undefined, undefined, '', 'pk')
+ .then(() => { connected = true; }, (err) => { out.connectError = err.message; });
+ await run(5000);
+ out.connected = connected;
+
+ const first = pcs[0];
+ if (scenario === 'change-dead' || scenario === 'change-alive') {
+ // Mobile data to Wi-Fi. The pair in use ran over mobile data; when it is
+ // gone, the node hears nothing more on it.
+ if (scenario === 'change-dead') first.channel.session.cut = true;
+ connection.type = 'wifi';
+ connection.dispatch('change');
+ await run(25000);
+ } else if (scenario === 'ice-dead' || scenario === 'ice-alive') {
+ if (scenario === 'ice-dead') first.channel.session.cut = true;
+ first.iceConnectionState = 'disconnected';
+ first.oniceconnectionstatechange();
+ await run(25000);
+ } else if (scenario === 'backoff') {
+ // The connection fails while the device has no network, and every attempt
+ // fails with it until the backoff has grown long. Then Wi-Fi comes back.
+ offline = true;
+ first.channel.session.cut = true;
+ first.connectionState = 'failed';
+ first.onconnectionstatechange();
+ await run(40000);
+ offline = false;
+ connection.type = 'wifi';
+ connection.dispatch('change');
+ out.backAt = now;
+ await run(80000);
+ } else if (scenario === 'closed') {
+ main.close();
+ connection.type = 'wifi';
+ connection.dispatch('change');
+ await run(25000);
+ out.leftListening = window.listening() + connection.listening() - moduleListeners;
+ }
+ out.reconnectedAt = reconnectedAt;
+ out.connections = pcs.length;
+ out.pings = pings;
+}
+
+process.stdout.write(JSON.stringify(out));