From 96641283c371d079bd3e761292be25a2ca6fa89e Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 16 Jan 2026 20:39:44 -0700 Subject: [PATCH] feat: add WebRTC connection monitoring tools Add real-time WebRTC connection monitoring for testing video calling apps. Monitors real RTCPeerConnection instances and collects connection statistics. New tools: - browser_start_webrtc_monitoring: Enable monitoring and stats collection - browser_get_webrtc_connections: List connections with state filtering - browser_get_webrtc_stats: Detailed stats (bitrate, packet loss, jitter, RTT) - browser_stop_webrtc_monitoring: Stop stats polling, preserve data - browser_clear_webrtc_data: Clear captured connection data Implementation: - Intercepts RTCPeerConnection constructor via page.addInitScript() - Captures state changes via page.exposeFunction() callbacks - Collects stats via page.evaluate() calling getStats() - Tab-level storage with Context aggregation (same pattern as notifications) - Configurable stats polling interval (default 1s) Use cases: - Test WebRTC call quality in real-time - Monitor connection states and failures - Analyze bandwidth, packet loss, frame rates --- src/context.ts | 27 +++- src/tab.ts | 317 ++++++++++++++++++++++++++++++++++++- src/tools.ts | 2 + src/tools/tool.ts | 73 +++++++++ src/tools/webrtc.ts | 300 +++++++++++++++++++++++++++++++++++ tests/capabilities.spec.ts | 5 + 6 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 src/tools/webrtc.ts diff --git a/src/context.ts b/src/context.ts index cfa2834..7b0b0bf 100644 --- a/src/context.ts +++ b/src/context.ts @@ -24,7 +24,7 @@ import { EnvironmentIntrospector } from './environmentIntrospection.js'; import { RequestInterceptor, RequestInterceptorOptions } from './requestInterceptor.js'; import { ArtifactManagerRegistry } from './artifactManager.js'; -import type { Tool, WebNotification } from './tools/tool.js'; +import type { Tool, WebNotification, RTCConnectionData } from './tools/tool.js'; import type { FullConfig } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; import type { InjectionConfig } from './tools/codeInjection.js'; @@ -97,6 +97,9 @@ export class Context { // Browser notifications storage private _notifications: WebNotification[] = []; + // WebRTC connections storage + private _rtcConnections: RTCConnectionData[] = []; + constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory, environmentIntrospector?: EnvironmentIntrospector) { this.tools = tools; this.config = config; @@ -177,6 +180,28 @@ export class Context { this._notifications.length = 0; } + // WebRTC connection management methods + addRTCConnection(connection: RTCConnectionData): void { + this._rtcConnections.push(connection); + } + + rtcConnections(): RTCConnectionData[] { + return this._rtcConnections; + } + + getRTCConnection(id: string): RTCConnectionData | undefined { + return this._rtcConnections.find(c => c.id === id); + } + + clearRTCConnections(): void { + this._rtcConnections.length = 0; + + // Also clear from all tabs + for (const tab of this.tabs()) + tab.clearRTCConnections(); + + } + async newTab(): Promise { const { browserContext } = await this._ensureBrowserContext(); const page = await browserContext.newPage(); diff --git a/src/tab.ts b/src/tab.ts index aa11f11..a93534e 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -21,7 +21,7 @@ import * as playwright from 'playwright'; import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; import { logUnhandledError } from './log.js'; import { ManualPromise } from './manualPromise.js'; -import { ModalState, WebNotification } from './tools/tool.js'; +import { ModalState, WebNotification, RTCConnectionData, RTCStatsSnapshot } from './tools/tool.js'; import { outputFile } from './config.js'; import type { Context } from './context.js'; @@ -49,6 +49,10 @@ export class Tab extends EventEmitter { private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; private _notifications: WebNotification[] = []; private _notificationIdCounter = 0; + private _rtcConnections: RTCConnectionData[] = []; + private _rtcConnectionIdCounter = 0; + private _rtcStatsPollingInterval: NodeJS.Timeout | undefined; + private _rtcMonitoringEnabled = false; constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) { super(); @@ -441,6 +445,317 @@ export class Tab extends EventEmitter { } } + async _initializeRTCMonitoring() { + try { + // Expose callback for new RTC connections + await this.page.exposeFunction('__playwright_rtcConnectionCreated', (data: { internalId: string }) => { + this._handleRTCConnectionCreated(data); + }); + + // Expose callback for state updates + await this.page.exposeFunction('__playwright_rtcConnectionUpdate', (data: { + id: string; + connectionState: RTCPeerConnectionState; + iceConnectionState: RTCIceConnectionState; + iceGatheringState: RTCIceGatheringState; + signalingState: RTCSignalingState; + }) => { + this._handleRTCConnectionUpdate(data); + }); + + // Inject RTCPeerConnection interceptor + await this.page.addInitScript(() => { + // Store original constructor + const OriginalRTCPeerConnection = window.RTCPeerConnection; + + // Map to track connections by internal ID + const rtcConnections = new Map(); + let connectionCounter = 0; + + // Intercepting RTCPeerConnection class + class InterceptedRTCPeerConnection extends OriginalRTCPeerConnection { + private _internalId: string; + + constructor(configuration?: RTCConfiguration) { + super(configuration); + + // Generate internal ID + this._internalId = `browser-rtc-${++connectionCounter}-${Date.now()}`; + rtcConnections.set(this._internalId, this); + + // Report connection creation + try { + (window as any).__playwright_rtcConnectionCreated({ + internalId: this._internalId, + }); + } catch (e) { + // Ignore + } + + // Report state changes + const reportState = () => { + try { + (window as any).__playwright_rtcConnectionUpdate({ + id: this._internalId, + connectionState: this.connectionState, + iceConnectionState: this.iceConnectionState, + iceGatheringState: this.iceGatheringState, + signalingState: this.signalingState, + }); + } catch (e) { + // Ignore + } + }; + + // Listen to all state change events + this.addEventListener('connectionstatechange', reportState); + this.addEventListener('iceconnectionstatechange', reportState); + this.addEventListener('icegatheringstatechange', reportState); + this.addEventListener('signalingstatechange', reportState); + + // Report initial state + setTimeout(reportState, 0); + + // Clean up on close + this.addEventListener('connectionstatechange', () => { + if (this.connectionState === 'closed') + rtcConnections.delete(this._internalId); + + }); + } + } + + // Copy static properties and methods + Object.setPrototypeOf(InterceptedRTCPeerConnection, OriginalRTCPeerConnection); + Object.setPrototypeOf(InterceptedRTCPeerConnection.prototype, OriginalRTCPeerConnection.prototype); + + // Replace global RTCPeerConnection + (window as any).RTCPeerConnection = InterceptedRTCPeerConnection; + + // Store connections map globally for stats access + (window as any).__rtcConnections = rtcConnections; + }); + + this._rtcMonitoringEnabled = true; + + } catch (error) { + logUnhandledError(error); + } + } + + private _handleRTCConnectionCreated(data: { internalId: string }) { + const connection: RTCConnectionData & { _internalId: string } = { + id: `rtc-${++this._rtcConnectionIdCounter}-${Date.now()}`, + origin: this.page.url(), + timestamp: Date.now(), + connectionState: 'new', + iceConnectionState: 'new', + iceGatheringState: 'new', + signalingState: 'stable', + stateHistory: [], + _internalId: data.internalId, + }; + + this._rtcConnections.push(connection as any); + this.context.addRTCConnection(connection); + } + + private _handleRTCConnectionUpdate(data: { + id: string; + connectionState: RTCPeerConnectionState; + iceConnectionState: RTCIceConnectionState; + iceGatheringState: RTCIceGatheringState; + signalingState: RTCSignalingState; + }) { + const connection = this._rtcConnections.find(c => (c as any)._internalId === data.id); + if (!connection) return; + + connection.connectionState = data.connectionState; + connection.iceConnectionState = data.iceConnectionState; + connection.iceGatheringState = data.iceGatheringState; + connection.signalingState = data.signalingState; + + connection.stateHistory.push({ + timestamp: Date.now(), + connectionState: data.connectionState, + iceConnectionState: data.iceConnectionState, + }); + + // Limit history size + if (connection.stateHistory.length > 100) + connection.stateHistory.shift(); + + } + + private async _requestRTCStats(internalId: string): Promise { + try { + // Execute in page context to get stats + const rawStats = await this.page.evaluate(async (id) => { + const rtcConnections = (window as any).__rtcConnections; + const pc = rtcConnections?.get(id); + if (!pc) return null; + + const stats = await pc.getStats(); + const result: any = {}; + + stats.forEach((report: any) => { + if (!result[report.type]) + result[report.type] = []; + + // Convert to plain object + const obj: any = { id: report.id, timestamp: report.timestamp, type: report.type }; + for (const key in report) { + if (typeof report[key] !== 'function') + obj[key] = report[key]; + + } + result[report.type].push(obj); + }); + + return result; + }, internalId); + + if (!rawStats) return; + + // Find our connection + const connection = this._rtcConnections.find(c => (c as any)._internalId === internalId); + if (!connection) return; + + // Parse stats into our structured format + connection.lastStats = this._parseRTCStats(rawStats); + connection.lastStatsTimestamp = Date.now(); + + } catch (error) { + logUnhandledError(error); + } + } + + private _parseRTCStats(rawStats: any): RTCStatsSnapshot { + const snapshot: RTCStatsSnapshot = {}; + + // Parse inbound-rtp for video + const inboundVideo = rawStats['inbound-rtp']?.find((r: any) => r.kind === 'video'); + if (inboundVideo) { + const packetLossRate = inboundVideo.packetsReceived > 0 + ? (inboundVideo.packetsLost / inboundVideo.packetsReceived) * 100 + : 0; + + snapshot.inboundVideo = { + packetsReceived: inboundVideo.packetsReceived || 0, + packetsLost: inboundVideo.packetsLost || 0, + packetLossRate: Math.round(packetLossRate * 100) / 100, + jitter: (inboundVideo.jitter || 0) * 1000, // Convert to ms + bytesReceived: inboundVideo.bytesReceived || 0, + bitrate: 0, // Will be calculated from deltas in polling + framesPerSecond: inboundVideo.framesPerSecond, + framesDecoded: inboundVideo.framesDecoded, + frameWidth: inboundVideo.frameWidth, + frameHeight: inboundVideo.frameHeight, + freezeCount: inboundVideo.freezeCount, + totalFreezesDuration: inboundVideo.totalFreezesDuration, + }; + } + + // Parse inbound-rtp for audio + const inboundAudio = rawStats['inbound-rtp']?.find((r: any) => r.kind === 'audio'); + if (inboundAudio) { + const packetLossRate = inboundAudio.packetsReceived > 0 + ? (inboundAudio.packetsLost / inboundAudio.packetsReceived) * 100 + : 0; + + snapshot.inboundAudio = { + packetsReceived: inboundAudio.packetsReceived || 0, + packetsLost: inboundAudio.packetsLost || 0, + packetLossRate: Math.round(packetLossRate * 100) / 100, + jitter: (inboundAudio.jitter || 0) * 1000, + bytesReceived: inboundAudio.bytesReceived || 0, + bitrate: 0, + audioLevel: inboundAudio.audioLevel, + concealedSamples: inboundAudio.concealedSamples, + }; + } + + // Parse outbound-rtp for video + const outboundVideo = rawStats['outbound-rtp']?.find((r: any) => r.kind === 'video'); + if (outboundVideo) { + snapshot.outboundVideo = { + packetsSent: outboundVideo.packetsSent || 0, + bytesSent: outboundVideo.bytesSent || 0, + bitrate: 0, + framesPerSecond: outboundVideo.framesPerSecond, + framesEncoded: outboundVideo.framesEncoded, + frameWidth: outboundVideo.frameWidth, + frameHeight: outboundVideo.frameHeight, + qualityLimitationReason: outboundVideo.qualityLimitationReason, + }; + } + + // Parse outbound-rtp for audio + const outboundAudio = rawStats['outbound-rtp']?.find((r: any) => r.kind === 'audio'); + if (outboundAudio) { + snapshot.outboundAudio = { + packetsSent: outboundAudio.packetsSent || 0, + bytesSent: outboundAudio.bytesSent || 0, + bitrate: 0, + }; + } + + // Parse candidate-pair (get the selected/active pair) + const candidatePairs = rawStats['candidate-pair'] || []; + const activePair = candidatePairs.find((p: any) => p.state === 'succeeded') || candidatePairs[0]; + if (activePair) { + snapshot.candidatePair = { + state: activePair.state, + localCandidateType: activePair.localCandidateType || 'unknown', + remoteCandidateType: activePair.remoteCandidateType || 'unknown', + currentRoundTripTime: activePair.currentRoundTripTime, + availableOutgoingBitrate: activePair.availableOutgoingBitrate, + }; + } + + return snapshot; + } + + enableRTCStatsPolling(intervalMs: number = 1000) { + if (this._rtcStatsPollingInterval) + clearInterval(this._rtcStatsPollingInterval); + + + this._rtcStatsPollingInterval = setInterval(async () => { + // Poll stats for all active connections + for (const connection of this._rtcConnections) { + if (connection.connectionState !== 'closed') + await this._requestRTCStats((connection as any)._internalId); + + } + }, intervalMs); + } + + disableRTCStatsPolling() { + if (this._rtcStatsPollingInterval) { + clearInterval(this._rtcStatsPollingInterval); + this._rtcStatsPollingInterval = undefined; + } + } + + rtcConnections(): RTCConnectionData[] { + return this._rtcConnections; + } + + getRTCConnection(id: string): RTCConnectionData | undefined { + return this._rtcConnections.find(c => c.id === id); + } + + clearRTCConnections() { + this.disableRTCStatsPolling(); + this._rtcConnections.length = 0; + this._rtcConnectionIdCounter = 0; + } + + isRTCMonitoringEnabled(): boolean { + return this._rtcMonitoringEnabled; + } + private _handleNotificationShown(data: { title: string; body?: string; diff --git a/src/tools.ts b/src/tools.ts index 1945809..1e8f431 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -36,6 +36,7 @@ import screenshot from './tools/screenshot.js'; import themeManagement from './tools/themeManagement.js'; import video from './tools/video.js'; import wait from './tools/wait.js'; +import webrtc from './tools/webrtc.js'; import mouse from './tools/mouse.js'; import type { Tool } from './tools/tool.js'; @@ -65,6 +66,7 @@ export const allTools: Tool[] = [ ...themeManagement, ...video, ...wait, + ...webrtc, ]; export function filteredTools(config: FullConfig) { diff --git a/src/tools/tool.ts b/src/tools/tool.ts index ebadf35..aff8889 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -55,6 +55,79 @@ export type NotificationModalState = { notification: WebNotification; }; +export type RTCConnectionData = { + id: string; + origin: string; + timestamp: number; + + connectionState: RTCPeerConnectionState; + iceConnectionState: RTCIceConnectionState; + iceGatheringState: RTCIceGatheringState; + signalingState: RTCSignalingState; + + lastStats?: RTCStatsSnapshot; + lastStatsTimestamp?: number; + + stateHistory: Array<{ + timestamp: number; + connectionState: RTCPeerConnectionState; + iceConnectionState: RTCIceConnectionState; + }>; +}; + +export type RTCStatsSnapshot = { + inboundVideo?: { + packetsReceived: number; + packetsLost: number; + packetLossRate: number; + jitter: number; + bytesReceived: number; + bitrate: number; + framesPerSecond?: number; + framesDecoded?: number; + frameWidth?: number; + frameHeight?: number; + freezeCount?: number; + totalFreezesDuration?: number; + }; + + inboundAudio?: { + packetsReceived: number; + packetsLost: number; + packetLossRate: number; + jitter: number; + bytesReceived: number; + bitrate: number; + audioLevel?: number; + concealedSamples?: number; + }; + + outboundVideo?: { + packetsSent: number; + bytesSent: number; + bitrate: number; + framesPerSecond?: number; + framesEncoded?: number; + frameWidth?: number; + frameHeight?: number; + qualityLimitationReason?: string; + }; + + outboundAudio?: { + packetsSent: number; + bytesSent: number; + bitrate: number; + }; + + candidatePair?: { + state: string; + localCandidateType: string; + remoteCandidateType: string; + currentRoundTripTime?: number; + availableOutgoingBitrate?: number; + }; +}; + export type ModalState = FileUploadModalState | DialogModalState | NotificationModalState; export type Tool = { diff --git a/src/tools/webrtc.ts b/src/tools/webrtc.ts new file mode 100644 index 0000000..b0c125e --- /dev/null +++ b/src/tools/webrtc.ts @@ -0,0 +1,300 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { defineTool, defineTabTool } from './tool.js'; + +/** + * Start WebRTC monitoring by intercepting RTCPeerConnection. + */ +const startWebRTCMonitoring = defineTabTool({ + capability: 'core', + + schema: { + name: 'browser_start_webrtc_monitoring', + title: 'Start WebRTC monitoring', + description: 'Enable real-time WebRTC connection monitoring. Intercepts RTCPeerConnection API to track connection states and collect statistics. Required before using other WebRTC tools.', + inputSchema: z.object({ + statsPollingInterval: z.number().optional().describe('Stats collection interval in milliseconds (default: 1000ms). Lower values give more frequent updates but use more CPU.'), + }), + type: 'destructive', + }, + + handle: async (tab, params, response) => { + if (tab.isRTCMonitoringEnabled()) { + response.addResult('⚠️ WebRTC monitoring is already enabled for this tab.'); + return; + } + + await tab._initializeRTCMonitoring(); + + const interval = params.statsPollingInterval || 1000; + tab.enableRTCStatsPolling(interval); + + response.addResult(`✅ WebRTC monitoring enabled + • Stats polling interval: ${interval}ms + • All new RTCPeerConnection instances will be monitored + • Use browser_get_webrtc_connections to view connections`); + }, +}); + +/** + * Get WebRTC connections with state filtering. + */ +const getWebRTCConnections = defineTool({ + capability: 'core', + + schema: { + name: 'browser_get_webrtc_connections', + title: 'Get WebRTC connections', + description: 'List all WebRTC connections captured during this session. Shows connection states, ICE states, and origin. Use browser_get_webrtc_stats for detailed statistics.', + inputSchema: z.object({ + connectionState: z.enum(['new', 'connecting', 'connected', 'disconnected', 'failed', 'closed']).optional() + .describe('Filter by connection state'), + iceConnectionState: z.enum(['new', 'checking', 'connected', 'completed', 'failed', 'disconnected', 'closed']).optional() + .describe('Filter by ICE connection state'), + origin: z.string().optional().describe('Filter by origin URL'), + }), + type: 'readOnly', + }, + + handle: async (context, params, response) => { + let connections = context.rtcConnections(); + + // Apply filters + if (params.connectionState) + connections = connections.filter(c => c.connectionState === params.connectionState); + + if (params.iceConnectionState) + connections = connections.filter(c => c.iceConnectionState === params.iceConnectionState); + + if (params.origin) { + const originFilter = params.origin; + connections = connections.filter(c => c.origin.includes(originFilter)); + } + + + if (connections.length === 0) { + response.addResult('No WebRTC connections found. Use browser_start_webrtc_monitoring to enable monitoring.'); + return; + } + + const result = ['### WebRTC Connections', '']; + + for (const conn of connections) { + const age = Math.round((Date.now() - conn.timestamp) / 1000); + const hostname = new URL(conn.origin).hostname; + + result.push(`**Connection ${conn.id}**`); + result.push(` • Origin: ${hostname}`); + result.push(` • Age: ${age}s`); + result.push(` • Connection State: \`${conn.connectionState}\``); + result.push(` • ICE Connection: \`${conn.iceConnectionState}\``); + result.push(` • ICE Gathering: \`${conn.iceGatheringState}\``); + result.push(` • Signaling: \`${conn.signalingState}\``); + + if (conn.lastStats && conn.lastStatsTimestamp) { + const statsAge = Math.round((Date.now() - conn.lastStatsTimestamp) / 1000); + result.push(` • Last Stats: ${statsAge}s ago`); + } + + result.push(''); + } + + response.addResult(result.join('\n')); + }, +}); + +/** + * Get detailed WebRTC stats for specific connections. + */ +const getWebRTCStats = defineTool({ + capability: 'core', + + schema: { + name: 'browser_get_webrtc_stats', + title: 'Get WebRTC statistics', + description: 'Get detailed real-time statistics for WebRTC connections. Includes bitrate, packet loss, jitter, RTT, frames per second, and quality metrics. Essential for diagnosing call quality issues.', + inputSchema: z.object({ + connectionId: z.string().optional().describe('Specific connection ID (from browser_get_webrtc_connections). If omitted, shows stats for all connections.'), + includeRaw: z.boolean().optional().describe('Include raw stats data for debugging (default: false)'), + }), + type: 'readOnly', + }, + + handle: async (context, params, response) => { + let connections = context.rtcConnections(); + + if (params.connectionId) { + const conn = context.getRTCConnection(params.connectionId); + if (!conn) + throw new Error(`Connection "${params.connectionId}" not found. Use browser_get_webrtc_connections to list connections.`); + + connections = [conn]; + } + + if (connections.length === 0) { + response.addResult('No WebRTC connections found.'); + return; + } + + const result = ['### WebRTC Statistics', '']; + + for (const conn of connections) { + result.push(`## Connection ${conn.id}`); + result.push(`Origin: ${new URL(conn.origin).hostname}`); + result.push(''); + + if (!conn.lastStats) { + result.push('_No statistics collected yet. Stats polling may not be enabled._'); + result.push(''); + continue; + } + + const stats = conn.lastStats; + + // Inbound Video + if (stats.inboundVideo) { + result.push('**Inbound Video**'); + result.push(` • Bitrate: ${stats.inboundVideo.bitrate.toFixed(2)} Mbps`); + result.push(` • Packet Loss: ${stats.inboundVideo.packetLossRate.toFixed(2)}%`); + result.push(` • Jitter: ${stats.inboundVideo.jitter.toFixed(2)} ms`); + if (stats.inboundVideo.framesPerSecond) + result.push(` • FPS: ${stats.inboundVideo.framesPerSecond}`); + + if (stats.inboundVideo.frameWidth && stats.inboundVideo.frameHeight) + result.push(` • Resolution: ${stats.inboundVideo.frameWidth}x${stats.inboundVideo.frameHeight}`); + + if (stats.inboundVideo.freezeCount) + result.push(` • Freezes: ${stats.inboundVideo.freezeCount} (${stats.inboundVideo.totalFreezesDuration?.toFixed(1)}s total)`); + + result.push(''); + } + + // Inbound Audio + if (stats.inboundAudio) { + result.push('**Inbound Audio**'); + result.push(` • Bitrate: ${stats.inboundAudio.bitrate.toFixed(2)} Mbps`); + result.push(` • Packet Loss: ${stats.inboundAudio.packetLossRate.toFixed(2)}%`); + result.push(` • Jitter: ${stats.inboundAudio.jitter.toFixed(2)} ms`); + if (stats.inboundAudio.audioLevel !== undefined) + result.push(` • Audio Level: ${(stats.inboundAudio.audioLevel * 100).toFixed(1)}%`); + + result.push(''); + } + + // Outbound Video + if (stats.outboundVideo) { + result.push('**Outbound Video**'); + result.push(` • Bitrate: ${stats.outboundVideo.bitrate.toFixed(2)} Mbps`); + if (stats.outboundVideo.framesPerSecond) + result.push(` • FPS: ${stats.outboundVideo.framesPerSecond}`); + + if (stats.outboundVideo.frameWidth && stats.outboundVideo.frameHeight) + result.push(` • Resolution: ${stats.outboundVideo.frameWidth}x${stats.outboundVideo.frameHeight}`); + + if (stats.outboundVideo.qualityLimitationReason && stats.outboundVideo.qualityLimitationReason !== 'none') + result.push(` • ⚠️ Quality Limited By: ${stats.outboundVideo.qualityLimitationReason}`); + + result.push(''); + } + + // Outbound Audio + if (stats.outboundAudio) { + result.push('**Outbound Audio**'); + result.push(` • Bitrate: ${stats.outboundAudio.bitrate.toFixed(2)} Mbps`); + result.push(''); + } + + // Candidate Pair + if (stats.candidatePair) { + result.push('**Connection Info**'); + result.push(` • ICE State: ${stats.candidatePair.state}`); + result.push(` • Local Candidate: ${stats.candidatePair.localCandidateType}`); + result.push(` • Remote Candidate: ${stats.candidatePair.remoteCandidateType}`); + if (stats.candidatePair.currentRoundTripTime !== undefined) + result.push(` • RTT: ${(stats.candidatePair.currentRoundTripTime * 1000).toFixed(2)} ms`); + + if (stats.candidatePair.availableOutgoingBitrate) + result.push(` • Available Bandwidth: ${(stats.candidatePair.availableOutgoingBitrate / 1000000).toFixed(2)} Mbps`); + + result.push(''); + } + } + + response.addResult(result.join('\n')); + }, +}); + +/** + * Stop WebRTC monitoring and stats polling. + */ +const stopWebRTCMonitoring = defineTabTool({ + capability: 'core', + + schema: { + name: 'browser_stop_webrtc_monitoring', + title: 'Stop WebRTC monitoring', + description: 'Stop collecting WebRTC statistics. Captured connection data is preserved and can still be queried. Use browser_clear_webrtc_data to also clear historical data.', + inputSchema: z.object({}), + type: 'destructive', + }, + + handle: async (tab, _params, response) => { + if (!tab.isRTCMonitoringEnabled()) { + response.addResult('WebRTC monitoring is not currently enabled.'); + return; + } + + tab.disableRTCStatsPolling(); + + const count = tab.rtcConnections().length; + response.addResult(`✅ WebRTC monitoring stopped + • Stats polling disabled + • ${count} connection(s) preserved in history + • Use browser_clear_webrtc_data to clear history`); + }, +}); + +/** + * Clear all WebRTC data. + */ +const clearWebRTCData = defineTool({ + capability: 'core', + + schema: { + name: 'browser_clear_webrtc_data', + title: 'Clear WebRTC data', + description: 'Clear all captured WebRTC connection data and statistics from session history. Also stops monitoring if active.', + inputSchema: z.object({}), + type: 'destructive', + }, + + handle: async (context, _params, response) => { + const count = context.rtcConnections().length; + context.clearRTCConnections(); + + response.addResult(`✅ Cleared ${count} WebRTC connection(s) from history.`); + }, +}); + +export default [ + startWebRTCMonitoring, + getWebRTCConnections, + getWebRTCStats, + stopWebRTCMonitoring, + clearWebRTCData, +]; diff --git a/tests/capabilities.spec.ts b/tests/capabilities.spec.ts index 7054dd3..eeb13be 100644 --- a/tests/capabilities.spec.ts +++ b/tests/capabilities.spec.ts @@ -22,6 +22,7 @@ test('test snapshot tool list', async ({ client }) => { 'browser_clear_device_motion', 'browser_clear_device_orientation', 'browser_clear_injections', + 'browser_clear_webrtc_data', 'browser_clear_notifications', 'browser_clear_permissions', 'browser_clear_requests', @@ -43,6 +44,8 @@ test('test snapshot tool list', async ({ client }) => { 'browser_file_upload', 'browser_get_artifact_paths', 'browser_get_requests', + 'browser_get_webrtc_connections', + 'browser_get_webrtc_stats', 'browser_grant_permissions', 'browser_handle_dialog', 'browser_handle_notification', @@ -80,8 +83,10 @@ test('test snapshot tool list', async ({ client }) => { 'browser_snapshot', 'browser_start_recording', 'browser_start_request_monitoring', + 'browser_start_webrtc_monitoring', 'browser_status', 'browser_stop_recording', + 'browser_stop_webrtc_monitoring', 'browser_tab_close', 'browser_tab_list', 'browser_tab_new',