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
87
README.md
87
README.md
@ -529,6 +529,14 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_click**
|
- **browser_click**
|
||||||
- Title: Click
|
- Title: Click
|
||||||
- Description: Perform click on a web page. Returns page snapshot after click (configurable via browser_configure_snapshots). Use browser_snapshot for explicit full snapshots.
|
- Description: Perform click on a web page. Returns page snapshot after click (configurable via browser_configure_snapshots). Use browser_snapshot for explicit full snapshots.
|
||||||
@ -598,6 +606,22 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_dismiss_all_file_choosers**
|
||||||
|
- Title: Dismiss all file choosers
|
||||||
|
- Description: Dismiss/cancel all open file chooser dialogs without uploading files. Useful when multiple file choosers are stuck open. Returns page snapshot after dismissal (configurable via browser_configure_snapshots).
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_dismiss_file_chooser**
|
||||||
|
- Title: Dismiss file chooser
|
||||||
|
- Description: Dismiss/cancel a file chooser dialog without uploading files. Returns page snapshot after dismissal (configurable via browser_configure_snapshots).
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_drag**
|
- **browser_drag**
|
||||||
- Title: Drag mouse
|
- Title: Drag mouse
|
||||||
- Description: Perform drag and drop between two elements. Returns page snapshot after drag (configurable via browser_configure_snapshots).
|
- Description: Perform drag and drop between two elements. Returns page snapshot after drag (configurable via browser_configure_snapshots).
|
||||||
@ -621,6 +645,18 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- Parameters:
|
||||||
|
- `format` (string, optional): Export format: json (full data), har (HTTP Archive), csv (spreadsheet), summary (human-readable report)
|
||||||
|
- `filename` (string, optional): Custom filename for export. Auto-generated if not specified with timestamp
|
||||||
|
- `filter` (string, optional): Filter which requests to export
|
||||||
|
- `includeBody` (boolean, optional): Include request/response bodies in export (warning: may create large files)
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_file_upload**
|
- **browser_file_upload**
|
||||||
- Title: Upload files
|
- Title: Upload files
|
||||||
- Description: Upload one or multiple files. Returns page snapshot after upload (configurable via browser_configure_snapshots).
|
- Description: Upload one or multiple files. Returns page snapshot after upload (configurable via browser_configure_snapshots).
|
||||||
@ -630,6 +666,29 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_get_artifact_paths**
|
||||||
|
- Title: Get artifact storage paths
|
||||||
|
- Description: Reveal the actual filesystem paths where artifacts (screenshots, videos, PDFs) are stored. Useful for locating generated files.
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- Parameters:
|
||||||
|
- `filter` (string, optional): Filter requests by type: all, failed (network failures), slow (>1s), errors (4xx/5xx), success (2xx/3xx)
|
||||||
|
- `domain` (string, optional): Filter requests by domain hostname
|
||||||
|
- `method` (string, optional): Filter requests by HTTP method (GET, POST, etc.)
|
||||||
|
- `status` (number, optional): Filter requests by HTTP status code
|
||||||
|
- `limit` (number, optional): Maximum number of requests to return (default: 100)
|
||||||
|
- `format` (string, optional): Response format: summary (basic info), detailed (full data), stats (statistics only)
|
||||||
|
- `slowThreshold` (number, optional): Threshold in milliseconds for considering requests "slow" (default: 1000ms)
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_handle_dialog**
|
- **browser_handle_dialog**
|
||||||
- Title: Handle a dialog
|
- Title: Handle a dialog
|
||||||
- Description: Handle a dialog. Returns page snapshot after handling dialog (configurable via browser_configure_snapshots).
|
- Description: Handle a dialog. Returns page snapshot after handling dialog (configurable via browser_configure_snapshots).
|
||||||
@ -713,8 +772,9 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
- **browser_network_requests**
|
- **browser_network_requests**
|
||||||
- Title: List network requests
|
- Title: List network requests
|
||||||
- Description: Returns all network requests since loading the page
|
- 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).
|
||||||
- Parameters: None
|
- Parameters:
|
||||||
|
- `detailed` (boolean, optional): Show detailed request information if request monitoring is active
|
||||||
- Read-only: **true**
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
@ -736,6 +796,14 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_resize**
|
- **browser_resize**
|
||||||
- Title: Resize browser window
|
- Title: Resize browser window
|
||||||
- Description: Resize the browser window
|
- Description: Resize the browser window
|
||||||
@ -784,6 +852,19 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- Parameters:
|
||||||
|
- `urlFilter` (optional): Filter URLs to capture. Can be a string (contains match), regex pattern, or custom function. Examples: "/api/", ".*\.json$", or custom logic
|
||||||
|
- `captureBody` (boolean, optional): Whether to capture request and response bodies (default: true)
|
||||||
|
- `maxBodySize` (number, optional): Maximum body size to capture in bytes (default: 10MB). Larger bodies will be truncated
|
||||||
|
- `autoSave` (boolean, optional): Automatically save captured requests after each response (default: false for performance)
|
||||||
|
- `outputPath` (string, optional): Custom output directory path. If not specified, uses session artifact directory
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_stop_recording**
|
- **browser_stop_recording**
|
||||||
- Title: Stop video recording
|
- Title: Stop video recording
|
||||||
- Description: Stop video recording and return the paths to recorded video files. This closes all active pages to ensure videos are properly saved. Call this when you want to finalize and access the recorded videos.
|
- Description: Stop video recording and return the paths to recorded video files. This closes all active pages to ensure videos are properly saved. Call this when you want to finalize and access the recorded videos.
|
||||||
@ -800,7 +881,7 @@ http.createServer(async (req, res) => {
|
|||||||
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
||||||
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
||||||
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
||||||
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.
|
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots. WARNING: Full page screenshots may exceed API size limits on long pages.
|
||||||
- `allowLargeImages` (boolean, optional): Allow images with dimensions exceeding 8000 pixels (API limit). Default false - will error if image is too large to prevent API failures.
|
- `allowLargeImages` (boolean, optional): Allow images with dimensions exceeding 8000 pixels (API limit). Default false - will error if image is too large to prevent API failures.
|
||||||
- Read-only: **true**
|
- Read-only: **true**
|
||||||
|
|
||||||
|
|||||||
@ -69,6 +69,20 @@ export class ArtifactManager {
|
|||||||
return path.join(this._sessionDir, sanitizeForFilePath(filename));
|
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
|
* 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 { logUnhandledError } from './log.js';
|
||||||
import { Tab } from './tab.js';
|
import { Tab } from './tab.js';
|
||||||
import { EnvironmentIntrospector } from './environmentIntrospection.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 { Tool } from './tools/tool.js';
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
@ -51,6 +53,9 @@ export class Context {
|
|||||||
// Chrome extension management
|
// Chrome extension management
|
||||||
private _installedExtensions: Array<{ path: string; name: string; version?: string }> = [];
|
private _installedExtensions: Array<{ path: string; name: string; version?: string }> = [];
|
||||||
|
|
||||||
|
// Request interception for traffic analysis
|
||||||
|
private _requestInterceptor: RequestInterceptor | undefined;
|
||||||
|
|
||||||
// Differential snapshot tracking
|
// Differential snapshot tracking
|
||||||
private _lastSnapshotFingerprint: string | undefined;
|
private _lastSnapshotFingerprint: string | undefined;
|
||||||
private _lastPageState: { url: string; title: string } | undefined;
|
private _lastPageState: { url: string; title: string } | undefined;
|
||||||
@ -178,8 +183,17 @@ export class Context {
|
|||||||
this._currentTab = tab;
|
this._currentTab = tab;
|
||||||
|
|
||||||
// Track pages with video recording
|
// 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);
|
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() {
|
async dispose() {
|
||||||
|
// Clean up request interceptor
|
||||||
|
this.stopRequestMonitoring();
|
||||||
|
|
||||||
await this.closeBrowserContext();
|
await this.closeBrowserContext();
|
||||||
Context._allContexts.delete(this);
|
Context._allContexts.delete(this);
|
||||||
}
|
}
|
||||||
@ -316,9 +333,9 @@ export class Context {
|
|||||||
const browserContext = await browser.newContext(contextOptions);
|
const browserContext = await browser.newContext(contextOptions);
|
||||||
|
|
||||||
// Apply offline mode if configured
|
// 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);
|
await browserContext.setOffline((this.config as any).offline);
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
browserContext,
|
browserContext,
|
||||||
@ -330,6 +347,9 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setVideoRecording(config: { dir: string; size?: { width: number; height: number } }, baseFilename: string) {
|
setVideoRecording(config: { dir: string; size?: { width: number; height: number } }, baseFilename: string) {
|
||||||
|
// Clear any existing video recording state first
|
||||||
|
this.clearVideoRecordingState();
|
||||||
|
|
||||||
this._videoRecordingConfig = config;
|
this._videoRecordingConfig = config;
|
||||||
this._videoBaseFilename = baseFilename;
|
this._videoBaseFilename = baseFilename;
|
||||||
|
|
||||||
@ -339,6 +359,8 @@ export class Context {
|
|||||||
// The next call to _ensureBrowserContext will create a new context with video recording
|
// The next call to _ensureBrowserContext will create a new context with video recording
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testDebug(`Video recording configured: ${JSON.stringify(config)}, filename: ${baseFilename}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideoRecordingInfo() {
|
getVideoRecordingInfo() {
|
||||||
@ -452,33 +474,140 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async stopVideoRecording(): Promise<string[]> {
|
async stopVideoRecording(): Promise<string[]> {
|
||||||
if (!this._videoRecordingConfig)
|
if (!this._videoRecordingConfig) {
|
||||||
|
testDebug('stopVideoRecording called but no recording config found');
|
||||||
return [];
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
testDebug(`stopVideoRecording: ${this._activePagesWithVideos.size} pages tracked for video`);
|
||||||
const videoPaths: string[] = [];
|
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) {
|
for (const page of this._activePagesWithVideos) {
|
||||||
try {
|
try {
|
||||||
if (!page.isClosed()) {
|
if (!page.isClosed()) {
|
||||||
await page.close();
|
|
||||||
const video = page.video();
|
const video = page.video();
|
||||||
if (video) {
|
if (video) {
|
||||||
|
// Get the video path before closing
|
||||||
const videoPath = await video.path();
|
const videoPath = await video.path();
|
||||||
videoPaths.push(videoPath);
|
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) {
|
} catch (error) {
|
||||||
testDebug('Error closing page for video recording:', 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();
|
this._activePagesWithVideos.clear();
|
||||||
|
|
||||||
|
return videoPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add method to clear video recording state (called by start recording)
|
||||||
|
clearVideoRecordingState(): void {
|
||||||
this._videoRecordingConfig = undefined;
|
this._videoRecordingConfig = undefined;
|
||||||
this._videoBaseFilename = 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
|
// 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
|
// Initialize service worker console capture
|
||||||
void this._initializeServiceWorkerConsoleCapture();
|
void this._initializeServiceWorkerConsoleCapture();
|
||||||
|
|
||||||
// Initialize extension-based console capture
|
// Initialize extension-based console capture
|
||||||
void this._initializeExtensionConsoleCapture();
|
void this._initializeExtensionConsoleCapture();
|
||||||
}
|
}
|
||||||
@ -177,13 +177,13 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||||||
|
|
||||||
// Enable runtime domain for console API calls
|
// Enable runtime domain for console API calls
|
||||||
await cdpSession.send('Runtime.enable');
|
await cdpSession.send('Runtime.enable');
|
||||||
|
|
||||||
// Enable network domain for network-related errors
|
// Enable network domain for network-related errors
|
||||||
await cdpSession.send('Network.enable');
|
await cdpSession.send('Network.enable');
|
||||||
|
|
||||||
// Enable security domain for mixed content warnings
|
// Enable security domain for mixed content warnings
|
||||||
await cdpSession.send('Security.enable');
|
await cdpSession.send('Security.enable');
|
||||||
|
|
||||||
// Enable log domain for browser log entries
|
// Enable log domain for browser log entries
|
||||||
await cdpSession.send('Log.enable');
|
await cdpSession.send('Log.enable');
|
||||||
|
|
||||||
@ -196,17 +196,17 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||||||
cdpSession.on('Runtime.exceptionThrown', (event: any) => {
|
cdpSession.on('Runtime.exceptionThrown', (event: any) => {
|
||||||
this._handleServiceWorkerException(event);
|
this._handleServiceWorkerException(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for network failed events
|
// Listen for network failed events
|
||||||
cdpSession.on('Network.loadingFailed', (event: any) => {
|
cdpSession.on('Network.loadingFailed', (event: any) => {
|
||||||
this._handleNetworkError(event);
|
this._handleNetworkError(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for security state changes (mixed content)
|
// Listen for security state changes (mixed content)
|
||||||
cdpSession.on('Security.securityStateChanged', (event: any) => {
|
cdpSession.on('Security.securityStateChanged', (event: any) => {
|
||||||
this._handleSecurityStateChange(event);
|
this._handleSecurityStateChange(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for log entries (browser-level logs)
|
// Listen for log entries (browser-level logs)
|
||||||
cdpSession.on('Log.entryAdded', (event: any) => {
|
cdpSession.on('Log.entryAdded', (event: any) => {
|
||||||
this._handleLogEntry(event);
|
this._handleLogEntry(event);
|
||||||
@ -327,14 +327,14 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||||||
try {
|
try {
|
||||||
// Listen for console messages from the extension
|
// Listen for console messages from the extension
|
||||||
await this.page.evaluate(() => {
|
await this.page.evaluate(() => {
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', event => {
|
||||||
if (event.data && event.data.type === 'PLAYWRIGHT_CONSOLE_CAPTURE') {
|
if (event.data && event.data.type === 'PLAYWRIGHT_CONSOLE_CAPTURE') {
|
||||||
const message = event.data.consoleMessage;
|
const message = event.data.consoleMessage;
|
||||||
|
|
||||||
// Store the message in a global array for Playwright to access
|
// 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 = [];
|
||||||
}
|
|
||||||
(window as any)._playwrightExtensionConsoleMessages.push(message);
|
(window as any)._playwrightExtensionConsoleMessages.push(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -342,7 +342,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||||||
|
|
||||||
// Poll for new extension console messages
|
// Poll for new extension console messages
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this._checkForExtensionConsoleMessages();
|
void this._checkForExtensionConsoleMessages();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -353,9 +353,9 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||||||
private async _checkForExtensionConsoleMessages() {
|
private async _checkForExtensionConsoleMessages() {
|
||||||
try {
|
try {
|
||||||
const newMessages = await this.page.evaluate(() => {
|
const newMessages = await this.page.evaluate(() => {
|
||||||
if (!(window as any)._playwrightExtensionConsoleMessages) {
|
if (!(window as any)._playwrightExtensionConsoleMessages)
|
||||||
return [];
|
return [];
|
||||||
}
|
|
||||||
const messages = (window as any)._playwrightExtensionConsoleMessages;
|
const messages = (window as any)._playwrightExtensionConsoleMessages;
|
||||||
(window as any)._playwrightExtensionConsoleMessages = [];
|
(window as any)._playwrightExtensionConsoleMessages = [];
|
||||||
return messages;
|
return messages;
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import artifacts from './tools/artifacts.js';
|
||||||
import common from './tools/common.js';
|
import common from './tools/common.js';
|
||||||
import configure from './tools/configure.js';
|
import configure from './tools/configure.js';
|
||||||
import console from './tools/console.js';
|
import console from './tools/console.js';
|
||||||
@ -25,6 +26,7 @@ import keyboard from './tools/keyboard.js';
|
|||||||
import navigate from './tools/navigate.js';
|
import navigate from './tools/navigate.js';
|
||||||
import network from './tools/network.js';
|
import network from './tools/network.js';
|
||||||
import pdf from './tools/pdf.js';
|
import pdf from './tools/pdf.js';
|
||||||
|
import requests from './tools/requests.js';
|
||||||
import snapshot from './tools/snapshot.js';
|
import snapshot from './tools/snapshot.js';
|
||||||
import tabs from './tools/tabs.js';
|
import tabs from './tools/tabs.js';
|
||||||
import screenshot from './tools/screenshot.js';
|
import screenshot from './tools/screenshot.js';
|
||||||
@ -36,6 +38,7 @@ import type { Tool } from './tools/tool.js';
|
|||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
export const allTools: Tool<any>[] = [
|
export const allTools: Tool<any>[] = [
|
||||||
|
...artifacts,
|
||||||
...common,
|
...common,
|
||||||
...configure,
|
...configure,
|
||||||
...console,
|
...console,
|
||||||
@ -48,6 +51,7 @@ export const allTools: Tool<any>[] = [
|
|||||||
...network,
|
...network,
|
||||||
...mouse,
|
...mouse,
|
||||||
...pdf,
|
...pdf,
|
||||||
|
...requests,
|
||||||
...screenshot,
|
...screenshot,
|
||||||
...snapshot,
|
...snapshot,
|
||||||
...tabs,
|
...tabs,
|
||||||
|
|||||||
@ -25,14 +25,76 @@ const requests = defineTabTool({
|
|||||||
schema: {
|
schema: {
|
||||||
name: 'browser_network_requests',
|
name: 'browser_network_requests',
|
||||||
title: 'List network requests',
|
title: 'List network requests',
|
||||||
description: 'Returns all network requests since loading the page',
|
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({}),
|
inputSchema: z.object({
|
||||||
|
detailed: z.boolean().optional().default(false).describe('Show detailed request information if request monitoring is active')
|
||||||
|
}),
|
||||||
type: 'readOnly',
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (tab, params, response) => {
|
handle: async (tab, params, response) => {
|
||||||
const requests = tab.requests();
|
// Check if request interceptor is active and can provide richer data
|
||||||
[...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
|
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('1. Use browser_start_recording to enable recording');
|
||||||
response.addResult('2. Navigate to pages and perform actions');
|
response.addResult('2. Navigate to pages and perform actions');
|
||||||
response.addResult('3. Use browser_stop_recording to save videos');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,13 +159,159 @@ const getRecordingStatus = defineTool({
|
|||||||
response.addResult(`📐 Video size: auto-scaled to fit 800x800`);
|
response.addResult(`📐 Video size: auto-scaled to fit 800x800`);
|
||||||
|
|
||||||
response.addResult(`🎬 Active recordings: ${recordingInfo.activeRecordings}`);
|
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)
|
if (recordingInfo.activeRecordings === 0)
|
||||||
response.addResult(`\n💡 Tip: Navigate to pages to start recording browser actions`);
|
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 [
|
export default [
|
||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
getRecordingStatus,
|
getRecordingStatus,
|
||||||
|
revealArtifactPaths,
|
||||||
];
|
];
|
||||||
|
|||||||
165
test-video-debug.js
Executable file
165
test-video-debug.js
Executable file
@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video Recording Debug Script
|
||||||
|
*
|
||||||
|
* This script helps debug video recording issues by:
|
||||||
|
* 1. Testing the complete video recording workflow
|
||||||
|
* 2. Showing actual artifact paths
|
||||||
|
* 3. Verifying video file creation
|
||||||
|
* 4. Checking session persistence
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
async function runMCPTool(toolName, params = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const mcp = spawn('node', ['cli.js'], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
cwd: __dirname
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
mcp.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
mcp.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
mcp.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
} else {
|
||||||
|
reject(new Error(`MCP tool failed: ${stderr}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send MCP request
|
||||||
|
const request = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: toolName,
|
||||||
|
arguments: params
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mcp.stdin.write(JSON.stringify(request) + '\n');
|
||||||
|
mcp.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findVideoFiles(searchDir) {
|
||||||
|
const videoFiles = [];
|
||||||
|
|
||||||
|
function scanDir(dir) {
|
||||||
|
try {
|
||||||
|
const items = fs.readdirSync(dir);
|
||||||
|
for (const item of items) {
|
||||||
|
const fullPath = path.join(dir, item);
|
||||||
|
const stats = fs.statSync(fullPath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
scanDir(fullPath);
|
||||||
|
} else if (item.endsWith('.webm')) {
|
||||||
|
videoFiles.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore permission errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanDir(searchDir);
|
||||||
|
return videoFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function debugVideoRecording() {
|
||||||
|
console.log('🎥 Video Recording Debug Script');
|
||||||
|
console.log('================================\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Check recording status before starting
|
||||||
|
console.log('1️⃣ Checking initial recording status...');
|
||||||
|
const initialStatus = await runMCPTool('mcp__playwright__browser_recording_status');
|
||||||
|
console.log('Initial status:', initialStatus.stdout);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Step 2: Start recording
|
||||||
|
console.log('2️⃣ Starting video recording...');
|
||||||
|
const startResult = await runMCPTool('mcp__playwright__browser_start_recording', {
|
||||||
|
size: { width: 1280, height: 720 },
|
||||||
|
filename: 'debug-test-session'
|
||||||
|
});
|
||||||
|
console.log('Start result:', startResult.stdout);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Step 3: Check status after starting
|
||||||
|
console.log('3️⃣ Checking recording status after start...');
|
||||||
|
const activeStatus = await runMCPTool('mcp__playwright__browser_recording_status');
|
||||||
|
console.log('Active status:', activeStatus.stdout);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Step 4: Navigate to a page
|
||||||
|
console.log('4️⃣ Navigating to test page...');
|
||||||
|
const navResult = await runMCPTool('mcp__playwright__browser_navigate', {
|
||||||
|
url: 'https://example.com'
|
||||||
|
});
|
||||||
|
console.log('Navigation result:', navResult.stdout);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Step 5: Check status after navigation
|
||||||
|
console.log('5️⃣ Checking recording status after navigation...');
|
||||||
|
const navStatus = await runMCPTool('mcp__playwright__browser_recording_status');
|
||||||
|
console.log('Status after navigation:', navStatus.stdout);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Step 6: Stop recording
|
||||||
|
console.log('6️⃣ Stopping video recording...');
|
||||||
|
const stopResult = await runMCPTool('mcp__playwright__browser_stop_recording');
|
||||||
|
console.log('Stop result:', stopResult.stdout);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Step 7: Search for video files
|
||||||
|
console.log('7️⃣ Searching for video files...');
|
||||||
|
const commonPaths = [
|
||||||
|
process.cwd(),
|
||||||
|
path.join(process.cwd(), 'artifacts'),
|
||||||
|
path.join(process.cwd(), '@artifacts'),
|
||||||
|
path.join(process.env.HOME || '.', '.cache'),
|
||||||
|
'/tmp'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const searchPath of commonPaths) {
|
||||||
|
if (fs.existsSync(searchPath)) {
|
||||||
|
console.log(`Searching in: ${searchPath}`);
|
||||||
|
const videos = await findVideoFiles(searchPath);
|
||||||
|
if (videos.length > 0) {
|
||||||
|
console.log(`✅ Found ${videos.length} video files:`);
|
||||||
|
videos.forEach(video => {
|
||||||
|
const stats = fs.statSync(video);
|
||||||
|
console.log(` 📹 ${video} (${Math.round(stats.size / 1024)}KB, ${stats.mtime.toISOString()})`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ No video files found`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ Path doesn't exist: ${searchPath}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Debug script failed:', error.message);
|
||||||
|
console.error('Stack:', error.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugVideoRecording();
|
||||||
Loading…
x
Reference in New Issue
Block a user