@ruvector/edge-net 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * P2P Migration Test Suite
4
+ *
5
+ * Tests the HybridBootstrap migration flow:
6
+ * firebase -> hybrid -> p2p
7
+ *
8
+ * Validates:
9
+ * 1. Migration thresholds
10
+ * 2. DHT routing table population
11
+ * 3. Signaling fallback behavior
12
+ * 4. Network partition recovery
13
+ * 5. Node churn handling
14
+ *
15
+ * @module @ruvector/edge-net/tests/p2p-migration-test
16
+ */
17
+
18
+ import { EventEmitter } from 'events';
19
+ import { createHash, randomBytes } from 'crypto';
20
+
21
+ // ============================================
22
+ // MOCK IMPLEMENTATIONS
23
+ // ============================================
24
+
25
+ /**
26
+ * Mock WebRTC Peer Manager for simulation
27
+ */
28
+ class MockWebRTCPeerManager extends EventEmitter {
29
+ constructor(peerId) {
30
+ super();
31
+ this.peerId = peerId;
32
+ this.peers = new Map();
33
+ this.externalSignaling = null;
34
+ this.stats = {
35
+ totalConnections: 0,
36
+ successfulConnections: 0,
37
+ failedConnections: 0,
38
+ };
39
+ }
40
+
41
+ setExternalSignaling(callback) {
42
+ this.externalSignaling = callback;
43
+ }
44
+
45
+ async connectToPeer(peerId) {
46
+ if (this.peers.has(peerId)) return;
47
+ this.stats.totalConnections++;
48
+
49
+ // Simulate connection delay
50
+ await new Promise(resolve => setTimeout(resolve, 50));
51
+
52
+ // Simulate successful connection
53
+ this.peers.set(peerId, {
54
+ peerId,
55
+ state: 'connected',
56
+ lastSeen: Date.now(),
57
+ });
58
+ this.stats.successfulConnections++;
59
+ this.emit('peer-connected', peerId);
60
+ }
61
+
62
+ disconnectPeer(peerId) {
63
+ if (this.peers.has(peerId)) {
64
+ this.peers.delete(peerId);
65
+ this.emit('peer-disconnected', peerId);
66
+ }
67
+ }
68
+
69
+ isConnected(peerId) {
70
+ return this.peers.has(peerId);
71
+ }
72
+
73
+ sendToPeer(peerId, message) {
74
+ if (this.peers.has(peerId)) {
75
+ this.emit('message-sent', { to: peerId, message });
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ async handleOffer({ from, offer }) {
82
+ // Simulate offer handling
83
+ await new Promise(resolve => setTimeout(resolve, 20));
84
+ }
85
+
86
+ async handleAnswer({ from, answer }) {
87
+ // Simulate answer handling
88
+ await new Promise(resolve => setTimeout(resolve, 20));
89
+ }
90
+
91
+ async handleIceCandidate({ from, candidate }) {
92
+ // Simulate ICE handling
93
+ await new Promise(resolve => setTimeout(resolve, 10));
94
+ }
95
+
96
+ getStats() {
97
+ return {
98
+ ...this.stats,
99
+ connectedPeers: this.peers.size,
100
+ };
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Mock DHT Node for simulation
106
+ */
107
+ class MockDHTNode extends EventEmitter {
108
+ constructor(id) {
109
+ super();
110
+ this.id = id || createHash('sha1').update(randomBytes(32)).digest('hex');
111
+ this.peers = new Map();
112
+ this.storage = new Map();
113
+ this.stats = {
114
+ lookups: 0,
115
+ stores: 0,
116
+ };
117
+ }
118
+
119
+ addPeer(peer) {
120
+ if (peer.id === this.id) return false;
121
+ this.peers.set(peer.id, { ...peer, lastSeen: Date.now() });
122
+ this.emit('peer-added', peer);
123
+ return true;
124
+ }
125
+
126
+ removePeer(peerId) {
127
+ if (this.peers.has(peerId)) {
128
+ this.peers.delete(peerId);
129
+ this.emit('peer-removed', peerId);
130
+ return true;
131
+ }
132
+ return false;
133
+ }
134
+
135
+ getPeers() {
136
+ return Array.from(this.peers.values());
137
+ }
138
+
139
+ getStats() {
140
+ return {
141
+ ...this.stats,
142
+ totalPeers: this.peers.size,
143
+ };
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Mock Firebase Signaling for simulation
149
+ */
150
+ class MockFirebaseSignaling extends EventEmitter {
151
+ constructor(options = {}) {
152
+ super();
153
+ this.peerId = options.peerId;
154
+ this.isConnected = false;
155
+ this.peers = new Map();
156
+ this.stats = {
157
+ firebaseSignals: 0,
158
+ };
159
+ }
160
+
161
+ async connect() {
162
+ await new Promise(resolve => setTimeout(resolve, 100));
163
+ this.isConnected = true;
164
+ this.emit('connected');
165
+ return true;
166
+ }
167
+
168
+ async disconnect() {
169
+ this.isConnected = false;
170
+ this.peers.clear();
171
+ this.emit('disconnected');
172
+ }
173
+
174
+ addPeer(peerId, data = {}) {
175
+ this.peers.set(peerId, { peerId, ...data, lastSeen: Date.now() });
176
+ this.emit('peer-discovered', { peerId, ...data });
177
+ }
178
+
179
+ removePeer(peerId) {
180
+ if (this.peers.has(peerId)) {
181
+ this.peers.delete(peerId);
182
+ this.emit('peer-left', { peerId });
183
+ }
184
+ }
185
+
186
+ async sendOffer(toPeerId, offer) {
187
+ this.stats.firebaseSignals++;
188
+ return true;
189
+ }
190
+
191
+ async sendAnswer(toPeerId, answer) {
192
+ this.stats.firebaseSignals++;
193
+ return true;
194
+ }
195
+
196
+ async sendIceCandidate(toPeerId, candidate) {
197
+ this.stats.firebaseSignals++;
198
+ return true;
199
+ }
200
+
201
+ async sendSignal(toPeerId, type, data) {
202
+ this.stats.firebaseSignals++;
203
+ return true;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Simulated HybridBootstrap for testing (mirrors real implementation)
209
+ */
210
+ class SimulatedHybridBootstrap extends EventEmitter {
211
+ constructor(options = {}) {
212
+ super();
213
+ this.peerId = options.peerId || createHash('sha1').update(randomBytes(16)).digest('hex');
214
+ this.mode = 'firebase';
215
+ this.dhtPeerThreshold = options.dhtPeerThreshold || 5;
216
+ this.p2pPeerThreshold = options.p2pPeerThreshold || 10;
217
+
218
+ // Components
219
+ this.firebase = null;
220
+ this.dht = null;
221
+ this.webrtc = null;
222
+
223
+ // Stats
224
+ this.stats = {
225
+ firebaseDiscoveries: 0,
226
+ dhtDiscoveries: 0,
227
+ directConnections: 0,
228
+ firebaseSignals: 0,
229
+ p2pSignals: 0,
230
+ modeTransitions: [],
231
+ };
232
+
233
+ // Migration timing
234
+ this.migrationTimestamps = {
235
+ started: null,
236
+ toHybrid: null,
237
+ toP2P: null,
238
+ fallbackToHybrid: null,
239
+ fallbackToFirebase: null,
240
+ };
241
+ }
242
+
243
+ async start(webrtc, dht) {
244
+ this.webrtc = webrtc;
245
+ this.dht = dht;
246
+ this.migrationTimestamps.started = Date.now();
247
+
248
+ // Create mock Firebase
249
+ this.firebase = new MockFirebaseSignaling({ peerId: this.peerId });
250
+ this.setupFirebaseEvents();
251
+
252
+ // Set up WebRTC external signaling
253
+ if (this.webrtc) {
254
+ this.webrtc.setExternalSignaling(async (type, toPeerId, data) => {
255
+ switch (type) {
256
+ case 'offer':
257
+ await this.firebase.sendOffer(toPeerId, data);
258
+ break;
259
+ case 'answer':
260
+ await this.firebase.sendAnswer(toPeerId, data);
261
+ break;
262
+ case 'ice-candidate':
263
+ await this.firebase.sendIceCandidate(toPeerId, data);
264
+ break;
265
+ }
266
+ this.stats.firebaseSignals++;
267
+ });
268
+ }
269
+
270
+ const connected = await this.firebase.connect();
271
+ if (connected) {
272
+ this.mode = 'firebase';
273
+ this.stats.modeTransitions.push({ from: null, to: 'firebase', timestamp: Date.now() });
274
+ }
275
+
276
+ return connected;
277
+ }
278
+
279
+ setupFirebaseEvents() {
280
+ this.firebase.on('peer-discovered', async ({ peerId }) => {
281
+ this.stats.firebaseDiscoveries++;
282
+ if (this.webrtc) {
283
+ await this.connectToPeer(peerId);
284
+ }
285
+ this.emit('peer-discovered', { peerId, source: 'firebase' });
286
+ });
287
+
288
+ this.firebase.on('offer', async ({ from, offer }) => {
289
+ this.stats.firebaseSignals++;
290
+ if (this.webrtc) {
291
+ await this.webrtc.handleOffer({ from, offer });
292
+ }
293
+ });
294
+
295
+ this.firebase.on('answer', async ({ from, answer }) => {
296
+ this.stats.firebaseSignals++;
297
+ if (this.webrtc) {
298
+ await this.webrtc.handleAnswer({ from, answer });
299
+ }
300
+ });
301
+ }
302
+
303
+ async connectToPeer(peerId) {
304
+ if (!this.webrtc) return;
305
+ try {
306
+ await this.webrtc.connectToPeer(peerId);
307
+ this.stats.directConnections++;
308
+
309
+ // Also add to DHT
310
+ if (this.dht) {
311
+ this.dht.addPeer({ id: peerId, lastSeen: Date.now() });
312
+ }
313
+ } catch (error) {
314
+ // Connection failed
315
+ }
316
+ }
317
+
318
+ async signal(toPeerId, type, data) {
319
+ if (this.webrtc?.isConnected(toPeerId)) {
320
+ this.webrtc.sendToPeer(toPeerId, { type, data });
321
+ this.stats.p2pSignals++;
322
+ return;
323
+ }
324
+ if (this.firebase?.isConnected) {
325
+ await this.firebase.sendSignal(toPeerId, type, data);
326
+ this.stats.firebaseSignals++;
327
+ return;
328
+ }
329
+ throw new Error('No signaling path available');
330
+ }
331
+
332
+ checkMigration() {
333
+ const connectedPeers = this.webrtc?.peers?.size || 0;
334
+ const dhtPeers = this.dht?.getPeers?.()?.length || 0;
335
+ const previousMode = this.mode;
336
+
337
+ // Migration logic (mirrors real implementation)
338
+ if (this.mode === 'firebase') {
339
+ if (dhtPeers >= this.dhtPeerThreshold) {
340
+ this.mode = 'hybrid';
341
+ this.migrationTimestamps.toHybrid = Date.now();
342
+ }
343
+ } else if (this.mode === 'hybrid') {
344
+ if (connectedPeers >= this.p2pPeerThreshold) {
345
+ this.mode = 'p2p';
346
+ this.migrationTimestamps.toP2P = Date.now();
347
+ } else if (dhtPeers < this.dhtPeerThreshold / 2) {
348
+ this.mode = 'firebase';
349
+ this.migrationTimestamps.fallbackToFirebase = Date.now();
350
+ }
351
+ } else if (this.mode === 'p2p') {
352
+ if (connectedPeers < this.p2pPeerThreshold / 2) {
353
+ this.mode = 'hybrid';
354
+ this.migrationTimestamps.fallbackToHybrid = Date.now();
355
+ }
356
+ }
357
+
358
+ if (this.mode !== previousMode) {
359
+ this.stats.modeTransitions.push({
360
+ from: previousMode,
361
+ to: this.mode,
362
+ timestamp: Date.now(),
363
+ dhtPeers,
364
+ connectedPeers,
365
+ });
366
+ this.emit('mode-changed', { from: previousMode, to: this.mode });
367
+ }
368
+
369
+ return { previousMode, currentMode: this.mode, dhtPeers, connectedPeers };
370
+ }
371
+
372
+ getStats() {
373
+ return {
374
+ mode: this.mode,
375
+ ...this.stats,
376
+ firebaseConnected: this.firebase?.isConnected || false,
377
+ firebasePeers: this.firebase?.peers?.size || 0,
378
+ dhtPeers: this.dht?.getPeers?.()?.length || 0,
379
+ directPeers: this.webrtc?.peers?.size || 0,
380
+ migrationTimestamps: this.migrationTimestamps,
381
+ };
382
+ }
383
+
384
+ async stop() {
385
+ if (this.firebase) {
386
+ await this.firebase.disconnect();
387
+ }
388
+ }
389
+ }
390
+
391
+ // ============================================
392
+ // TEST UTILITIES
393
+ // ============================================
394
+
395
+ /**
396
+ * Create a test network with multiple nodes
397
+ */
398
+ function createTestNetwork(nodeCount, options = {}) {
399
+ const nodes = [];
400
+ for (let i = 0; i < nodeCount; i++) {
401
+ const peerId = createHash('sha1').update(`test-node-${i}`).digest('hex');
402
+ const webrtc = new MockWebRTCPeerManager(peerId);
403
+ const dht = new MockDHTNode(peerId);
404
+ const bootstrap = new SimulatedHybridBootstrap({
405
+ peerId,
406
+ dhtPeerThreshold: options.dhtPeerThreshold || 5,
407
+ p2pPeerThreshold: options.p2pPeerThreshold || 10,
408
+ });
409
+
410
+ nodes.push({
411
+ id: i,
412
+ peerId,
413
+ webrtc,
414
+ dht,
415
+ bootstrap,
416
+ });
417
+ }
418
+ return nodes;
419
+ }
420
+
421
+ /**
422
+ * Simulate peer discovery via Firebase
423
+ */
424
+ async function simulateFirebaseDiscovery(nodes) {
425
+ for (const node of nodes) {
426
+ for (const otherNode of nodes) {
427
+ if (node.peerId !== otherNode.peerId) {
428
+ node.bootstrap.firebase.addPeer(otherNode.peerId);
429
+ await new Promise(r => setTimeout(r, 10));
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Simulate nodes joining the network gradually
437
+ */
438
+ async function simulateGradualJoin(nodes, delayMs = 100) {
439
+ for (const node of nodes) {
440
+ await node.bootstrap.start(node.webrtc, node.dht);
441
+ await new Promise(r => setTimeout(r, delayMs));
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Connect nodes directly (simulate WebRTC connections)
447
+ */
448
+ async function connectNodes(nodeA, nodeB) {
449
+ await nodeA.webrtc.connectToPeer(nodeB.peerId);
450
+ await nodeB.webrtc.connectToPeer(nodeA.peerId);
451
+ nodeA.dht.addPeer({ id: nodeB.peerId });
452
+ nodeB.dht.addPeer({ id: nodeA.peerId });
453
+ }
454
+
455
+ /**
456
+ * Test result formatter
457
+ */
458
+ function formatTestResult(name, passed, details = {}) {
459
+ const status = passed ? '\x1b[32mPASSED\x1b[0m' : '\x1b[31mFAILED\x1b[0m';
460
+ console.log(` ${status}: ${name}`);
461
+ if (!passed && Object.keys(details).length > 0) {
462
+ console.log(' Details:', JSON.stringify(details, null, 2));
463
+ }
464
+ return passed;
465
+ }
466
+
467
+ // ============================================
468
+ // TEST SCENARIOS
469
+ // ============================================
470
+
471
+ /**
472
+ * TEST 1: Happy Path - Gradual Network Growth
473
+ *
474
+ * Scenario: Nodes join gradually, network grows, migration occurs
475
+ * Expected: firebase -> hybrid -> p2p transitions
476
+ */
477
+ async function testHappyPathGradualGrowth() {
478
+ console.log('\n--- TEST 1: Happy Path - Gradual Network Growth ---');
479
+
480
+ const nodes = createTestNetwork(15, {
481
+ dhtPeerThreshold: 3,
482
+ p2pPeerThreshold: 8,
483
+ });
484
+
485
+ // Start first node
486
+ const firstNode = nodes[0];
487
+ await firstNode.bootstrap.start(firstNode.webrtc, firstNode.dht);
488
+
489
+ let passed = true;
490
+
491
+ // Verify starts in firebase mode
492
+ passed = formatTestResult(
493
+ 'Node starts in firebase mode',
494
+ firstNode.bootstrap.mode === 'firebase',
495
+ { mode: firstNode.bootstrap.mode }
496
+ ) && passed;
497
+
498
+ // Add nodes gradually and check transitions
499
+ for (let i = 1; i < nodes.length; i++) {
500
+ const node = nodes[i];
501
+ await node.bootstrap.start(node.webrtc, node.dht);
502
+
503
+ // Connect to first node
504
+ await connectNodes(firstNode, node);
505
+
506
+ // Check migration
507
+ firstNode.bootstrap.checkMigration();
508
+ }
509
+
510
+ // Final state checks
511
+ const stats = firstNode.bootstrap.getStats();
512
+
513
+ passed = formatTestResult(
514
+ 'Transitioned to hybrid mode',
515
+ stats.modeTransitions.some(t => t.to === 'hybrid'),
516
+ { transitions: stats.modeTransitions }
517
+ ) && passed;
518
+
519
+ passed = formatTestResult(
520
+ 'Transitioned to p2p mode',
521
+ stats.mode === 'p2p' || stats.modeTransitions.some(t => t.to === 'p2p'),
522
+ { currentMode: stats.mode, transitions: stats.modeTransitions }
523
+ ) && passed;
524
+
525
+ passed = formatTestResult(
526
+ 'DHT routing table populated',
527
+ stats.dhtPeers >= 10,
528
+ { dhtPeers: stats.dhtPeers }
529
+ ) && passed;
530
+
531
+ // Cleanup
532
+ for (const node of nodes) {
533
+ await node.bootstrap.stop();
534
+ }
535
+
536
+ return passed;
537
+ }
538
+
539
+ /**
540
+ * TEST 2: Edge Case - Nodes Leaving
541
+ *
542
+ * Scenario: Network grows then nodes leave
543
+ * Expected: p2p -> hybrid -> firebase fallback
544
+ *
545
+ * Fallback thresholds are half the original:
546
+ * - P2P -> Hybrid: when connectedPeers < p2pPeerThreshold / 2
547
+ * - Hybrid -> Firebase: when dhtPeers < dhtPeerThreshold / 2
548
+ */
549
+ async function testNodesLeaving() {
550
+ console.log('\n--- TEST 2: Edge Case - Nodes Leaving ---');
551
+
552
+ const nodes = createTestNetwork(12, {
553
+ dhtPeerThreshold: 3,
554
+ p2pPeerThreshold: 6,
555
+ });
556
+
557
+ let passed = true;
558
+
559
+ // Build up network
560
+ for (const node of nodes) {
561
+ await node.bootstrap.start(node.webrtc, node.dht);
562
+ }
563
+
564
+ // Connect all nodes to first node
565
+ const firstNode = nodes[0];
566
+ for (let i = 1; i < nodes.length; i++) {
567
+ await connectNodes(firstNode, nodes[i]);
568
+ firstNode.bootstrap.checkMigration();
569
+ }
570
+
571
+ // Verify in p2p mode
572
+ passed = formatTestResult(
573
+ 'Reached p2p mode with full network',
574
+ firstNode.bootstrap.mode === 'p2p',
575
+ { mode: firstNode.bootstrap.mode }
576
+ ) && passed;
577
+
578
+ // Simulate nodes leaving - need to get below p2pPeerThreshold/2 = 3
579
+ // So we need to disconnect until we have < 3 peers
580
+ for (let i = nodes.length - 1; i >= 4; i--) {
581
+ firstNode.webrtc.disconnectPeer(nodes[i].peerId);
582
+ firstNode.dht.removePeer(nodes[i].peerId);
583
+ firstNode.bootstrap.checkMigration();
584
+ }
585
+
586
+ // Now we should have 3 peers (indices 1,2,3), still at or above threshold
587
+ // Need to drop one more to trigger fallback
588
+ firstNode.webrtc.disconnectPeer(nodes[3].peerId);
589
+ firstNode.dht.removePeer(nodes[3].peerId);
590
+ firstNode.bootstrap.checkMigration();
591
+
592
+ // Should fall back to hybrid (2 peers < 3)
593
+ passed = formatTestResult(
594
+ 'Falls back to hybrid when peers drop below half threshold',
595
+ firstNode.bootstrap.mode === 'hybrid',
596
+ { mode: firstNode.bootstrap.mode, directPeers: firstNode.webrtc.peers.size, threshold: 'p2pPeerThreshold/2 = 3' }
597
+ ) && passed;
598
+
599
+ // More nodes leave - need to get DHT below dhtPeerThreshold/2 = 1.5
600
+ firstNode.webrtc.disconnectPeer(nodes[2].peerId);
601
+ firstNode.dht.removePeer(nodes[2].peerId);
602
+ firstNode.bootstrap.checkMigration();
603
+
604
+ // Now 1 DHT peer, should fall back to firebase (1 < 1.5)
605
+ passed = formatTestResult(
606
+ 'Falls back to firebase when DHT peers drop below half threshold',
607
+ firstNode.bootstrap.mode === 'firebase',
608
+ { mode: firstNode.bootstrap.mode, dhtPeers: firstNode.dht.getPeers().length, threshold: 'dhtPeerThreshold/2 = 1.5' }
609
+ ) && passed;
610
+
611
+ // Cleanup
612
+ for (const node of nodes) {
613
+ await node.bootstrap.stop();
614
+ }
615
+
616
+ return passed;
617
+ }
618
+
619
+ /**
620
+ * TEST 3: Edge Case - Nodes Rejoining
621
+ *
622
+ * Scenario: Nodes leave then rejoin
623
+ * Expected: Proper re-migration
624
+ */
625
+ async function testNodesRejoining() {
626
+ console.log('\n--- TEST 3: Edge Case - Nodes Rejoining ---');
627
+
628
+ const nodes = createTestNetwork(10, {
629
+ dhtPeerThreshold: 3,
630
+ p2pPeerThreshold: 6,
631
+ });
632
+
633
+ let passed = true;
634
+
635
+ // Initial build-up
636
+ const firstNode = nodes[0];
637
+ await firstNode.bootstrap.start(firstNode.webrtc, firstNode.dht);
638
+
639
+ for (let i = 1; i < 8; i++) {
640
+ await nodes[i].bootstrap.start(nodes[i].webrtc, nodes[i].dht);
641
+ await connectNodes(firstNode, nodes[i]);
642
+ firstNode.bootstrap.checkMigration();
643
+ }
644
+
645
+ passed = formatTestResult(
646
+ 'Initial p2p state reached',
647
+ firstNode.bootstrap.mode === 'p2p',
648
+ { mode: firstNode.bootstrap.mode }
649
+ ) && passed;
650
+
651
+ // Nodes leave
652
+ for (let i = 7; i >= 2; i--) {
653
+ firstNode.webrtc.disconnectPeer(nodes[i].peerId);
654
+ firstNode.dht.removePeer(nodes[i].peerId);
655
+ }
656
+ firstNode.bootstrap.checkMigration();
657
+
658
+ const modeAfterLeaving = firstNode.bootstrap.mode;
659
+ passed = formatTestResult(
660
+ 'Mode degraded after nodes left',
661
+ modeAfterLeaving !== 'p2p',
662
+ { mode: modeAfterLeaving }
663
+ ) && passed;
664
+
665
+ // Nodes rejoin
666
+ for (let i = 2; i < 8; i++) {
667
+ await connectNodes(firstNode, nodes[i]);
668
+ firstNode.bootstrap.checkMigration();
669
+ }
670
+
671
+ // New nodes join
672
+ for (let i = 8; i < 10; i++) {
673
+ await nodes[i].bootstrap.start(nodes[i].webrtc, nodes[i].dht);
674
+ await connectNodes(firstNode, nodes[i]);
675
+ firstNode.bootstrap.checkMigration();
676
+ }
677
+
678
+ passed = formatTestResult(
679
+ 'Re-migrated to p2p after rejoin',
680
+ firstNode.bootstrap.mode === 'p2p',
681
+ { mode: firstNode.bootstrap.mode, directPeers: firstNode.webrtc.peers.size }
682
+ ) && passed;
683
+
684
+ // Cleanup
685
+ for (const node of nodes) {
686
+ await node.bootstrap.stop();
687
+ }
688
+
689
+ return passed;
690
+ }
691
+
692
+ /**
693
+ * TEST 4: Network Partition Recovery
694
+ *
695
+ * Scenario: Network splits then recovers
696
+ * Expected: Graceful handling of partition
697
+ */
698
+ async function testNetworkPartitionRecovery() {
699
+ console.log('\n--- TEST 4: Network Partition Recovery ---');
700
+
701
+ const nodes = createTestNetwork(12, {
702
+ dhtPeerThreshold: 3,
703
+ p2pPeerThreshold: 6,
704
+ });
705
+
706
+ let passed = true;
707
+
708
+ // Build full network
709
+ for (const node of nodes) {
710
+ await node.bootstrap.start(node.webrtc, node.dht);
711
+ }
712
+
713
+ // Connect all in mesh
714
+ for (let i = 0; i < nodes.length; i++) {
715
+ for (let j = i + 1; j < nodes.length; j++) {
716
+ await connectNodes(nodes[i], nodes[j]);
717
+ }
718
+ }
719
+
720
+ // Check migration multiple times to allow state to propagate
721
+ // (real implementation has 30s interval, but we check immediately)
722
+ for (let round = 0; round < 3; round++) {
723
+ for (const node of nodes) {
724
+ node.bootstrap.checkMigration();
725
+ }
726
+ }
727
+
728
+ // In mesh topology with 11 connections each, all should be in p2p mode
729
+ const p2pCount = nodes.filter(n => n.bootstrap.mode === 'p2p').length;
730
+ passed = formatTestResult(
731
+ 'Most nodes in p2p mode initially (mesh has 11 connections each)',
732
+ p2pCount >= 10, // Allow some variance
733
+ { p2pNodes: p2pCount, totalNodes: nodes.length, modes: nodes.map(n => n.bootstrap.mode) }
734
+ ) && passed;
735
+
736
+ // Simulate partition: split into two groups
737
+ const groupA = nodes.slice(0, 6);
738
+ const groupB = nodes.slice(6);
739
+
740
+ // Disconnect cross-group connections
741
+ for (const nodeA of groupA) {
742
+ for (const nodeB of groupB) {
743
+ nodeA.webrtc.disconnectPeer(nodeB.peerId);
744
+ nodeA.dht.removePeer(nodeB.peerId);
745
+ nodeB.webrtc.disconnectPeer(nodeA.peerId);
746
+ nodeB.dht.removePeer(nodeA.peerId);
747
+ }
748
+ }
749
+
750
+ // Check migration for both groups
751
+ for (const node of nodes) {
752
+ node.bootstrap.checkMigration();
753
+ }
754
+
755
+ passed = formatTestResult(
756
+ 'Both partitions still operational',
757
+ groupA[0].bootstrap.mode !== 'firebase' && groupB[0].bootstrap.mode !== 'firebase',
758
+ { groupA: groupA[0].bootstrap.mode, groupB: groupB[0].bootstrap.mode }
759
+ ) && passed;
760
+
761
+ // Heal partition
762
+ for (const nodeA of groupA) {
763
+ for (const nodeB of groupB) {
764
+ await connectNodes(nodeA, nodeB);
765
+ }
766
+ }
767
+
768
+ for (const node of nodes) {
769
+ node.bootstrap.checkMigration();
770
+ }
771
+
772
+ passed = formatTestResult(
773
+ 'Network recovered to p2p after healing',
774
+ nodes.every(n => n.bootstrap.mode === 'p2p'),
775
+ { modes: nodes.map(n => n.bootstrap.mode) }
776
+ ) && passed;
777
+
778
+ // Cleanup
779
+ for (const node of nodes) {
780
+ await node.bootstrap.stop();
781
+ }
782
+
783
+ return passed;
784
+ }
785
+
786
+ /**
787
+ * TEST 5: Signaling Fallback Behavior
788
+ *
789
+ * Scenario: Test signaling routes through correct channel
790
+ * Expected: P2P when available, Firebase fallback
791
+ */
792
+ async function testSignalingFallback() {
793
+ console.log('\n--- TEST 5: Signaling Fallback Behavior ---');
794
+
795
+ const nodes = createTestNetwork(3, {
796
+ dhtPeerThreshold: 1,
797
+ p2pPeerThreshold: 2,
798
+ });
799
+
800
+ let passed = true;
801
+
802
+ // Start all nodes
803
+ for (const node of nodes) {
804
+ await node.bootstrap.start(node.webrtc, node.dht);
805
+ }
806
+
807
+ const nodeA = nodes[0];
808
+ const nodeB = nodes[1];
809
+ const nodeC = nodes[2];
810
+
811
+ // Signal without P2P connection (should use Firebase)
812
+ const statsBeforeFirebase = { ...nodeA.bootstrap.stats };
813
+ await nodeA.bootstrap.signal(nodeB.peerId, 'test', { data: 'hello' });
814
+
815
+ passed = formatTestResult(
816
+ 'Uses Firebase for signaling without P2P',
817
+ nodeA.bootstrap.stats.firebaseSignals > statsBeforeFirebase.firebaseSignals,
818
+ { before: statsBeforeFirebase.firebaseSignals, after: nodeA.bootstrap.stats.firebaseSignals }
819
+ ) && passed;
820
+
821
+ // Connect nodes directly
822
+ await connectNodes(nodeA, nodeB);
823
+
824
+ // Signal with P2P connection (should use P2P)
825
+ const statsBeforeP2P = { ...nodeA.bootstrap.stats };
826
+ await nodeA.bootstrap.signal(nodeB.peerId, 'test', { data: 'hello' });
827
+
828
+ passed = formatTestResult(
829
+ 'Uses P2P for signaling when connected',
830
+ nodeA.bootstrap.stats.p2pSignals > statsBeforeP2P.p2pSignals,
831
+ { before: statsBeforeP2P.p2pSignals, after: nodeA.bootstrap.stats.p2pSignals }
832
+ ) && passed;
833
+
834
+ // Signal to unconnected peer (should fallback to Firebase)
835
+ const statsBeforeFallback = { ...nodeA.bootstrap.stats };
836
+ await nodeA.bootstrap.signal(nodeC.peerId, 'test', { data: 'hello' });
837
+
838
+ passed = formatTestResult(
839
+ 'Falls back to Firebase for unconnected peer',
840
+ nodeA.bootstrap.stats.firebaseSignals > statsBeforeFallback.firebaseSignals,
841
+ { before: statsBeforeFallback.firebaseSignals, after: nodeA.bootstrap.stats.firebaseSignals }
842
+ ) && passed;
843
+
844
+ // Cleanup
845
+ for (const node of nodes) {
846
+ await node.bootstrap.stop();
847
+ }
848
+
849
+ return passed;
850
+ }
851
+
852
+ /**
853
+ * TEST 6: DHT Routing Table Population
854
+ *
855
+ * Scenario: Validate DHT is properly populated during migration
856
+ * Expected: DHT contains all connected peers
857
+ */
858
+ async function testDHTRoutingTablePopulation() {
859
+ console.log('\n--- TEST 6: DHT Routing Table Population ---');
860
+
861
+ const nodes = createTestNetwork(8, {
862
+ dhtPeerThreshold: 3,
863
+ p2pPeerThreshold: 6,
864
+ });
865
+
866
+ let passed = true;
867
+
868
+ const firstNode = nodes[0];
869
+ await firstNode.bootstrap.start(firstNode.webrtc, firstNode.dht);
870
+
871
+ // Connect nodes and verify DHT population
872
+ for (let i = 1; i < nodes.length; i++) {
873
+ await nodes[i].bootstrap.start(nodes[i].webrtc, nodes[i].dht);
874
+ await connectNodes(firstNode, nodes[i]);
875
+ }
876
+
877
+ // Check DHT stats
878
+ const dhtStats = firstNode.dht.getStats();
879
+ const webrtcStats = firstNode.webrtc.getStats();
880
+
881
+ passed = formatTestResult(
882
+ 'DHT peers matches WebRTC connections',
883
+ dhtStats.totalPeers === webrtcStats.connectedPeers,
884
+ { dhtPeers: dhtStats.totalPeers, webrtcPeers: webrtcStats.connectedPeers }
885
+ ) && passed;
886
+
887
+ // Verify all peers are in DHT
888
+ const dhtPeerIds = new Set(firstNode.dht.getPeers().map(p => p.id));
889
+ const allConnected = nodes.slice(1).every(n => dhtPeerIds.has(n.peerId));
890
+
891
+ passed = formatTestResult(
892
+ 'All connected peers in DHT routing table',
893
+ allConnected,
894
+ { dhtPeers: Array.from(dhtPeerIds).map(p => p.slice(0, 8)) }
895
+ ) && passed;
896
+
897
+ // Cleanup
898
+ for (const node of nodes) {
899
+ await node.bootstrap.stop();
900
+ }
901
+
902
+ return passed;
903
+ }
904
+
905
+ /**
906
+ * TEST 7: Migration Timing Measurement
907
+ *
908
+ * Scenario: Measure migration timing for performance analysis
909
+ * Expected: Record timing data for analysis
910
+ */
911
+ async function testMigrationTiming() {
912
+ console.log('\n--- TEST 7: Migration Timing Measurement ---');
913
+
914
+ const nodes = createTestNetwork(15, {
915
+ dhtPeerThreshold: 3,
916
+ p2pPeerThreshold: 8,
917
+ });
918
+
919
+ let passed = true;
920
+
921
+ const firstNode = nodes[0];
922
+ await firstNode.bootstrap.start(firstNode.webrtc, firstNode.dht);
923
+
924
+ // Connect nodes and track timing
925
+ for (let i = 1; i < nodes.length; i++) {
926
+ await nodes[i].bootstrap.start(nodes[i].webrtc, nodes[i].dht);
927
+ await connectNodes(firstNode, nodes[i]);
928
+ firstNode.bootstrap.checkMigration();
929
+ }
930
+
931
+ const stats = firstNode.bootstrap.getStats();
932
+ const timestamps = stats.migrationTimestamps;
933
+
934
+ passed = formatTestResult(
935
+ 'Migration timestamps recorded',
936
+ timestamps.started !== null,
937
+ { timestamps }
938
+ ) && passed;
939
+
940
+ if (timestamps.toHybrid) {
941
+ const firebaseToHybridTime = timestamps.toHybrid - timestamps.started;
942
+ console.log(` Firebase -> Hybrid: ${firebaseToHybridTime}ms`);
943
+
944
+ passed = formatTestResult(
945
+ 'Firebase to Hybrid migration completed',
946
+ firebaseToHybridTime > 0,
947
+ { timeMs: firebaseToHybridTime }
948
+ ) && passed;
949
+ }
950
+
951
+ if (timestamps.toP2P) {
952
+ const hybridToP2PTime = timestamps.toP2P - timestamps.toHybrid;
953
+ const totalTime = timestamps.toP2P - timestamps.started;
954
+ console.log(` Hybrid -> P2P: ${hybridToP2PTime}ms`);
955
+ console.log(` Total migration: ${totalTime}ms`);
956
+
957
+ passed = formatTestResult(
958
+ 'Hybrid to P2P migration completed',
959
+ hybridToP2PTime > 0,
960
+ { timeMs: hybridToP2PTime }
961
+ ) && passed;
962
+ }
963
+
964
+ // Cleanup
965
+ for (const node of nodes) {
966
+ await node.bootstrap.stop();
967
+ }
968
+
969
+ return passed;
970
+ }
971
+
972
+ /**
973
+ * TEST 8: Threshold Configuration
974
+ *
975
+ * Scenario: Test different threshold configurations
976
+ * Expected: Migration respects configured thresholds
977
+ */
978
+ async function testThresholdConfiguration() {
979
+ console.log('\n--- TEST 8: Threshold Configuration ---');
980
+
981
+ let passed = true;
982
+
983
+ // Test with low thresholds
984
+ const lowNodes = createTestNetwork(5, {
985
+ dhtPeerThreshold: 2,
986
+ p2pPeerThreshold: 3,
987
+ });
988
+
989
+ const lowFirst = lowNodes[0];
990
+ await lowFirst.bootstrap.start(lowFirst.webrtc, lowFirst.dht);
991
+
992
+ for (let i = 1; i < 4; i++) {
993
+ await lowNodes[i].bootstrap.start(lowNodes[i].webrtc, lowNodes[i].dht);
994
+ await connectNodes(lowFirst, lowNodes[i]);
995
+ lowFirst.bootstrap.checkMigration();
996
+ }
997
+
998
+ passed = formatTestResult(
999
+ 'Low thresholds: Reaches P2P with fewer peers',
1000
+ lowFirst.bootstrap.mode === 'p2p',
1001
+ { mode: lowFirst.bootstrap.mode, peers: lowFirst.webrtc.peers.size }
1002
+ ) && passed;
1003
+
1004
+ // Test with high thresholds
1005
+ const highNodes = createTestNetwork(5, {
1006
+ dhtPeerThreshold: 10,
1007
+ p2pPeerThreshold: 20,
1008
+ });
1009
+
1010
+ const highFirst = highNodes[0];
1011
+ await highFirst.bootstrap.start(highFirst.webrtc, highFirst.dht);
1012
+
1013
+ for (let i = 1; i < 5; i++) {
1014
+ await highNodes[i].bootstrap.start(highNodes[i].webrtc, highNodes[i].dht);
1015
+ await connectNodes(highFirst, highNodes[i]);
1016
+ highFirst.bootstrap.checkMigration();
1017
+ }
1018
+
1019
+ passed = formatTestResult(
1020
+ 'High thresholds: Stays in firebase with few peers',
1021
+ highFirst.bootstrap.mode === 'firebase',
1022
+ { mode: highFirst.bootstrap.mode, peers: highFirst.webrtc.peers.size }
1023
+ ) && passed;
1024
+
1025
+ // Cleanup
1026
+ for (const node of [...lowNodes, ...highNodes]) {
1027
+ await node.bootstrap.stop();
1028
+ }
1029
+
1030
+ return passed;
1031
+ }
1032
+
1033
+ // ============================================
1034
+ // TEST RUNNER
1035
+ // ============================================
1036
+
1037
+ async function runAllTests() {
1038
+ console.log('\n' + '='.repeat(60));
1039
+ console.log('P2P MIGRATION TEST SUITE');
1040
+ console.log('='.repeat(60));
1041
+
1042
+ const results = [];
1043
+
1044
+ try {
1045
+ results.push({ name: 'Happy Path - Gradual Growth', passed: await testHappyPathGradualGrowth() });
1046
+ results.push({ name: 'Edge Case - Nodes Leaving', passed: await testNodesLeaving() });
1047
+ results.push({ name: 'Edge Case - Nodes Rejoining', passed: await testNodesRejoining() });
1048
+ results.push({ name: 'Network Partition Recovery', passed: await testNetworkPartitionRecovery() });
1049
+ results.push({ name: 'Signaling Fallback Behavior', passed: await testSignalingFallback() });
1050
+ results.push({ name: 'DHT Routing Table Population', passed: await testDHTRoutingTablePopulation() });
1051
+ results.push({ name: 'Migration Timing Measurement', passed: await testMigrationTiming() });
1052
+ results.push({ name: 'Threshold Configuration', passed: await testThresholdConfiguration() });
1053
+ } catch (error) {
1054
+ console.error('\nTest suite error:', error);
1055
+ }
1056
+
1057
+ // Summary
1058
+ console.log('\n' + '='.repeat(60));
1059
+ console.log('TEST SUMMARY');
1060
+ console.log('='.repeat(60));
1061
+
1062
+ const passed = results.filter(r => r.passed).length;
1063
+ const failed = results.filter(r => !r.passed).length;
1064
+
1065
+ for (const result of results) {
1066
+ const status = result.passed ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
1067
+ console.log(` ${status}: ${result.name}`);
1068
+ }
1069
+
1070
+ console.log('\n' + '-'.repeat(60));
1071
+ console.log(`Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
1072
+ console.log('='.repeat(60) + '\n');
1073
+
1074
+ return failed === 0;
1075
+ }
1076
+
1077
+ // Run if executed directly
1078
+ if (process.argv[1]?.endsWith('p2p-migration-test.js')) {
1079
+ runAllTests()
1080
+ .then(success => process.exit(success ? 0 : 1))
1081
+ .catch(err => {
1082
+ console.error('Fatal error:', err);
1083
+ process.exit(1);
1084
+ });
1085
+ }
1086
+
1087
+ export {
1088
+ runAllTests,
1089
+ testHappyPathGradualGrowth,
1090
+ testNodesLeaving,
1091
+ testNodesRejoining,
1092
+ testNetworkPartitionRecovery,
1093
+ testSignalingFallback,
1094
+ testDHTRoutingTablePopulation,
1095
+ testMigrationTiming,
1096
+ testThresholdConfiguration,
1097
+ SimulatedHybridBootstrap,
1098
+ MockWebRTCPeerManager,
1099
+ MockDHTNode,
1100
+ MockFirebaseSignaling,
1101
+ createTestNetwork,
1102
+ };