# Multi-Client WebRTC Testing This reference covers the hardest E2E testing scenario in MetoYou: verifying that voice/video connections actually work between multiple clients. ## Core Concept: Multiple Browser Contexts Playwright can create multiple **independent** browser contexts within a single test. Each context is an isolated session (separate cookies, storage, WebRTC state). This is how we simulate multiple users. ```typescript import { test, expect, chromium } from '@playwright/test'; test('two users can voice chat', async () => { const browser = await chromium.launch({ args: [ '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--use-file-for-fake-audio-capture=e2e/fixtures/test-tone-440hz.wav', ], }); const contextA = await browser.newContext({ permissions: ['microphone', 'camera'], }); const contextB = await browser.newContext({ permissions: ['microphone', 'camera'], }); const alice = await contextA.newPage(); const bob = await contextB.newPage(); // ... test logic with alice and bob ... await contextA.close(); await contextB.close(); await browser.close(); }); ``` ## Custom Fixture: Multi-Client Create a reusable fixture for multi-client tests: ```typescript // e2e/fixtures/multi-client.ts import { test as base, chromium, type Page, type BrowserContext, type Browser } from '@playwright/test'; type Client = { page: Page; context: BrowserContext; }; type MultiClientFixture = { createClient: () => Promise; browser: Browser; }; export const test = base.extend({ browser: async ({}, use) => { const browser = await chromium.launch({ args: [ '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', ], }); await use(browser); await browser.close(); }, createClient: async ({ browser }, use) => { const clients: Client[] = []; const factory = async (): Promise => { const context = await browser.newContext({ permissions: ['microphone', 'camera'], baseURL: 'http://localhost:4200', }); const page = await context.newPage(); clients.push({ page, context }); return { page, context }; }; await use(factory); // Cleanup for (const client of clients) { await client.context.close(); } }, }); export { expect } from '@playwright/test'; ``` **Usage:** ```typescript import { test, expect } from '../fixtures/multi-client'; test('voice call connects between two users', async ({ createClient }) => { const alice = await createClient(); const bob = await createClient(); // Login both users await alice.page.goto('/login'); await bob.page.goto('/login'); // ... login flows ... }); ``` ## Fake Media Devices Chromium's fake device flags are essential for headless WebRTC testing: | Flag | Purpose | |------|---------| | `--use-fake-device-for-media-stream` | Provides fake mic/camera devices (no real hardware needed) | | `--use-fake-ui-for-media-stream` | Auto-grants device permission prompts | | `--use-file-for-fake-audio-capture=` | Feeds a WAV/WebM file as fake mic input | | `--use-file-for-fake-video-capture=` | Feeds a Y4M/MJPEG file as fake video input | ### Test Audio File Create a 440Hz sine wave test tone for audio verification: ```bash # Generate a 5-second 440Hz mono WAV test tone ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -ar 48000 -ac 1 e2e/fixtures/test-tone-440hz.wav ``` Or use Playwright's built-in fake device which generates a test pattern (beep) automatically when `--use-fake-device-for-media-stream` is set without specifying a file. ## WebRTC Connection Introspection ### Checking RTCPeerConnection State Inject JavaScript to inspect WebRTC internals via `page.evaluate()`: ```typescript // e2e/helpers/webrtc-helpers.ts /** * Get all RTCPeerConnection instances and their states. * Requires the app to expose connections (or use a monkey-patch approach). */ export async function getPeerConnectionStates(page: Page): Promise> { return page.evaluate(() => { // Approach 1: Use chrome://webrtc-internals equivalent // Approach 2: Monkey-patch RTCPeerConnection (install before app loads) // Approach 3: Expose from app (preferred for this project) // MetoYou exposes via Angular — access the injector const appRef = (window as any).ng?.getComponent(document.querySelector('app-root')); // This needs adaptation based on actual exposure method // Fallback: Use performance.getEntriesByType or WebRTC stats return []; }); } /** * Wait for at least one peer connection to reach 'connected' state. */ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise { await page.waitForFunction(() => { // Check if any RTCPeerConnection reached 'connected' return (window as any).__rtcConnections?.some( (pc: RTCPeerConnection) => pc.connectionState === 'connected' ) ?? false; }, { timeout }); } /** * Get WebRTC stats for audio tracks (inbound/outbound). */ export async function getAudioStats(page: Page): Promise<{ outbound: { bytesSent: number; packetsSent: number } | null; inbound: { bytesReceived: number; packetsReceived: number } | null; }> { return page.evaluate(async () => { const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return { outbound: null, inbound: null }; const pc = connections[0]; const stats = await pc.getStats(); let outbound: any = null; let inbound: any = null; stats.forEach((report) => { if (report.type === 'outbound-rtp' && report.kind === 'audio') { outbound = { bytesSent: report.bytesSent, packetsSent: report.packetsSent }; } if (report.type === 'inbound-rtp' && report.kind === 'audio') { inbound = { bytesReceived: report.bytesReceived, packetsReceived: report.packetsReceived }; } }); return { outbound, inbound }; }); } ``` ### RTCPeerConnection Monkey-Patch To track all peer connections created by the app, inject a monkey-patch **before** navigation: ```typescript /** * Install RTCPeerConnection tracking on a page BEFORE navigating. * Call this immediately after page creation, before any goto(). */ export async function installWebRTCTracking(page: Page): Promise { await page.addInitScript(() => { const connections: RTCPeerConnection[] = []; (window as any).__rtcConnections = connections; const OriginalRTCPeerConnection = window.RTCPeerConnection; (window as any).RTCPeerConnection = function (...args: any[]) { const pc = new OriginalRTCPeerConnection(...args); connections.push(pc); pc.addEventListener('connectionstatechange', () => { (window as any).__lastRtcState = pc.connectionState; }); // Track remote streams pc.addEventListener('track', (event) => { if (!((window as any).__rtcRemoteTracks)) { (window as any).__rtcRemoteTracks = []; } (window as any).__rtcRemoteTracks.push({ kind: event.track.kind, id: event.track.id, readyState: event.track.readyState, }); }); return pc; } as any; // Preserve prototype chain (window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype; }); } ``` ## Voice Call Test Pattern — Full Example This is the canonical pattern for testing that voice actually connects between two clients: ```typescript import { test, expect } from '../fixtures/multi-client'; import { installWebRTCTracking, getAudioStats } from '../helpers/webrtc-helpers'; test.describe('Voice Call', () => { test('two users connect voice and exchange audio', async ({ createClient }) => { const alice = await createClient(); const bob = await createClient(); // Install WebRTC tracking BEFORE navigation await installWebRTCTracking(alice.page); await installWebRTCTracking(bob.page); await test.step('Both users log in', async () => { // Login Alice await alice.page.goto('/login'); await alice.page.getByLabel('Email').fill('alice@test.com'); await alice.page.getByLabel('Password').fill('password123'); await alice.page.getByRole('button', { name: /sign in/i }).click(); await expect(alice.page).toHaveURL(/\/search/); // Login Bob await bob.page.goto('/login'); await bob.page.getByLabel('Email').fill('bob@test.com'); await bob.page.getByLabel('Password').fill('password123'); await bob.page.getByRole('button', { name: /sign in/i }).click(); await expect(bob.page).toHaveURL(/\/search/); }); await test.step('Both users join the same room', async () => { // Navigate to a shared room (adapt URL to actual room ID) const roomUrl = '/room/test-room-id'; await alice.page.goto(roomUrl); await bob.page.goto(roomUrl); // Verify both are in the room await expect(alice.page.locator('app-chat-room')).toBeVisible(); await expect(bob.page.locator('app-chat-room')).toBeVisible(); }); await test.step('Alice starts voice', async () => { // Click the voice/call join button (adapt selector to actual UI) await alice.page.getByRole('button', { name: /join voice|connect/i }).click(); // Voice workspace should appear await expect(alice.page.locator('app-voice-workspace')).toBeVisible(); // Voice controls should be visible await expect(alice.page.locator('app-voice-controls')).toBeVisible(); }); await test.step('Bob joins voice', async () => { await bob.page.getByRole('button', { name: /join voice|connect/i }).click(); await expect(bob.page.locator('app-voice-workspace')).toBeVisible(); }); await test.step('WebRTC connection establishes', async () => { // Wait for peer connection to reach 'connected' on both sides await alice.page.waitForFunction( () => (window as any).__rtcConnections?.some( (pc: any) => pc.connectionState === 'connected' ), { timeout: 30_000 } ); await bob.page.waitForFunction( () => (window as any).__rtcConnections?.some( (pc: any) => pc.connectionState === 'connected' ), { timeout: 30_000 } ); }); await test.step('Audio is flowing in both directions', async () => { // Wait a moment for audio stats to accumulate await alice.page.waitForTimeout(3_000); // Acceptable here: waiting for stats accumulation // Check Alice is sending audio const aliceStats = await getAudioStats(alice.page); expect(aliceStats.outbound).not.toBeNull(); expect(aliceStats.outbound!.bytesSent).toBeGreaterThan(0); expect(aliceStats.outbound!.packetsSent).toBeGreaterThan(0); // Check Bob is sending audio const bobStats = await getAudioStats(bob.page); expect(bobStats.outbound).not.toBeNull(); expect(bobStats.outbound!.bytesSent).toBeGreaterThan(0); // Check Alice receives Bob's audio expect(aliceStats.inbound).not.toBeNull(); expect(aliceStats.inbound!.bytesReceived).toBeGreaterThan(0); // Check Bob receives Alice's audio expect(bobStats.inbound).not.toBeNull(); expect(bobStats.inbound!.bytesReceived).toBeGreaterThan(0); }); await test.step('Voice UI states are correct', async () => { // Both should see stream tiles for each other await expect(alice.page.locator('app-voice-workspace-stream-tile')).toHaveCount(2); await expect(bob.page.locator('app-voice-workspace-stream-tile')).toHaveCount(2); // Mute button should be visible and in unmuted state // (lucideMic icon visible, lucideMicOff NOT visible) await expect(alice.page.locator('app-voice-controls')).toBeVisible(); }); await test.step('Mute toggles correctly', async () => { // Alice mutes await alice.page.getByRole('button', { name: /mute/i }).click(); // Alice's local UI shows muted state // Bob should see Alice as muted (mute indicator on her tile) // Verify no audio being sent from Alice after mute const preStats = await getAudioStats(alice.page); await alice.page.waitForTimeout(2_000); const postStats = await getAudioStats(alice.page); // Bytes sent should not increase (or increase minimally — comfort noise) // The exact assertion depends on whether mute stops the track or sends silence }); await test.step('Alice hangs up', async () => { await alice.page.getByRole('button', { name: /hang up|disconnect|leave/i }).click(); // Voice workspace should disappear for Alice await expect(alice.page.locator('app-voice-workspace')).not.toBeVisible(); // Bob should see Alice's tile disappear await expect(bob.page.locator('app-voice-workspace-stream-tile')).toHaveCount(1); }); }); }); ``` ## Verifying Audio Is Actually Received Beyond checking `bytesReceived > 0`, you can verify actual audio energy: ```typescript /** * Check if audio energy is present on a received stream. * Uses Web Audio AnalyserNode — same approach as MetoYou's VoiceActivityService. */ export async function hasAudioEnergy(page: Page): Promise { return page.evaluate(async () => { const connections = (window as any).__rtcConnections as RTCPeerConnection[]; if (!connections?.length) return false; for (const pc of connections) { const receivers = pc.getReceivers(); for (const receiver of receivers) { if (receiver.track.kind !== 'audio' || receiver.track.readyState !== 'live') continue; const audioCtx = new AudioContext(); const source = audioCtx.createMediaStreamSource(new MediaStream([receiver.track])); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 256; source.connect(analyser); // Sample over 500ms const dataArray = new Float32Array(analyser.frequencyBinCount); await new Promise(resolve => setTimeout(resolve, 500)); analyser.getFloatTimeDomainData(dataArray); // Calculate RMS (same as VoiceActivityService) let sum = 0; for (const sample of dataArray) { sum += sample * sample; } const rms = Math.sqrt(sum / dataArray.length); audioCtx.close(); // MetoYou uses threshold 0.015 — use lower threshold for test if (rms > 0.005) return true; } } return false; }); } ``` ## Testing Speaker/Playback Volume MetoYou uses per-peer GainNode chains (0–200%). To verify: ```typescript await test.step('Bob adjusts Alice volume to 50%', async () => { // Interact with volume slider in stream tile // (adapt selector to actual volume control UI) const volumeSlider = bob.page.locator('app-voice-workspace-stream-tile') .filter({ hasText: 'Alice' }) .getByRole('slider'); await volumeSlider.fill('50'); // Verify the gain was set (check via WebRTC or app state) const gain = await bob.page.evaluate(() => { // Access VoicePlaybackService through Angular DI if exposed // Or check audio element volume const audioElements = document.querySelectorAll('audio'); return audioElements[0]?.volume; }); }); ``` ## Testing Screen Share Screen share requires `getDisplayMedia()` which cannot be auto-granted. Options: 1. **Mock at the browser level** — use `page.addInitScript()` to replace `getDisplayMedia` with a fake stream 2. **Use Chromium flags** — `--auto-select-desktop-capture-source=Entire screen` ```typescript // Mock getDisplayMedia before navigation await page.addInitScript(() => { navigator.mediaDevices.getDisplayMedia = async () => { // Create a simple canvas stream as fake screen share const canvas = document.createElement('canvas'); canvas.width = 1280; canvas.height = 720; const ctx = canvas.getContext('2d')!; ctx.fillStyle = '#4a90d9'; ctx.fillRect(0, 0, 1280, 720); ctx.fillStyle = 'white'; ctx.font = '48px sans-serif'; ctx.fillText('Fake Screen Share', 400, 380); return canvas.captureStream(30); }; }); ``` ## Debugging Tips ### Trace Viewer When a WebRTC test fails, the trace captures network requests, console logs, and screenshots: ```bash npx playwright show-trace test-results/trace.zip ``` ### Console Log Forwarding Forward browser console to Node for real-time debugging: ```typescript page.on('console', msg => console.log(`[${name}]`, msg.text())); page.on('pageerror', err => console.error(`[${name}] PAGE ERROR:`, err.message)); ``` ### WebRTC Internals Chromium exposes WebRTC stats at `chrome://webrtc-internals/`. In Playwright, access the same data via: ```typescript const stats = await page.evaluate(async () => { const pcs = (window as any).__rtcConnections; return Promise.all(pcs.map(async (pc: any) => { const stats = await pc.getStats(); const result: any[] = []; stats.forEach((report: any) => result.push(report)); return result; })); }); ``` ## Timeout Guidelines | Operation | Recommended Timeout | |-----------|-------------------| | Page navigation | 10s (default) | | Login flow | 15s | | WebRTC connection establishment | 30s | | ICE negotiation (TURN fallback) | 45s | | Audio stats accumulation | 3–5s after connection | | Full voice test (end-to-end) | 90s | | Screen share setup | 15s | ## Parallelism Warning WebRTC multi-client tests **must** run with `workers: 1` (sequential) because: - All clients share the same signaling server instance - Server state (rooms, users) is mutable - ICE candidates reference `localhost` — port conflicts possible with parallel launches If you need parallelism, use separate server instances with different ports per worker.