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
This commit is contained in:
parent
a7d10f71bd
commit
96641283c3
@ -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<Tab> {
|
||||
const { browserContext } = await this._ensureBrowserContext();
|
||||
const page = await browserContext.newPage();
|
||||
|
||||
317
src/tab.ts
317
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<TabEventsInterface> {
|
||||
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<TabEventsInterface> {
|
||||
}
|
||||
}
|
||||
|
||||
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<string, RTCPeerConnection>();
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@ -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<any>[] = [
|
||||
...themeManagement,
|
||||
...video,
|
||||
...wait,
|
||||
...webrtc,
|
||||
];
|
||||
|
||||
export function filteredTools(config: FullConfig) {
|
||||
|
||||
@ -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<Input extends z.Schema = z.Schema> = {
|
||||
|
||||
300
src/tools/webrtc.ts
Normal file
300
src/tools/webrtc.ts
Normal file
@ -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,
|
||||
];
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user