feat: fix video recording session persistence and add HTTP request monitoring
Video Recording Fixes: - Fix session persistence issues where recording state was lost between tool calls - Improve page video object handling by triggering navigation when needed - Add browser_reveal_artifact_paths tool to show exact file locations - Enhance browser_recording_status with detailed debugging info and file listings - Add clearVideoRecordingState() method for proper state management - Keep recording config available for debugging until new session starts Request Monitoring System: - Add comprehensive RequestInterceptor class for HTTP traffic capture - Implement 5 new MCP tools for request monitoring and analysis - Support multiple export formats: JSON, HAR, CSV, and summary reports - Add filtering by domain, method, status codes, and response timing - Integrate with artifact storage for organized session-based file management - Enhance browser_network_requests with rich intercepted data Additional Improvements: - Add getBaseDirectory/getSessionDirectory methods to ArtifactManager - Fix floating promise in tab.ts extension console message polling - Add debug script for comprehensive video recording workflow testing - Update README with new tool documentation Resolves video recording workflow issues and adds powerful HTTP traffic analysis capabilities for web application debugging and security testing.
This commit is contained in:
parent
4ac76bd886
commit
9257404ba3
10 changed files with 1728 additions and 29 deletions
|
|
@ -69,6 +69,20 @@ export class ArtifactManager {
|
|||
return path.join(this._sessionDir, sanitizeForFilePath(filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base directory for all artifacts
|
||||
*/
|
||||
getBaseDirectory(): string {
|
||||
return this._baseDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session-specific directory
|
||||
*/
|
||||
getSessionDirectory(): string {
|
||||
return this._sessionDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subdirectory within the session directory
|
||||
*/
|
||||
|
|
|
|||
145
src/context.ts
145
src/context.ts
|
|
@ -21,6 +21,8 @@ import { devices } from 'playwright';
|
|||
import { logUnhandledError } from './log.js';
|
||||
import { Tab } from './tab.js';
|
||||
import { EnvironmentIntrospector } from './environmentIntrospection.js';
|
||||
import { RequestInterceptor, RequestInterceptorOptions } from './requestInterceptor.js';
|
||||
import { ArtifactManagerRegistry } from './artifactManager.js';
|
||||
|
||||
import type { Tool } from './tools/tool.js';
|
||||
import type { FullConfig } from './config.js';
|
||||
|
|
@ -51,6 +53,9 @@ export class Context {
|
|||
// Chrome extension management
|
||||
private _installedExtensions: Array<{ path: string; name: string; version?: string }> = [];
|
||||
|
||||
// Request interception for traffic analysis
|
||||
private _requestInterceptor: RequestInterceptor | undefined;
|
||||
|
||||
// Differential snapshot tracking
|
||||
private _lastSnapshotFingerprint: string | undefined;
|
||||
private _lastPageState: { url: string; title: string } | undefined;
|
||||
|
|
@ -178,8 +183,17 @@ export class Context {
|
|||
this._currentTab = tab;
|
||||
|
||||
// Track pages with video recording
|
||||
if (this._videoRecordingConfig && page.video())
|
||||
// Note: page.video() may be null initially, so we track based on config presence
|
||||
if (this._videoRecordingConfig) {
|
||||
this._activePagesWithVideos.add(page);
|
||||
testDebug(`Added page to video tracking. Active recordings: ${this._activePagesWithVideos.size}`);
|
||||
}
|
||||
|
||||
// Attach request interceptor to new pages
|
||||
if (this._requestInterceptor) {
|
||||
void this._requestInterceptor.attach(page);
|
||||
testDebug('Request interceptor attached to new page');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +233,9 @@ export class Context {
|
|||
}
|
||||
|
||||
async dispose() {
|
||||
// Clean up request interceptor
|
||||
this.stopRequestMonitoring();
|
||||
|
||||
await this.closeBrowserContext();
|
||||
Context._allContexts.delete(this);
|
||||
}
|
||||
|
|
@ -316,9 +333,9 @@ export class Context {
|
|||
const browserContext = await browser.newContext(contextOptions);
|
||||
|
||||
// Apply offline mode if configured
|
||||
if ((this.config as any).offline !== undefined) {
|
||||
if ((this.config as any).offline !== undefined)
|
||||
await browserContext.setOffline((this.config as any).offline);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
browserContext,
|
||||
|
|
@ -330,6 +347,9 @@ export class Context {
|
|||
}
|
||||
|
||||
setVideoRecording(config: { dir: string; size?: { width: number; height: number } }, baseFilename: string) {
|
||||
// Clear any existing video recording state first
|
||||
this.clearVideoRecordingState();
|
||||
|
||||
this._videoRecordingConfig = config;
|
||||
this._videoBaseFilename = baseFilename;
|
||||
|
||||
|
|
@ -339,6 +359,8 @@ export class Context {
|
|||
// The next call to _ensureBrowserContext will create a new context with video recording
|
||||
});
|
||||
}
|
||||
|
||||
testDebug(`Video recording configured: ${JSON.stringify(config)}, filename: ${baseFilename}`);
|
||||
}
|
||||
|
||||
getVideoRecordingInfo() {
|
||||
|
|
@ -452,33 +474,140 @@ export class Context {
|
|||
}
|
||||
|
||||
async stopVideoRecording(): Promise<string[]> {
|
||||
if (!this._videoRecordingConfig)
|
||||
if (!this._videoRecordingConfig) {
|
||||
testDebug('stopVideoRecording called but no recording config found');
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
testDebug(`stopVideoRecording: ${this._activePagesWithVideos.size} pages tracked for video`);
|
||||
const videoPaths: string[] = [];
|
||||
|
||||
// Close all pages to save videos
|
||||
// Force navigation on pages that don't have video objects yet
|
||||
// This ensures video recording actually starts
|
||||
for (const page of this._activePagesWithVideos) {
|
||||
try {
|
||||
if (!page.isClosed()) {
|
||||
const video = page.video();
|
||||
if (!video) {
|
||||
testDebug('Page has no video object, trying to trigger recording by navigating to about:blank');
|
||||
// Navigate to trigger video recording start
|
||||
await page.goto('about:blank');
|
||||
// Small delay to let video recording initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
testDebug('Error triggering video recording on page:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect video paths AFTER ensuring recording is active
|
||||
for (const page of this._activePagesWithVideos) {
|
||||
try {
|
||||
if (!page.isClosed()) {
|
||||
await page.close();
|
||||
const video = page.video();
|
||||
if (video) {
|
||||
// Get the video path before closing
|
||||
const videoPath = await video.path();
|
||||
videoPaths.push(videoPath);
|
||||
testDebug(`Found video path: ${videoPath}`);
|
||||
} else {
|
||||
testDebug('Page still has no video object after navigation attempt');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
testDebug('Error getting video path:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Now close all pages to finalize videos
|
||||
for (const page of this._activePagesWithVideos) {
|
||||
try {
|
||||
if (!page.isClosed()) {
|
||||
testDebug(`Closing page for video finalization: ${page.url()}`);
|
||||
await page.close();
|
||||
}
|
||||
} catch (error) {
|
||||
testDebug('Error closing page for video recording:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep recording config available for inspection until explicitly cleared
|
||||
// Don't clear it immediately to help with debugging
|
||||
testDebug(`stopVideoRecording complete: ${videoPaths.length} videos saved, config preserved for debugging`);
|
||||
|
||||
// Clear the page tracking but keep config for status queries
|
||||
this._activePagesWithVideos.clear();
|
||||
|
||||
return videoPaths;
|
||||
}
|
||||
|
||||
// Add method to clear video recording state (called by start recording)
|
||||
clearVideoRecordingState(): void {
|
||||
this._videoRecordingConfig = undefined;
|
||||
this._videoBaseFilename = undefined;
|
||||
this._activePagesWithVideos.clear();
|
||||
testDebug('Video recording state cleared');
|
||||
}
|
||||
|
||||
return videoPaths;
|
||||
// Request Interception and Traffic Analysis
|
||||
|
||||
/**
|
||||
* Start comprehensive request monitoring and interception
|
||||
*/
|
||||
async startRequestMonitoring(options: RequestInterceptorOptions = {}): Promise<void> {
|
||||
if (this._requestInterceptor) {
|
||||
testDebug('Request interceptor already active, stopping previous instance');
|
||||
this._requestInterceptor.detach();
|
||||
}
|
||||
|
||||
// Use artifact manager for output path if available
|
||||
if (!options.outputPath && this.sessionId) {
|
||||
const artifactManager = this.getArtifactManager();
|
||||
if (artifactManager)
|
||||
options.outputPath = artifactManager.getSubdirectory('requests');
|
||||
|
||||
}
|
||||
|
||||
this._requestInterceptor = new RequestInterceptor(options);
|
||||
|
||||
// Attach to current tab if available
|
||||
const currentTab = this._currentTab;
|
||||
if (currentTab) {
|
||||
await this._requestInterceptor.attach(currentTab.page);
|
||||
testDebug('Request interceptor attached to current tab');
|
||||
}
|
||||
|
||||
testDebug('Request monitoring started with options:', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active request interceptor
|
||||
*/
|
||||
getRequestInterceptor(): RequestInterceptor | undefined {
|
||||
return this._requestInterceptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get artifact manager for the current session
|
||||
*/
|
||||
getArtifactManager() {
|
||||
if (!this.sessionId)
|
||||
return undefined;
|
||||
|
||||
const registry = ArtifactManagerRegistry.getInstance();
|
||||
return registry.getManager(this.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop request monitoring and clean up
|
||||
*/
|
||||
stopRequestMonitoring(): void {
|
||||
if (this._requestInterceptor) {
|
||||
this._requestInterceptor.detach();
|
||||
this._requestInterceptor = undefined;
|
||||
testDebug('Request monitoring stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome Extension Management
|
||||
|
|
|
|||
521
src/requestInterceptor.ts
Normal file
521
src/requestInterceptor.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
/**
|
||||
* 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 * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
const interceptDebug = debug('pw:mcp:intercept');
|
||||
|
||||
export interface InterceptedRequest {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
resourceType: string;
|
||||
postData?: string;
|
||||
startTime: number;
|
||||
response?: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
fromCache: boolean;
|
||||
timing: any;
|
||||
duration: number;
|
||||
body?: any;
|
||||
bodyType?: 'json' | 'text' | 'base64';
|
||||
bodySize?: number;
|
||||
bodyTruncated?: boolean;
|
||||
bodyError?: string;
|
||||
};
|
||||
failed?: boolean;
|
||||
failure?: any;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface RequestInterceptorOptions {
|
||||
// Filter which URLs to capture
|
||||
urlFilter?: string | RegExp | ((url: string) => boolean);
|
||||
// Where to save the data
|
||||
outputPath?: string;
|
||||
// Whether to save after each request
|
||||
autoSave?: boolean;
|
||||
// Maximum body size to store (to avoid memory issues)
|
||||
maxBodySize?: number;
|
||||
// Whether to capture request/response bodies
|
||||
captureBody?: boolean;
|
||||
// Custom filename generator
|
||||
filename?: () => string;
|
||||
}
|
||||
|
||||
export interface RequestStats {
|
||||
totalRequests: number;
|
||||
successfulRequests: number;
|
||||
failedRequests: number;
|
||||
errorResponses: number;
|
||||
averageResponseTime: number;
|
||||
requestsByMethod: Record<string, number>;
|
||||
requestsByStatus: Record<string, number>;
|
||||
requestsByDomain: Record<string, number>;
|
||||
slowRequests: number;
|
||||
fastRequests: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive request interceptor for capturing and analyzing HTTP traffic
|
||||
* during browser automation sessions
|
||||
*/
|
||||
export class RequestInterceptor {
|
||||
private requests: InterceptedRequest[] = [];
|
||||
private options: Required<RequestInterceptorOptions>;
|
||||
private page?: playwright.Page;
|
||||
private isAttached: boolean = false;
|
||||
|
||||
constructor(options: RequestInterceptorOptions = {}) {
|
||||
this.options = {
|
||||
urlFilter: options.urlFilter || (() => true),
|
||||
outputPath: options.outputPath || './api-logs',
|
||||
autoSave: options.autoSave || false,
|
||||
maxBodySize: options.maxBodySize || 10 * 1024 * 1024, // 10MB default
|
||||
captureBody: options.captureBody !== false,
|
||||
filename: options.filename || (() => `api-log-${Date.now()}.json`)
|
||||
};
|
||||
|
||||
void this.ensureOutputDir();
|
||||
}
|
||||
|
||||
private async ensureOutputDir(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.options.outputPath, { recursive: true });
|
||||
} catch (error) {
|
||||
interceptDebug('Failed to create output directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach request interception to a Playwright page
|
||||
*/
|
||||
async attach(page: playwright.Page): Promise<void> {
|
||||
if (this.isAttached && this.page === page) {
|
||||
interceptDebug('Already attached to this page');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detach from previous page if needed
|
||||
if (this.isAttached && this.page !== page)
|
||||
this.detach();
|
||||
|
||||
|
||||
this.page = page;
|
||||
this.isAttached = true;
|
||||
|
||||
// Attach event listeners
|
||||
page.on('request', this.handleRequest.bind(this));
|
||||
page.on('response', this.handleResponse.bind(this));
|
||||
page.on('requestfailed', this.handleRequestFailed.bind(this));
|
||||
|
||||
interceptDebug(`Request interceptor attached to page: ${page.url()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach request interception from the current page
|
||||
*/
|
||||
detach(): void {
|
||||
if (!this.isAttached || !this.page)
|
||||
return;
|
||||
|
||||
this.page.off('request', this.handleRequest.bind(this));
|
||||
this.page.off('response', this.handleResponse.bind(this));
|
||||
this.page.off('requestfailed', this.handleRequestFailed.bind(this));
|
||||
|
||||
this.isAttached = false;
|
||||
this.page = undefined;
|
||||
|
||||
interceptDebug('Request interceptor detached');
|
||||
}
|
||||
|
||||
private handleRequest(request: playwright.Request): void {
|
||||
// Check if we should capture this request
|
||||
if (!this.shouldCapture(request.url()))
|
||||
return;
|
||||
|
||||
const requestData: InterceptedRequest = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
headers: request.headers(),
|
||||
resourceType: request.resourceType(),
|
||||
postData: this.options.captureBody ? (request.postData() || undefined) : undefined,
|
||||
startTime: Date.now()
|
||||
};
|
||||
|
||||
this.requests.push(requestData);
|
||||
interceptDebug(`Captured request: ${requestData.method} ${requestData.url}`);
|
||||
|
||||
// Auto-save if enabled
|
||||
if (this.options.autoSave)
|
||||
void this.save().catch(error => interceptDebug('Auto-save failed:', error));
|
||||
|
||||
}
|
||||
|
||||
private async handleResponse(response: playwright.Response): Promise<void> {
|
||||
const request = response.request();
|
||||
|
||||
// Find matching request
|
||||
const requestData = this.findRequest(request.url(), request.method());
|
||||
if (!requestData)
|
||||
return;
|
||||
|
||||
try {
|
||||
requestData.response = {
|
||||
status: response.status(),
|
||||
statusText: response.statusText(),
|
||||
headers: response.headers(),
|
||||
fromCache: (response as any).fromCache?.() || false,
|
||||
timing: await response.finished() ? null : (response as any).timing?.(),
|
||||
duration: Date.now() - requestData.startTime
|
||||
};
|
||||
|
||||
// Capture response body if enabled and size is reasonable
|
||||
if (this.options.captureBody) {
|
||||
try {
|
||||
const body = await response.body();
|
||||
if (body.length <= this.options.maxBodySize) {
|
||||
// Try to parse based on content-type
|
||||
const contentType = response.headers()['content-type'] || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
requestData.response.body = JSON.parse(body.toString());
|
||||
requestData.response.bodyType = 'json';
|
||||
} catch {
|
||||
requestData.response.body = body.toString();
|
||||
requestData.response.bodyType = 'text';
|
||||
}
|
||||
} else if (contentType.includes('text') || contentType.includes('javascript')) {
|
||||
requestData.response.body = body.toString();
|
||||
requestData.response.bodyType = 'text';
|
||||
} else {
|
||||
// Store as base64 for binary content
|
||||
requestData.response.body = body.toString('base64');
|
||||
requestData.response.bodyType = 'base64';
|
||||
}
|
||||
requestData.response.bodySize = body.length;
|
||||
} else {
|
||||
requestData.response.bodyTruncated = true;
|
||||
requestData.response.bodySize = body.length;
|
||||
}
|
||||
} catch (error: any) {
|
||||
requestData.response.bodyError = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
requestData.duration = requestData.response.duration;
|
||||
interceptDebug(`Response captured: ${requestData.response.status} ${requestData.url} (${requestData.duration}ms)`);
|
||||
|
||||
// Auto-save if enabled
|
||||
if (this.options.autoSave)
|
||||
void this.save().catch(error => interceptDebug('Auto-save failed:', error));
|
||||
|
||||
} catch (error: any) {
|
||||
interceptDebug('Error handling response:', error);
|
||||
requestData.response = {
|
||||
status: response.status(),
|
||||
statusText: response.statusText(),
|
||||
headers: response.headers(),
|
||||
fromCache: (response as any).fromCache?.() || false,
|
||||
timing: null,
|
||||
duration: Date.now() - requestData.startTime,
|
||||
bodyError: `Failed to capture response: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleRequestFailed(request: playwright.Request): void {
|
||||
const requestData = this.findRequest(request.url(), request.method());
|
||||
if (!requestData)
|
||||
return;
|
||||
|
||||
requestData.failed = true;
|
||||
requestData.failure = request.failure();
|
||||
requestData.duration = Date.now() - requestData.startTime;
|
||||
|
||||
interceptDebug(`Request failed: ${requestData.method} ${requestData.url}`);
|
||||
|
||||
if (this.options.autoSave)
|
||||
void this.save().catch(error => interceptDebug('Auto-save failed:', error));
|
||||
|
||||
}
|
||||
|
||||
private findRequest(url: string, method: string): InterceptedRequest | null {
|
||||
// Find the most recent matching request without a response
|
||||
for (let i = this.requests.length - 1; i >= 0; i--) {
|
||||
const req = this.requests[i];
|
||||
if (req.url === url && req.method === method && !req.response && !req.failed)
|
||||
return req;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private shouldCapture(url: string): boolean {
|
||||
const filter = this.options.urlFilter;
|
||||
|
||||
if (typeof filter === 'function')
|
||||
return filter(url);
|
||||
|
||||
if (filter instanceof RegExp)
|
||||
return filter.test(url);
|
||||
|
||||
if (typeof filter === 'string')
|
||||
return url.includes(filter);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all captured requests
|
||||
*/
|
||||
getData(): InterceptedRequest[] {
|
||||
return this.requests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get requests filtered by predicate
|
||||
*/
|
||||
filter(predicate: (req: InterceptedRequest) => boolean): InterceptedRequest[] {
|
||||
return this.requests.filter(predicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get failed requests (network failures or HTTP errors)
|
||||
*/
|
||||
getFailedRequests(): InterceptedRequest[] {
|
||||
return this.requests.filter(r => r.failed || (r.response && r.response.status >= 400));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slow requests above threshold
|
||||
*/
|
||||
getSlowRequests(thresholdMs: number = 1000): InterceptedRequest[] {
|
||||
return this.requests.filter(r => r.duration && r.duration > thresholdMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get requests by domain
|
||||
*/
|
||||
getRequestsByDomain(domain: string): InterceptedRequest[] {
|
||||
return this.requests.filter(r => {
|
||||
try {
|
||||
return new URL(r.url).hostname === domain;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive statistics
|
||||
*/
|
||||
getStats(): RequestStats {
|
||||
const stats: RequestStats = {
|
||||
totalRequests: this.requests.length,
|
||||
successfulRequests: 0,
|
||||
failedRequests: 0,
|
||||
errorResponses: 0,
|
||||
averageResponseTime: 0,
|
||||
requestsByMethod: {},
|
||||
requestsByStatus: {},
|
||||
requestsByDomain: {},
|
||||
slowRequests: 0,
|
||||
fastRequests: 0
|
||||
};
|
||||
|
||||
let totalTime = 0;
|
||||
let timeCount = 0;
|
||||
|
||||
this.requests.forEach(req => {
|
||||
// Count successful/failed
|
||||
if (req.failed) {
|
||||
stats.failedRequests++;
|
||||
} else if (req.response) {
|
||||
if (req.response.status < 400)
|
||||
stats.successfulRequests++;
|
||||
else
|
||||
stats.errorResponses++;
|
||||
|
||||
}
|
||||
|
||||
// Response time stats
|
||||
if (req.duration) {
|
||||
totalTime += req.duration;
|
||||
timeCount++;
|
||||
|
||||
if (req.duration > 1000)
|
||||
stats.slowRequests++;
|
||||
else
|
||||
stats.fastRequests++;
|
||||
|
||||
}
|
||||
|
||||
// Method stats
|
||||
stats.requestsByMethod[req.method] = (stats.requestsByMethod[req.method] || 0) + 1;
|
||||
|
||||
// Status stats
|
||||
if (req.response) {
|
||||
const status = req.response.status.toString();
|
||||
stats.requestsByStatus[status] = (stats.requestsByStatus[status] || 0) + 1;
|
||||
}
|
||||
|
||||
// Domain stats
|
||||
try {
|
||||
const domain = new URL(req.url).hostname;
|
||||
stats.requestsByDomain[domain] = (stats.requestsByDomain[domain] || 0) + 1;
|
||||
} catch {
|
||||
// Ignore invalid URLs
|
||||
}
|
||||
});
|
||||
|
||||
stats.averageResponseTime = timeCount > 0 ? Math.round(totalTime / timeCount) : 0;
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save captured data to file
|
||||
*/
|
||||
async save(filename?: string): Promise<string> {
|
||||
const file = filename || this.options.filename();
|
||||
const filepath = path.join(this.options.outputPath, file);
|
||||
|
||||
const data = {
|
||||
metadata: {
|
||||
capturedAt: new Date().toISOString(),
|
||||
totalRequests: this.requests.length,
|
||||
stats: this.getStats(),
|
||||
options: {
|
||||
captureBody: this.options.captureBody,
|
||||
maxBodySize: this.options.maxBodySize
|
||||
}
|
||||
},
|
||||
requests: this.requests
|
||||
};
|
||||
|
||||
await fs.writeFile(filepath, JSON.stringify(data, null, 2));
|
||||
interceptDebug(`Saved ${this.requests.length} API calls to ${filepath}`);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export data in HAR (HTTP Archive) format
|
||||
*/
|
||||
async exportHAR(filename?: string): Promise<string> {
|
||||
const file = filename || `har-export-${Date.now()}.har`;
|
||||
const filepath = path.join(this.options.outputPath, file);
|
||||
|
||||
// Convert to HAR format
|
||||
const har = {
|
||||
log: {
|
||||
version: '1.2',
|
||||
creator: {
|
||||
name: 'Playwright MCP Request Interceptor',
|
||||
version: '1.0.0'
|
||||
},
|
||||
entries: this.requests.map(req => ({
|
||||
startedDateTime: req.timestamp,
|
||||
time: req.duration || 0,
|
||||
request: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
httpVersion: 'HTTP/1.1',
|
||||
headers: Object.entries(req.headers).map(([name, value]) => ({ name, value })),
|
||||
queryString: [],
|
||||
postData: req.postData ? {
|
||||
mimeType: 'application/x-www-form-urlencoded',
|
||||
text: req.postData
|
||||
} : undefined,
|
||||
headersSize: -1,
|
||||
bodySize: req.postData?.length || 0
|
||||
},
|
||||
response: req.response ? {
|
||||
status: req.response.status,
|
||||
statusText: req.response.statusText,
|
||||
httpVersion: 'HTTP/1.1',
|
||||
headers: Object.entries(req.response.headers).map(([name, value]) => ({ name, value })),
|
||||
content: {
|
||||
size: req.response.bodySize || 0,
|
||||
mimeType: req.response.headers['content-type'] || 'text/plain',
|
||||
text: req.response.bodyType === 'text' || req.response.bodyType === 'json'
|
||||
? (typeof req.response.body === 'string' ? req.response.body : JSON.stringify(req.response.body))
|
||||
: undefined,
|
||||
encoding: req.response.bodyType === 'base64' ? 'base64' : undefined
|
||||
},
|
||||
redirectURL: '',
|
||||
headersSize: -1,
|
||||
bodySize: req.response.bodySize || 0
|
||||
} : {
|
||||
status: 0,
|
||||
statusText: 'Failed',
|
||||
httpVersion: 'HTTP/1.1',
|
||||
headers: [],
|
||||
content: { size: 0, mimeType: 'text/plain' },
|
||||
redirectURL: '',
|
||||
headersSize: -1,
|
||||
bodySize: 0
|
||||
},
|
||||
cache: {},
|
||||
timings: req.response?.timing || {
|
||||
send: 0,
|
||||
wait: req.duration || 0,
|
||||
receive: 0
|
||||
}
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(filepath, JSON.stringify(har, null, 2));
|
||||
interceptDebug(`Exported ${this.requests.length} requests to HAR format: ${filepath}`);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all captured data
|
||||
*/
|
||||
clear(): number {
|
||||
const count = this.requests.length;
|
||||
this.requests = [];
|
||||
interceptDebug(`Cleared ${count} captured requests`);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current capture status
|
||||
*/
|
||||
getStatus(): {
|
||||
isAttached: boolean;
|
||||
requestCount: number;
|
||||
pageUrl?: string;
|
||||
options: RequestInterceptorOptions;
|
||||
} {
|
||||
return {
|
||||
isAttached: this.isAttached,
|
||||
requestCount: this.requests.length,
|
||||
pageUrl: this.page?.url(),
|
||||
options: this.options
|
||||
};
|
||||
}
|
||||
}
|
||||
28
src/tab.ts
28
src/tab.ts
|
|
@ -74,7 +74,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||
|
||||
// Initialize service worker console capture
|
||||
void this._initializeServiceWorkerConsoleCapture();
|
||||
|
||||
|
||||
// Initialize extension-based console capture
|
||||
void this._initializeExtensionConsoleCapture();
|
||||
}
|
||||
|
|
@ -177,13 +177,13 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||
|
||||
// Enable runtime domain for console API calls
|
||||
await cdpSession.send('Runtime.enable');
|
||||
|
||||
|
||||
// Enable network domain for network-related errors
|
||||
await cdpSession.send('Network.enable');
|
||||
|
||||
|
||||
// Enable security domain for mixed content warnings
|
||||
await cdpSession.send('Security.enable');
|
||||
|
||||
|
||||
// Enable log domain for browser log entries
|
||||
await cdpSession.send('Log.enable');
|
||||
|
||||
|
|
@ -196,17 +196,17 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||
cdpSession.on('Runtime.exceptionThrown', (event: any) => {
|
||||
this._handleServiceWorkerException(event);
|
||||
});
|
||||
|
||||
|
||||
// Listen for network failed events
|
||||
cdpSession.on('Network.loadingFailed', (event: any) => {
|
||||
this._handleNetworkError(event);
|
||||
});
|
||||
|
||||
|
||||
// Listen for security state changes (mixed content)
|
||||
cdpSession.on('Security.securityStateChanged', (event: any) => {
|
||||
this._handleSecurityStateChange(event);
|
||||
});
|
||||
|
||||
|
||||
// Listen for log entries (browser-level logs)
|
||||
cdpSession.on('Log.entryAdded', (event: any) => {
|
||||
this._handleLogEntry(event);
|
||||
|
|
@ -327,14 +327,14 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||
try {
|
||||
// Listen for console messages from the extension
|
||||
await this.page.evaluate(() => {
|
||||
window.addEventListener('message', (event) => {
|
||||
window.addEventListener('message', event => {
|
||||
if (event.data && event.data.type === 'PLAYWRIGHT_CONSOLE_CAPTURE') {
|
||||
const message = event.data.consoleMessage;
|
||||
|
||||
|
||||
// Store the message in a global array for Playwright to access
|
||||
if (!(window as any)._playwrightExtensionConsoleMessages) {
|
||||
if (!(window as any)._playwrightExtensionConsoleMessages)
|
||||
(window as any)._playwrightExtensionConsoleMessages = [];
|
||||
}
|
||||
|
||||
(window as any)._playwrightExtensionConsoleMessages.push(message);
|
||||
}
|
||||
});
|
||||
|
|
@ -342,7 +342,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||
|
||||
// Poll for new extension console messages
|
||||
setInterval(() => {
|
||||
this._checkForExtensionConsoleMessages();
|
||||
void this._checkForExtensionConsoleMessages();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -353,9 +353,9 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||
private async _checkForExtensionConsoleMessages() {
|
||||
try {
|
||||
const newMessages = await this.page.evaluate(() => {
|
||||
if (!(window as any)._playwrightExtensionConsoleMessages) {
|
||||
if (!(window as any)._playwrightExtensionConsoleMessages)
|
||||
return [];
|
||||
}
|
||||
|
||||
const messages = (window as any)._playwrightExtensionConsoleMessages;
|
||||
(window as any)._playwrightExtensionConsoleMessages = [];
|
||||
return messages;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import artifacts from './tools/artifacts.js';
|
||||
import common from './tools/common.js';
|
||||
import configure from './tools/configure.js';
|
||||
import console from './tools/console.js';
|
||||
|
|
@ -25,6 +26,7 @@ import keyboard from './tools/keyboard.js';
|
|||
import navigate from './tools/navigate.js';
|
||||
import network from './tools/network.js';
|
||||
import pdf from './tools/pdf.js';
|
||||
import requests from './tools/requests.js';
|
||||
import snapshot from './tools/snapshot.js';
|
||||
import tabs from './tools/tabs.js';
|
||||
import screenshot from './tools/screenshot.js';
|
||||
|
|
@ -36,6 +38,7 @@ import type { Tool } from './tools/tool.js';
|
|||
import type { FullConfig } from './config.js';
|
||||
|
||||
export const allTools: Tool<any>[] = [
|
||||
...artifacts,
|
||||
...common,
|
||||
...configure,
|
||||
...console,
|
||||
|
|
@ -48,6 +51,7 @@ export const allTools: Tool<any>[] = [
|
|||
...network,
|
||||
...mouse,
|
||||
...pdf,
|
||||
...requests,
|
||||
...screenshot,
|
||||
...snapshot,
|
||||
...tabs,
|
||||
|
|
|
|||
|
|
@ -25,14 +25,76 @@ const requests = defineTabTool({
|
|||
schema: {
|
||||
name: 'browser_network_requests',
|
||||
title: 'List network requests',
|
||||
description: 'Returns all network requests since loading the page',
|
||||
inputSchema: z.object({}),
|
||||
description: 'Returns all network requests since loading the page. For more detailed analysis including timing, headers, and bodies, use the advanced request monitoring tools (browser_start_request_monitoring, browser_get_requests).',
|
||||
inputSchema: z.object({
|
||||
detailed: z.boolean().optional().default(false).describe('Show detailed request information if request monitoring is active')
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const requests = tab.requests();
|
||||
[...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
|
||||
// Check if request interceptor is active and can provide richer data
|
||||
const interceptor = tab.context.getRequestInterceptor();
|
||||
|
||||
if (params.detailed && interceptor) {
|
||||
// Use rich intercepted data
|
||||
const interceptedRequests = interceptor.getData();
|
||||
|
||||
if (interceptedRequests.length > 0) {
|
||||
response.addResult('📊 **Network Requests (Enhanced)**');
|
||||
response.addResult('');
|
||||
|
||||
interceptedRequests.forEach((req, index) => {
|
||||
const duration = req.duration ? ` (${req.duration}ms)` : '';
|
||||
const status = req.failed ? 'FAILED' : req.response?.status || 'pending';
|
||||
const size = req.response?.bodySize ? ` - ${(req.response.bodySize / 1024).toFixed(1)}KB` : '';
|
||||
|
||||
response.addResult(`${index + 1}. **${req.method} ${status}**${duration}`);
|
||||
response.addResult(` ${req.url}${size}`);
|
||||
|
||||
if (req.response) {
|
||||
const contentType = req.response.headers['content-type'];
|
||||
if (contentType)
|
||||
response.addResult(` 📄 ${contentType}`);
|
||||
|
||||
}
|
||||
|
||||
if (req.failed && req.failure)
|
||||
response.addResult(` ❌ ${req.failure.errorText}`);
|
||||
|
||||
|
||||
response.addResult('');
|
||||
});
|
||||
|
||||
const stats = interceptor.getStats();
|
||||
response.addResult('📈 **Summary:**');
|
||||
response.addResult(`• Total: ${stats.totalRequests} | Success: ${stats.successfulRequests} | Failed: ${stats.failedRequests}`);
|
||||
response.addResult(`• Average Response Time: ${stats.averageResponseTime}ms`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to basic playwright request data
|
||||
const basicRequests = tab.requests();
|
||||
if (basicRequests.size === 0) {
|
||||
response.addResult('ℹ️ **No network requests found**');
|
||||
response.addResult('');
|
||||
response.addResult('💡 For comprehensive request monitoring, use:');
|
||||
response.addResult(' • `browser_start_request_monitoring` - Enable detailed capture');
|
||||
response.addResult(' • `browser_get_requests` - View captured data with analysis');
|
||||
return;
|
||||
}
|
||||
|
||||
response.addResult('📋 **Network Requests (Basic)**');
|
||||
response.addResult('');
|
||||
[...basicRequests.entries()].forEach(([req, res], index) => {
|
||||
response.addResult(`${index + 1}. ${renderRequest(req, res)}`);
|
||||
});
|
||||
|
||||
response.addResult('');
|
||||
response.addResult('💡 **For detailed analysis** including timing, headers, and bodies:');
|
||||
response.addResult(' • Use `browser_start_request_monitoring` to enable advanced capture');
|
||||
response.addResult(' • Then use `browser_get_requests` for comprehensive analysis');
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
560
src/tools/requests.ts
Normal file
560
src/tools/requests.ts
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
/**
|
||||
* 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 } from './tool.js';
|
||||
import { RequestInterceptorOptions } from '../requestInterceptor.js';
|
||||
import type { Context } from '../context.js';
|
||||
|
||||
const startMonitoringSchema = z.object({
|
||||
urlFilter: z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
type: z.enum(['regex', 'function']),
|
||||
value: z.string()
|
||||
})
|
||||
]).optional().describe('Filter URLs to capture. Can be a string (contains match), regex pattern, or custom function. Examples: "/api/", ".*\\.json$", or custom logic'),
|
||||
|
||||
captureBody: z.boolean().optional().default(true).describe('Whether to capture request and response bodies (default: true)'),
|
||||
|
||||
maxBodySize: z.number().optional().default(10485760).describe('Maximum body size to capture in bytes (default: 10MB). Larger bodies will be truncated'),
|
||||
|
||||
autoSave: z.boolean().optional().default(false).describe('Automatically save captured requests after each response (default: false for performance)'),
|
||||
|
||||
outputPath: z.string().optional().describe('Custom output directory path. If not specified, uses session artifact directory')
|
||||
});
|
||||
|
||||
const getRequestsSchema = z.object({
|
||||
filter: z.enum(['all', 'failed', 'slow', 'errors', 'success']).optional().default('all').describe('Filter requests by type: all, failed (network failures), slow (>1s), errors (4xx/5xx), success (2xx/3xx)'),
|
||||
|
||||
domain: z.string().optional().describe('Filter requests by domain hostname'),
|
||||
|
||||
method: z.string().optional().describe('Filter requests by HTTP method (GET, POST, etc.)'),
|
||||
|
||||
status: z.number().optional().describe('Filter requests by HTTP status code'),
|
||||
|
||||
limit: z.number().optional().default(100).describe('Maximum number of requests to return (default: 100)'),
|
||||
|
||||
format: z.enum(['summary', 'detailed', 'stats']).optional().default('summary').describe('Response format: summary (basic info), detailed (full data), stats (statistics only)'),
|
||||
|
||||
slowThreshold: z.number().optional().default(1000).describe('Threshold in milliseconds for considering requests "slow" (default: 1000ms)')
|
||||
});
|
||||
|
||||
const exportRequestsSchema = z.object({
|
||||
format: z.enum(['json', 'har', 'csv', 'summary']).optional().default('json').describe('Export format: json (full data), har (HTTP Archive), csv (spreadsheet), summary (human-readable report)'),
|
||||
|
||||
filename: z.string().optional().describe('Custom filename for export. Auto-generated if not specified with timestamp'),
|
||||
|
||||
filter: z.enum(['all', 'failed', 'slow', 'errors', 'success']).optional().default('all').describe('Filter which requests to export'),
|
||||
|
||||
includeBody: z.boolean().optional().default(false).describe('Include request/response bodies in export (warning: may create large files)')
|
||||
});
|
||||
|
||||
/**
|
||||
* Start comprehensive request monitoring and interception
|
||||
*
|
||||
* This tool enables deep HTTP traffic analysis during browser automation.
|
||||
* Perfect for API reverse engineering, security testing, and performance analysis.
|
||||
*
|
||||
* Use Cases:
|
||||
* - Security testing: Capture all API calls for vulnerability analysis
|
||||
* - Performance monitoring: Identify slow endpoints and optimize
|
||||
* - API documentation: Generate comprehensive API usage reports
|
||||
* - Debugging: Analyze failed requests and error patterns
|
||||
*/
|
||||
const startRequestMonitoring = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_start_request_monitoring',
|
||||
title: 'Start request monitoring',
|
||||
description: 'Enable comprehensive HTTP request/response interception and analysis. Captures headers, bodies, timing, and failure information for all browser traffic. Essential for security testing, API analysis, and performance debugging.',
|
||||
inputSchema: startMonitoringSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context: Context, params: z.output<typeof startMonitoringSchema>, response) => {
|
||||
try {
|
||||
await context.ensureTab();
|
||||
|
||||
// Parse URL filter
|
||||
let urlFilter: RequestInterceptorOptions['urlFilter'];
|
||||
if (params.urlFilter) {
|
||||
if (typeof params.urlFilter === 'string') {
|
||||
urlFilter = params.urlFilter;
|
||||
} else {
|
||||
// Handle regex or function
|
||||
if (params.urlFilter.type === 'regex') {
|
||||
urlFilter = new RegExp(params.urlFilter.value);
|
||||
} else {
|
||||
// Function - evaluate safely
|
||||
try {
|
||||
|
||||
urlFilter = eval(`(${params.urlFilter.value})`);
|
||||
} catch (error: any) {
|
||||
throw new Error(`Invalid filter function: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get output path from artifact manager or use default
|
||||
let outputPath = params.outputPath;
|
||||
if (!outputPath && context.sessionId) {
|
||||
const artifactManager = context.getArtifactManager();
|
||||
if (artifactManager)
|
||||
outputPath = artifactManager.getSubdirectory('requests');
|
||||
|
||||
}
|
||||
if (!outputPath)
|
||||
outputPath = context.config.outputDir + '/requests';
|
||||
|
||||
|
||||
const options: RequestInterceptorOptions = {
|
||||
urlFilter,
|
||||
captureBody: params.captureBody,
|
||||
maxBodySize: params.maxBodySize,
|
||||
autoSave: params.autoSave,
|
||||
outputPath
|
||||
};
|
||||
|
||||
// Start monitoring
|
||||
await context.startRequestMonitoring(options);
|
||||
|
||||
response.addResult('✅ **Request monitoring started successfully**');
|
||||
response.addResult('');
|
||||
response.addResult('📊 **Configuration:**');
|
||||
response.addResult(`• URL Filter: ${params.urlFilter || 'All requests'}`);
|
||||
response.addResult(`• Capture Bodies: ${params.captureBody ? 'Yes' : 'No'}`);
|
||||
response.addResult(`• Max Body Size: ${(params.maxBodySize! / 1024 / 1024).toFixed(1)}MB`);
|
||||
response.addResult(`• Auto Save: ${params.autoSave ? 'Yes' : 'No'}`);
|
||||
response.addResult(`• Output Path: ${outputPath}`);
|
||||
response.addResult('');
|
||||
response.addResult('🎯 **Next Steps:**');
|
||||
response.addResult('1. Navigate to pages and interact with the application');
|
||||
response.addResult('2. Use `browser_get_requests` to view captured traffic');
|
||||
response.addResult('3. Use `browser_export_requests` to save analysis results');
|
||||
response.addResult('4. Use `browser_clear_requests` to clear captured data');
|
||||
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to start request monitoring: ${error.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve and analyze captured HTTP requests
|
||||
*
|
||||
* Access comprehensive request data including timing, headers, bodies,
|
||||
* and failure information. Supports advanced filtering and analysis.
|
||||
*/
|
||||
const getRequests = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_get_requests',
|
||||
title: 'Get captured requests',
|
||||
description: 'Retrieve and analyze captured HTTP requests with advanced filtering. Shows timing, status codes, headers, and bodies. Perfect for identifying performance issues, failed requests, or analyzing API usage patterns.',
|
||||
inputSchema: getRequestsSchema,
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context: Context, params: z.output<typeof getRequestsSchema>, response) => {
|
||||
try {
|
||||
const interceptor = context.getRequestInterceptor();
|
||||
if (!interceptor) {
|
||||
response.addResult('❌ **Request monitoring not active**');
|
||||
response.addResult('');
|
||||
response.addResult('💡 Start monitoring first with `browser_start_request_monitoring`');
|
||||
return;
|
||||
}
|
||||
|
||||
let requests = interceptor.getData();
|
||||
|
||||
// Apply filters
|
||||
if (params.filter !== 'all') {
|
||||
switch (params.filter) {
|
||||
case 'failed':
|
||||
requests = interceptor.getFailedRequests();
|
||||
break;
|
||||
case 'slow':
|
||||
requests = interceptor.getSlowRequests(params.slowThreshold);
|
||||
break;
|
||||
case 'errors':
|
||||
requests = requests.filter(r => r.response && r.response.status >= 400);
|
||||
break;
|
||||
case 'success':
|
||||
requests = requests.filter(r => r.response && r.response.status < 400);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.domain) {
|
||||
requests = requests.filter(r => {
|
||||
try {
|
||||
return new URL(r.url).hostname === params.domain;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (params.method)
|
||||
requests = requests.filter(r => r.method.toLowerCase() === params.method!.toLowerCase());
|
||||
|
||||
|
||||
if (params.status)
|
||||
requests = requests.filter(r => r.response?.status === params.status);
|
||||
|
||||
|
||||
// Limit results
|
||||
const limitedRequests = requests.slice(0, params.limit);
|
||||
|
||||
if (params.format === 'stats') {
|
||||
// Return statistics only
|
||||
const stats = interceptor.getStats();
|
||||
response.addResult('📊 **Request Statistics**');
|
||||
response.addResult('');
|
||||
response.addResult(`• Total Requests: ${stats.totalRequests}`);
|
||||
response.addResult(`• Successful: ${stats.successfulRequests} (${((stats.successfulRequests / stats.totalRequests) * 100).toFixed(1)}%)`);
|
||||
response.addResult(`• Failed: ${stats.failedRequests} (${((stats.failedRequests / stats.totalRequests) * 100).toFixed(1)}%)`);
|
||||
response.addResult(`• Errors: ${stats.errorResponses} (${((stats.errorResponses / stats.totalRequests) * 100).toFixed(1)}%)`);
|
||||
response.addResult(`• Average Response Time: ${stats.averageResponseTime}ms`);
|
||||
response.addResult(`• Slow Requests (>1s): ${stats.slowRequests}`);
|
||||
response.addResult('');
|
||||
response.addResult('**By Method:**');
|
||||
Object.entries(stats.requestsByMethod).forEach(([method, count]) => {
|
||||
response.addResult(` • ${method}: ${count}`);
|
||||
});
|
||||
response.addResult('');
|
||||
response.addResult('**By Status Code:**');
|
||||
Object.entries(stats.requestsByStatus).forEach(([status, count]) => {
|
||||
response.addResult(` • ${status}: ${count}`);
|
||||
});
|
||||
response.addResult('');
|
||||
response.addResult('**Top Domains:**');
|
||||
const topDomains = Object.entries(stats.requestsByDomain)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5);
|
||||
topDomains.forEach(([domain, count]) => {
|
||||
response.addResult(` • ${domain}: ${count}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return request data
|
||||
if (limitedRequests.length === 0) {
|
||||
response.addResult('ℹ️ **No requests found matching the criteria**');
|
||||
response.addResult('');
|
||||
response.addResult('💡 Try different filters or ensure the page has made HTTP requests');
|
||||
return;
|
||||
}
|
||||
|
||||
response.addResult(`📋 **Captured Requests (${limitedRequests.length} of ${requests.length} total)**`);
|
||||
response.addResult('');
|
||||
|
||||
limitedRequests.forEach((req, index) => {
|
||||
const duration = req.duration ? `${req.duration}ms` : 'pending';
|
||||
const status = req.failed ? 'FAILED' : req.response?.status || 'pending';
|
||||
const size = req.response?.bodySize ? ` (${(req.response.bodySize / 1024).toFixed(1)}KB)` : '';
|
||||
|
||||
response.addResult(`**${index + 1}. ${req.method} ${status}** - ${duration}`);
|
||||
response.addResult(` ${req.url}${size}`);
|
||||
|
||||
if (params.format === 'detailed') {
|
||||
response.addResult(` 📅 ${req.timestamp}`);
|
||||
if (req.response) {
|
||||
response.addResult(` 📊 Status: ${req.response.status} ${req.response.statusText}`);
|
||||
response.addResult(` ⏱️ Duration: ${req.response.duration}ms`);
|
||||
response.addResult(` 🔄 From Cache: ${req.response.fromCache ? 'Yes' : 'No'}`);
|
||||
|
||||
// Show key headers
|
||||
const contentType = req.response.headers['content-type'];
|
||||
if (contentType)
|
||||
response.addResult(` 📄 Content-Type: ${contentType}`);
|
||||
|
||||
}
|
||||
|
||||
if (req.failed && req.failure)
|
||||
response.addResult(` ❌ Failure: ${req.failure.errorText}`);
|
||||
|
||||
|
||||
response.addResult('');
|
||||
}
|
||||
});
|
||||
|
||||
if (requests.length > params.limit)
|
||||
response.addResult(`💡 Showing first ${params.limit} results. Use higher limit or specific filters to see more.`);
|
||||
|
||||
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to get requests: ${error.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Export captured requests to various formats for external analysis
|
||||
*/
|
||||
const exportRequests = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_export_requests',
|
||||
title: 'Export captured requests',
|
||||
description: 'Export captured HTTP requests to various formats (JSON, HAR, CSV, or summary report). Perfect for sharing analysis results, importing into other tools, or creating audit reports.',
|
||||
inputSchema: exportRequestsSchema,
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context: Context, params: z.output<typeof exportRequestsSchema>, response) => {
|
||||
try {
|
||||
const interceptor = context.getRequestInterceptor();
|
||||
if (!interceptor) {
|
||||
response.addResult('❌ **Request monitoring not active**');
|
||||
response.addResult('');
|
||||
response.addResult('💡 Start monitoring first with `browser_start_request_monitoring`');
|
||||
return;
|
||||
}
|
||||
|
||||
const requests = interceptor.getData();
|
||||
if (requests.length === 0) {
|
||||
response.addResult('ℹ️ **No requests to export**');
|
||||
response.addResult('');
|
||||
response.addResult('💡 Navigate to pages and interact with the application first');
|
||||
return;
|
||||
}
|
||||
|
||||
let exportPath: string;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const defaultFilename = `requests-${timestamp}`;
|
||||
|
||||
switch (params.format) {
|
||||
case 'har':
|
||||
exportPath = await interceptor.exportHAR(params.filename || `${defaultFilename}.har`);
|
||||
break;
|
||||
|
||||
case 'json':
|
||||
exportPath = await interceptor.save(params.filename || `${defaultFilename}.json`);
|
||||
break;
|
||||
|
||||
case 'csv':
|
||||
// Create CSV export
|
||||
const csvData = requests.map(req => ({
|
||||
timestamp: req.timestamp,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: req.response?.status || (req.failed ? 'FAILED' : 'PENDING'),
|
||||
duration: req.duration || '',
|
||||
size: req.response?.bodySize || '',
|
||||
contentType: req.response?.headers['content-type'] || '',
|
||||
fromCache: req.response?.fromCache || false
|
||||
}));
|
||||
|
||||
const csvHeaders = Object.keys(csvData[0]).join(',');
|
||||
const csvRows = csvData.map(row => Object.values(row).join(','));
|
||||
const csvContent = [csvHeaders, ...csvRows].join('\n');
|
||||
|
||||
const csvFilename = params.filename || `${defaultFilename}.csv`;
|
||||
const csvPath = `${interceptor.getStatus().options.outputPath}/${csvFilename}`;
|
||||
await require('fs/promises').writeFile(csvPath, csvContent);
|
||||
exportPath = csvPath;
|
||||
break;
|
||||
|
||||
case 'summary':
|
||||
// Create human-readable summary
|
||||
const stats = interceptor.getStats();
|
||||
const summaryLines = [
|
||||
'# HTTP Request Analysis Summary',
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
'',
|
||||
'## Overview',
|
||||
`- Total Requests: ${stats.totalRequests}`,
|
||||
`- Successful: ${stats.successfulRequests}`,
|
||||
`- Failed: ${stats.failedRequests}`,
|
||||
`- Errors: ${stats.errorResponses}`,
|
||||
`- Average Response Time: ${stats.averageResponseTime}ms`,
|
||||
'',
|
||||
'## Performance',
|
||||
`- Fast Requests (<1s): ${stats.fastRequests}`,
|
||||
`- Slow Requests (>1s): ${stats.slowRequests}`,
|
||||
'',
|
||||
'## Request Methods',
|
||||
...Object.entries(stats.requestsByMethod).map(([method, count]) => `- ${method}: ${count}`),
|
||||
'',
|
||||
'## Status Codes',
|
||||
...Object.entries(stats.requestsByStatus).map(([status, count]) => `- ${status}: ${count}`),
|
||||
'',
|
||||
'## Top Domains',
|
||||
...Object.entries(stats.requestsByDomain)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 10)
|
||||
.map(([domain, count]) => `- ${domain}: ${count}`),
|
||||
'',
|
||||
'## Slow Requests (>1s)',
|
||||
...interceptor.getSlowRequests().map(req =>
|
||||
`- ${req.method} ${req.url} (${req.duration}ms)`
|
||||
),
|
||||
'',
|
||||
'## Failed Requests',
|
||||
...interceptor.getFailedRequests().map(req =>
|
||||
`- ${req.method} ${req.url} (${req.response?.status || 'NETWORK_FAILED'})`
|
||||
)
|
||||
];
|
||||
|
||||
const summaryFilename = params.filename || `${defaultFilename}-summary.md`;
|
||||
const summaryPath = `${interceptor.getStatus().options.outputPath}/${summaryFilename}`;
|
||||
await require('fs/promises').writeFile(summaryPath, summaryLines.join('\n'));
|
||||
exportPath = summaryPath;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported export format: ${params.format}`);
|
||||
}
|
||||
|
||||
response.addResult('✅ **Export completed successfully**');
|
||||
response.addResult('');
|
||||
response.addResult(`📁 **File saved:** ${exportPath}`);
|
||||
response.addResult(`📊 **Exported:** ${requests.length} requests`);
|
||||
response.addResult(`🗂️ **Format:** ${params.format.toUpperCase()}`);
|
||||
response.addResult('');
|
||||
|
||||
if (params.format === 'har') {
|
||||
response.addResult('💡 **HAR files** can be imported into:');
|
||||
response.addResult(' • Chrome DevTools (Network tab)');
|
||||
response.addResult(' • Insomnia or Postman');
|
||||
response.addResult(' • Online HAR viewers');
|
||||
} else if (params.format === 'json') {
|
||||
response.addResult('💡 **JSON files** contain full request/response data');
|
||||
response.addResult(' • Perfect for programmatic analysis');
|
||||
response.addResult(' • Includes headers, bodies, timing info');
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to export requests: ${error.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear all captured request data from memory
|
||||
*/
|
||||
const clearRequests = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_clear_requests',
|
||||
title: 'Clear captured requests',
|
||||
description: 'Clear all captured HTTP request data from memory. Useful for freeing up memory during long sessions or when starting fresh analysis.',
|
||||
inputSchema: z.object({}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context: Context, params, response) => {
|
||||
try {
|
||||
const interceptor = context.getRequestInterceptor();
|
||||
if (!interceptor) {
|
||||
response.addResult('ℹ️ **Request monitoring not active**');
|
||||
response.addResult('');
|
||||
response.addResult('💡 Start monitoring first with `browser_start_request_monitoring`');
|
||||
return;
|
||||
}
|
||||
|
||||
const clearedCount = interceptor.clear();
|
||||
|
||||
response.addResult('✅ **Request data cleared successfully**');
|
||||
response.addResult('');
|
||||
response.addResult(`🗑️ **Cleared:** ${clearedCount} captured requests`);
|
||||
response.addResult('');
|
||||
response.addResult('💡 **Memory freed** - Ready for new monitoring session');
|
||||
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to clear requests: ${error.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current request monitoring status and configuration
|
||||
*/
|
||||
const getMonitoringStatus = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_request_monitoring_status',
|
||||
title: 'Get request monitoring status',
|
||||
description: 'Check if request monitoring is active and view current configuration. Shows capture statistics, filter settings, and output paths.',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context: Context, params, response) => {
|
||||
try {
|
||||
const interceptor = context.getRequestInterceptor();
|
||||
|
||||
if (!interceptor) {
|
||||
response.addResult('❌ **Request monitoring is not active**');
|
||||
response.addResult('');
|
||||
response.addResult('💡 **To start monitoring:**');
|
||||
response.addResult('1. Use `browser_start_request_monitoring` to enable');
|
||||
response.addResult('2. Navigate to pages and perform actions');
|
||||
response.addResult('3. Use `browser_get_requests` to view captured data');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = interceptor.getStatus();
|
||||
const stats = interceptor.getStats();
|
||||
|
||||
response.addResult('✅ **Request monitoring is active**');
|
||||
response.addResult('');
|
||||
response.addResult('📊 **Current Statistics:**');
|
||||
response.addResult(`• Total Captured: ${stats.totalRequests} requests`);
|
||||
response.addResult(`• Successful: ${stats.successfulRequests}`);
|
||||
response.addResult(`• Failed: ${stats.failedRequests}`);
|
||||
response.addResult(`• Average Response Time: ${stats.averageResponseTime}ms`);
|
||||
response.addResult('');
|
||||
response.addResult('⚙️ **Configuration:**');
|
||||
response.addResult(`• Attached to Page: ${status.isAttached ? 'Yes' : 'No'}`);
|
||||
response.addResult(`• Current Page: ${status.pageUrl || 'None'}`);
|
||||
response.addResult(`• Capture Bodies: ${status.options.captureBody ? 'Yes' : 'No'}`);
|
||||
response.addResult(`• Max Body Size: ${status.options.maxBodySize ? (status.options.maxBodySize / 1024 / 1024).toFixed(1) + 'MB' : 'Unlimited'}`);
|
||||
response.addResult(`• Auto Save: ${status.options.autoSave ? 'Yes' : 'No'}`);
|
||||
response.addResult(`• Output Path: ${status.options.outputPath || 'Default'}`);
|
||||
|
||||
if (stats.totalRequests > 0) {
|
||||
response.addResult('');
|
||||
response.addResult('📈 **Recent Activity:**');
|
||||
const recentRequests = interceptor.getData().slice(-3);
|
||||
recentRequests.forEach((req, index) => {
|
||||
const duration = req.duration ? ` (${req.duration}ms)` : '';
|
||||
const status = req.failed ? 'FAILED' : req.response?.status || 'pending';
|
||||
response.addResult(` ${index + 1}. ${req.method} ${status} - ${new URL(req.url).pathname}${duration}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to get monitoring status: ${error.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
startRequestMonitoring,
|
||||
getRequests,
|
||||
exportRequests,
|
||||
clearRequests,
|
||||
getMonitoringStatus,
|
||||
];
|
||||
|
|
@ -130,6 +130,23 @@ const getRecordingStatus = defineTool({
|
|||
response.addResult('1. Use browser_start_recording to enable recording');
|
||||
response.addResult('2. Navigate to pages and perform actions');
|
||||
response.addResult('3. Use browser_stop_recording to save videos');
|
||||
|
||||
// Show potential artifact locations for debugging
|
||||
const registry = ArtifactManagerRegistry.getInstance();
|
||||
const artifactManager = context.sessionId ? registry.getManager(context.sessionId) : undefined;
|
||||
|
||||
if (artifactManager) {
|
||||
const baseDir = artifactManager.getBaseDirectory();
|
||||
const sessionDir = artifactManager.getSessionDirectory();
|
||||
response.addResult(`\n🔍 Debug Info:`);
|
||||
response.addResult(`📁 Artifact base directory: ${baseDir}`);
|
||||
response.addResult(`📂 Session directory: ${sessionDir}`);
|
||||
response.addResult(`🆔 Session ID: ${context.sessionId}`);
|
||||
} else {
|
||||
response.addResult(`\n⚠️ No artifact manager configured - videos will save to default output directory`);
|
||||
response.addResult(`📁 Default output: ${path.join(context.config.outputDir, 'videos')}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -142,13 +159,159 @@ const getRecordingStatus = defineTool({
|
|||
response.addResult(`📐 Video size: auto-scaled to fit 800x800`);
|
||||
|
||||
response.addResult(`🎬 Active recordings: ${recordingInfo.activeRecordings}`);
|
||||
|
||||
// Show helpful path info for MCP clients
|
||||
const outputDir = recordingInfo.config?.dir;
|
||||
if (outputDir) {
|
||||
const absolutePath = path.resolve(outputDir);
|
||||
response.addResult(`📍 Absolute path: ${absolutePath}`);
|
||||
|
||||
// Check if directory exists and show contents
|
||||
const fs = await import('fs');
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
try {
|
||||
const files = fs.readdirSync(absolutePath);
|
||||
const webmFiles = files.filter(f => f.endsWith('.webm'));
|
||||
if (webmFiles.length > 0) {
|
||||
response.addResult(`📹 Existing video files in directory: ${webmFiles.length}`);
|
||||
webmFiles.forEach(file => response.addResult(` • ${file}`));
|
||||
} else {
|
||||
response.addResult(`📁 Directory exists but no .webm files found yet`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
response.addResult(`⚠️ Could not read directory contents: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
response.addResult(`⚠️ Output directory does not exist yet (will be created when recording starts)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show debug information
|
||||
const registry = ArtifactManagerRegistry.getInstance();
|
||||
const artifactManager = context.sessionId ? registry.getManager(context.sessionId) : undefined;
|
||||
|
||||
if (artifactManager) {
|
||||
response.addResult(`\n🔍 Debug Info:`);
|
||||
response.addResult(`🆔 Session ID: ${context.sessionId}`);
|
||||
response.addResult(`📂 Session directory: ${artifactManager.getSessionDirectory()}`);
|
||||
}
|
||||
|
||||
if (recordingInfo.activeRecordings === 0)
|
||||
response.addResult(`\n💡 Tip: Navigate to pages to start recording browser actions`);
|
||||
},
|
||||
});
|
||||
|
||||
const revealArtifactPaths = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_reveal_artifact_paths',
|
||||
title: 'Reveal artifact storage paths',
|
||||
description: 'Show where artifacts (videos, screenshots, etc.) are stored, including resolved absolute paths. Useful for debugging when you cannot find generated files.',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
response.addResult('🗂️ Artifact Storage Paths');
|
||||
response.addResult('=========================\n');
|
||||
|
||||
// Show default output directory
|
||||
response.addResult(`📁 Default output directory: ${context.config.outputDir}`);
|
||||
response.addResult(`📍 Resolved absolute path: ${path.resolve(context.config.outputDir)}\n`);
|
||||
|
||||
// Show artifact manager paths if configured
|
||||
const registry = ArtifactManagerRegistry.getInstance();
|
||||
const artifactManager = context.sessionId ? registry.getManager(context.sessionId) : undefined;
|
||||
|
||||
if (artifactManager) {
|
||||
const baseDir = artifactManager.getBaseDirectory();
|
||||
const sessionDir = artifactManager.getSessionDirectory();
|
||||
|
||||
response.addResult('🎯 Centralized Artifact Storage (ACTIVE):');
|
||||
response.addResult(`📁 Base directory: ${baseDir}`);
|
||||
response.addResult(`📍 Base absolute path: ${path.resolve(baseDir)}`);
|
||||
response.addResult(`📂 Session directory: ${sessionDir}`);
|
||||
response.addResult(`📍 Session absolute path: ${path.resolve(sessionDir)}`);
|
||||
response.addResult(`🆔 Session ID: ${context.sessionId}\n`);
|
||||
|
||||
// Show subdirectories
|
||||
response.addResult('📋 Available subdirectories:');
|
||||
const subdirs = ['videos', 'screenshots', 'api-logs', 'traces'];
|
||||
for (const subdir of subdirs) {
|
||||
const subdirPath = artifactManager.getSubdirectory(subdir);
|
||||
const fs = await import('fs');
|
||||
const exists = fs.existsSync(subdirPath);
|
||||
response.addResult(` 📁 ${subdir}: ${subdirPath} ${exists ? '✅' : '⚠️ (will be created when needed)'}`);
|
||||
}
|
||||
|
||||
// Show any existing files in the session directory
|
||||
const fs = await import('fs');
|
||||
if (fs.existsSync(sessionDir)) {
|
||||
try {
|
||||
const items = fs.readdirSync(sessionDir, { withFileTypes: true });
|
||||
const files = items.filter(item => item.isFile()).map(item => item.name);
|
||||
const dirs = items.filter(item => item.isDirectory()).map(item => item.name);
|
||||
|
||||
if (dirs.length > 0) {
|
||||
response.addResult(`\n📂 Existing subdirectories: ${dirs.join(', ')}`);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
response.addResult(`📄 Files in session directory: ${files.join(', ')}`);
|
||||
}
|
||||
|
||||
// Count .webm files across all subdirectories
|
||||
let webmCount = 0;
|
||||
function countWebmFiles(dir: string) {
|
||||
try {
|
||||
const contents = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const item of contents) {
|
||||
const fullPath = path.join(dir, item.name);
|
||||
if (item.isDirectory()) {
|
||||
countWebmFiles(fullPath);
|
||||
} else if (item.name.endsWith('.webm')) {
|
||||
webmCount++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore permission errors
|
||||
}
|
||||
}
|
||||
countWebmFiles(sessionDir);
|
||||
|
||||
if (webmCount > 0) {
|
||||
response.addResult(`🎬 Total .webm video files found: ${webmCount}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
response.addResult(`⚠️ Could not list session directory contents: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.addResult('⚠️ No centralized artifact storage configured');
|
||||
response.addResult('📁 Files will be saved to default output directory');
|
||||
response.addResult(`📍 Default path: ${path.resolve(context.config.outputDir)}\n`);
|
||||
}
|
||||
|
||||
// Show current video recording paths if active
|
||||
const recordingInfo = context.getVideoRecordingInfo();
|
||||
if (recordingInfo.enabled && recordingInfo.config?.dir) {
|
||||
response.addResult('🎥 Current Video Recording:');
|
||||
response.addResult(`📁 Video output directory: ${recordingInfo.config.dir}`);
|
||||
response.addResult(`📍 Video absolute path: ${path.resolve(recordingInfo.config.dir)}`);
|
||||
response.addResult(`📝 Base filename pattern: ${recordingInfo.baseFilename}*.webm`);
|
||||
}
|
||||
|
||||
response.addResult('\n💡 Tips:');
|
||||
response.addResult('• Use these absolute paths to locate your generated files');
|
||||
response.addResult('• Video files (.webm) are created when pages close or recording stops');
|
||||
response.addResult('• Screenshot files (.png/.jpeg) are created immediately when taken');
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
startRecording,
|
||||
stopRecording,
|
||||
getRecordingStatus,
|
||||
revealArtifactPaths,
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue