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:
Ryan Malloy 2025-09-05 07:17:11 -06:00
parent 4ac76bd886
commit 9257404ba3
10 changed files with 1728 additions and 29 deletions

View file

@ -69,6 +69,20 @@ export class ArtifactManager {
return path.join(this._sessionDir, sanitizeForFilePath(filename));
}
/**
* Get the base directory for all artifacts
*/
getBaseDirectory(): string {
return this._baseDir;
}
/**
* Get the session-specific directory
*/
getSessionDirectory(): string {
return this._sessionDir;
}
/**
* Create a subdirectory within the session directory
*/

View file

@ -21,6 +21,8 @@ import { devices } from 'playwright';
import { logUnhandledError } from './log.js';
import { Tab } from './tab.js';
import { EnvironmentIntrospector } from './environmentIntrospection.js';
import { RequestInterceptor, RequestInterceptorOptions } from './requestInterceptor.js';
import { ArtifactManagerRegistry } from './artifactManager.js';
import type { Tool } from './tools/tool.js';
import type { FullConfig } from './config.js';
@ -51,6 +53,9 @@ export class Context {
// Chrome extension management
private _installedExtensions: Array<{ path: string; name: string; version?: string }> = [];
// Request interception for traffic analysis
private _requestInterceptor: RequestInterceptor | undefined;
// Differential snapshot tracking
private _lastSnapshotFingerprint: string | undefined;
private _lastPageState: { url: string; title: string } | undefined;
@ -178,8 +183,17 @@ export class Context {
this._currentTab = tab;
// Track pages with video recording
if (this._videoRecordingConfig && page.video())
// Note: page.video() may be null initially, so we track based on config presence
if (this._videoRecordingConfig) {
this._activePagesWithVideos.add(page);
testDebug(`Added page to video tracking. Active recordings: ${this._activePagesWithVideos.size}`);
}
// Attach request interceptor to new pages
if (this._requestInterceptor) {
void this._requestInterceptor.attach(page);
testDebug('Request interceptor attached to new page');
}
}
@ -219,6 +233,9 @@ export class Context {
}
async dispose() {
// Clean up request interceptor
this.stopRequestMonitoring();
await this.closeBrowserContext();
Context._allContexts.delete(this);
}
@ -316,9 +333,9 @@ export class Context {
const browserContext = await browser.newContext(contextOptions);
// Apply offline mode if configured
if ((this.config as any).offline !== undefined) {
if ((this.config as any).offline !== undefined)
await browserContext.setOffline((this.config as any).offline);
}
return {
browserContext,
@ -330,6 +347,9 @@ export class Context {
}
setVideoRecording(config: { dir: string; size?: { width: number; height: number } }, baseFilename: string) {
// Clear any existing video recording state first
this.clearVideoRecordingState();
this._videoRecordingConfig = config;
this._videoBaseFilename = baseFilename;
@ -339,6 +359,8 @@ export class Context {
// The next call to _ensureBrowserContext will create a new context with video recording
});
}
testDebug(`Video recording configured: ${JSON.stringify(config)}, filename: ${baseFilename}`);
}
getVideoRecordingInfo() {
@ -452,33 +474,140 @@ export class Context {
}
async stopVideoRecording(): Promise<string[]> {
if (!this._videoRecordingConfig)
if (!this._videoRecordingConfig) {
testDebug('stopVideoRecording called but no recording config found');
return [];
}
testDebug(`stopVideoRecording: ${this._activePagesWithVideos.size} pages tracked for video`);
const videoPaths: string[] = [];
// Close all pages to save videos
// Force navigation on pages that don't have video objects yet
// This ensures video recording actually starts
for (const page of this._activePagesWithVideos) {
try {
if (!page.isClosed()) {
const video = page.video();
if (!video) {
testDebug('Page has no video object, trying to trigger recording by navigating to about:blank');
// Navigate to trigger video recording start
await page.goto('about:blank');
// Small delay to let video recording initialize
await new Promise(resolve => setTimeout(resolve, 100));
}
}
} catch (error) {
testDebug('Error triggering video recording on page:', error);
}
}
// Collect video paths AFTER ensuring recording is active
for (const page of this._activePagesWithVideos) {
try {
if (!page.isClosed()) {
await page.close();
const video = page.video();
if (video) {
// Get the video path before closing
const videoPath = await video.path();
videoPaths.push(videoPath);
testDebug(`Found video path: ${videoPath}`);
} else {
testDebug('Page still has no video object after navigation attempt');
}
}
} catch (error) {
testDebug('Error getting video path:', error);
}
}
// Now close all pages to finalize videos
for (const page of this._activePagesWithVideos) {
try {
if (!page.isClosed()) {
testDebug(`Closing page for video finalization: ${page.url()}`);
await page.close();
}
} catch (error) {
testDebug('Error closing page for video recording:', error);
}
}
// Keep recording config available for inspection until explicitly cleared
// Don't clear it immediately to help with debugging
testDebug(`stopVideoRecording complete: ${videoPaths.length} videos saved, config preserved for debugging`);
// Clear the page tracking but keep config for status queries
this._activePagesWithVideos.clear();
return videoPaths;
}
// Add method to clear video recording state (called by start recording)
clearVideoRecordingState(): void {
this._videoRecordingConfig = undefined;
this._videoBaseFilename = undefined;
this._activePagesWithVideos.clear();
testDebug('Video recording state cleared');
}
return videoPaths;
// Request Interception and Traffic Analysis
/**
* Start comprehensive request monitoring and interception
*/
async startRequestMonitoring(options: RequestInterceptorOptions = {}): Promise<void> {
if (this._requestInterceptor) {
testDebug('Request interceptor already active, stopping previous instance');
this._requestInterceptor.detach();
}
// Use artifact manager for output path if available
if (!options.outputPath && this.sessionId) {
const artifactManager = this.getArtifactManager();
if (artifactManager)
options.outputPath = artifactManager.getSubdirectory('requests');
}
this._requestInterceptor = new RequestInterceptor(options);
// Attach to current tab if available
const currentTab = this._currentTab;
if (currentTab) {
await this._requestInterceptor.attach(currentTab.page);
testDebug('Request interceptor attached to current tab');
}
testDebug('Request monitoring started with options:', options);
}
/**
* Get the active request interceptor
*/
getRequestInterceptor(): RequestInterceptor | undefined {
return this._requestInterceptor;
}
/**
* Get artifact manager for the current session
*/
getArtifactManager() {
if (!this.sessionId)
return undefined;
const registry = ArtifactManagerRegistry.getInstance();
return registry.getManager(this.sessionId);
}
/**
* Stop request monitoring and clean up
*/
stopRequestMonitoring(): void {
if (this._requestInterceptor) {
this._requestInterceptor.detach();
this._requestInterceptor = undefined;
testDebug('Request monitoring stopped');
}
}
// Chrome Extension Management

521
src/requestInterceptor.ts Normal file
View 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
};
}
}

View file

@ -74,7 +74,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
// Initialize service worker console capture
void this._initializeServiceWorkerConsoleCapture();
// Initialize extension-based console capture
void this._initializeExtensionConsoleCapture();
}
@ -177,13 +177,13 @@ export class Tab extends EventEmitter<TabEventsInterface> {
// Enable runtime domain for console API calls
await cdpSession.send('Runtime.enable');
// Enable network domain for network-related errors
await cdpSession.send('Network.enable');
// Enable security domain for mixed content warnings
await cdpSession.send('Security.enable');
// Enable log domain for browser log entries
await cdpSession.send('Log.enable');
@ -196,17 +196,17 @@ export class Tab extends EventEmitter<TabEventsInterface> {
cdpSession.on('Runtime.exceptionThrown', (event: any) => {
this._handleServiceWorkerException(event);
});
// Listen for network failed events
cdpSession.on('Network.loadingFailed', (event: any) => {
this._handleNetworkError(event);
});
// Listen for security state changes (mixed content)
cdpSession.on('Security.securityStateChanged', (event: any) => {
this._handleSecurityStateChange(event);
});
// Listen for log entries (browser-level logs)
cdpSession.on('Log.entryAdded', (event: any) => {
this._handleLogEntry(event);
@ -327,14 +327,14 @@ export class Tab extends EventEmitter<TabEventsInterface> {
try {
// Listen for console messages from the extension
await this.page.evaluate(() => {
window.addEventListener('message', (event) => {
window.addEventListener('message', event => {
if (event.data && event.data.type === 'PLAYWRIGHT_CONSOLE_CAPTURE') {
const message = event.data.consoleMessage;
// Store the message in a global array for Playwright to access
if (!(window as any)._playwrightExtensionConsoleMessages) {
if (!(window as any)._playwrightExtensionConsoleMessages)
(window as any)._playwrightExtensionConsoleMessages = [];
}
(window as any)._playwrightExtensionConsoleMessages.push(message);
}
});
@ -342,7 +342,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
// Poll for new extension console messages
setInterval(() => {
this._checkForExtensionConsoleMessages();
void this._checkForExtensionConsoleMessages();
}, 1000);
} catch (error) {
@ -353,9 +353,9 @@ export class Tab extends EventEmitter<TabEventsInterface> {
private async _checkForExtensionConsoleMessages() {
try {
const newMessages = await this.page.evaluate(() => {
if (!(window as any)._playwrightExtensionConsoleMessages) {
if (!(window as any)._playwrightExtensionConsoleMessages)
return [];
}
const messages = (window as any)._playwrightExtensionConsoleMessages;
(window as any)._playwrightExtensionConsoleMessages = [];
return messages;

View file

@ -14,6 +14,7 @@
* limitations under the License.
*/
import artifacts from './tools/artifacts.js';
import common from './tools/common.js';
import configure from './tools/configure.js';
import console from './tools/console.js';
@ -25,6 +26,7 @@ import keyboard from './tools/keyboard.js';
import navigate from './tools/navigate.js';
import network from './tools/network.js';
import pdf from './tools/pdf.js';
import requests from './tools/requests.js';
import snapshot from './tools/snapshot.js';
import tabs from './tools/tabs.js';
import screenshot from './tools/screenshot.js';
@ -36,6 +38,7 @@ import type { Tool } from './tools/tool.js';
import type { FullConfig } from './config.js';
export const allTools: Tool<any>[] = [
...artifacts,
...common,
...configure,
...console,
@ -48,6 +51,7 @@ export const allTools: Tool<any>[] = [
...network,
...mouse,
...pdf,
...requests,
...screenshot,
...snapshot,
...tabs,

View file

@ -25,14 +25,76 @@ const requests = defineTabTool({
schema: {
name: 'browser_network_requests',
title: 'List network requests',
description: 'Returns all network requests since loading the page',
inputSchema: z.object({}),
description: 'Returns all network requests since loading the page. For more detailed analysis including timing, headers, and bodies, use the advanced request monitoring tools (browser_start_request_monitoring, browser_get_requests).',
inputSchema: z.object({
detailed: z.boolean().optional().default(false).describe('Show detailed request information if request monitoring is active')
}),
type: 'readOnly',
},
handle: async (tab, params, response) => {
const requests = tab.requests();
[...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
// Check if request interceptor is active and can provide richer data
const interceptor = tab.context.getRequestInterceptor();
if (params.detailed && interceptor) {
// Use rich intercepted data
const interceptedRequests = interceptor.getData();
if (interceptedRequests.length > 0) {
response.addResult('📊 **Network Requests (Enhanced)**');
response.addResult('');
interceptedRequests.forEach((req, index) => {
const duration = req.duration ? ` (${req.duration}ms)` : '';
const status = req.failed ? 'FAILED' : req.response?.status || 'pending';
const size = req.response?.bodySize ? ` - ${(req.response.bodySize / 1024).toFixed(1)}KB` : '';
response.addResult(`${index + 1}. **${req.method} ${status}**${duration}`);
response.addResult(` ${req.url}${size}`);
if (req.response) {
const contentType = req.response.headers['content-type'];
if (contentType)
response.addResult(` 📄 ${contentType}`);
}
if (req.failed && req.failure)
response.addResult(`${req.failure.errorText}`);
response.addResult('');
});
const stats = interceptor.getStats();
response.addResult('📈 **Summary:**');
response.addResult(`• Total: ${stats.totalRequests} | Success: ${stats.successfulRequests} | Failed: ${stats.failedRequests}`);
response.addResult(`• Average Response Time: ${stats.averageResponseTime}ms`);
return;
}
}
// Fall back to basic playwright request data
const basicRequests = tab.requests();
if (basicRequests.size === 0) {
response.addResult(' **No network requests found**');
response.addResult('');
response.addResult('💡 For comprehensive request monitoring, use:');
response.addResult(' • `browser_start_request_monitoring` - Enable detailed capture');
response.addResult(' • `browser_get_requests` - View captured data with analysis');
return;
}
response.addResult('📋 **Network Requests (Basic)**');
response.addResult('');
[...basicRequests.entries()].forEach(([req, res], index) => {
response.addResult(`${index + 1}. ${renderRequest(req, res)}`);
});
response.addResult('');
response.addResult('💡 **For detailed analysis** including timing, headers, and bodies:');
response.addResult(' • Use `browser_start_request_monitoring` to enable advanced capture');
response.addResult(' • Then use `browser_get_requests` for comprehensive analysis');
},
});

560
src/tools/requests.ts Normal file
View 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,
];

View file

@ -130,6 +130,23 @@ const getRecordingStatus = defineTool({
response.addResult('1. Use browser_start_recording to enable recording');
response.addResult('2. Navigate to pages and perform actions');
response.addResult('3. Use browser_stop_recording to save videos');
// Show potential artifact locations for debugging
const registry = ArtifactManagerRegistry.getInstance();
const artifactManager = context.sessionId ? registry.getManager(context.sessionId) : undefined;
if (artifactManager) {
const baseDir = artifactManager.getBaseDirectory();
const sessionDir = artifactManager.getSessionDirectory();
response.addResult(`\n🔍 Debug Info:`);
response.addResult(`📁 Artifact base directory: ${baseDir}`);
response.addResult(`📂 Session directory: ${sessionDir}`);
response.addResult(`🆔 Session ID: ${context.sessionId}`);
} else {
response.addResult(`\n⚠ No artifact manager configured - videos will save to default output directory`);
response.addResult(`📁 Default output: ${path.join(context.config.outputDir, 'videos')}`);
}
return;
}
@ -142,13 +159,159 @@ const getRecordingStatus = defineTool({
response.addResult(`📐 Video size: auto-scaled to fit 800x800`);
response.addResult(`🎬 Active recordings: ${recordingInfo.activeRecordings}`);
// Show helpful path info for MCP clients
const outputDir = recordingInfo.config?.dir;
if (outputDir) {
const absolutePath = path.resolve(outputDir);
response.addResult(`📍 Absolute path: ${absolutePath}`);
// Check if directory exists and show contents
const fs = await import('fs');
if (fs.existsSync(absolutePath)) {
try {
const files = fs.readdirSync(absolutePath);
const webmFiles = files.filter(f => f.endsWith('.webm'));
if (webmFiles.length > 0) {
response.addResult(`📹 Existing video files in directory: ${webmFiles.length}`);
webmFiles.forEach(file => response.addResult(`${file}`));
} else {
response.addResult(`📁 Directory exists but no .webm files found yet`);
}
} catch (error: any) {
response.addResult(`⚠️ Could not read directory contents: ${error.message}`);
}
} else {
response.addResult(`⚠️ Output directory does not exist yet (will be created when recording starts)`);
}
}
// Show debug information
const registry = ArtifactManagerRegistry.getInstance();
const artifactManager = context.sessionId ? registry.getManager(context.sessionId) : undefined;
if (artifactManager) {
response.addResult(`\n🔍 Debug Info:`);
response.addResult(`🆔 Session ID: ${context.sessionId}`);
response.addResult(`📂 Session directory: ${artifactManager.getSessionDirectory()}`);
}
if (recordingInfo.activeRecordings === 0)
response.addResult(`\n💡 Tip: Navigate to pages to start recording browser actions`);
},
});
const revealArtifactPaths = defineTool({
capability: 'core',
schema: {
name: 'browser_reveal_artifact_paths',
title: 'Reveal artifact storage paths',
description: 'Show where artifacts (videos, screenshots, etc.) are stored, including resolved absolute paths. Useful for debugging when you cannot find generated files.',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async (context, params, response) => {
response.addResult('🗂️ Artifact Storage Paths');
response.addResult('=========================\n');
// Show default output directory
response.addResult(`📁 Default output directory: ${context.config.outputDir}`);
response.addResult(`📍 Resolved absolute path: ${path.resolve(context.config.outputDir)}\n`);
// Show artifact manager paths if configured
const registry = ArtifactManagerRegistry.getInstance();
const artifactManager = context.sessionId ? registry.getManager(context.sessionId) : undefined;
if (artifactManager) {
const baseDir = artifactManager.getBaseDirectory();
const sessionDir = artifactManager.getSessionDirectory();
response.addResult('🎯 Centralized Artifact Storage (ACTIVE):');
response.addResult(`📁 Base directory: ${baseDir}`);
response.addResult(`📍 Base absolute path: ${path.resolve(baseDir)}`);
response.addResult(`📂 Session directory: ${sessionDir}`);
response.addResult(`📍 Session absolute path: ${path.resolve(sessionDir)}`);
response.addResult(`🆔 Session ID: ${context.sessionId}\n`);
// Show subdirectories
response.addResult('📋 Available subdirectories:');
const subdirs = ['videos', 'screenshots', 'api-logs', 'traces'];
for (const subdir of subdirs) {
const subdirPath = artifactManager.getSubdirectory(subdir);
const fs = await import('fs');
const exists = fs.existsSync(subdirPath);
response.addResult(` 📁 ${subdir}: ${subdirPath} ${exists ? '✅' : '⚠️ (will be created when needed)'}`);
}
// Show any existing files in the session directory
const fs = await import('fs');
if (fs.existsSync(sessionDir)) {
try {
const items = fs.readdirSync(sessionDir, { withFileTypes: true });
const files = items.filter(item => item.isFile()).map(item => item.name);
const dirs = items.filter(item => item.isDirectory()).map(item => item.name);
if (dirs.length > 0) {
response.addResult(`\n📂 Existing subdirectories: ${dirs.join(', ')}`);
}
if (files.length > 0) {
response.addResult(`📄 Files in session directory: ${files.join(', ')}`);
}
// Count .webm files across all subdirectories
let webmCount = 0;
function countWebmFiles(dir: string) {
try {
const contents = fs.readdirSync(dir, { withFileTypes: true });
for (const item of contents) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
countWebmFiles(fullPath);
} else if (item.name.endsWith('.webm')) {
webmCount++;
}
}
} catch (error) {
// Ignore permission errors
}
}
countWebmFiles(sessionDir);
if (webmCount > 0) {
response.addResult(`🎬 Total .webm video files found: ${webmCount}`);
}
} catch (error: any) {
response.addResult(`⚠️ Could not list session directory contents: ${error.message}`);
}
}
} else {
response.addResult('⚠️ No centralized artifact storage configured');
response.addResult('📁 Files will be saved to default output directory');
response.addResult(`📍 Default path: ${path.resolve(context.config.outputDir)}\n`);
}
// Show current video recording paths if active
const recordingInfo = context.getVideoRecordingInfo();
if (recordingInfo.enabled && recordingInfo.config?.dir) {
response.addResult('🎥 Current Video Recording:');
response.addResult(`📁 Video output directory: ${recordingInfo.config.dir}`);
response.addResult(`📍 Video absolute path: ${path.resolve(recordingInfo.config.dir)}`);
response.addResult(`📝 Base filename pattern: ${recordingInfo.baseFilename}*.webm`);
}
response.addResult('\n💡 Tips:');
response.addResult('• Use these absolute paths to locate your generated files');
response.addResult('• Video files (.webm) are created when pages close or recording stops');
response.addResult('• Screenshot files (.png/.jpeg) are created immediately when taken');
},
});
export default [
startRecording,
stopRecording,
getRecordingStatus,
revealArtifactPaths,
];