/* eslint-disable @typescript-eslint/no-explicit-any */ import { type Page } from '@playwright/test'; /** * Install RTCPeerConnection monkey-patch on a page BEFORE navigating. * Tracks all created peer connections and their remote tracks so tests * can inspect WebRTC state via `page.evaluate()`. * * Call immediately after page creation, before any `goto()`. */ export async function installWebRTCTracking(page: Page): Promise { await page.addInitScript(() => { const connections: RTCPeerConnection[] = []; const syntheticMediaResources: { audioCtx: AudioContext; source?: AudioScheduledSourceNode; drawIntervalId?: number; }[] = []; (window as any).__rtcConnections = connections; (window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[]; (window as any).__rtcSyntheticMediaResources = syntheticMediaResources; const OriginalRTCPeerConnection = window.RTCPeerConnection; (window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) { const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args); connections.push(pc); pc.addEventListener('connectionstatechange', () => { (window as any).__lastRtcState = pc.connectionState; }); pc.addEventListener('track', (event: RTCTrackEvent) => { (window as any).__rtcRemoteTracks.push({ kind: event.track.kind, id: event.track.id, readyState: event.track.readyState }); }); return pc; } as any; (window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype; Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection); // Patch getDisplayMedia to return a synthetic screen share stream // (canvas-based video + 880Hz oscillator audio) so the browser // picker dialog is never shown. navigator.mediaDevices.getDisplayMedia = async (_constraints?: DisplayMediaStreamOptions) => { const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 480; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Canvas 2D context unavailable'); } let frameCount = 0; // Draw animated frames so video stats show increasing bytes const drawFrame = () => { frameCount++; ctx.fillStyle = `hsl(${frameCount % 360}, 70%, 50%)`; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#fff'; ctx.font = '24px monospace'; ctx.fillText(`Screen Share Frame ${frameCount}`, 40, 60); }; drawFrame(); const drawInterval = setInterval(drawFrame, 100); const videoStream = canvas.captureStream(10); // 10 fps const videoTrack = videoStream.getVideoTracks()[0]; // Stop drawing when the track ends videoTrack.addEventListener('ended', () => clearInterval(drawInterval)); // Create 880Hz oscillator for screen share audio (distinct from 440Hz voice) const audioCtx = new AudioContext(); const osc = audioCtx.createOscillator(); osc.frequency.value = 880; const dest = audioCtx.createMediaStreamDestination(); osc.connect(dest); osc.start(); if (audioCtx.state === 'suspended') { try { await audioCtx.resume(); } catch {} } const audioTrack = dest.stream.getAudioTracks()[0]; // Combine video + audio into one stream const resultStream = new MediaStream([videoTrack, audioTrack]); syntheticMediaResources.push({ audioCtx, source: osc, drawIntervalId: drawInterval as unknown as number }); audioTrack.addEventListener('ended', () => { clearInterval(drawInterval); try { osc.stop(); } catch {} void audioCtx.close().catch(() => {}); }, { once: true }); // Tag the stream so tests can identify it (resultStream as any).__isScreenShare = true; return resultStream; }; }); } /** * Wait until at least one RTCPeerConnection reaches the 'connected' state. */ /** * Ensure every `AudioContext` created by the page auto-resumes so that * the input-gain Web Audio pipeline (`source -> gain -> destination`) never * stalls in the "suspended" state. * * On Linux with multiple headless Chromium instances, `new AudioContext()` * can start suspended without a user-gesture gate, causing the media * pipeline to emit only a single RTP packet. * * Call once per page, BEFORE navigating, alongside `installWebRTCTracking`. */ export async function installAutoResumeAudioContext(page: Page): Promise { await page.addInitScript(() => { const OrigAudioContext = window.AudioContext; (window as any).AudioContext = function(this: AudioContext, ...args: any[]) { const ctx: AudioContext = new OrigAudioContext(...args); // Track all created AudioContexts for test diagnostics const tracked = ((window as any).__trackedAudioContexts ??= []) as AudioContext[]; tracked.push(ctx); if (ctx.state === 'suspended') { ctx.resume().catch(() => { /* noop */ }); } // Also catch transitions to suspended after creation ctx.addEventListener('statechange', () => { if (ctx.state === 'suspended') { ctx.resume().catch(() => { /* noop */ }); } }); return ctx; } as any; (window as any).AudioContext.prototype = OrigAudioContext.prototype; Object.setPrototypeOf((window as any).AudioContext, OrigAudioContext); }); } export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise { await page.waitForFunction( () => (window as any).__rtcConnections?.some( (pc: RTCPeerConnection) => pc.connectionState === 'connected' ) ?? false, { timeout } ); } /** * Check that a peer connection is still in 'connected' state (not failed/disconnected). */ export async function isPeerStillConnected(page: Page): Promise { return page.evaluate( () => (window as any).__rtcConnections?.some( (pc: RTCPeerConnection) => pc.connectionState === 'connected' ) ?? false ); } /** Returns the number of tracked peer connections in `connected` state. */ export async function getConnectedPeerCount(page: Page): Promise { return page.evaluate( () => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( (pc) => pc.connectionState === 'connected' ).length ?? 0 ); } /** Wait until the expected number of peer connections are `connected`. */ export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise { await page.waitForFunction( (count) => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( (pc) => pc.connectionState === 'connected' ).length === count, expectedCount, { timeout } ); } /** * Resume all suspended AudioContext instances created by the synthetic * media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so * Chrome treats the call as a user-gesture - this satisfies the autoplay * policy that otherwise blocks `AudioContext.resume()`. */ export async function resumeSyntheticAudioContexts(page: Page): Promise { const cdpSession = await page.context().newCDPSession(page); try { const result = await cdpSession.send('Runtime.evaluate', { expression: `(async () => { const resources = window.__rtcSyntheticMediaResources; if (!resources) return 0; let resumed = 0; for (const r of resources) { if (r.audioCtx.state === 'suspended') { await r.audioCtx.resume(); resumed++; } } return resumed; })()`, awaitPromise: true, userGesture: true }); return result.result.value ?? 0; } finally { await cdpSession.detach(); } } interface PerPeerAudioStat { connectionState: string; inboundBytes: number; inboundPackets: number; outboundBytes: number; outboundPackets: number; } /** Get per-peer audio stats for every tracked RTCPeerConnection. */ export async function getPerPeerAudioStats(page: Page): Promise { return page.evaluate(async () => { const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) { return []; } const snapshots: PerPeerAudioStat[] = []; for (const pc of connections) { let inboundBytes = 0; let inboundPackets = 0; let outboundBytes = 0; let outboundPackets = 0; try { const stats = await pc.getStats(); stats.forEach((report: any) => { const kind = report.kind ?? report.mediaType; if (report.type === 'outbound-rtp' && kind === 'audio') { outboundBytes += report.bytesSent ?? 0; outboundPackets += report.packetsSent ?? 0; } if (report.type === 'inbound-rtp' && kind === 'audio') { inboundBytes += report.bytesReceived ?? 0; inboundPackets += report.packetsReceived ?? 0; } }); } catch { // Closed connection. } snapshots.push({ connectionState: pc.connectionState, inboundBytes, inboundPackets, outboundBytes, outboundPackets }); } return snapshots; }); } /** Wait until every connected peer connection shows inbound and outbound audio flow. */ export async function waitForAllPeerAudioFlow( page: Page, expectedConnectedPeers: number, timeoutMs = 45_000, pollIntervalMs = 1_000 ): Promise { const deadline = Date.now() + timeoutMs; // Track which peer indices have been confirmed flowing at least once. // This prevents a peer from being missed just because it briefly paused // during one specific poll interval. const confirmedFlowing = new Set(); let previous = await getPerPeerAudioStats(page); while (Date.now() < deadline) { await page.waitForTimeout(pollIntervalMs); const current = await getPerPeerAudioStats(page); const connectedPeers = current.filter((stat) => stat.connectionState === 'connected'); if (connectedPeers.length >= expectedConnectedPeers) { for (let index = 0; index < current.length; index++) { const curr = current[index]; if (!curr || curr.connectionState !== 'connected') { continue; } const prev = previous[index] ?? { connectionState: 'new', inboundBytes: 0, inboundPackets: 0, outboundBytes: 0, outboundPackets: 0 }; const inboundFlowing = curr.inboundBytes > prev.inboundBytes || curr.inboundPackets > prev.inboundPackets; const outboundFlowing = curr.outboundBytes > prev.outboundBytes || curr.outboundPackets > prev.outboundPackets; if (inboundFlowing && outboundFlowing) { confirmedFlowing.add(index); } } // Check if enough peers have been confirmed across all samples const connectedIndices = current .map((stat, idx) => stat.connectionState === 'connected' ? idx : -1) .filter((idx) => idx >= 0); const confirmedCount = connectedIndices.filter((idx) => confirmedFlowing.has(idx)).length; if (confirmedCount >= expectedConnectedPeers) { return; } } previous = current; } throw new Error(`Timed out waiting for ${expectedConnectedPeers} peers with bidirectional audio flow`); } /** * Get outbound and inbound audio RTP stats aggregated across all peer * connections. Uses a per-connection high water mark stored on `window` so * that connections that close mid-measurement still contribute their last * known counters, preventing the aggregate from going backwards. */ 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 }; interface HWMEntry { outBytesSent: number; outPacketsSent: number; inBytesReceived: number; inPacketsReceived: number; hasOutbound: boolean; hasInbound: boolean; }; const hwm: Record = (window as any).__rtcStatsHWM = ((window as any).__rtcStatsHWM as Record | undefined) ?? {}; for (let idx = 0; idx < connections.length; idx++) { let stats: RTCStatsReport; try { stats = await connections[idx].getStats(); } catch { continue; // closed connection - keep its last HWM } let obytes = 0; let opackets = 0; let ibytes = 0; let ipackets = 0; let hasOut = false; let hasIn = false; stats.forEach((report: any) => { const kind = report.kind ?? report.mediaType; if (report.type === 'outbound-rtp' && kind === 'audio') { hasOut = true; obytes += report.bytesSent ?? 0; opackets += report.packetsSent ?? 0; } if (report.type === 'inbound-rtp' && kind === 'audio') { hasIn = true; ibytes += report.bytesReceived ?? 0; ipackets += report.packetsReceived ?? 0; } }); if (hasOut || hasIn) { hwm[idx] = { outBytesSent: obytes, outPacketsSent: opackets, inBytesReceived: ibytes, inPacketsReceived: ipackets, hasOutbound: hasOut, hasInbound: hasIn }; } } let totalOutBytes = 0; let totalOutPackets = 0; let totalInBytes = 0; let totalInPackets = 0; let anyOutbound = false; let anyInbound = false; for (const entry of Object.values(hwm)) { totalOutBytes += entry.outBytesSent; totalOutPackets += entry.outPacketsSent; totalInBytes += entry.inBytesReceived; totalInPackets += entry.inPacketsReceived; if (entry.hasOutbound) anyOutbound = true; if (entry.hasInbound) anyInbound = true; } return { outbound: anyOutbound ? { bytesSent: totalOutBytes, packetsSent: totalOutPackets } : null, inbound: anyInbound ? { bytesReceived: totalInBytes, packetsReceived: totalInPackets } : null }; }); } /** * Snapshot audio stats, wait `durationMs`, snapshot again, and return the delta. * Useful for verifying audio is actively flowing (bytes increasing). */ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promise<{ outboundBytesDelta: number; inboundBytesDelta: number; outboundPacketsDelta: number; inboundPacketsDelta: number; }> { const before = await getAudioStats(page); await page.waitForTimeout(durationMs); const after = await getAudioStats(page); return { outboundBytesDelta: (after.outbound?.bytesSent ?? 0) - (before.outbound?.bytesSent ?? 0), inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0), outboundPacketsDelta: (after.outbound?.packetsSent ?? 0) - (before.outbound?.packetsSent ?? 0), inboundPacketsDelta: (after.inbound?.packetsReceived ?? 0) - (before.inbound?.packetsReceived ?? 0) }; } /** * Wait until at least one connection has both outbound-rtp and inbound-rtp * audio reports. Call after `waitForPeerConnected` to ensure the audio * pipeline is ready before measuring deltas. */ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise { await page.waitForFunction( async () => { const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return false; for (const pc of connections) { let stats: RTCStatsReport; try { stats = await pc.getStats(); } catch { continue; } let hasOut = false; let hasIn = false; stats.forEach((report: any) => { const kind = report.kind ?? report.mediaType; if (report.type === 'outbound-rtp' && kind === 'audio') hasOut = true; if (report.type === 'inbound-rtp' && kind === 'audio') hasIn = true; }); if (hasOut && hasIn) return true; } return false; }, { timeout } ); } interface AudioFlowDelta { outboundBytesDelta: number; inboundBytesDelta: number; outboundPacketsDelta: number; inboundPacketsDelta: number; } function snapshotToDelta( curr: Awaited>, prev: Awaited> ): AudioFlowDelta { return { outboundBytesDelta: (curr.outbound?.bytesSent ?? 0) - (prev.outbound?.bytesSent ?? 0), inboundBytesDelta: (curr.inbound?.bytesReceived ?? 0) - (prev.inbound?.bytesReceived ?? 0), outboundPacketsDelta: (curr.outbound?.packetsSent ?? 0) - (prev.outbound?.packetsSent ?? 0), inboundPacketsDelta: (curr.inbound?.packetsReceived ?? 0) - (prev.inbound?.packetsReceived ?? 0) }; } function isDeltaFlowing(delta: AudioFlowDelta): boolean { const outFlowing = delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0; const inFlowing = delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0; return outFlowing && inFlowing; } /** * Poll until two consecutive HWM-based reads show both outbound and inbound * audio byte counts increasing. Combines per-connection high-water marks * (which prevent totals from going backwards after connection churn) with * consecutive comparison (which avoids a stale single baseline). */ export async function waitForAudioFlow( page: Page, timeoutMs = 30_000, pollIntervalMs = 1_000 ): Promise { const deadline = Date.now() + timeoutMs; let prev = await getAudioStats(page); while (Date.now() < deadline) { await page.waitForTimeout(pollIntervalMs); const curr = await getAudioStats(page); const delta = snapshotToDelta(curr, prev); if (isDeltaFlowing(delta)) { return delta; } prev = curr; } // Timeout - return zero deltas so the caller's assertion reports the failure. return { outboundBytesDelta: 0, inboundBytesDelta: 0, outboundPacketsDelta: 0, inboundPacketsDelta: 0 }; } /** * Get outbound and inbound video RTP stats aggregated across all peer * connections. Uses the same HWM pattern as {@link getAudioStats}. */ export async function getVideoStats(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 }; interface VHWM { outBytesSent: number; outPacketsSent: number; inBytesReceived: number; inPacketsReceived: number; hasOutbound: boolean; hasInbound: boolean; } const hwm: Record = (window as any).__rtcVideoStatsHWM = ((window as any).__rtcVideoStatsHWM as Record | undefined) ?? {}; for (let idx = 0; idx < connections.length; idx++) { let stats: RTCStatsReport; try { stats = await connections[idx].getStats(); } catch { continue; } let obytes = 0; let opackets = 0; let ibytes = 0; let ipackets = 0; let hasOut = false; let hasIn = false; stats.forEach((report: any) => { const kind = report.kind ?? report.mediaType; if (report.type === 'outbound-rtp' && kind === 'video') { hasOut = true; obytes += report.bytesSent ?? 0; opackets += report.packetsSent ?? 0; } if (report.type === 'inbound-rtp' && kind === 'video') { hasIn = true; ibytes += report.bytesReceived ?? 0; ipackets += report.packetsReceived ?? 0; } }); if (hasOut || hasIn) { hwm[idx] = { outBytesSent: obytes, outPacketsSent: opackets, inBytesReceived: ibytes, inPacketsReceived: ipackets, hasOutbound: hasOut, hasInbound: hasIn }; } } let totalOutBytes = 0; let totalOutPackets = 0; let totalInBytes = 0; let totalInPackets = 0; let anyOutbound = false; let anyInbound = false; for (const entry of Object.values(hwm)) { totalOutBytes += entry.outBytesSent; totalOutPackets += entry.outPacketsSent; totalInBytes += entry.inBytesReceived; totalInPackets += entry.inPacketsReceived; if (entry.hasOutbound) anyOutbound = true; if (entry.hasInbound) anyInbound = true; } return { outbound: anyOutbound ? { bytesSent: totalOutBytes, packetsSent: totalOutPackets } : null, inbound: anyInbound ? { bytesReceived: totalInBytes, packetsReceived: totalInPackets } : null }; }); } /** * Wait until at least one connection has both outbound-rtp and inbound-rtp * video reports. */ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise { await page.waitForFunction( async () => { const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return false; for (const pc of connections) { let stats: RTCStatsReport; try { stats = await pc.getStats(); } catch { continue; } let hasOut = false; let hasIn = false; stats.forEach((report: any) => { const kind = report.kind ?? report.mediaType; if (report.type === 'outbound-rtp' && kind === 'video') hasOut = true; if (report.type === 'inbound-rtp' && kind === 'video') hasIn = true; }); if (hasOut && hasIn) return true; } return false; }, { timeout } ); } interface VideoFlowDelta { outboundBytesDelta: number; inboundBytesDelta: number; outboundPacketsDelta: number; inboundPacketsDelta: number; } function videoSnapshotToDelta( curr: Awaited>, prev: Awaited> ): VideoFlowDelta { return { outboundBytesDelta: (curr.outbound?.bytesSent ?? 0) - (prev.outbound?.bytesSent ?? 0), inboundBytesDelta: (curr.inbound?.bytesReceived ?? 0) - (prev.inbound?.bytesReceived ?? 0), outboundPacketsDelta: (curr.outbound?.packetsSent ?? 0) - (prev.outbound?.packetsSent ?? 0), inboundPacketsDelta: (curr.inbound?.packetsReceived ?? 0) - (prev.inbound?.packetsReceived ?? 0) }; } function isVideoDeltaFlowing(delta: VideoFlowDelta): boolean { const outFlowing = delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0; const inFlowing = delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0; return outFlowing && inFlowing; } /** * Poll until two consecutive HWM-based reads show both outbound and inbound * video byte counts increasing - proving screen share video is flowing. */ export async function waitForVideoFlow( page: Page, timeoutMs = 30_000, pollIntervalMs = 1_000 ): Promise { const deadline = Date.now() + timeoutMs; let prev = await getVideoStats(page); while (Date.now() < deadline) { await page.waitForTimeout(pollIntervalMs); const curr = await getVideoStats(page); const delta = videoSnapshotToDelta(curr, prev); if (isVideoDeltaFlowing(delta)) { return delta; } prev = curr; } return { outboundBytesDelta: 0, inboundBytesDelta: 0, outboundPacketsDelta: 0, inboundPacketsDelta: 0 }; } /** * Wait until outbound video bytes are increasing (sender side). * Use on the page that is sharing its screen. */ export async function waitForOutboundVideoFlow( page: Page, timeoutMs = 30_000, pollIntervalMs = 1_000 ): Promise { const deadline = Date.now() + timeoutMs; let prev = await getVideoStats(page); while (Date.now() < deadline) { await page.waitForTimeout(pollIntervalMs); const curr = await getVideoStats(page); const delta = videoSnapshotToDelta(curr, prev); if (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0) { return delta; } prev = curr; } return { outboundBytesDelta: 0, inboundBytesDelta: 0, outboundPacketsDelta: 0, inboundPacketsDelta: 0 }; } /** * Wait until inbound video bytes are increasing (receiver side). * Use on the page that is viewing someone else's screen share. */ export async function waitForInboundVideoFlow( page: Page, timeoutMs = 30_000, pollIntervalMs = 1_000 ): Promise { const deadline = Date.now() + timeoutMs; let prev = await getVideoStats(page); while (Date.now() < deadline) { await page.waitForTimeout(pollIntervalMs); const curr = await getVideoStats(page); const delta = videoSnapshotToDelta(curr, prev); if (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0) { return delta; } prev = curr; } return { outboundBytesDelta: 0, inboundBytesDelta: 0, outboundPacketsDelta: 0, inboundPacketsDelta: 0 }; } /** * Dump full RTC connection diagnostics for debugging audio flow failures. */ export async function dumpRtcDiagnostics(page: Page): Promise { return page.evaluate(async () => { const conns = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; if (!conns?.length) return 'No connections tracked'; const lines: string[] = [`Total connections: ${conns.length}`]; for (let idx = 0; idx < conns.length; idx++) { const pc = conns[idx]; lines.push(`PC[${idx}]: connection=${pc.connectionState}, signaling=${pc.signalingState}`); const senders = pc.getSenders().map( (sender) => `${sender.track?.kind ?? 'none'}:enabled=${sender.track?.enabled}:${sender.track?.readyState ?? 'null'}` ); const receivers = pc.getReceivers().map( (recv) => `${recv.track?.kind ?? 'none'}:enabled=${recv.track?.enabled}:${recv.track?.readyState ?? 'null'}` ); lines.push(` senders=[${senders.join(', ')}]`); lines.push(` receivers=[${receivers.join(', ')}]`); try { const stats = await pc.getStats(); stats.forEach((report: any) => { if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp') return; const kind = report.kind ?? report.mediaType; const bytes = report.type === 'outbound-rtp' ? report.bytesSent : report.bytesReceived; const packets = report.type === 'outbound-rtp' ? report.packetsSent : report.packetsReceived; lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`); }); } catch (err: any) { lines.push(` getStats() failed: ${err?.message ?? err}`); } } return lines.join('\n'); }); }