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

537 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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:**
```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=<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:
```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<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:
```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<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:
```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<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:
```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 | 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.