diff --git a/README.md b/README.md index 0a38bd6..023c883 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ Playwright MCP server supports following arguments. They can be provided in the vision, pdf. --cdp-endpoint CDP endpoint to connect to. --config path to the configuration file. + --console-output-file file path to write browser console output to + for debugging and monitoring. --device device to emulate, for example: "iPhone 15" --executable-path path to the browser executable. --headless run browser in headless mode, headed by @@ -529,7 +531,7 @@ http.createServer(async (req, res) => { - **browser_click** - Title: Click - - Description: Perform click on a web page. Returns page snapshot after click unless disabled with --no-snapshots. Large snapshots (>10k tokens) are truncated - use browser_snapshot for full capture. + - Description: Perform click on a web page. Returns page snapshot after click (configurable via browser_configure_snapshots). Use browser_snapshot for explicit full snapshots. - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot @@ -575,6 +577,18 @@ http.createServer(async (req, res) => { +- **browser_configure_snapshots** + - Title: Configure snapshot behavior + - Description: Configure how page snapshots are handled during the session. Control automatic snapshots, size limits, and differential modes. Changes take effect immediately for subsequent tool calls. + - Parameters: + - `includeSnapshots` (boolean, optional): Enable/disable automatic snapshots after interactive operations. When false, use browser_snapshot for explicit snapshots. + - `maxSnapshotTokens` (number, optional): Maximum tokens allowed in snapshots before truncation. Use 0 to disable truncation. + - `differentialSnapshots` (boolean, optional): Enable differential snapshots that show only changes since last snapshot instead of full page snapshots. + - `consoleOutputFile` (string, optional): File path to write browser console output to. Set to empty string to disable console file output. + - Read-only: **false** + + + - **browser_console_messages** - Title: Get console messages - Description: Returns all console messages @@ -585,7 +599,7 @@ http.createServer(async (req, res) => { - **browser_drag** - Title: Drag mouse - - Description: Perform drag and drop between two elements. Returns page snapshot after drag unless disabled with --no-snapshots. + - Description: Perform drag and drop between two elements. Returns page snapshot after drag (configurable via browser_configure_snapshots). - Parameters: - `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element - `startRef` (string): Exact source element reference from the page snapshot @@ -597,7 +611,7 @@ http.createServer(async (req, res) => { - **browser_evaluate** - Title: Evaluate JavaScript - - Description: Evaluate JavaScript expression on page or element + - Description: Evaluate JavaScript expression on page or element. Returns page snapshot after evaluation (configurable via browser_configure_snapshots). - Parameters: - `function` (string): () => { /* code */ } or (element) => { /* code */ } when element is provided - `element` (string, optional): Human-readable element description used to obtain permission to interact with the element @@ -608,7 +622,7 @@ http.createServer(async (req, res) => { - **browser_file_upload** - Title: Upload files - - Description: Upload one or multiple files + - Description: Upload one or multiple files. Returns page snapshot after upload (configurable via browser_configure_snapshots). - Parameters: - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files. - Read-only: **false** @@ -617,7 +631,7 @@ http.createServer(async (req, res) => { - **browser_handle_dialog** - Title: Handle a dialog - - Description: Handle a dialog + - Description: Handle a dialog. Returns page snapshot after handling dialog (configurable via browser_configure_snapshots). - Parameters: - `accept` (boolean): Whether to accept the dialog. - `promptText` (string, optional): The text of the prompt in case of a prompt dialog. @@ -627,7 +641,7 @@ http.createServer(async (req, res) => { - **browser_hover** - Title: Hover mouse - - Description: Hover over element on page. Returns page snapshot after hover unless disabled with --no-snapshots. + - Description: Hover over element on page. Returns page snapshot after hover (configurable via browser_configure_snapshots). - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot @@ -673,7 +687,7 @@ http.createServer(async (req, res) => { - **browser_navigate** - Title: Navigate to a URL - - Description: Navigate to a URL. Returns page snapshot after navigation unless disabled with --no-snapshots. + - Description: Navigate to a URL. Returns page snapshot after navigation (configurable via browser_configure_snapshots). - Parameters: - `url` (string): The URL to navigate to - Read-only: **false** @@ -706,7 +720,7 @@ http.createServer(async (req, res) => { - **browser_press_key** - Title: Press a key - - Description: Press a key on the keyboard. Returns page snapshot after keypress unless disabled with --no-snapshots. + - Description: Press a key on the keyboard. Returns page snapshot after keypress (configurable via browser_configure_snapshots). - Parameters: - `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a` - Read-only: **false** @@ -733,7 +747,7 @@ http.createServer(async (req, res) => { - **browser_select_option** - Title: Select option - - Description: Select an option in a dropdown. Returns page snapshot after selection unless disabled with --no-snapshots. + - Description: Select an option in a dropdown. Returns page snapshot after selection (configurable via browser_configure_snapshots). - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot @@ -744,7 +758,7 @@ http.createServer(async (req, res) => { - **browser_snapshot** - Title: Page snapshot - - Description: Capture complete accessibility snapshot of the current page. Always returns full snapshot regardless of --no-snapshots or size limits. Better than screenshot for understanding page structure. + - Description: Capture complete accessibility snapshot of the current page. Always returns full snapshot regardless of session snapshot configuration. Better than screenshot for understanding page structure. - Parameters: None - Read-only: **true** @@ -770,20 +784,21 @@ http.createServer(async (req, res) => { - **browser_take_screenshot** - Title: Take a screenshot - - Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions. + - Description: Take a screenshot of the current page. Images exceeding 8000 pixels in either dimension will be rejected unless allowLargeImages=true. You can't perform actions based on the screenshot, use browser_snapshot for actions. - Parameters: - `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image. - `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. - `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. + - `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** - **browser_type** - Title: Type text - - Description: Type text into editable element. Returns page snapshot after typing unless disabled with --no-snapshots. + - Description: Type text into editable element. Returns page snapshot after typing (configurable via browser_configure_snapshots). - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot @@ -805,7 +820,7 @@ http.createServer(async (req, res) => { - **browser_wait_for** - Title: Wait for - - Description: Wait for text to appear or disappear or a specified time to pass + - Description: Wait for text to appear or disappear or a specified time to pass. Returns page snapshot after waiting (configurable via browser_configure_snapshots). - Parameters: - `time` (number, optional): The time to wait in seconds - `text` (string, optional): The text to wait for @@ -821,7 +836,7 @@ http.createServer(async (req, res) => { - **browser_tab_close** - Title: Close a tab - - Description: Close a tab + - Description: Close a tab. Returns page snapshot after closing tab (configurable via browser_configure_snapshots). - Parameters: - `index` (number, optional): The index of the tab to close. Closes current tab if not provided. - Read-only: **false** @@ -838,7 +853,7 @@ http.createServer(async (req, res) => { - **browser_tab_new** - Title: Open a new tab - - Description: Open a new tab + - Description: Open a new tab. Returns page snapshot after opening tab (configurable via browser_configure_snapshots). - Parameters: - `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank. - Read-only: **true** @@ -847,7 +862,7 @@ http.createServer(async (req, res) => { - **browser_tab_select** - Title: Select a tab - - Description: Select a tab by index + - Description: Select a tab by index. Returns page snapshot after selecting tab (configurable via browser_configure_snapshots). - Parameters: - `index` (number): The index of the tab to select - Read-only: **true** diff --git a/console-capture-extension/background.js b/console-capture-extension/background.js new file mode 100644 index 0000000..1facb6a --- /dev/null +++ b/console-capture-extension/background.js @@ -0,0 +1,193 @@ +// Background script for comprehensive console capture +console.log('Console Capture Extension: Background script loaded'); + +// Track active debug sessions +const debugSessions = new Map(); + +// Message storage for each tab +const tabConsoleMessages = new Map(); + +chrome.tabs.onCreated.addListener((tab) => { + if (tab.id) { + attachDebugger(tab.id); + } +}); + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'loading' && tab.url && !tab.url.startsWith('chrome://')) { + attachDebugger(tabId); + } +}); + +chrome.tabs.onRemoved.addListener((tabId) => { + if (debugSessions.has(tabId)) { + try { + chrome.debugger.detach({ tabId }); + } catch (e) { + // Ignore errors when detaching + } + debugSessions.delete(tabId); + tabConsoleMessages.delete(tabId); + } +}); + +async function attachDebugger(tabId) { + try { + // Don't attach to chrome:// pages or if already attached + if (debugSessions.has(tabId)) { + return; + } + + // Attach debugger + await chrome.debugger.attach({ tabId }, '1.3'); + debugSessions.set(tabId, true); + + console.log(`Console Capture Extension: Attached to tab ${tabId}`); + + // Enable domains for comprehensive console capture + await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); + await chrome.debugger.sendCommand({ tabId }, 'Log.enable'); + await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); + await chrome.debugger.sendCommand({ tabId }, 'Security.enable'); + + // Initialize console messages array for this tab + if (!tabConsoleMessages.has(tabId)) { + tabConsoleMessages.set(tabId, []); + } + + } catch (error) { + console.log(`Console Capture Extension: Failed to attach to tab ${tabId}:`, error); + debugSessions.delete(tabId); + } +} + +// Listen for debugger events +chrome.debugger.onEvent.addListener((source, method, params) => { + const tabId = source.tabId; + if (!tabId || !debugSessions.has(tabId)) return; + + let consoleMessage = null; + + try { + switch (method) { + case 'Runtime.consoleAPICalled': + consoleMessage = { + type: params.type || 'log', + text: params.args?.map(arg => + arg.value !== undefined ? String(arg.value) : + arg.unserializableValue || '[object]' + ).join(' ') || '', + location: `runtime:${params.stackTrace?.callFrames?.[0]?.lineNumber || 0}`, + source: 'js-console', + timestamp: Date.now() + }; + break; + + case 'Runtime.exceptionThrown': + const exception = params.exceptionDetails; + consoleMessage = { + type: 'error', + text: exception?.text || exception?.exception?.description || 'Runtime Exception', + location: `runtime:${exception?.lineNumber || 0}`, + source: 'js-exception', + timestamp: Date.now() + }; + break; + + case 'Log.entryAdded': + const entry = params.entry; + if (entry && entry.text) { + consoleMessage = { + type: entry.level || 'info', + text: entry.text, + location: `browser-log:${entry.lineNumber || 0}`, + source: 'browser-log', + timestamp: Date.now() + }; + } + break; + + case 'Network.loadingFailed': + if (params.errorText) { + consoleMessage = { + type: 'error', + text: `Network Error: ${params.errorText} - ${params.blockedReason || 'Unknown reason'}`, + location: 'network-layer', + source: 'network-error', + timestamp: Date.now() + }; + } + break; + + case 'Security.securityStateChanged': + if (params.securityState === 'insecure' && params.explanations) { + for (const explanation of params.explanations) { + if (explanation.description && explanation.description.toLowerCase().includes('mixed content')) { + consoleMessage = { + type: 'error', + text: `Security Warning: ${explanation.description}`, + location: 'security-layer', + source: 'security-warning', + timestamp: Date.now() + }; + break; + } + } + } + break; + } + + if (consoleMessage) { + // Store the message + const messages = tabConsoleMessages.get(tabId) || []; + messages.push(consoleMessage); + tabConsoleMessages.set(tabId, messages); + + console.log(`Console Capture Extension: Captured message from tab ${tabId}:`, consoleMessage); + + // Send to content script for potential file writing + chrome.tabs.sendMessage(tabId, { + type: 'CONSOLE_MESSAGE', + message: consoleMessage + }).catch(() => { + // Ignore errors if content script not ready + }); + } + + } catch (error) { + console.log('Console Capture Extension: Error processing event:', error); + } +}); + +// Handle detach events +chrome.debugger.onDetach.addListener((source, reason) => { + const tabId = source.tabId; + if (tabId && debugSessions.has(tabId)) { + console.log(`Console Capture Extension: Detached from tab ${tabId}, reason: ${reason}`); + debugSessions.delete(tabId); + tabConsoleMessages.delete(tabId); + } +}); + +// API to get console messages for a tab +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.type === 'GET_CONSOLE_MESSAGES') { + const tabId = request.tabId || sender.tab?.id; + if (tabId) { + const messages = tabConsoleMessages.get(tabId) || []; + sendResponse({ messages }); + } else { + sendResponse({ messages: [] }); + } + return true; + } +}); + +// Initialize for existing tabs +chrome.tabs.query({}, (tabs) => { + for (const tab of tabs) { + if (tab.id && tab.url && !tab.url.startsWith('chrome://')) { + attachDebugger(tab.id); + } + } +}); \ No newline at end of file diff --git a/console-capture-extension/content.js b/console-capture-extension/content.js new file mode 100644 index 0000000..ee8a105 --- /dev/null +++ b/console-capture-extension/content.js @@ -0,0 +1,50 @@ +// Content script for console capture extension +console.log('Console Capture Extension: Content script loaded'); + +// Listen for console messages from background script +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.type === 'CONSOLE_MESSAGE') { + const message = request.message; + + // Forward to window for Playwright to access + window.postMessage({ + type: 'PLAYWRIGHT_CONSOLE_CAPTURE', + consoleMessage: message + }, '*'); + + console.log('Console Capture Extension: Forwarded message:', message); + } +}); + +// Also capture any window-level console messages that might be missed +const originalConsole = { + log: window.console.log, + warn: window.console.warn, + error: window.console.error, + info: window.console.info +}; + +function wrapConsoleMethod(method, level) { + return function(...args) { + // Call original method + originalConsole[method].apply(window.console, args); + + // Forward to Playwright + window.postMessage({ + type: 'PLAYWRIGHT_CONSOLE_CAPTURE', + consoleMessage: { + type: level, + text: args.map(arg => String(arg)).join(' '), + location: `content-script:${new Error().stack?.split('\n')[2]?.match(/:(\d+):/)?.[1] || 0}`, + source: 'content-wrapper', + timestamp: Date.now() + } + }, '*'); + }; +} + +// Wrap console methods +window.console.log = wrapConsoleMethod('log', 'log'); +window.console.warn = wrapConsoleMethod('warn', 'warning'); +window.console.error = wrapConsoleMethod('error', 'error'); +window.console.info = wrapConsoleMethod('info', 'info'); \ No newline at end of file diff --git a/console-capture-extension/icon-128.png b/console-capture-extension/icon-128.png new file mode 100644 index 0000000..c4bc8b0 Binary files /dev/null and b/console-capture-extension/icon-128.png differ diff --git a/console-capture-extension/icon-16.png b/console-capture-extension/icon-16.png new file mode 100644 index 0000000..0bab712 Binary files /dev/null and b/console-capture-extension/icon-16.png differ diff --git a/console-capture-extension/icon-32.png b/console-capture-extension/icon-32.png new file mode 100644 index 0000000..1f9a8cc Binary files /dev/null and b/console-capture-extension/icon-32.png differ diff --git a/console-capture-extension/icon-48.png b/console-capture-extension/icon-48.png new file mode 100644 index 0000000..ac23ef0 Binary files /dev/null and b/console-capture-extension/icon-48.png differ diff --git a/console-capture-extension/manifest.json b/console-capture-extension/manifest.json new file mode 100644 index 0000000..9cffae8 --- /dev/null +++ b/console-capture-extension/manifest.json @@ -0,0 +1,37 @@ +{ + "manifest_version": 3, + "name": "Console Capture Extension", + "version": "1.0.0", + "description": "Captures comprehensive console messages including browser-level warnings and errors", + + "permissions": [ + "debugger", + "tabs", + "activeTab", + "storage" + ], + + "background": { + "service_worker": "background.js", + "type": "module" + }, + + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_start" + } + ], + + "host_permissions": [ + "" + ], + + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "48": "icon-48.png", + "128": "icon-128.png" + } +} \ No newline at end of file diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index a81ef50..2c7cc3f 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -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) }; } diff --git a/src/config.ts b/src/config.ts index da46597..b4d878a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,7 +60,6 @@ const defaultConfig: FullConfig = { browserName: 'chromium', isolated: true, launchOptions: { - channel: 'chrome', headless: false, chromiumSandbox: true, }, diff --git a/src/context.ts b/src/context.ts index 400208a..040b4e9 100644 --- a/src/context.ts +++ b/src/context.ts @@ -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 { 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; diff --git a/src/tab.ts b/src/tab.ts index 871e23d..c8fad2e 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -71,6 +71,12 @@ export class Tab extends EventEmitter { }); 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 { } } + 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); diff --git a/src/tools/configure.ts b/src/tools/configure.ts index 4f9c51b..8f14901 100644 --- a/src/tools/configure.ts +++ b/src/tools/configure.ts @@ -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, 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