diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 00000000..01428092 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,344 @@ +/** + * 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. + */ + +/** + * Simple Chrome Extension that pumps CDP messages between chrome.debugger and WebSocket + */ + +// @ts-check + +function debugLog(...args) { + const enabled = false; + if (enabled) { + console.log('[Extension]', ...args); + } +} + +class TabShareExtension { + constructor() { + this.activeConnections = new Map(); // tabId -> connection info + + // Remove page action click handler since we now use popup + chrome.tabs.onRemoved.addListener(this.onTabRemoved.bind(this)); + + // Handle messages from popup + chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); + } + + /** + * Handle messages from popup + * @param {any} message + * @param {chrome.runtime.MessageSender} sender + * @param {Function} sendResponse + */ + onMessage(message, sender, sendResponse) { + switch (message.type) { + case 'getStatus': + this.getStatus(message.tabId, sendResponse); + return true; // Will respond asynchronously + + case 'connect': + this.connectTab(message.tabId, message.bridgeUrl).then( + () => sendResponse({ success: true }), + (error) => sendResponse({ success: false, error: error.message }) + ); + return true; // Will respond asynchronously + + case 'disconnect': + this.disconnectTab(message.tabId).then( + () => sendResponse({ success: true }), + (error) => sendResponse({ success: false, error: error.message }) + ); + return true; // Will respond asynchronously + } + return false; + } + + /** + * Get connection status for popup + * @param {number} requestedTabId + * @param {Function} sendResponse + */ + getStatus(requestedTabId, sendResponse) { + const isConnected = this.activeConnections.size > 0; + let activeTabId = null; + let activeTabInfo = null; + + if (isConnected) { + const [tabId, connection] = this.activeConnections.entries().next().value; + activeTabId = tabId; + + // Get tab info + chrome.tabs.get(tabId, (tab) => { + if (chrome.runtime.lastError) { + sendResponse({ + isConnected: false, + error: 'Active tab not found' + }); + } else { + sendResponse({ + isConnected: true, + activeTabId, + activeTabInfo: { + title: tab.title, + url: tab.url + } + }); + } + }); + } else { + sendResponse({ + isConnected: false, + activeTabId: null, + activeTabInfo: null + }); + } + } + + /** + * Connect a tab to the bridge server + * @param {number} tabId + * @param {string} bridgeUrl + */ + async connectTab(tabId, bridgeUrl) { + try { + debugLog(`Connecting tab ${tabId} to bridge at ${bridgeUrl}`); + + // Attach chrome debugger + const debuggee = { tabId }; + await chrome.debugger.attach(debuggee, '1.3'); + + if (chrome.runtime.lastError) + throw new Error(chrome.runtime.lastError.message); + const targetInfo = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')); + debugLog('Target info:', targetInfo); + + // Connect to bridge server + const socket = new WebSocket(bridgeUrl); + + const connection = { + debuggee, + socket, + tabId, + sessionId: `pw-tab-${tabId}` + }; + + await new Promise((resolve, reject) => { + socket.onopen = () => { + debugLog(`WebSocket connected for tab ${tabId}`); + // Send initial connection info to bridge + socket.send(JSON.stringify({ + type: 'connection_info', + sessionId: connection.sessionId, + targetInfo: targetInfo?.targetInfo + })); + resolve(undefined); + }; + socket.onerror = reject; + setTimeout(() => reject(new Error('Connection timeout')), 5000); + }); + + // Set up message handling + this.setupMessageHandling(connection); + + // Store connection + this.activeConnections.set(tabId, connection); + + // Update UI + chrome.action.setBadgeText({ tabId, text: '●' }); + chrome.action.setBadgeBackgroundColor({ tabId, color: '#4CAF50' }); + chrome.action.setTitle({ tabId, title: 'Disconnect from Playwright MCP' }); + + debugLog(`Tab ${tabId} connected successfully`); + + } catch (error) { + debugLog(`Failed to connect tab ${tabId}:`, error.message); + await this.cleanupConnection(tabId); + + // Show error to user + chrome.action.setBadgeText({ tabId, text: '!' }); + chrome.action.setBadgeBackgroundColor({ tabId, color: '#F44336' }); + chrome.action.setTitle({ tabId, title: `Connection failed: ${error.message}` }); + + throw error; // Re-throw for popup to handle + } + } + + /** + * Set up bidirectional message handling between debugger and WebSocket + * @param {Object} connection + */ + setupMessageHandling(connection) { + const { debuggee, socket, tabId, sessionId: rootSessionId } = connection; + + // WebSocket -> chrome.debugger + socket.onmessage = async (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch (error) { + debugLog('Error parsing message:', error); + socket.send(JSON.stringify({ + error: { + code: -32700, + message: `Error parsing message: ${error.message}` + } + })); + return; + } + + try { + debugLog('Received from bridge:', message); + + const debuggerSession = { ...debuggee }; + const sessionId = message.sessionId; + // Pass session id, unless it's the root session. + if (sessionId && sessionId !== rootSessionId) + debuggerSession.sessionId = sessionId; + + // Forward CDP command to chrome.debugger + const result = await chrome.debugger.sendCommand( + debuggerSession, + message.method, + message.params || {} + ); + + // Send response back to bridge + const response = { + id: message.id, + sessionId, + result + }; + + if (chrome.runtime.lastError) { + response.error = { + code: -32000, + message: chrome.runtime.lastError.message, + }; + } + + socket.send(JSON.stringify(response)); + } catch (error) { + debugLog('Error processing WebSocket message:', error); + const response = { + id: message.id, + sessionId: message.sessionId, + error: { + code: -32000, + message: error.message, + }, + }; + socket.send(JSON.stringify(response)); + } + }; + + // chrome.debugger events -> WebSocket + const eventListener = (source, method, params) => { + if (source.tabId === tabId && socket.readyState === WebSocket.OPEN) { + // If the sessionId is not provided, use the root sessionId. + const event = { + sessionId: source.sessionId || rootSessionId, + method, + params, + }; + debugLog('Forwarding CDP event:', event); + socket.send(JSON.stringify(event)); + } + }; + + const detachListener = (source, reason) => { + if (source.tabId === tabId) { + debugLog(`Debugger detached from tab ${tabId}, reason: ${reason}`); + this.disconnectTab(tabId); + } + }; + + // Store listeners for cleanup + connection.eventListener = eventListener; + connection.detachListener = detachListener; + + chrome.debugger.onEvent.addListener(eventListener); + chrome.debugger.onDetach.addListener(detachListener); + + // Handle WebSocket close + socket.onclose = () => { + debugLog(`WebSocket closed for tab ${tabId}`); + this.disconnectTab(tabId); + }; + + socket.onerror = (error) => { + debugLog(`WebSocket error for tab ${tabId}:`, error); + this.disconnectTab(tabId); + }; + } + + /** + * Disconnect a tab from the bridge + * @param {number} tabId + */ + async disconnectTab(tabId) { + await this.cleanupConnection(tabId); + + // Update UI + chrome.action.setBadgeText({ tabId, text: '' }); + chrome.action.setTitle({ tabId, title: 'Share tab with Playwright MCP' }); + + debugLog(`Tab ${tabId} disconnected`); + } + + /** + * Clean up connection resources + * @param {number} tabId + */ + async cleanupConnection(tabId) { + const connection = this.activeConnections.get(tabId); + if (!connection) return; + + // Remove listeners + if (connection.eventListener) { + chrome.debugger.onEvent.removeListener(connection.eventListener); + } + if (connection.detachListener) { + chrome.debugger.onDetach.removeListener(connection.detachListener); + } + + // Close WebSocket + if (connection.socket && connection.socket.readyState === WebSocket.OPEN) { + connection.socket.close(); + } + + // Detach debugger + try { + await chrome.debugger.detach(connection.debuggee); + } catch (error) { + // Ignore detach errors - might already be detached + } + + this.activeConnections.delete(tabId); + } + + /** + * Handle tab removal + * @param {number} tabId + */ + async onTabRemoved(tabId) { + if (this.activeConnections.has(tabId)) { + await this.cleanupConnection(tabId); + } + } +} + +new TabShareExtension(); diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 00000000..c4bc8b02 Binary files /dev/null and b/extension/icons/icon-128.png differ diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 00000000..0bab7121 Binary files /dev/null and b/extension/icons/icon-16.png differ diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 00000000..1f9a8ccb Binary files /dev/null and b/extension/icons/icon-32.png differ diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 00000000..ac23ef07 Binary files /dev/null and b/extension/icons/icon-48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 00000000..d3f5dba7 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,40 @@ +{ + "manifest_version": 3, + "name": "Playwright MCP Bridge", + "version": "1.0.0", + "description": "Share browser tabs with Playwright MCP server through CDP bridge", + + "permissions": [ + "debugger", + "activeTab", + "tabs", + "storage" + ], + + "host_permissions": [ + "" + ], + + "background": { + "service_worker": "background.js", + "type": "module" + }, + + "action": { + "default_title": "Share tab with Playwright MCP", + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 00000000..c10d5e47 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,173 @@ + + + + + + + + +
+

Playwright MCP Bridge

+
+ +
+ +
+ + +
Enter the WebSocket URL of your MCP bridge server
+
+ +
+ +
+ + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 00000000..bc537f1a --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,228 @@ +/** + * 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. + */ + +// @ts-check + +/** + * Popup script for Playwright MCP Bridge extension + */ + +class PopupController { + constructor() { + this.currentTab = null; + this.bridgeUrlInput = /** @type {HTMLInputElement} */ (document.getElementById('bridge-url')); + this.connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); + this.statusContainer = /** @type {HTMLElement} */ (document.getElementById('status-container')); + this.actionContainer = /** @type {HTMLElement} */ (document.getElementById('action-container')); + + this.init(); + } + + async init() { + // Get current tab + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + this.currentTab = tab; + + // Load saved bridge URL + const result = await chrome.storage.sync.get(['bridgeUrl']); + const savedUrl = result.bridgeUrl || 'ws://localhost:9223/extension'; + this.bridgeUrlInput.value = savedUrl; + this.bridgeUrlInput.disabled = false; + + // Set up event listeners + this.bridgeUrlInput.addEventListener('input', this.onUrlChange.bind(this)); + this.connectBtn.addEventListener('click', this.onConnectClick.bind(this)); + + // Update UI based on current state + await this.updateUI(); + } + + async updateUI() { + if (!this.currentTab?.id) return; + + // Get connection status from background script + const response = await chrome.runtime.sendMessage({ + type: 'getStatus', + tabId: this.currentTab.id + }); + + const { isConnected, activeTabId, activeTabInfo, error } = response; + + if (!this.statusContainer || !this.actionContainer) return; + + this.statusContainer.innerHTML = ''; + this.actionContainer.innerHTML = ''; + + if (error) { + this.showStatus('error', `Error: ${error}`); + this.showConnectButton(); + } else if (isConnected && activeTabId === this.currentTab.id) { + // Current tab is connected + this.showStatus('connected', 'This tab is currently shared with MCP server'); + this.showDisconnectButton(); + } else if (isConnected && activeTabId !== this.currentTab.id) { + // Another tab is connected + this.showStatus('warning', 'Another tab is already sharing the CDP session'); + this.showActiveTabInfo(activeTabInfo); + this.showFocusButton(activeTabId); + } else { + // No connection + this.showConnectButton(); + } + } + + showStatus(type, message) { + const statusDiv = document.createElement('div'); + statusDiv.className = `status ${type}`; + statusDiv.textContent = message; + this.statusContainer.appendChild(statusDiv); + } + + showConnectButton() { + if (!this.actionContainer) return; + + this.actionContainer.innerHTML = ` + + `; + + const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); + if (connectBtn) { + connectBtn.addEventListener('click', this.onConnectClick.bind(this)); + + // Disable if URL is invalid + const isValidUrl = this.bridgeUrlInput ? this.isValidWebSocketUrl(this.bridgeUrlInput.value) : false; + connectBtn.disabled = !isValidUrl; + } + } + + showDisconnectButton() { + if (!this.actionContainer) return; + + this.actionContainer.innerHTML = ` + + `; + + const disconnectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('disconnect-btn')); + if (disconnectBtn) { + disconnectBtn.addEventListener('click', this.onDisconnectClick.bind(this)); + } + } + + showActiveTabInfo(tabInfo) { + if (!tabInfo) return; + + const tabDiv = document.createElement('div'); + tabDiv.className = 'tab-info'; + tabDiv.innerHTML = ` +
${tabInfo.title || 'Unknown Tab'}
+
${tabInfo.url || ''}
+ `; + this.statusContainer.appendChild(tabDiv); + } + + showFocusButton(activeTabId) { + if (!this.actionContainer) return; + + this.actionContainer.innerHTML = ` + + `; + + const focusBtn = /** @type {HTMLButtonElement} */ (document.getElementById('focus-btn')); + if (focusBtn) { + focusBtn.addEventListener('click', () => this.onFocusClick(activeTabId)); + } + } + + onUrlChange() { + if (!this.bridgeUrlInput) return; + + const isValid = this.isValidWebSocketUrl(this.bridgeUrlInput.value); + const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn')); + if (connectBtn) { + connectBtn.disabled = !isValid; + } + + // Save URL to storage + if (isValid) { + chrome.storage.sync.set({ bridgeUrl: this.bridgeUrlInput.value }); + } + } + + async onConnectClick() { + if (!this.bridgeUrlInput || !this.currentTab?.id) return; + + const url = this.bridgeUrlInput.value.trim(); + if (!this.isValidWebSocketUrl(url)) { + this.showStatus('error', 'Please enter a valid WebSocket URL'); + return; + } + + // Save URL to storage + await chrome.storage.sync.set({ bridgeUrl: url }); + + // Send connect message to background script + const response = await chrome.runtime.sendMessage({ + type: 'connect', + tabId: this.currentTab.id, + bridgeUrl: url + }); + + if (response.success) { + await this.updateUI(); + } else { + this.showStatus('error', response.error || 'Failed to connect'); + } + } + + async onDisconnectClick() { + if (!this.currentTab?.id) return; + + const response = await chrome.runtime.sendMessage({ + type: 'disconnect', + tabId: this.currentTab.id + }); + + if (response.success) { + await this.updateUI(); + } else { + this.showStatus('error', response.error || 'Failed to disconnect'); + } + } + + async onFocusClick(activeTabId) { + try { + await chrome.tabs.update(activeTabId, { active: true }); + window.close(); // Close popup after switching + } catch (error) { + this.showStatus('error', 'Failed to switch to tab'); + } + } + + isValidWebSocketUrl(url) { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.protocol === 'ws:' || parsed.protocol === 'wss:'; + } catch { + return false; + } + } +} + +// Initialize popup when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + new PopupController(); +}); diff --git a/package-lock.json b/package-lock.json index a365025d..04df3b3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "debug": "^4.4.1", "mime": "^4.0.7", "playwright": "1.53.0", + "ws": "^8.18.1", "zod-to-json-schema": "^3.24.4" }, "bin": { @@ -24,8 +25,10 @@ "@eslint/js": "^9.19.0", "@playwright/test": "1.53.0", "@stylistic/eslint-plugin": "^3.0.1", + "@types/chrome": "^0.0.315", "@types/debug": "^4.1.12", "@types/node": "^22.13.10", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/utils": "^8.26.1", @@ -356,6 +359,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@types/chrome": { + "version": "0.0.315", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.315.tgz", + "integrity": "sha512-Oy1dYWkr6BCmgwBtOngLByCHstQ3whltZg7/7lubgIZEYvKobDneqplgc6LKERNRBwckFviV4UU5AZZNUFrJ4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -373,6 +387,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -404,6 +442,16 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.27.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz", @@ -4243,6 +4291,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index ad815550..68cc90ab 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "ctest": "playwright test --project=chrome", "ftest": "playwright test --project=firefox", "wtest": "playwright test --project=webkit", + "etest": "playwright test --project=chromium-extension", "run-server": "node lib/browserServer.js", "clean": "rm -rf lib", "npm-publish": "npm run clean && npm run build && npm run test && npm publish" @@ -41,6 +42,7 @@ "debug": "^4.4.1", "mime": "^4.0.7", "playwright": "1.53.0", + "ws": "^8.18.1", "zod-to-json-schema": "^3.24.4" }, "devDependencies": { @@ -48,8 +50,10 @@ "@eslint/js": "^9.19.0", "@playwright/test": "1.53.0", "@stylistic/eslint-plugin": "^3.0.1", + "@types/chrome": "^0.0.315", "@types/debug": "^4.1.12", "@types/node": "^22.13.10", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/utils": "^8.26.1", diff --git a/playwright.config.ts b/playwright.config.ts index 9c8ba59b..709e85d9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -39,5 +39,6 @@ export default defineConfig({ }] : [], { name: 'firefox', use: { mcpBrowser: 'firefox' } }, { name: 'webkit', use: { mcpBrowser: 'webkit' } }, + { name: 'chromium-extension', use: { mcpBrowser: 'chromium', mcpMode: 'extension' } }, ], }); diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index f14cd7d9..ba62cabb 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -28,10 +28,10 @@ import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.js'; const testDebug = debug('pw:mcp:test'); -export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory { +export function contextFactory(browserConfig: FullConfig['browser'], { forceCdp }: { forceCdp?: boolean } = {}): BrowserContextFactory { if (browserConfig.remoteEndpoint) return new RemoteContextFactory(browserConfig); - if (browserConfig.cdpEndpoint) + if (browserConfig.cdpEndpoint || forceCdp) return new CdpContextFactory(browserConfig); if (browserConfig.isolated) return new IsolatedContextFactory(browserConfig); diff --git a/src/cdp-relay.ts b/src/cdp-relay.ts new file mode 100644 index 00000000..13e88600 --- /dev/null +++ b/src/cdp-relay.ts @@ -0,0 +1,306 @@ +/** + * 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. + */ + +/** + * Bridge Server - Standalone WebSocket server that bridges Playwright MCP and Chrome Extension + * + * Endpoints: + * - /cdp - Full CDP interface for Playwright MCP + * - /extension - Extension connection for chrome.debugger forwarding + */ + +/* eslint-disable no-console */ + +import { WebSocket, WebSocketServer } from 'ws'; +import http from 'node:http'; +import { EventEmitter } from 'node:events'; +import debug from 'debug'; + +const debugLogger = debug('pw-mcp:cdp-relay'); + +export class CDPBridgeServer extends EventEmitter { + private _wss: WebSocketServer; + private _playwrightSocket: WebSocket | null = null; + private _extensionSocket: WebSocket | null = null; + private _connectionInfo: { + targetInfo: any; + sessionId: string; + } | undefined; + + public static readonly CDP_PATH = '/cdp'; + public static readonly EXTENSION_PATH = '/extension'; + + constructor(server: http.Server) { + super(); + this._wss = new WebSocketServer({ server }); + this._wss.on('connection', this._onConnection.bind(this)); + } + + stop(): void { + this._playwrightSocket?.close(); + this._extensionSocket?.close(); + } + + private _onConnection(ws: WebSocket, request: http.IncomingMessage): void { + const url = new URL(`http://localhost${request.url}`); + + debugLogger(`New connection to ${url.pathname}`); + + if (url.pathname === CDPBridgeServer.CDP_PATH) { + this._handlePlaywrightConnection(ws); + } else if (url.pathname === CDPBridgeServer.EXTENSION_PATH) { + this._handleExtensionConnection(ws); + } else { + debugLogger(`Invalid path: ${url.pathname}`); + ws.close(4004, 'Invalid path'); + } + } + + /** + * Handle Playwright MCP connection - provides full CDP interface + */ + private _handlePlaywrightConnection(ws: WebSocket): void { + if (this._playwrightSocket?.readyState === WebSocket.OPEN) { + debugLogger('Closing previous Playwright connection'); + this._playwrightSocket.close(1000, 'New connection established'); + } + + this._playwrightSocket = ws; + debugLogger('Playwright MCP connected'); + + ws.on('message', data => { + try { + const message = JSON.parse(data.toString()); + this._handlePlaywrightMessage(message); + } catch (error) { + debugLogger('Error parsing Playwright message:', error); + } + }); + + ws.on('close', () => { + if (this._playwrightSocket === ws) + this._playwrightSocket = null; + + debugLogger('Playwright MCP disconnected'); + }); + + ws.on('error', error => { + debugLogger('Playwright WebSocket error:', error); + }); + } + + /** + * Handle Extension connection - forwards to chrome.debugger + */ + private _handleExtensionConnection(ws: WebSocket): void { + if (this._extensionSocket?.readyState === WebSocket.OPEN) { + debugLogger('Closing previous extension connection'); + this._extensionSocket.close(1000, 'New connection established'); + } + + this._extensionSocket = ws; + debugLogger('Extension connected'); + + ws.on('message', data => { + try { + const message = JSON.parse(data.toString()); + this._handleExtensionMessage(message); + } catch (error) { + debugLogger('Error parsing extension message:', error); + } + }); + + ws.on('close', () => { + if (this._extensionSocket === ws) + this._extensionSocket = null; + + debugLogger('Extension disconnected'); + }); + + ws.on('error', error => { + debugLogger('Extension WebSocket error:', error); + }); + } + + /** + * Handle messages from Playwright MCP + */ + private _handlePlaywrightMessage(message: any): void { + debugLogger('← Playwright:', message.method || `response(${message.id})`); + + // Handle Browser domain methods locally + if (message.method?.startsWith('Browser.')) { + this._handleBrowserDomainMethod(message); + return; + } + + // Handle Target domain methods + if (message.method?.startsWith('Target.')) { + this._handleTargetDomainMethod(message); + return; + } + + // Forward other commands to extension + if (message.method) + this._forwardToExtension(message); + + } + + /** + * Handle messages from Extension + */ + private _handleExtensionMessage(message: any): void { + // Handle connection info from extension + if (message.type === 'connection_info') { + debugLogger('← Extension connected to tab:', message); + this._connectionInfo = { + targetInfo: message.targetInfo, + // Page sessionId that should be used by this connection. + sessionId: message.sessionId + }; + return; + } + + // CDP event from extension + debugLogger(`← Extension message: ${message.method ?? (message.id && `response(id=${message.id})`) ?? 'unknown'}`); + this._sendToPlaywright(message); + } + + /** + * Handle Browser domain methods locally + */ + private _handleBrowserDomainMethod(message: any): void { + switch (message.method) { + case 'Browser.getVersion': + this._sendToPlaywright({ + id: message.id, + result: { + protocolVersion: '1.3', + product: 'Chrome/Extension-Bridge', + userAgent: 'CDP-Bridge-Server/1.0.0', + } + }); + break; + + case 'Browser.setDownloadBehavior': + this._sendToPlaywright({ + id: message.id, + result: {} + }); + break; + + default: + // Forward unknown Browser methods to extension + this._forwardToExtension(message); + } + } + + /** + * Handle Target domain methods + */ + private _handleTargetDomainMethod(message: any): void { + switch (message.method) { + case 'Target.setAutoAttach': + // Simulate auto-attach behavior with real target info + if (this._connectionInfo && !message.sessionId) { + debugLogger('Simulating auto-attach for target:', JSON.stringify(message)); + this._sendToPlaywright({ + method: 'Target.attachedToTarget', + params: { + sessionId: this._connectionInfo.sessionId, + targetInfo: { + ...this._connectionInfo.targetInfo, + attached: true, + }, + waitingForDebugger: false + } + }); + this._sendToPlaywright({ + id: message.id, + result: {} + }); + } else { + this._forwardToExtension(message); + } + break; + + case 'Target.getTargets': + const targetInfos = []; + + if (this._connectionInfo) { + targetInfos.push({ + ...this._connectionInfo.targetInfo, + attached: true, + }); + } + + this._sendToPlaywright({ + id: message.id, + result: { targetInfos } + }); + break; + + default: + this._forwardToExtension(message); + } + } + + /** + * Forward message to extension + */ + private _forwardToExtension(message: any): void { + if (this._extensionSocket?.readyState === WebSocket.OPEN) { + debugLogger('→ Extension:', message.method || `command(${message.id})`); + this._extensionSocket.send(JSON.stringify(message)); + } else { + debugLogger('Extension not connected, cannot forward message'); + if (message.id) { + this._sendToPlaywright({ + id: message.id, + error: { message: 'Extension not connected' } + }); + } + } + } + + /** + * Forward message to Playwright + */ + private _sendToPlaywright(message: any): void { + if (this._playwrightSocket?.readyState === WebSocket.OPEN) { + debugLogger('→ Playwright:', JSON.stringify(message)); + this._playwrightSocket.send(JSON.stringify(message)); + } + } +} + +// CLI usage +if (import.meta.url === `file://${process.argv[1]}`) { + const port = parseInt(process.argv[2], 10) || 9223; + const httpServer = http.createServer(); + await new Promise(resolve => httpServer.listen(port, resolve)); + const server = new CDPBridgeServer(httpServer); + + console.error(`CDP Bridge Server listening on ws://localhost:${port}`); + console.error(`- Playwright MCP: ws://localhost:${port}${CDPBridgeServer.CDP_PATH}`); + console.error(`- Extension: ws://localhost:${port}${CDPBridgeServer.EXTENSION_PATH}`); + + process.on('SIGINT', () => { + debugLogger('\nShutting down bridge server...'); + server.stop(); + process.exit(0); + }); +} diff --git a/src/config.ts b/src/config.ts index f25e5a27..1c4a6f83 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,10 +19,18 @@ import os from 'os'; import path from 'path'; import { devices } from 'playwright'; -import type { Config, ToolCapability } from '../config.js'; +import type { Config as PublicConfig, ToolCapability } from '../config.js'; import type { BrowserContextOptions, LaunchOptions } from 'playwright'; import { sanitizeForFilePath } from './tools/utils.js'; +type Config = PublicConfig & { + /** + * TODO: Move to PublicConfig once we are ready to release this feature. + * Run server that is able to connect to the 'Playwright MCP' Chrome extension. + */ + extension?: boolean; +}; + export type CLIOptions = { allowedOrigins?: string[]; blockedOrigins?: string[]; @@ -50,6 +58,7 @@ export type CLIOptions = { userDataDir?: string; viewportSize?: string; vision?: boolean; + extension?: boolean; }; const defaultConfig: FullConfig = { @@ -99,6 +108,13 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise { let browserName: 'chromium' | 'firefox' | 'webkit' | undefined; let channel: string | undefined; @@ -142,6 +158,11 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise c.trim() as ToolCapability), vision: !!cliOptions.vision, + extension: !!cliOptions.extension, network: { allowedOrigins: cliOptions.allowedOrigins, blockedOrigins: cliOptions.blockedOrigins, diff --git a/src/connection.ts b/src/connection.ts index 1c931f88..eff554d7 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -22,14 +22,14 @@ import { Context } from './context.js'; import { snapshotTools, visionTools } from './tools.js'; import { packageJSON } from './package.js'; -import { FullConfig } from './config.js'; +import { FullConfig, validateConfig } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection { const allTools = config.vision ? visionTools : snapshotTools; const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability)); - + validateConfig(config); const context = new Context(tools, config, browserContextFactory); const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, { capabilities: { diff --git a/src/program.ts b/src/program.ts index 537a2445..8bcc9b30 100644 --- a/src/program.ts +++ b/src/program.ts @@ -14,14 +14,16 @@ * limitations under the License. */ -import { program } from 'commander'; +import type http from 'http'; +import { Option, program } from 'commander'; // @ts-ignore import { startTraceViewerServer } from 'playwright-core/lib/server'; -import { startHttpTransport, startStdioTransport } from './transport.js'; +import { httpAddressToString, startHttpTransport, startStdioTransport } from './transport.js'; import { resolveCLIConfig } from './config.js'; import { Server } from './server.js'; import { packageJSON } from './package.js'; +import { CDPBridgeServer } from './cdp-relay.js'; program .version('Version ' + packageJSON.version) @@ -52,13 +54,15 @@ program .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280, 720"') .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') + .addOption(new Option('--extension', 'Allow connecting to a running browser instance (Edge/Chrome only). Requires the \'Playwright MCP\' browser extension to be installed.').hideHelp()) .action(async options => { const config = await resolveCLIConfig(options); - const server = new Server(config); + const server = new Server(config, { forceCdp: !!config.extension }); server.setupExitWatchdog(); + let httpServer: http.Server | undefined = undefined; if (config.server.port !== undefined) - startHttpTransport(server); + httpServer = await startHttpTransport(server); else await startStdioTransport(server); @@ -69,6 +73,14 @@ program // eslint-disable-next-line no-console console.error('\nTrace viewer listening on ' + url); } + if (config.extension && httpServer) { + const wsAddress = httpAddressToString(httpServer.address()).replace(/^http/, 'ws'); + config.browser.cdpEndpoint = `${wsAddress}${CDPBridgeServer.CDP_PATH}`; + const cdpRelayServer = new CDPBridgeServer(httpServer); + process.on('exit', () => cdpRelayServer.stop()); + // eslint-disable-next-line no-console + console.error(`CDP relay server started on ${wsAddress}${CDPBridgeServer.EXTENSION_PATH} - Connect to it using the browser extension.`); + } }); function semicolonSeparatedList(value: string): string[] { diff --git a/src/server.ts b/src/server.ts index 8c143e13..14b33ddf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,10 +28,10 @@ export class Server { private _browserConfig: FullConfig['browser']; private _contextFactory: BrowserContextFactory; - constructor(config: FullConfig) { + constructor(config: FullConfig, { forceCdp }: { forceCdp: boolean }) { this.config = config; this._browserConfig = config.browser; - this._contextFactory = contextFactory(this._browserConfig); + this._contextFactory = contextFactory(this._browserConfig, { forceCdp }); } async createConnection(transport: Transport): Promise { diff --git a/src/transport.ts b/src/transport.ts index 14f6a8df..ac9898cf 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -23,6 +23,7 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import type { AddressInfo } from 'node:net'; import type { Server } from './server.js'; export async function startStdioTransport(server: Server) { @@ -96,7 +97,7 @@ async function handleStreamable(server: Server, req: http.IncomingMessage, res: res.end('Invalid request'); } -export function startHttpTransport(server: Server) { +export async function startHttpTransport(server: Server): Promise { const sseSessions = new Map(); const streamableSessions = new Map(); const httpServer = http.createServer(async (req, res) => { @@ -107,32 +108,32 @@ export function startHttpTransport(server: Server) { await handleSSE(server, req, res, url, sseSessions); }); const { host, port } = server.config.server; - httpServer.listen(port, host, () => { - const address = httpServer.address(); - assert(address, 'Could not bind server socket'); - let url: string; - if (typeof address === 'string') { - url = address; - } else { - const resolvedPort = address.port; - let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; - if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') - resolvedHost = 'localhost'; - url = `http://${resolvedHost}:${resolvedPort}`; - } - const message = [ - `Listening on ${url}`, - 'Put this in your client config:', - JSON.stringify({ - 'mcpServers': { - 'playwright': { - 'url': `${url}/sse` - } + await new Promise(resolve => httpServer.listen(port, host, resolve)); + const url = httpAddressToString(httpServer.address()); + const message = [ + `Listening on ${url}`, + 'Put this in your client config:', + JSON.stringify({ + 'mcpServers': { + 'playwright': { + 'url': `${url}/sse` } - }, undefined, 2), - 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.', - ].join('\n'); + } + }, undefined, 2), + 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.', + ].join('\n'); // eslint-disable-next-line no-console - console.error(message); - }); + console.error(message); + return httpServer; +} + +export function httpAddressToString(address: string | AddressInfo | null): string { + assert(address, 'Could not bind server socket'); + if (typeof address === 'string') + return address; + const resolvedPort = address.port; + let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; + if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') + resolvedHost = 'localhost'; + return `http://${resolvedHost}:${resolvedPort}`; } diff --git a/tests/cdp.spec.ts b/tests/cdp.spec.ts index 7a3492bd..df5c119b 100644 --- a/tests/cdp.spec.ts +++ b/tests/cdp.spec.ts @@ -14,8 +14,13 @@ * limitations under the License. */ +import url from 'node:url'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { test, expect } from './fixtures.js'; +test.skip(({ mcpMode }) => mcpMode === 'extension', 'Connecting to CDP server is not supported in combination with --extension'); + test('cdp server', async ({ cdpServer, startClient, server }) => { await cdpServer.start(); const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); @@ -75,3 +80,15 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer arguments: { url: server.PREFIX }, })).toContainTextContent(`- generic [ref=e1]: Hello, world!`); }); + +// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. +const __filename = url.fileURLToPath(import.meta.url); + +test('does not support --device', async () => { + const result = spawnSync('node', [ + path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234', + ]); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr.toString()).toContain('Device emulation is not supported with cdpEndpoint.'); +}); diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 4478347d..8f12645b 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -19,7 +19,8 @@ import fs from 'node:fs'; import { Config } from '../config.js'; import { test, expect } from './fixtures.js'; -test('config user data dir', async ({ startClient, server }, testInfo) => { +test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => { + test.skip(mcpMode === 'extension', 'Connecting to CDP server does not use user data dir'); server.setContent('/', ` Title Hello, world! @@ -45,7 +46,8 @@ test('config user data dir', async ({ startClient, server }, testInfo) => { test.describe(() => { test.use({ mcpBrowser: '' }); - test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => { + test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => { + test.skip(mcpMode === 'extension', 'Extension mode only supports Chromium'); const config: Config = { browser: { browserName: 'firefox', diff --git a/tests/device.spec.ts b/tests/device.spec.ts index 32ceecb4..03dc5ee4 100644 --- a/tests/device.spec.ts +++ b/tests/device.spec.ts @@ -16,7 +16,8 @@ import { test, expect } from './fixtures.js'; -test('--device should work', async ({ startClient, server }) => { +test('--device should work', async ({ startClient, server, mcpMode }) => { + test.skip(mcpMode === 'extension', 'Viewport is not supported when connecting via CDP. There we re-use the browser viewport.'); const { client } = await startClient({ args: ['--device', 'iPhone 15'], }); diff --git a/tests/extension.spec.ts b/tests/extension.spec.ts new file mode 100644 index 00000000..a34dc541 --- /dev/null +++ b/tests/extension.spec.ts @@ -0,0 +1,43 @@ +/** + * 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 url from 'url'; +import path from 'path'; +import { spawnSync } from 'child_process'; + +import { test, expect } from './fixtures.js'; + +import { createConnection } from '@playwright/mcp'; + +test.skip(({ mcpMode }) => mcpMode !== 'extension'); + +test('does not allow --cdp-endpoint', async ({ startClient }) => { + await expect(createConnection({ + browser: { browserName: 'firefox' }, + ...({ extension: true }) + })).rejects.toThrow(/Extension mode is only supported for Chromium browsers/); +}); + +// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. +const __filename = url.fileURLToPath(import.meta.url); + +test('does not support --device', async () => { + const result = spawnSync('node', [ + path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--extension', + ]); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr.toString()).toContain('Device emulation is not supported with extension mode.'); +}); diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 3653bca5..11ff58fc 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -100,7 +100,8 @@ The tool "browser_file_upload" can only be used when there is related modal stat } }); -test('clicking on download link emits download', async ({ startClient, server }, testInfo) => { +test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => { + test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension'); const { client } = await startClient({ config: { outputDir: testInfo.outputPath('output') }, }); @@ -124,7 +125,8 @@ test('clicking on download link emits download', async ({ startClient, server }, - Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`); }); -test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => { +test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => { + test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension'); const { client } = await startClient({ config: { outputDir: testInfo.outputPath('output') }, }); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 8b3a24b1..3e51a879 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -17,19 +17,25 @@ import fs from 'fs'; import url from 'url'; import path from 'path'; +import net from 'net'; import { chromium } from 'playwright'; +import { fork } from 'child_process'; import { test as baseTest, expect as baseExpect } from '@playwright/test'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { TestServer } from './testserver/index.ts'; +import { ManualPromise } from '../src/manualPromise.js'; import type { Config } from '../config'; import type { BrowserContext } from 'playwright'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Stream } from 'stream'; export type TestOptions = { mcpBrowser: string | undefined; - mcpMode: 'docker' | undefined; + mcpMode: 'docker' | 'extension' | undefined; }; type CDPServer = { @@ -46,6 +52,7 @@ type TestFixtures = { server: TestServer; httpsServer: TestServer; mcpHeadless: boolean; + startMcpExtension: (relayServerURL: string) => Promise; }; type WorkerFixtures = { @@ -64,7 +71,7 @@ export const test = baseTest.extend( await use(client); }, - startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => { + startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, startMcpExtension }, use, testInfo) => { const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined; const configDir = path.dirname(test.info().config.configFile!); let client: Client | undefined; @@ -88,16 +95,18 @@ export const test = baseTest.extend( } client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }); - const transport = createTransport(args, mcpMode); - let stderr = ''; - transport.stderr?.on('data', data => { + const { transport, stderr, relayServerURL } = await createTransport(args, mcpMode); + let stderrBuffer = ''; + stderr?.on('data', data => { if (process.env.PWMCP_DEBUG) process.stderr.write(data); - stderr += data.toString(); + stderrBuffer += data.toString(); }); await client.connect(transport); + if (mcpMode === 'extension') + await startMcpExtension(relayServerURL!); await client.ping(); - return { client, stderr: () => stderr }; + return { client, stderr: () => stderrBuffer }; }); await client?.close(); @@ -138,7 +147,39 @@ export const test = baseTest.extend( mcpMode: [undefined, { option: true }], - _workerServers: [async ({}, use, workerInfo) => { + startMcpExtension: async ({ mcpMode, mcpHeadless }, use) => { + let context: BrowserContext | undefined; + await use(async (relayServerURL: string) => { + if (mcpMode !== 'extension') + throw new Error('Must be running in MCP extension mode to use this fixture.'); + const cdpPort = await findFreePort(); + const pathToExtension = path.join(url.fileURLToPath(import.meta.url), '../../extension'); + context = await chromium.launchPersistentContext('', { + headless: mcpHeadless, + args: [ + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + '--enable-features=AllowContentInitiatedDataUrlNavigations', + ], + channel: 'chromium', + ...{ assistantMode: true, cdpPort }, + }); + const popupPage = await context.newPage(); + const page = context.pages()[0]; + await page.bringToFront(); + // Do not auto dismiss dialogs. + page.on('dialog', () => { }); + await expect.poll(() => context?.serviceWorkers()).toHaveLength(1); + // Connect to the relay server. + await popupPage.goto(new URL('/popup.html', context.serviceWorkers()[0].url()).toString()); + await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).clear(); + await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(relayServerURL); + await popupPage.getByRole('button', { name: 'Share This Tab' }).click(); + }); + await context?.close(); + }, + + _workerServers: [async ({ }, use, workerInfo) => { const port = 8907 + workerInfo.workerIndex * 4; const server = await TestServer.create(port); @@ -164,17 +205,62 @@ export const test = baseTest.extend( }, }); -function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) { +async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{ + transport: Transport, + stderr: Stream | null, + relayServerURL?: string, +}> { // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. const __filename = url.fileURLToPath(import.meta.url); if (mcpMode === 'docker') { const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`]; - return new StdioClientTransport({ + const transport = new StdioClientTransport({ command: 'docker', args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args], }); + return { + transport, + stderr: transport.stderr, + }; } - return new StdioClientTransport({ + if (mcpMode === 'extension') { + const relay = fork(path.join(__filename, '../../cli.js'), [...args, '--extension', '--port=0'], { + stdio: 'pipe' + }); + const cdpRelayServerReady = new ManualPromise(); + const sseEndpointPromise = new ManualPromise(); + let stderrBuffer = ''; + relay.stderr!.on('data', data => { + stderrBuffer += data.toString(); + const match = stderrBuffer.match(/Listening on (http:\/\/.*)/); + if (match) + sseEndpointPromise.resolve(match[1].toString()); + const extensionMatch = stderrBuffer.match(/CDP relay server started on (ws:\/\/.*\/extension)/); + if (extensionMatch) + cdpRelayServerReady.resolve(extensionMatch[1].toString()); + }); + relay.on('exit', () => { + sseEndpointPromise.reject(new Error(`Process exited`)); + cdpRelayServerReady.reject(new Error(`Process exited`)); + }); + const relayServerURL = await cdpRelayServerReady; + const sseEndpoint = await sseEndpointPromise; + + const transport = new SSEClientTransport(new URL(sseEndpoint)); + // We cannot just add transport.onclose here as Client.connect() overrides it. + const origClose = transport.close; + transport.close = async () => { + await origClose.call(transport); + relay.kill(); + }; + return { + transport, + stderr: relay.stderr!, + relayServerURL, + }; + } + + const transport = new StdioClientTransport({ command: 'node', args: [path.join(path.dirname(__filename), '../cli.js'), ...args], cwd: path.join(path.dirname(__filename), '..'), @@ -186,6 +272,10 @@ function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) { DEBUG_HIDE_DATE: '1', }, }); + return { + transport, + stderr: transport.stderr!, + }; } type Response = Awaited>; @@ -242,6 +332,17 @@ export const expect = baseExpect.extend({ }, }); +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + export function formatOutput(output: string): string[] { return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean); } diff --git a/tests/launch.spec.ts b/tests/launch.spec.ts index f0ad4b2e..25a24e5e 100644 --- a/tests/launch.spec.ts +++ b/tests/launch.spec.ts @@ -18,7 +18,9 @@ import fs from 'fs'; import { test, expect, formatOutput } from './fixtures.js'; -test('test reopen browser', async ({ startClient, server }) => { +test.skip(({ mcpMode }) => mcpMode === 'extension', 'launch scenarios are not supported with --extension - the browser is already launched'); + +test('test reopen browser', async ({ startClient, server, mcpMode }) => { const { client, stderr } = await startClient(); await client.callTool({ name: 'browser_navigate', diff --git a/tests/sse.spec.ts b/tests/sse.spec.ts index 9e888a8c..14996c35 100644 --- a/tests/sse.spec.ts +++ b/tests/sse.spec.ts @@ -29,6 +29,8 @@ import type { Config } from '../config.d.ts'; // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. const __filename = url.fileURLToPath(import.meta.url); +baseTest.skip(({ mcpMode }) => mcpMode === 'extension', 'Extension tests run via SSE anyways'); + const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({ serverEndpoint: async ({ mcpHeadless }, use, testInfo) => { let cp: ChildProcess | undefined; diff --git a/tests/tabs.spec.ts b/tests/tabs.spec.ts index 08afd631..061e58c4 100644 --- a/tests/tabs.spec.ts +++ b/tests/tabs.spec.ts @@ -27,6 +27,8 @@ async function createTab(client: Client, title: string, body: string) { }); } +test.skip(({ mcpMode }) => mcpMode === 'extension', 'Multi-tab scenarios are not supported with --extension'); + test('list initial tabs', async ({ client }) => { expect(await client.callTool({ name: 'browser_tab_list', diff --git a/tests/trace.spec.ts b/tests/trace.spec.ts index 13e9d4f4..a72b92f8 100644 --- a/tests/trace.spec.ts +++ b/tests/trace.spec.ts @@ -19,7 +19,9 @@ import path from 'path'; import { test, expect } from './fixtures.js'; -test('check that trace is saved', async ({ startClient, server }, testInfo) => { +test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => { + test.fixme(mcpMode === 'extension', 'Tracing is not supported via CDP'); + const outputDir = testInfo.outputPath('output'); const { client } = await startClient({