/** * WebRTC Reconnection Test * * Tests that WebRTC DataChannel handles disconnection and reconnection seamlessly: * - Messages continue flowing after reconnection * - Handlers persist across reconnections (no need to reattach) * - Messages sent during disconnection are buffered and delivered * * Usage: * Terminal 1 (Responder - start first): * PHYHUB_DIRECT=true DEVICE_ID= ACCESS_KEY= PEER_TWIN_ID= ROLE=responder \ * node dist/test/webrtc-reconnect-test.js * * Terminal 2 (Initiator): * PHYHUB_DIRECT=true DEVICE_ID= ACCESS_KEY= PEER_TWIN_ID= ROLE=initiator \ * node dist/test/webrtc-reconnect-test.js */ import { PhyHubClient } from '../index'; import { PhygridDataChannel } from '../services/webrtc/types'; const TEST_DURATION_MS = 120000; // 2 minutes const MESSAGE_INTERVAL_MS = 2000; const DISCONNECT_AFTER_MESSAGES = 5; interface TestMessage { seq: number; timestamp: number; phase: 'initial' | 'after-reconnect'; from: string; } interface TestStats { messagesSent: number; messagesReceived: number; reconnectCount: number; errors: string[]; phases: Set; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function runInitiatorTest(peerTwinId: string): Promise { console.log('\n=== INITIATOR: Reconnection Test ===\n'); const stats: TestStats = { messagesSent: 0, messagesReceived: 0, reconnectCount: 0, errors: [], phases: new Set(), }; // Connect to PhyHub console.log('[INIT] Connecting to PhyHub...'); const client = await PhyHubClient.connect(); console.log('[INIT] Connected!\n'); const manager = await client.getWebRTCManager({ verbose: true }); // Track reconnection events manager.on('connected', (data) => { console.log(`[EVENT] Connected to ${data.targetTwinId}`); }); manager.on('disconnected', (data) => { console.log(`[EVENT] Disconnected from ${data.targetTwinId}`); }); manager.on('reconnecting', (data) => { stats.reconnectCount++; console.log(`[EVENT] Reconnecting to ${data.targetTwinId} (attempt ${data.attempt})`); }); manager.on('reconnected', (data) => { console.log(`[EVENT] Reconnected to ${data.targetTwinId}`); }); manager.on('error', (data) => { stats.errors.push(data.error.message); console.error(`[EVENT] Error:`, data.error.message); }); // Create data channel console.log(`[INIT] Creating DataChannel to: ${peerTwinId}`); const channel = await client.getDataChannel(peerTwinId); console.log('[INIT] DataChannel created!\n'); // Setup message handler ONCE - it should persist across reconnections channel.onMessage((data: TestMessage) => { stats.messagesReceived++; stats.phases.add(data.phase); console.log(`[RECV] seq=${data.seq} phase=${data.phase} from=${data.from}`); }); // Test phases let phase: 'initial' | 'after-reconnect' = 'initial'; let seq = 0; console.log('[TEST] Starting message loop...\n'); console.log('[INFO] The peer will simulate a disconnect after receiving messages.'); console.log('[INFO] Messages should continue after reconnection automatically.\n'); const startTime = Date.now(); while (Date.now() - startTime < TEST_DURATION_MS) { seq++; const msg: TestMessage = { seq, timestamp: Date.now(), phase, from: 'initiator', }; // Use channel.send - it should buffer if disconnected channel.send(msg); stats.messagesSent++; console.log(`[SEND] seq=${seq} phase=${phase}`); // After reconnect events, switch phase if (stats.reconnectCount > 0 && phase === 'initial') { phase = 'after-reconnect'; console.log('\n[PHASE] Switched to "after-reconnect" phase\n'); } await sleep(MESSAGE_INTERVAL_MS); // Check if we've received messages from both phases (success condition) if (stats.phases.has('after-reconnect') && stats.messagesReceived > 10) { console.log('\n[SUCCESS] Received messages from both phases!'); break; } } // Print results console.log('\n' + '='.repeat(50)); console.log('Test Results (Initiator)'); console.log('='.repeat(50)); console.log(`Messages sent: ${stats.messagesSent}`); console.log(`Messages received: ${stats.messagesReceived}`); console.log(`Reconnect events: ${stats.reconnectCount}`); console.log(`Phases seen: ${Array.from(stats.phases).join(', ')}`); console.log(`Errors: ${stats.errors.length > 0 ? stats.errors.join(', ') : 'None'}`); console.log('='.repeat(50)); if (stats.phases.has('after-reconnect') && stats.messagesReceived > 5) { console.log('\n[PASS] Reconnection test passed!'); } else { console.log('\n[FAIL] Reconnection test failed - did not receive messages after reconnect'); } channel.close(); } async function runResponderTest(peerTwinId: string): Promise { console.log('\n=== RESPONDER: Reconnection Test ===\n'); const stats: TestStats = { messagesSent: 0, messagesReceived: 0, reconnectCount: 0, errors: [], phases: new Set(), }; // Connect to PhyHub console.log('[INIT] Connecting to PhyHub...'); const client = await PhyHubClient.connect(); console.log('[INIT] Connected!\n'); const manager = await client.getWebRTCManager({ verbose: true }); // Track reconnection events manager.on('connected', (data) => { console.log(`[EVENT] Connected to ${data.targetTwinId}`); }); manager.on('disconnected', (data) => { console.log(`[EVENT] Disconnected from ${data.targetTwinId}`); }); manager.on('reconnecting', (data) => { stats.reconnectCount++; console.log(`[EVENT] Reconnecting to ${data.targetTwinId} (attempt ${data.attempt})`); }); manager.on('reconnected', (data) => { console.log(`[EVENT] Reconnected to ${data.targetTwinId}`); }); manager.on('error', (data) => { stats.errors.push(data.error.message); console.error(`[EVENT] Error:`, data.error.message); }); console.log(`[INIT] Waiting for DataChannel from: ${peerTwinId}\n`); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Timeout waiting for connection')); }, TEST_DURATION_MS); let phase: 'initial' | 'after-reconnect' = 'initial'; let disconnectSimulated = false; // Setup handler ONCE - this is the key test: // The handler should persist across any reconnections client .onDataChannel((ch) => { console.log('[INIT] DataChannel received!\n'); // Setup message handler - should only need to do this ONCE ch.onMessage((data: TestMessage) => { stats.messagesReceived++; stats.phases.add(data.phase); console.log(`[RECV] seq=${data.seq} phase=${data.phase} from=${data.from}`); // Echo back const response: TestMessage = { seq: stats.messagesSent + 1, timestamp: Date.now(), phase, from: 'responder', }; ch.send(response); stats.messagesSent++; console.log(`[SEND] seq=${response.seq} phase=${phase}`); // Simulate disconnect after receiving some messages if (stats.messagesReceived === DISCONNECT_AFTER_MESSAGES && !disconnectSimulated) { disconnectSimulated = true; console.log('\n[TEST] Simulating network disconnect...'); console.log('[TEST] Closing peer connection to trigger reconnection...\n'); // Access the internal peer connection and close it to simulate disconnect // This should trigger automatic reconnection try { const webrtcManager = (client as any).webrtcManager; if (webrtcManager) { const handler = webrtcManager.dataChannelHandlers?.get(peerTwinId); if (handler) { const pcManager = (handler as any).pcManager; if (pcManager) { const pc = pcManager.getPeerConnection(); if (pc) { console.log('[TEST] Closing RTCPeerConnection...'); pc.close(); } } } } } catch (err) { console.error('[TEST] Error simulating disconnect:', err); } } // After reconnect, update phase if (stats.reconnectCount > 0 && phase === 'initial') { phase = 'after-reconnect'; console.log('\n[PHASE] Switched to "after-reconnect" phase\n'); } // Success condition: received messages in both phases if (stats.phases.has('after-reconnect') && stats.messagesReceived > 10) { clearTimeout(timeout); // Print results console.log('\n' + '='.repeat(50)); console.log('Test Results (Responder)'); console.log('='.repeat(50)); console.log(`Messages sent: ${stats.messagesSent}`); console.log(`Messages received: ${stats.messagesReceived}`); console.log(`Reconnect events: ${stats.reconnectCount}`); console.log(`Phases seen: ${Array.from(stats.phases).join(', ')}`); console.log(`Errors: ${stats.errors.length > 0 ? stats.errors.join(', ') : 'None'}`); console.log('='.repeat(50)); if (stats.phases.has('after-reconnect')) { console.log('\n[PASS] Reconnection test passed!'); } else { console.log('\n[FAIL] Reconnection test failed'); } resolve(); } }); ch.onClose(() => { console.log('[EVENT] Channel closed'); }); }) .catch(reject); }); } async function main(): Promise { console.log('='.repeat(60)); console.log('WebRTC Reconnection Test'); console.log('='.repeat(60)); // Validate environment if (process.env.PHYHUB_DIRECT !== 'true') { console.error('ERROR: Set PHYHUB_DIRECT=true'); process.exit(1); } const deviceId = process.env.DEVICE_ID || process.env.PHYGRID_DEVICE_ID; const accessKey = process.env.ACCESS_KEY || process.env.PHYGRID_DEVICE_KEY; const peerTwinId = process.env.PEER_TWIN_ID; const role = process.env.ROLE?.toLowerCase(); if (!deviceId || !accessKey || !peerTwinId) { console.error('ERROR: Missing DEVICE_ID, ACCESS_KEY, or PEER_TWIN_ID'); process.exit(1); } if (role !== 'initiator' && role !== 'responder') { console.error('ERROR: ROLE must be "initiator" or "responder"'); process.exit(1); } console.log('\nConfiguration:'); console.log(` Device ID: ${deviceId}`); console.log(` Peer Twin ID: ${peerTwinId}`); console.log(` Role: ${role}`); console.log(''); try { if (role === 'initiator') { await runInitiatorTest(peerTwinId); } else { await runResponderTest(peerTwinId); } process.exit(0); } catch (error) { console.error('\n[FAILED]', error); process.exit(1); } } if (require.main === module) { main(); } export { runInitiatorTest, runResponderTest };