/** * WebRTC Comprehensive Test Suite * * Tests the following scenarios: * 1. DataChannel - default (unnamed) channel * 2. DataChannel - named channels (multiple to same peer) * 3. DataChannel - reconnection with handler persistence * 4. MediaStream - connection establishment * 5. MediaStream - named channels * * Usage (two terminals): * * Terminal 1 (initiator): * ``` * PHYHUB_DIRECT=true \ * DEVICE_ID= \ * ACCESS_KEY= \ * PEER_TWIN_ID= \ * ROLE=initiator \ * bunx ts-node src/test/webrtc-comprehensive-test.ts * ``` * * Terminal 2 (responder): * ``` * PHYHUB_DIRECT=true \ * DEVICE_ID= \ * ACCESS_KEY= \ * PEER_TWIN_ID= \ * ROLE=responder \ * bunx ts-node src/test/webrtc-comprehensive-test.ts * ``` */ import { PhyHubClient } from '../index'; import { PhygridDataChannel } from '../services/webrtc/types'; interface TestResult { name: string; passed: boolean; details: string; } const MESSAGES_PER_TEST = 3; const results: TestResult[] = []; function log(msg: string): void { console.log(`[${new Date().toISOString().split('T')[1].slice(0, 8)}] ${msg}`); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function addResult(name: string, passed: boolean, details: string): void { results.push({ name, passed, details }); const status = passed ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; log(`[${status}] ${name}: ${details}`); } // ============================================================================= // Test 1: DataChannel Default (Unnamed) // ============================================================================= async function testDataChannelDefault(client: PhyHubClient, peerTwinId: string, isInitiator: boolean): Promise { log('\n=== Test 1: DataChannel Default ==='); try { let channel: PhygridDataChannel; let receivedCount = 0; if (isInitiator) { channel = await client.getDataChannel(peerTwinId); log(`Created default channel. Name: ${channel.getChannelName()}`); if (channel.getChannelName() !== 'default') { throw new Error(`Expected channel name 'default', got '${channel.getChannelName()}'`); } channel.onMessage(() => receivedCount++); await sleep(2000); for (let i = 0; i < MESSAGES_PER_TEST; i++) { channel.send({ test: 'default', num: i }); await sleep(200); } await sleep(2000); addResult( 'DataChannel Default', receivedCount >= MESSAGES_PER_TEST, `Sent ${MESSAGES_PER_TEST}, received ${receivedCount}`, ); } else { await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timeout')), 30000); client.onDataChannel((ch) => { clearTimeout(timeout); channel = ch; log(`Received default channel. Name: ${ch.getChannelName()}`); ch.onMessage((data) => { receivedCount++; ch.send({ echo: data }); }); // Wait for messages then resolve setTimeout(() => { addResult('DataChannel Default', receivedCount >= MESSAGES_PER_TEST, `Received ${receivedCount} messages`); resolve(); }, 8000); }); }); } } catch (error: any) { addResult('DataChannel Default', false, error.message); } } // ============================================================================= // Test 2: DataChannel Named Channels // ============================================================================= async function testDataChannelNamed(client: PhyHubClient, peerTwinId: string, isInitiator: boolean): Promise { log('\n=== Test 2: DataChannel Named Channels ==='); const channelNames = ['control', 'data', 'telemetry']; const receivedByChannel: Map = new Map(); try { if (isInitiator) { const channels: PhygridDataChannel[] = []; // Create all named channels for (const name of channelNames) { const ch = await client.getDataChannel(peerTwinId, name); log(`Created channel '${name}'. Reported name: ${ch.getChannelName()}`); if (ch.getChannelName() !== name) { throw new Error(`Expected channel name '${name}', got '${ch.getChannelName()}'`); } receivedByChannel.set(name, 0); ch.onMessage(() => { receivedByChannel.set(name, (receivedByChannel.get(name) || 0) + 1); }); channels.push(ch); } await sleep(3000); // Send messages on each channel for (let i = 0; i < channels.length; i++) { const ch = channels[i]; for (let j = 0; j < MESSAGES_PER_TEST; j++) { ch.send({ channel: channelNames[i], num: j }); await sleep(100); } } await sleep(3000); // Check results let allPassed = true; for (const name of channelNames) { const count = receivedByChannel.get(name) || 0; if (count < MESSAGES_PER_TEST) allPassed = false; } const details = channelNames.map((n) => `${n}:${receivedByChannel.get(n)}`).join(', '); addResult('DataChannel Named', allPassed, details); channels.forEach((ch) => ch.close()); } else { // Responder: accept all named channels const promises = channelNames.map( (name) => new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(`Timeout for ${name}`)), 30000); receivedByChannel.set(name, 0); client.onDataChannel( (ch) => { clearTimeout(timeout); log(`Received channel '${name}'. Reported: ${ch.getChannelName()}`); ch.onMessage((data) => { receivedByChannel.set(name, (receivedByChannel.get(name) || 0) + 1); ch.send({ echo: data }); }); setTimeout(resolve, 10000); }, { channelName: name }, ); }), ); await Promise.all(promises); const details = channelNames.map((n) => `${n}:${receivedByChannel.get(n)}`).join(', '); const allPassed = channelNames.every((n) => (receivedByChannel.get(n) || 0) >= MESSAGES_PER_TEST); addResult('DataChannel Named', allPassed, details); } } catch (error: any) { addResult('DataChannel Named', false, error.message); } } // ============================================================================= // Test 3: DataChannel Reconnection // ============================================================================= async function testDataChannelReconnection( client: PhyHubClient, peerTwinId: string, isInitiator: boolean, ): Promise { log('\n=== Test 3: DataChannel Reconnection ==='); try { const manager = await client.getWebRTCManager({ verbose: true }); let reconnectDetected = false; let messagesAfterReconnect = 0; let channel: PhygridDataChannel; manager.on('reconnecting', () => { log('[EVENT] Reconnecting...'); }); manager.on('reconnected', () => { log('[EVENT] Reconnected!'); reconnectDetected = true; }); if (isInitiator) { channel = await client.getDataChannel(peerTwinId, 'reconnect-test'); log('Channel created for reconnect test'); // Handler registered ONCE - should persist across reconnect channel.onMessage(() => messagesAfterReconnect++); // Send initial messages for (let i = 0; i < 3; i++) { channel.send({ phase: 'before', num: i }); await sleep(200); } log('Waiting 5s before simulating disconnect...'); await sleep(5000); // Simulate disconnect by requesting it from responder channel.send({ command: 'simulate-disconnect' }); log('Waiting 15s for reconnection...'); await sleep(15000); // Send messages after reconnection for (let i = 0; i < MESSAGES_PER_TEST; i++) { channel.send({ phase: 'after', num: i }); await sleep(200); } await sleep(3000); const passed = messagesAfterReconnect >= MESSAGES_PER_TEST; addResult( 'DataChannel Reconnection', passed, `Reconnect detected: ${reconnectDetected}, messages after: ${messagesAfterReconnect}`, ); } else { await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timeout')), 60000); client.onDataChannel( (ch) => { clearTimeout(timeout); channel = ch; log('Reconnect test channel received'); // Handler registered ONCE ch.onMessage((data) => { messagesAfterReconnect++; ch.send({ echo: data }); if (data.command === 'simulate-disconnect') { log('Simulating disconnect...'); // Note: In a real test, we would close the underlying connection // For now, we just acknowledge the command } }); setTimeout(() => { addResult( 'DataChannel Reconnection', messagesAfterReconnect > 0, `Received ${messagesAfterReconnect} total messages`, ); resolve(); }, 30000); }, { channelName: 'reconnect-test' }, ); }); } } catch (error: any) { addResult('DataChannel Reconnection', false, error.message); } } // ============================================================================= // Test 4: MediaStream Connection // ============================================================================= async function testMediaStreamConnection( client: PhyHubClient, peerTwinId: string, isInitiator: boolean, ): Promise { log('\n=== Test 4: MediaStream Connection ==='); try { if (isInitiator) { const { stream, close } = await client.getMediaStream(peerTwinId); log(`MediaStream created. Target: ${stream.getTargetTwinId()}, Channel: ${stream.getChannelName()}`); if (stream.getChannelName() !== 'default') { throw new Error(`Expected channel name 'default', got '${stream.getChannelName()}'`); } // Track listener stream.onTrack((track) => { log(`Received track: ${track.kind}`); }); await sleep(5000); addResult('MediaStream Connection', true, `Connected to ${stream.getTargetTwinId()}`); close(); } else { await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timeout')), 30000); client.onMediaStream((stream) => { clearTimeout(timeout); log(`MediaStream received. Channel: ${stream.getChannelName()}`); stream.onTrack((track) => { log(`Track received: ${track.kind}`); }); setTimeout(() => { addResult('MediaStream Connection', true, `Connected from ${stream.getTargetTwinId()}`); resolve(); }, 5000); }); }); } } catch (error: any) { addResult('MediaStream Connection', false, error.message); } } // ============================================================================= // Test 5: MediaStream Named Channels // ============================================================================= async function testMediaStreamNamed(client: PhyHubClient, peerTwinId: string, isInitiator: boolean): Promise { log('\n=== Test 5: MediaStream Named Channels ==='); const channelNames = ['video', 'audio']; try { if (isInitiator) { const streams: Array<{ stream: any; close: () => void }> = []; for (const name of channelNames) { const result = await client.getMediaStream(peerTwinId, { channelName: name, }); log(`Created MediaStream '${name}'. Reported: ${result.stream.getChannelName()}`); if (result.stream.getChannelName() !== name) { throw new Error(`Expected '${name}', got '${result.stream.getChannelName()}'`); } streams.push(result); } await sleep(5000); addResult('MediaStream Named', true, `Created ${channelNames.length} named streams`); streams.forEach((s) => s.close()); } else { const receivedChannels: string[] = []; const promises = channelNames.map( (name) => new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(`Timeout for ${name}`)), 30000); client.onMediaStream( (stream) => { clearTimeout(timeout); log(`Received MediaStream '${name}'. Reported: ${stream.getChannelName()}`); receivedChannels.push(stream.getChannelName()); setTimeout(resolve, 3000); }, { channelName: name }, ); }), ); await Promise.all(promises); const passed = receivedChannels.length === channelNames.length; addResult('MediaStream Named', passed, `Received channels: ${receivedChannels.join(', ')}`); } } catch (error: any) { addResult('MediaStream Named', false, error.message); } } // ============================================================================= // Main // ============================================================================= async function main(): Promise { console.log('='.repeat(60)); console.log('WebRTC Comprehensive Test Suite'); console.log('='.repeat(60)); if (process.env.PHYHUB_DIRECT !== 'true') { console.error('ERROR: Set PHYHUB_DIRECT=true'); process.exit(1); } const peerTwinId = process.env.PEER_TWIN_ID; const role = process.env.ROLE?.toLowerCase(); const testFilter = process.env.TEST; // Optional: run specific test if (!peerTwinId) { console.error('ERROR: Set PEER_TWIN_ID'); process.exit(1); } if (role !== 'initiator' && role !== 'responder') { console.error('ERROR: Set ROLE to initiator or responder'); process.exit(1); } const isInitiator = role === 'initiator'; log(`Role: ${role}`); log(`Peer Twin ID: ${peerTwinId}`); if (testFilter) log(`Running only: ${testFilter}`); try { log('\nConnecting to PhyHub...'); const client = await PhyHubClient.connect(); log('Connected!\n'); // Run tests based on filter const tests = [ { name: 'default', fn: testDataChannelDefault }, { name: 'named', fn: testDataChannelNamed }, { name: 'reconnect', fn: testDataChannelReconnection }, { name: 'media', fn: testMediaStreamConnection }, { name: 'media-named', fn: testMediaStreamNamed }, ]; for (const test of tests) { if (!testFilter || test.name === testFilter) { await test.fn(client, peerTwinId, isInitiator); await sleep(2000); } } // Print summary console.log('\n' + '='.repeat(60)); console.log('TEST SUMMARY'); console.log('='.repeat(60)); console.log('Test'.padEnd(30) + 'Status'); console.log('-'.repeat(45)); let passed = 0; let failed = 0; for (const r of results) { const status = r.passed ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; console.log(r.name.padEnd(30) + status); if (r.passed) passed++; else failed++; } console.log('-'.repeat(45)); console.log(`Total: ${passed} passed, ${failed} failed`); console.log('='.repeat(60)); process.exit(failed > 0 ? 1 : 0); } catch (error) { console.error('\n[FATAL]', error); process.exit(1); } } if (require.main === module) { main(); }