Files
Toju/.agents/skills/playwright-e2e/reference/multi-client-webrtc.md
2026-04-11 15:38:16 +02:00

18 KiB
Raw Blame History

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.

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:

// 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<Client>;
  browser: Browser;
};

export const test = base.extend<MultiClientFixture>({
  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<Client> => {
      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:

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=<path> Feeds a WAV/WebM file as fake mic input
--use-file-for-fake-video-capture=<path> Feeds a Y4M/MJPEG file as fake video input

Test Audio File

Create a 440Hz sine wave test tone for audio verification:

# 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():

// 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<Array<{
  connectionState: string;
  iceConnectionState: string;
  signalingState: string;
}>> {
  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<void> {
  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:

/**
 * Install RTCPeerConnection tracking on a page BEFORE navigating.
 * Call this immediately after page creation, before any goto().
 */
export async function installWebRTCTracking(page: Page): Promise<void> {
  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:

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:

/**
 * 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<boolean> {
  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 (0200%). To verify:

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
// 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:

npx playwright show-trace test-results/trace.zip

Console Log Forwarding

Forward browser console to Node for real-time debugging:

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:

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 35s 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.