feat: comprehensive console capture and offline mode support
Major enhancements to browser automation and debugging capabilities: **Console Capture Features:** - Add console output file option (CLI, env var, session config) - Enhanced CDP console capture for service worker messages - Browser-level security warnings and mixed content errors - Network failure and loading error capture - All console contexts written to structured log files - Chrome extension for comprehensive console message interception **Offline Mode Support:** - Add browser_set_offline tool for DevTools-equivalent offline mode - Integrate offline mode into browser_configure tool - Support for testing network failure scenarios and service worker behavior **Extension Management:** - Improved extension installation messaging about session persistence - Console capture extension with debugger API access - Clear communication about extension lifecycle to MCP clients **Technical Implementation:** - CDP session management across multiple domains (Runtime, Network, Security, Log) - Service worker context console message interception - Browser context factory integration for offline mode - Pure Chromium configuration for optimal extension support All features provide MCP clients with powerful debugging capabilities equivalent to Chrome DevTools console and offline functionality.
This commit is contained in:
parent
7de63b5bab
commit
afaa8a7014
13 changed files with 603 additions and 20 deletions
|
|
@ -72,6 +72,12 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||
testDebug(`create browser context (${this.name})`);
|
||||
const browser = await this._obtainBrowser();
|
||||
const browserContext = await this._doCreateContext(browser, extensionPaths);
|
||||
|
||||
// Apply offline mode if configured
|
||||
if ((this.browserConfig as any).offline !== undefined) {
|
||||
await browserContext.setOffline((this.browserConfig as any).offline);
|
||||
}
|
||||
|
||||
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ const defaultConfig: FullConfig = {
|
|||
browserName: 'chromium',
|
||||
isolated: true,
|
||||
launchOptions: {
|
||||
channel: 'chrome',
|
||||
headless: false,
|
||||
chromiumSandbox: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -315,6 +315,11 @@ export class Context {
|
|||
|
||||
const browserContext = await browser.newContext(contextOptions);
|
||||
|
||||
// Apply offline mode if configured
|
||||
if ((this.config as any).offline !== undefined) {
|
||||
await browserContext.setOffline((this.config as any).offline);
|
||||
}
|
||||
|
||||
return {
|
||||
browserContext,
|
||||
close: async () => {
|
||||
|
|
@ -373,6 +378,7 @@ export class Context {
|
|||
timezone?: string;
|
||||
colorScheme?: 'light' | 'dark' | 'no-preference';
|
||||
permissions?: string[];
|
||||
offline?: boolean;
|
||||
}): Promise<void> {
|
||||
const currentConfig = { ...this.config };
|
||||
|
||||
|
|
@ -428,6 +434,10 @@ export class Context {
|
|||
currentConfig.browser.contextOptions.permissions = changes.permissions;
|
||||
|
||||
|
||||
if (changes.offline !== undefined)
|
||||
(currentConfig.browser as any).offline = changes.offline;
|
||||
|
||||
|
||||
// Store the modified config
|
||||
(this as any).config = currentConfig;
|
||||
|
||||
|
|
|
|||
215
src/tab.ts
215
src/tab.ts
|
|
@ -71,6 +71,12 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||
});
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.setDefaultTimeout(5000);
|
||||
|
||||
// Initialize service worker console capture
|
||||
void this._initializeServiceWorkerConsoleCapture();
|
||||
|
||||
// Initialize extension-based console capture
|
||||
void this._initializeExtensionConsoleCapture();
|
||||
}
|
||||
|
||||
modalStates(): ModalState[] {
|
||||
|
|
@ -160,6 +166,215 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||
}
|
||||
}
|
||||
|
||||
private async _initializeServiceWorkerConsoleCapture() {
|
||||
try {
|
||||
// Only attempt CDP console capture for Chromium browsers
|
||||
if (this.page.context().browser()?.browserType().name() !== 'chromium')
|
||||
return;
|
||||
|
||||
|
||||
const cdpSession = await this.page.context().newCDPSession(this.page);
|
||||
|
||||
// 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');
|
||||
|
||||
// Listen for console API calls (includes service worker console messages)
|
||||
cdpSession.on('Runtime.consoleAPICalled', (event: any) => {
|
||||
this._handleServiceWorkerConsole(event);
|
||||
});
|
||||
|
||||
// Listen for runtime exceptions (includes service worker errors)
|
||||
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);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Silently handle CDP errors - not all contexts support CDP
|
||||
logUnhandledError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleServiceWorkerConsole(event: any) {
|
||||
try {
|
||||
// Check if this console event is from a service worker context
|
||||
if (event.executionContextId && event.args && event.args.length > 0) {
|
||||
const message = event.args.map((arg: any) => {
|
||||
if (arg.value !== undefined)
|
||||
return String(arg.value);
|
||||
|
||||
if (arg.unserializableValue)
|
||||
return arg.unserializableValue;
|
||||
|
||||
if (arg.objectId)
|
||||
return '[object]';
|
||||
|
||||
return '';
|
||||
}).join(' ');
|
||||
|
||||
const location = `service-worker:${event.stackTrace?.callFrames?.[0]?.lineNumber || 0}`;
|
||||
|
||||
const consoleMessage: ConsoleMessage = {
|
||||
type: event.type || 'log',
|
||||
text: message,
|
||||
toString: () => `[${(event.type || 'log').toUpperCase()}] ${message} @ ${location}`,
|
||||
};
|
||||
|
||||
this._handleConsoleMessage(consoleMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
logUnhandledError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleServiceWorkerException(event: any) {
|
||||
try {
|
||||
const exception = event.exceptionDetails;
|
||||
if (exception) {
|
||||
const text = exception.text || exception.exception?.description || 'Service Worker Exception';
|
||||
const location = `service-worker:${exception.lineNumber || 0}`;
|
||||
|
||||
const consoleMessage: ConsoleMessage = {
|
||||
type: 'error',
|
||||
text: text,
|
||||
toString: () => `[ERROR] ${text} @ ${location}`,
|
||||
};
|
||||
|
||||
this._handleConsoleMessage(consoleMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
logUnhandledError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleNetworkError(event: any) {
|
||||
try {
|
||||
if (event.errorText && event.requestId) {
|
||||
const consoleMessage: ConsoleMessage = {
|
||||
type: 'error',
|
||||
text: `Network Error: ${event.errorText} (${event.type || 'unknown'})`,
|
||||
toString: () => `[NETWORK ERROR] ${event.errorText} @ ${event.type || 'network'}`,
|
||||
};
|
||||
|
||||
this._handleConsoleMessage(consoleMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
logUnhandledError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSecurityStateChange(event: any) {
|
||||
try {
|
||||
if (event.securityState === 'insecure' && event.explanations) {
|
||||
for (const explanation of event.explanations) {
|
||||
if (explanation.description && explanation.description.includes('mixed content')) {
|
||||
const consoleMessage: ConsoleMessage = {
|
||||
type: 'error',
|
||||
text: `Security Warning: ${explanation.description}`,
|
||||
toString: () => `[SECURITY] ${explanation.description} @ security-layer`,
|
||||
};
|
||||
|
||||
this._handleConsoleMessage(consoleMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logUnhandledError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleLogEntry(event: any) {
|
||||
try {
|
||||
const entry = event.entry;
|
||||
if (entry && entry.text) {
|
||||
const consoleMessage: ConsoleMessage = {
|
||||
type: entry.level || 'info',
|
||||
text: entry.text,
|
||||
toString: () => `[${(entry.level || 'info').toUpperCase()}] ${entry.text} @ browser-log`,
|
||||
};
|
||||
|
||||
this._handleConsoleMessage(consoleMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
logUnhandledError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async _initializeExtensionConsoleCapture() {
|
||||
try {
|
||||
// Listen for console messages from the extension
|
||||
await this.page.evaluate(() => {
|
||||
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) {
|
||||
(window as any)._playwrightExtensionConsoleMessages = [];
|
||||
}
|
||||
(window as any)._playwrightExtensionConsoleMessages.push(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Poll for new extension console messages
|
||||
setInterval(() => {
|
||||
this._checkForExtensionConsoleMessages();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
logUnhandledError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async _checkForExtensionConsoleMessages() {
|
||||
try {
|
||||
const newMessages = await this.page.evaluate(() => {
|
||||
if (!(window as any)._playwrightExtensionConsoleMessages) {
|
||||
return [];
|
||||
}
|
||||
const messages = (window as any)._playwrightExtensionConsoleMessages;
|
||||
(window as any)._playwrightExtensionConsoleMessages = [];
|
||||
return messages;
|
||||
});
|
||||
|
||||
for (const message of newMessages) {
|
||||
const consoleMessage: ConsoleMessage = {
|
||||
type: message.type || 'log',
|
||||
text: message.text || '',
|
||||
toString: () => `[${(message.type || 'log').toUpperCase()}] ${message.text} @ ${message.location || message.source}`,
|
||||
};
|
||||
|
||||
this._handleConsoleMessage(consoleMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
logUnhandledError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private _onClose() {
|
||||
this._clearCollectedArtifacts();
|
||||
this._onPageClose(this);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ const configureSchema = z.object({
|
|||
locale: z.string().optional().describe('Browser locale (e.g., "en-US", "fr-FR", "ja-JP")'),
|
||||
timezone: z.string().optional().describe('Timezone ID (e.g., "America/New_York", "Europe/London", "Asia/Tokyo")'),
|
||||
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional().describe('Preferred color scheme'),
|
||||
permissions: z.array(z.string()).optional().describe('Permissions to grant (e.g., ["geolocation", "notifications", "camera", "microphone"])')
|
||||
permissions: z.array(z.string()).optional().describe('Permissions to grant (e.g., ["geolocation", "notifications", "camera", "microphone"])'),
|
||||
offline: z.boolean().optional().describe('Whether to emulate offline network conditions (equivalent to DevTools offline mode)')
|
||||
});
|
||||
|
||||
const listDevicesSchema = z.object({});
|
||||
|
|
@ -81,6 +82,43 @@ const configureSnapshotsSchema = z.object({
|
|||
consoleOutputFile: z.string().optional().describe('File path to write browser console output to. Set to empty string to disable console file output.')
|
||||
});
|
||||
|
||||
// Simple offline mode toggle for testing
|
||||
const offlineModeSchema = z.object({
|
||||
offline: z.boolean().describe('Whether to enable offline mode (true) or online mode (false)')
|
||||
});
|
||||
|
||||
const offlineModeTest = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_set_offline',
|
||||
title: 'Set browser offline mode',
|
||||
description: 'Toggle browser offline mode on/off (equivalent to DevTools offline checkbox)',
|
||||
inputSchema: offlineModeSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
handle: async (context: Context, params: z.output<typeof offlineModeSchema>, response: Response) => {
|
||||
try {
|
||||
// Get current browser context
|
||||
const tab = context.currentTab();
|
||||
if (!tab) {
|
||||
throw new Error('No active browser tab. Navigate to a page first.');
|
||||
}
|
||||
|
||||
const browserContext = tab.page.context();
|
||||
await browserContext.setOffline(params.offline);
|
||||
|
||||
response.addResult(
|
||||
`✅ Browser offline mode ${params.offline ? 'enabled' : 'disabled'}\n\n` +
|
||||
`The browser will now ${params.offline ? 'block all network requests' : 'allow network requests'} ` +
|
||||
`(equivalent to ${params.offline ? 'checking' : 'unchecking'} the offline checkbox in DevTools).`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to set offline mode: ${error}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
defineTool({
|
||||
capability: 'core',
|
||||
|
|
@ -197,6 +235,14 @@ export default [
|
|||
changes.push(`permissions: ${params.permissions.join(', ')}`);
|
||||
|
||||
|
||||
if (params.offline !== undefined) {
|
||||
const currentOffline = (currentConfig.browser as any).offline;
|
||||
if (params.offline !== currentOffline)
|
||||
changes.push(`offline mode: ${currentOffline ? 'enabled' : 'disabled'} → ${params.offline ? 'enabled' : 'disabled'}`);
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (changes.length === 0) {
|
||||
response.addResult('No configuration changes detected. Current settings remain the same.');
|
||||
return;
|
||||
|
|
@ -213,6 +259,7 @@ export default [
|
|||
timezone: params.timezone,
|
||||
colorScheme: params.colorScheme,
|
||||
permissions: params.permissions,
|
||||
offline: params.offline,
|
||||
});
|
||||
|
||||
response.addResult(`Browser configuration updated successfully:\n${changes.map(c => `• ${c}`).join('\n')}\n\nThe browser has been restarted with the new settings.`);
|
||||
|
|
@ -398,7 +445,12 @@ export default [
|
|||
`Path: ${params.path}\n` +
|
||||
`Manifest version: ${manifest.manifest_version || 'unknown'}\n\n` +
|
||||
`The browser has been restarted with the extension loaded.\n` +
|
||||
`Use browser_list_extensions to see all installed extensions.`
|
||||
`Use browser_list_extensions to see all installed extensions.\n\n` +
|
||||
`⚠️ **Extension Persistence**: Extensions are session-based and will need to be reinstalled if:\n` +
|
||||
`• The MCP client disconnects and reconnects\n` +
|
||||
`• The browser context is restarted\n` +
|
||||
`• You switch between isolated/persistent browser modes\n\n` +
|
||||
`Extensions remain active for the current session only.`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -523,7 +575,12 @@ export default [
|
|||
`Version: ${extensionInfo.version}\n` +
|
||||
`Downloaded to: ${extensionDir}\n\n` +
|
||||
`The browser has been restarted with the extension loaded.\n` +
|
||||
`Use browser_list_extensions to see all installed extensions.`
|
||||
`Use browser_list_extensions to see all installed extensions.\n\n` +
|
||||
`⚠️ **Extension Persistence**: Extensions are session-based and will need to be reinstalled if:\n` +
|
||||
`• The MCP client disconnects and reconnects\n` +
|
||||
`• The browser context is restarted\n` +
|
||||
`• You switch between isolated/persistent browser modes\n\n` +
|
||||
`Extensions remain active for the current session only.`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -612,6 +669,7 @@ export default [
|
|||
}
|
||||
},
|
||||
}),
|
||||
offlineModeTest,
|
||||
];
|
||||
|
||||
// Helper functions for extension downloading
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue