aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
-rw-r--r--packages/meshbay-hub/tests/harness/cast_session_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/group_tab_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/local_network_harness.mjs156
-rw-r--r--packages/meshbay-hub/tests/harness/music_grid_probe.py1
-rwxr-xr-xpackages/meshbay-hub/tests/harness/music_queue_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/music_untagged_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/network_change_harness.mjs329
-rw-r--r--packages/meshbay-hub/tests/harness/playlist_ui_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/reconnect_race_harness.mjs29
-rw-r--r--packages/meshbay-hub/tests/harness/search_connect_harness.mjs37
-rw-r--r--packages/meshbay-hub/tests/harness/search_fanout_harness.mjs17
-rwxr-xr-xpackages/meshbay-hub/tests/harness/sticky_header_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/video_series_probe.py1
13 files changed, 563 insertions, 13 deletions
diff --git a/packages/meshbay-hub/tests/harness/cast_session_probe.py b/packages/meshbay-hub/tests/harness/cast_session_probe.py
index 573dd950..901d27cd 100644
--- a/packages/meshbay-hub/tests/harness/cast_session_probe.py
+++ b/packages/meshbay-hub/tests/harness/cast_session_probe.py
@@ -99,6 +99,7 @@ window.MeshBayTransport = function () {
// One chunk per photo: every photo is the same picture.
async fetchChunk() { return { nonce: 'n', ct: await photoBytes() }; },
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/group_tab_probe.py b/packages/meshbay-hub/tests/harness/group_tab_probe.py
index e23a9462..a430c625 100644
--- a/packages/meshbay-hub/tests/harness/group_tab_probe.py
+++ b/packages/meshbay-hub/tests/harness/group_tab_probe.py
@@ -73,6 +73,7 @@ window.MeshBayTransport = class {
// unmount; a stub without this throws inside connect() and the page
// renders its error state instead of a tab bar.
addReconnectListener() { return () => {}; }
+ addReconnectFailureListener() { return () => {}; }
// What the node should hold of our identity, settled after connecting.
async settleNodeBundle() {}
close() {}
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 }));
+}
diff --git a/packages/meshbay-hub/tests/harness/music_grid_probe.py b/packages/meshbay-hub/tests/harness/music_grid_probe.py
index 4f5b0ce7..1995b2a1 100644
--- a/packages/meshbay-hub/tests/harness/music_grid_probe.py
+++ b/packages/meshbay-hub/tests/harness/music_grid_probe.py
@@ -81,6 +81,7 @@ window.MeshBayTransport = function () {
// Real, not left to the Proxy below: that would hand back a promise
// where an unsubscribe belongs, and the page calls it on unmount.
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/music_queue_probe.py b/packages/meshbay-hub/tests/harness/music_queue_probe.py
index 9be440b3..83c2972e 100755
--- a/packages/meshbay-hub/tests/harness/music_queue_probe.py
+++ b/packages/meshbay-hub/tests/harness/music_queue_probe.py
@@ -80,6 +80,7 @@ window.MeshBayTransport = function () {
// Real, not left to the Proxy below: that would hand back a promise
// where an unsubscribe belongs, and the page calls it on unmount.
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/music_untagged_probe.py b/packages/meshbay-hub/tests/harness/music_untagged_probe.py
index f4c484c3..7af1efc7 100644
--- a/packages/meshbay-hub/tests/harness/music_untagged_probe.py
+++ b/packages/meshbay-hub/tests/harness/music_untagged_probe.py
@@ -110,6 +110,7 @@ window.MeshBayTransport = function () {
async fetchChatHistory() { return { messages: [], hasMore: false }; },
async fetchLinkPreview() { return { ok: false }; },
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
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..a8bd0809
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/network_change_harness.mjs
@@ -0,0 +1,329 @@
+// 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" | "refused"
+// | "refused-during"}
+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';
+const candidate = (address, kind) => `a=candidate:1 1 udp 2130706431 ${address} 50000 typ ${kind}\r\n`;
+// On the node's Wi-Fi without the permission (test_local_network_refused.py):
+// the phone's offer and the node's answer as measured, and the node's address
+// on the phone's subnet missing from what the browser paired.
+const WIFI_OFFER = SDP + candidate('192.168.1.13', 'host') + candidate('90.112.206.172', 'srflx');
+const NODE_ANSWER = SDP + candidate('192.168.1.22', 'host') + candidate('90.112.206.172', 'srflx');
+const KEPT_WHEN_REFUSED = ['90.112.206.172'];
+let offline = false;
+let lanRefused = 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: lanRefused ? NODE_ANSWER : 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: lanRefused ? WIFI_OFFER : SDP }; }
+ async setLocalDescription(d) { this.localDescription = d; }
+ async getStats() {
+ const reports = new Map();
+ if (this.refused) {
+ KEPT_WHEN_REFUSED.forEach((address, i) => {
+ reports.set(`R${i}`, { id: `R${i}`, type: 'remote-candidate', address });
+ });
+ }
+ return reports;
+ }
+ async setRemoteDescription() {
+ if (lanRefused) {
+ // ICE gives up, and the channel goes with it, as Chrome reports it:
+ // there after thirty seconds, here after fifteen.
+ this.refused = true;
+ setTimeout(() => {
+ if (this.connectionState === 'closed') return;
+ this.iceConnectionState = 'failed';
+ this.connectionState = 'failed';
+ this.channel.close();
+ }, 15000);
+ return;
+ }
+ 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; });
+ const failures = [];
+ main.addReconnectFailureListener((err) => { failures.push(err.reason || null); });
+ 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 === 'refused-during') {
+ // The same, with the permission granted while an attempt set up before it
+ // is still waiting on ICE: that attempt is given up, the next goes at once.
+ lanRefused = true;
+ first.channel.session.cut = true;
+ connection.type = 'wifi';
+ connection.dispatch('change');
+ await run(40000);
+ const attempts = pcs.length;
+ while (pcs.length === attempts) await run(now + 100);
+ await run(now + 2000);
+ lanRefused = false;
+ out.backAt = now;
+ main.retryNow();
+ await run(now + 40000);
+ } else if (scenario === 'refused') {
+ // Connected on mobile data, then on the node's Wi-Fi without the local
+ // network permission: every attempt is refused, and says so. The
+ // permission is granted after a while, and the page asks to try now.
+ lanRefused = true;
+ first.channel.session.cut = true;
+ connection.type = 'wifi';
+ connection.dispatch('change');
+ await run(60000);
+ out.failures = [...failures];
+ out.stillReconnecting = main.reconnecting;
+ lanRefused = false;
+ out.backAt = now;
+ main.retryNow();
+ 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));
diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
index 6e3be1e2..0d6e4134 100644
--- a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
+++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
@@ -76,6 +76,7 @@ window.MeshBayTransport = function () {
// Real, not left to the Proxy below: that would hand back a promise
// where an unsubscribe belongs, and the page calls it on unmount.
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/reconnect_race_harness.mjs b/packages/meshbay-hub/tests/harness/reconnect_race_harness.mjs
index 0f1cd979..606598da 100644
--- a/packages/meshbay-hub/tests/harness/reconnect_race_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/reconnect_race_harness.mjs
@@ -30,6 +30,7 @@ const {
rttMs = 300, // one request → its reply, during the handshake
lateCloseMs = 0, // >0: the replaced channel's close event fires this late
runUntil = 300000,
+ stream = false, // a film playing instead of a download (see below)
} = JSON.parse(process.argv[4] || '{}');
for (const level of ['log', 'warn', 'error', 'info', 'debug']) {
@@ -106,6 +107,7 @@ globalThis.fetch = async (url) => ({
const OPENING = new Set(['handshake', 'handshake_response', 'keypair_bundle_fetch',
'gek_bundle_fetch', 'join_request', 'device_add_request', 'device_hello']);
let refusedBeforeHandshake = 0;
+const refusedTypes = new Set();
let served = 0;
const pcs = [];
@@ -127,6 +129,7 @@ class NodeSession {
const req_id = msg.req_id;
if (!this.authed && !OPENING.has(msg.type)) {
refusedBeforeHandshake += 1;
+ refusedTypes.add(msg.type);
return this.reply({ type: 'error', detail: 'Handshake required', req_id }, 1);
}
switch (msg.type) {
@@ -210,10 +213,26 @@ main.addReconnectListener(() => { reconnected += 1; });
const written = [];
const writable = { write: async (bytes) => { written.push(bytes[0]); } };
-main.connect('node', 'token', 'group', new Uint8Array(32), identity, null, 'u', 'uid',
- undefined, undefined, '', 'pk')
- .then(() => pipelinedDownload(main, 'gek', 'f'.repeat(64), chunks, null, writable, null, ''))
- .then(() => { outcome = 'done'; }, (err) => { outcome = `failed: ${err.message}`; });
+// A film playing (`stream`): the player goes on granting the node room for
+// segments as it plays from its buffer, and on telling it what it sees, through
+// the connection loss and the reconnect, as video-player.js does. A refusal of
+// either is a node error the player shows in place of the film.
+const opened = main.connect('node', 'token', 'group', new Uint8Array(32), identity, null,
+ 'u', 'uid', undefined, undefined, '', 'pk');
+if (stream) {
+ opened.then(() => {
+ main.requestStream('f'.repeat(64));
+ setInterval(() => {
+ main.grantStreamCredit(1);
+ main.sendStreamDiag({ buffered: 1 });
+ }, 50);
+ outcome = 'done';
+ }, (err) => { outcome = `failed: ${err.message}`; });
+} else {
+ opened
+ .then(() => pipelinedDownload(main, 'gek', 'f'.repeat(64), chunks, null, writable, null, ''))
+ .then(() => { outcome = 'done'; }, (err) => { outcome = `failed: ${err.message}`; });
+}
await run(hangAt);
pcs[0].channel.session.hung = true;
@@ -223,7 +242,7 @@ pcs[0].onconnectionstatechange();
await run(runUntil);
process.stdout.write(JSON.stringify({
- outcome, reconnected, refusedBeforeHandshake, served,
+ outcome, reconnected, refusedBeforeHandshake, refusedTypes: [...refusedTypes], served,
written: written.length,
inOrder: written.every((b, i) => b === (i & 255)),
connections: pcs.length,
diff --git a/packages/meshbay-hub/tests/harness/search_connect_harness.mjs b/packages/meshbay-hub/tests/harness/search_connect_harness.mjs
index 60cea2ba..0c10a39d 100644
--- a/packages/meshbay-hub/tests/harness/search_connect_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/search_connect_harness.mjs
@@ -26,6 +26,13 @@ const {
resolveAt = null,
// How many nodes the hub lists for the group.
nodes = 1,
+ // Which of them answer (resolveAt applies to these). null: all.
+ answering = null,
+ // Which of them this device refuses to reach on the local network. As
+ // measured on a Pixel: no progress, and connect() names the refusal only
+ // when ICE gives up, `refusedAt` ms in.
+ lanRefused = [],
+ refusedAt = 30000,
// How far to run the clock before giving up on the harness itself.
runUntil = 300000,
} = cfg;
@@ -65,11 +72,15 @@ async function run(until) {
// ── The environment ──────────────────────────────────────────────────────────
let closed = 0;
+const closedAt = [];
+const lateRefusals = [];
+let made = 0;
class FakeTransport {
constructor() {
this.onConnectProgress = null;
this.onNeedToken = null;
+ this.index = made++;
}
connect() {
@@ -83,14 +94,25 @@ class FakeTransport {
}
}, at);
}
- return new Promise((resolve) => {
- if (resolveAt !== null) {
+ return new Promise((resolve, reject) => {
+ if (lanRefused.includes(this.index)) {
+ setTimeout(() => {
+ const err = new Error('This device refused to reach the local network');
+ err.reason = 'local_network_blocked';
+ reject(err);
+ }, refusedAt);
+ return;
+ }
+ if (resolveAt !== null && (answering === null || answering.includes(this.index))) {
setTimeout(() => resolve({ ok: true, at: now - started }), resolveAt);
+ } else {
+ // The transport's own deadline for the channel: every connect() ends.
+ setTimeout(() => reject(new Error('DataChannel open timeout')), 30000);
}
});
}
- close() { closed += 1; }
+ close() { closed += 1; closedAt.push(now); }
}
globalThis.window = { MeshBayTransport: FakeTransport };
@@ -125,13 +147,18 @@ const connectToGroup = make(hubFetch, ensureFreshToken, globalThis.window);
// ── The scenario ─────────────────────────────────────────────────────────────
let outcome = null;
-connectToGroup('https://hub.example', 'g1', 'tok', null, 'alice', 'u1')
+connectToGroup('https://hub.example', 'g1', 'tok', null, 'alice', 'u1',
+ (groupId) => { lateRefusals.push({ groupId, at: now }); })
.then(() => { outcome = { result: 'connected', at: now }; })
- .catch((e) => { outcome = { result: 'failed', at: now, error: String(e.message) }; });
+ .catch((e) => {
+ outcome = { result: 'failed', at: now, error: String(e.message), reason: e.reason || null };
+ });
await run(runUntil);
console.log(JSON.stringify({
...(outcome || { result: 'still-waiting', at: now }),
closed,
+ closedAt,
+ lateRefusals,
}));
diff --git a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
index f84d90bc..27ad8757 100644
--- a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
@@ -23,6 +23,8 @@ const {
// Indexes (0-based, in the hub's listing order) of the groups whose node is
// down. They take `deadMs`; every other group answers in `liveMs`.
dead = [],
+ // Of those, the ones whose node this device refused on the local network.
+ lanRefused = [],
liveMs = 200,
deadMs = 10000,
// Group indexes the browser remembers as silent from a previous sweep.
@@ -87,8 +89,12 @@ const fetchGroupIndex = (_pool, groupId) => new Promise((resolve, reject) => {
const n = Number(String(groupId).split('-')[1]);
const isDead = dead.includes(n);
setTimeout(
- () => (isDead ? reject(new Error('Connection timeout'))
- : resolve({ entries: [{ id: `e${n}` }], roots: {} })),
+ () => {
+ if (!isDead) return resolve({ entries: [{ id: `e${n}` }], roots: {} });
+ const err = new Error('Connection timeout');
+ if (lanRefused.includes(n)) err.reason = 'local_network_blocked';
+ return reject(err);
+ },
isDead ? deadMs : liveMs,
);
});
@@ -125,11 +131,15 @@ const fetchAllIndexes = make(
const list = Array.from({ length: groups }, (_, i) => ({ id: `g-${i}`, name: `G${i}` }));
let finishedAt = null;
+let outcome = null;
fetchAllIndexes(
null, list, 'tok', 'alice', 'u1',
() => {}, // progress counters only
(results) => { shown.push({ at: now, groups: results.size }); },
-).then(() => { finishedAt = now; });
+).then((out) => {
+ finishedAt = now;
+ outcome = { unreachable: out.unreachable, lanBlocked: out.lanBlocked.map((g) => g.name) };
+});
await run(runUntil);
@@ -140,6 +150,7 @@ console.log(JSON.stringify({
// Every render, so a test can see whether live groups waited on dead ones.
renders: shown,
finishedAt,
+ outcome,
inFlight: Number(ceiling[1]),
// What the browser will remember for next time.
remembered: written === null ? null : JSON.parse(written),
diff --git a/packages/meshbay-hub/tests/harness/sticky_header_probe.py b/packages/meshbay-hub/tests/harness/sticky_header_probe.py
index 3e75494b..89fb2827 100755
--- a/packages/meshbay-hub/tests/harness/sticky_header_probe.py
+++ b/packages/meshbay-hub/tests/harness/sticky_header_probe.py
@@ -240,6 +240,7 @@ window.MeshBayTransport = function () {
// Real, not left to the Proxy below: that would hand back a promise
// where an unsubscribe belongs, and the page calls it on unmount.
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/video_series_probe.py b/packages/meshbay-hub/tests/harness/video_series_probe.py
index f161c305..2b9ade30 100644
--- a/packages/meshbay-hub/tests/harness/video_series_probe.py
+++ b/packages/meshbay-hub/tests/harness/video_series_probe.py
@@ -75,6 +75,7 @@ window.MeshBayTransport = function () {
// what TMDB would have said.
async fetchMediaMeta() { return { confidence: 0 }; },
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
// Everything else the player asks for never answers: it sits on its