summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/local_network_harness.mjs
blob: 9afdf59ef1f6df1f0bae7d333f3117f02e6e4435 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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 }));
}