mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-25 07:52:27 +08:00
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 <yurys@chromium.org>
This commit is contained in:
parent
0df6d7a441
commit
6c3f3b6576
344
extension/background.js
Normal file
344
extension/background.js
Normal file
@ -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();
|
BIN
extension/icons/icon-128.png
Normal file
BIN
extension/icons/icon-128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.2 KiB |
BIN
extension/icons/icon-16.png
Normal file
BIN
extension/icons/icon-16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 571 B |
BIN
extension/icons/icon-32.png
Normal file
BIN
extension/icons/icon-32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
BIN
extension/icons/icon-48.png
Normal file
BIN
extension/icons/icon-48.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
40
extension/manifest.json
Normal file
40
extension/manifest.json
Normal file
@ -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": [
|
||||
"<all_urls>"
|
||||
],
|
||||
|
||||
"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"
|
||||
}
|
||||
}
|
173
extension/popup.html
Normal file
173
extension/popup.html
Normal file
@ -0,0 +1,173 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
width: 320px;
|
||||
padding: 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="url"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type="url"]:focus {
|
||||
outline: none;
|
||||
border-color: #4CAF50;
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.button {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
background: #cccccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button.disconnect {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.button.disconnect:hover {
|
||||
background: #da190b;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #f44336;
|
||||
}
|
||||
|
||||
.status.warning {
|
||||
background: #fff3e0;
|
||||
color: #ef6c00;
|
||||
border: 1px solid #ff9800;
|
||||
}
|
||||
|
||||
.tab-info {
|
||||
background: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tab-url {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.focus-button {
|
||||
background: #2196F3;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.focus-button:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
|
||||
.small-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h3>Playwright MCP Bridge</h3>
|
||||
</div>
|
||||
|
||||
<div id="status-container"></div>
|
||||
|
||||
<div class="section">
|
||||
<label for="bridge-url">Bridge Server URL:</label>
|
||||
<input type="url" id="bridge-url" disabled placeholder="ws://localhost:9223/extension" />
|
||||
<div class="small-text">Enter the WebSocket URL of your MCP bridge server</div>
|
||||
</div>
|
||||
|
||||
<div id="action-container">
|
||||
<button id="connect-btn" class="button">Share This Tab</button>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
228
extension/popup.js
Normal file
228
extension/popup.js
Normal file
@ -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 = `
|
||||
<button id="connect-btn" class="button">Share This Tab</button>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<button id="disconnect-btn" class="button disconnect">Stop Sharing</button>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="tab-title">${tabInfo.title || 'Unknown Tab'}</div>
|
||||
<div class="tab-url">${tabInfo.url || ''}</div>
|
||||
`;
|
||||
this.statusContainer.appendChild(tabDiv);
|
||||
}
|
||||
|
||||
showFocusButton(activeTabId) {
|
||||
if (!this.actionContainer) return;
|
||||
|
||||
this.actionContainer.innerHTML = `
|
||||
<button id="focus-btn" class="button focus-button">Switch to Shared Tab</button>
|
||||
`;
|
||||
|
||||
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();
|
||||
});
|
69
package-lock.json
generated
69
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -39,5 +39,6 @@ export default defineConfig<TestOptions>({
|
||||
}] : [],
|
||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||
{ name: 'chromium-extension', use: { mcpBrowser: 'chromium', mcpMode: 'extension' } },
|
||||
],
|
||||
});
|
||||
|
@ -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);
|
||||
|
306
src/cdp-relay.ts
Normal file
306
src/cdp-relay.ts
Normal file
@ -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<void>(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);
|
||||
});
|
||||
}
|
@ -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<FullConf
|
||||
return result;
|
||||
}
|
||||
|
||||
export function validateConfig(config: Config) {
|
||||
if (config.extension) {
|
||||
if (config.browser?.browserName !== 'chromium')
|
||||
throw new Error('Extension mode is only supported for Chromium browsers.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
||||
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||
let channel: string | undefined;
|
||||
@ -142,6 +158,11 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
launchOptions.proxy.bypass = cliOptions.proxyBypass;
|
||||
}
|
||||
|
||||
if (cliOptions.device && cliOptions.cdpEndpoint)
|
||||
throw new Error('Device emulation is not supported with cdpEndpoint.');
|
||||
if (cliOptions.device && cliOptions.extension)
|
||||
throw new Error('Device emulation is not supported with extension mode.');
|
||||
|
||||
// Context options
|
||||
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
||||
if (cliOptions.storageState)
|
||||
@ -183,6 +204,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
},
|
||||
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||
vision: !!cliOptions.vision,
|
||||
extension: !!cliOptions.extension,
|
||||
network: {
|
||||
allowedOrigins: cliOptions.allowedOrigins,
|
||||
blockedOrigins: cliOptions.blockedOrigins,
|
||||
|
@ -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: {
|
||||
|
@ -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>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
||||
.option('--viewport-size <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[] {
|
||||
|
@ -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<Connection> {
|
||||
|
@ -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<http.Server> {
|
||||
const sseSessions = new Map<string, SSEServerTransport>();
|
||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||
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<void>(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}`;
|
||||
}
|
||||
|
@ -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.');
|
||||
});
|
||||
|
@ -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>Title</title>
|
||||
<body>Hello, world!</body>
|
||||
@ -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',
|
||||
|
@ -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'],
|
||||
});
|
||||
|
43
tests/extension.spec.ts
Normal file
43
tests/extension.spec.ts
Normal file
@ -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.');
|
||||
});
|
@ -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') },
|
||||
});
|
||||
|
@ -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<void>;
|
||||
};
|
||||
|
||||
type WorkerFixtures = {
|
||||
@ -64,7 +71,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
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<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
}
|
||||
|
||||
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<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
|
||||
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<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
},
|
||||
});
|
||||
|
||||
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<string>();
|
||||
const sseEndpointPromise = new ManualPromise<string>();
|
||||
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<ReturnType<Client['callTool']>>;
|
||||
@ -242,6 +332,17 @@ export const expect = baseExpect.extend({
|
||||
},
|
||||
});
|
||||
|
||||
async function findFreePort(): Promise<number> {
|
||||
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);
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -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({
|
||||
|
Loading…
x
Reference in New Issue
Block a user