aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/network_change_harness.mjs
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-17 18:37:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-17 18:37:35 +0200
commit8125df6d8149368ed8a84c9db6c5e37bfccb5a15 (patch)
tree6eba4f399c7a9b6e785f4d28bc197fb31923d0a3 /packages/meshbay-hub/tests/harness/network_change_harness.mjs
parentd5ba9552e27bac71168178f73a4c977a4d36dc1c (diff)
downloadmeshbay-8125df6d8149368ed8a84c9db6c5e37bfccb5a15.tar.gz
fix(spa): a reconnect refused the local network says so
Each failed attempt reaches the group page, which shows the same message and buttons as at opening while the reconnect goes on. Try now gives up an attempt still waiting on ICE and starts the next at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/harness/network_change_harness.mjs')
-rw-r--r--packages/meshbay-hub/tests/harness/network_change_harness.mjs70
1 files changed, 66 insertions, 4 deletions
diff --git a/packages/meshbay-hub/tests/harness/network_change_harness.mjs b/packages/meshbay-hub/tests/harness/network_change_harness.mjs
index e0201125..a8bd0809 100644
--- a/packages/meshbay-hub/tests/harness/network_change_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/network_change_harness.mjs
@@ -12,7 +12,8 @@
//
// Usage: node network_change_harness.mjs <transport sources> <json>
// {"scenario": "change-dead" | "change-alive" | "backoff" | "ice-dead" | "ice-alive"
-// | "events" | "closed"}
+// | "events" | "closed" | "refused"
+// | "refused-during"}
import { readFileSync } from 'fs';
import { delimiter } from 'path';
@@ -98,12 +99,21 @@ window.MeshBayCrypto = {
};
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: SDP }),
+ json: async () => (url.endsWith('/mnp-token') ? { mnp_token: 't' }
+ : { sdp: lanRefused ? NODE_ANSWER : SDP }),
};
};
@@ -167,10 +177,30 @@ class FakePC {
pcs.push(this);
}
createDataChannel() { this.channel = new FakeChannel(); return this.channel; }
- async createOffer() { return { type: 'offer', sdp: SDP }; }
+ async createOffer() { return { type: 'offer', sdp: lanRefused ? WIFI_OFFER : SDP }; }
async setLocalDescription(d) { this.localDescription = d; }
- async getStats() { return new Map(); }
+ 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);
@@ -218,6 +248,8 @@ if (scenario === 'events') {
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',
@@ -252,6 +284,36 @@ if (scenario === 'events') {
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';