From 6c3f3b6576881b9fd7ceb22faf0e4ff3e422f3d3 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 13 Jun 2025 22:15:17 +0200 Subject: [PATCH] feat: add MCP Chrome extension (#325) Instructions: 1. `git clone https://github.com/mxschmitt/playwright-mcp && git checkout extension-drafft` 2. `npm ci && npm run build` 3. `chrome://extensions` in your normal Chrome, "load unpacked" and select the extension folder. 4. `node cli.js --port=4242 --extension` - The URL it prints at the end you can put into the extension popup. 5. Put either this into Claude Desktop (it does not support SSE yet hence wrapping it or just put the URL into Cursor/VSCode) ```json { "mcpServers": { "playwright": { "command": "bash", "args": [ "-c", "source $HOME/.nvm/nvm.sh && nvm use --silent 22 && npx supergateway --streamableHttp http://127.0.0.1:4242/mcp" ] } } } ``` Things like `Take a snapshot of my browser.` should now work in your Prompt Chat. ---- - SSE only for now, since we already have a http server with a port there - Upstream "page tests" can be executed over this CDP relay via https://github.com/microsoft/playwright/pull/36286 - Limitations for now are everything what happens outside of the tab its session is shared with -> `window.open` / `target=_blank`. --------- Co-authored-by: Yury Semikhatsky --- extension/background.js | 344 +++++++++++++++++++++++++++++++++++ extension/icons/icon-128.png | Bin 0 -> 6352 bytes extension/icons/icon-16.png | Bin 0 -> 571 bytes extension/icons/icon-32.png | Bin 0 -> 1258 bytes extension/icons/icon-48.png | Bin 0 -> 2043 bytes extension/manifest.json | 40 ++++ extension/popup.html | 173 ++++++++++++++++++ extension/popup.js | 228 +++++++++++++++++++++++ package-lock.json | 69 +++++++ package.json | 4 + playwright.config.ts | 1 + src/browserContextFactory.ts | 4 +- src/cdp-relay.ts | 306 +++++++++++++++++++++++++++++++ src/config.ts | 24 ++- src/connection.ts | 4 +- src/program.ts | 20 +- src/server.ts | 4 +- src/transport.ts | 55 +++--- tests/cdp.spec.ts | 17 ++ tests/config.spec.ts | 6 +- tests/device.spec.ts | 3 +- tests/extension.spec.ts | 43 +++++ tests/files.spec.ts | 6 +- tests/fixtures.ts | 123 +++++++++++-- tests/launch.spec.ts | 4 +- tests/sse.spec.ts | 2 + tests/tabs.spec.ts | 2 + tests/trace.spec.ts | 4 +- 28 files changed, 1430 insertions(+), 56 deletions(-) create mode 100644 extension/background.js create mode 100644 extension/icons/icon-128.png create mode 100644 extension/icons/icon-16.png create mode 100644 extension/icons/icon-32.png create mode 100644 extension/icons/icon-48.png create mode 100644 extension/manifest.json create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 src/cdp-relay.ts create mode 100644 tests/extension.spec.ts diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..0142809 --- /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 0000000000000000000000000000000000000000..c4bc8b02508b40cec8a0d73465324a9612744172 GIT binary patch literal 6352 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 JPDHLkV1gAA4qyNP literal 0 HcmV?d00001 diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..1f9a8ccb89bdbccd6bdc1c7f945f72eed9b94017 GIT binary patch literal 1258 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 Z{soKhKGhw*_GbV9002ovPDHLkV1gda&R_rl literal 0 HcmV?d00001 diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..d3f5dba --- /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 0000000..c10d5e4 --- /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 0000000..bc537f1 --- /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 a365025..04df3b3 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 ad81555..68cc90a 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 9c8ba59..709e85d 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 f14cd7d..ba62cab 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 0000000..13e8860 --- /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 f25e5a2..1c4a6f8 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 1c931f8..eff554d 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 537a244..8bcc9b3 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 8c143e1..14b33dd 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 14f6a8d..ac9898c 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 7a3492b..df5c119 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 4478347..8f12645 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 32ceecb..03dc5ee 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 0000000..a34dc54 --- /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 3653bca..11ff58f 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 8b3a24b..3e51a87 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 f0ad4b2..25a24e5 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 9e888a8..14996c3 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 08afd63..061e58c 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 13e9d4f..a72b92f 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({