mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-24 06:42:26 +08:00
chore: remove extension code (#667)
This commit is contained in:
parent
7fca8f50f8
commit
128474b4aa
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -48,8 +48,6 @@ jobs:
|
|||||||
run: npx playwright install msedge
|
run: npx playwright install msedge
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Build Chrome extension
|
|
||||||
run: npm run build:extension
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
Before Width: | Height: | Size: 571 B |
Binary file not shown.
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
Before Width: | Height: | Size: 2.0 KiB |
@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "lib/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"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,173 +0,0 @@
|
|||||||
<!--
|
|
||||||
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="lib/popup.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,199 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { RelayConnection, debugLog } from './relayConnection.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple Chrome Extension that pumps CDP messages between chrome.debugger and WebSocket
|
|
||||||
*/
|
|
||||||
|
|
||||||
type PopupMessage = {
|
|
||||||
type: 'getStatus' | 'connect' | 'disconnect';
|
|
||||||
tabId: number;
|
|
||||||
bridgeUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SendResponse = (response: any) => void;
|
|
||||||
|
|
||||||
class TabShareExtension {
|
|
||||||
private activeConnections: Map<number, RelayConnection>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.activeConnections = new Map(); // tabId -> connection
|
|
||||||
|
|
||||||
// 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
|
|
||||||
*/
|
|
||||||
onMessage(message: PopupMessage, sender: chrome.runtime.MessageSender, sendResponse: SendResponse): boolean {
|
|
||||||
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: Error) => sendResponse({ success: false, error: error.message })
|
|
||||||
);
|
|
||||||
return true; // Will respond asynchronously
|
|
||||||
|
|
||||||
case 'disconnect':
|
|
||||||
this.disconnectTab(message.tabId).then(
|
|
||||||
() => sendResponse({ success: true }),
|
|
||||||
(error: Error) => sendResponse({ success: false, error: error.message })
|
|
||||||
);
|
|
||||||
return true; // Will respond asynchronously
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get connection status for popup
|
|
||||||
*/
|
|
||||||
getStatus(requestedTabId: number, sendResponse: SendResponse): void {
|
|
||||||
const isConnected = this.activeConnections.size > 0;
|
|
||||||
let activeTabId: number | null = null;
|
|
||||||
|
|
||||||
if (isConnected) {
|
|
||||||
const [tabId] = this.activeConnections.entries().next().value as [number, RelayConnection];
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
async connectTab(tabId: number, bridgeUrl: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
debugLog(`Connecting tab ${tabId} to bridge at ${bridgeUrl}`);
|
|
||||||
// Connect to bridge server
|
|
||||||
const socket = new WebSocket(bridgeUrl);
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.onopen = () => resolve();
|
|
||||||
socket.onerror = () => reject(new Error('WebSocket error'));
|
|
||||||
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
const info = this._createConnection(tabId, socket);
|
|
||||||
// Store connection
|
|
||||||
this.activeConnections.set(tabId, info);
|
|
||||||
|
|
||||||
await this._updateUI(tabId, { text: '●', color: '#4CAF50', title: 'Disconnect from Playwright MCP' });
|
|
||||||
debugLog(`Tab ${tabId} connected successfully`);
|
|
||||||
} catch (error: any) {
|
|
||||||
debugLog(`Failed to connect tab ${tabId}:`, error.message);
|
|
||||||
await this._cleanupConnection(tabId);
|
|
||||||
|
|
||||||
// Show error to user
|
|
||||||
await this._updateUI(tabId, { text: '!', color: '#F44336', title: `Connection failed: ${error.message}` });
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _updateUI(tabId: number, { text, color, title }: { text: string; color: string | null; title: string }): Promise<void> {
|
|
||||||
await chrome.action.setBadgeText({ tabId, text });
|
|
||||||
if (color)
|
|
||||||
await chrome.action.setBadgeBackgroundColor({ tabId, color });
|
|
||||||
await chrome.action.setTitle({ tabId, title });
|
|
||||||
}
|
|
||||||
|
|
||||||
private _createConnection(tabId: number, socket: WebSocket): RelayConnection {
|
|
||||||
const connection = new RelayConnection(tabId, socket);
|
|
||||||
socket.onclose = () => {
|
|
||||||
debugLog(`WebSocket closed for tab ${tabId}`);
|
|
||||||
void this.disconnectTab(tabId);
|
|
||||||
};
|
|
||||||
socket.onerror = error => {
|
|
||||||
debugLog(`WebSocket error for tab ${tabId}:`, error);
|
|
||||||
void this.disconnectTab(tabId);
|
|
||||||
};
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect a tab from the bridge
|
|
||||||
*/
|
|
||||||
async disconnectTab(tabId: number): Promise<void> {
|
|
||||||
await this._cleanupConnection(tabId);
|
|
||||||
await this._updateUI(tabId, { text: '', color: null, title: 'Share tab with Playwright MCP' });
|
|
||||||
debugLog(`Tab ${tabId} disconnected`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up connection resources
|
|
||||||
*/
|
|
||||||
async _cleanupConnection(tabId: number): Promise<void> {
|
|
||||||
const connection = this.activeConnections.get(tabId);
|
|
||||||
if (!connection)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.activeConnections.delete(tabId);
|
|
||||||
|
|
||||||
// Close WebSocket
|
|
||||||
connection.close();
|
|
||||||
|
|
||||||
// Detach debugger
|
|
||||||
try {
|
|
||||||
await connection.detachDebugger();
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore detach errors - might already be detached
|
|
||||||
debugLog('Error while detaching debugger:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle tab removal
|
|
||||||
*/
|
|
||||||
async onTabRemoved(tabId: number): Promise<void> {
|
|
||||||
if (this.activeConnections.has(tabId))
|
|
||||||
await this._cleanupConnection(tabId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
new TabShareExtension();
|
|
@ -1,243 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
class PopupController {
|
|
||||||
private currentTab: chrome.tabs.Tab | null;
|
|
||||||
private readonly bridgeUrlInput: HTMLInputElement;
|
|
||||||
private readonly connectBtn: HTMLButtonElement;
|
|
||||||
private readonly statusContainer: HTMLElement;
|
|
||||||
private readonly actionContainer: HTMLElement;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.currentTab = null;
|
|
||||||
this.bridgeUrlInput = document.getElementById('bridge-url') as HTMLInputElement;
|
|
||||||
this.connectBtn = document.getElementById('connect-btn') as HTMLButtonElement;
|
|
||||||
this.statusContainer = document.getElementById('status-container') as HTMLElement;
|
|
||||||
this.actionContainer = document.getElementById('action-container') as HTMLElement;
|
|
||||||
|
|
||||||
void this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
async init(): Promise<void> {
|
|
||||||
// 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';
|
|
||||||
if (this.bridgeUrlInput) {
|
|
||||||
this.bridgeUrlInput.value = savedUrl;
|
|
||||||
this.bridgeUrlInput.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up event listeners
|
|
||||||
if (this.bridgeUrlInput)
|
|
||||||
this.bridgeUrlInput.addEventListener('input', this.onUrlChange.bind(this));
|
|
||||||
if (this.connectBtn)
|
|
||||||
this.connectBtn.addEventListener('click', this.onConnectClick.bind(this));
|
|
||||||
|
|
||||||
// Update UI based on current state
|
|
||||||
await this.updateUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUI(): Promise<void> {
|
|
||||||
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 as {
|
|
||||||
isConnected: boolean;
|
|
||||||
activeTabId: number | undefined;
|
|
||||||
activeTabInfo?: { title?: string; url?: string };
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
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: string, message: string): void {
|
|
||||||
if (!this.statusContainer)
|
|
||||||
return;
|
|
||||||
const statusDiv = document.createElement('div');
|
|
||||||
statusDiv.className = `status ${type}`;
|
|
||||||
statusDiv.textContent = message;
|
|
||||||
this.statusContainer.appendChild(statusDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
showConnectButton(): void {
|
|
||||||
if (!this.actionContainer)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.actionContainer.innerHTML = `
|
|
||||||
<button id="connect-btn" class="button">Share This Tab</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const connectBtn = document.getElementById('connect-btn') as HTMLButtonElement | null;
|
|
||||||
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(): void {
|
|
||||||
if (!this.actionContainer)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.actionContainer.innerHTML = `
|
|
||||||
<button id="disconnect-btn" class="button disconnect">Stop Sharing</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement | null;
|
|
||||||
if (disconnectBtn)
|
|
||||||
disconnectBtn.addEventListener('click', this.onDisconnectClick.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
showActiveTabInfo(tabInfo?: { title?: string; url?: string }): void {
|
|
||||||
if (!tabInfo || !this.statusContainer)
|
|
||||||
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?: number): void {
|
|
||||||
if (!this.actionContainer)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.actionContainer.innerHTML = `
|
|
||||||
<button id="focus-btn" class="button focus-button">Switch to Shared Tab</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const focusBtn = document.getElementById('focus-btn') as HTMLButtonElement | null;
|
|
||||||
if (focusBtn && activeTabId !== undefined)
|
|
||||||
focusBtn.addEventListener('click', () => this.onFocusClick(activeTabId));
|
|
||||||
}
|
|
||||||
|
|
||||||
onUrlChange(): void {
|
|
||||||
if (!this.bridgeUrlInput)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const isValid = this.isValidWebSocketUrl(this.bridgeUrlInput.value);
|
|
||||||
const connectBtn = document.getElementById('connect-btn') as HTMLButtonElement | null;
|
|
||||||
if (connectBtn)
|
|
||||||
connectBtn.disabled = !isValid;
|
|
||||||
|
|
||||||
// Save URL to storage
|
|
||||||
if (isValid)
|
|
||||||
void chrome.storage.sync.set({ bridgeUrl: this.bridgeUrlInput.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
async onConnectClick(): Promise<void> {
|
|
||||||
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(): Promise<void> {
|
|
||||||
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: number): Promise<void> {
|
|
||||||
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: string): boolean {
|
|
||||||
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();
|
|
||||||
});
|
|
@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function debugLog(...args: unknown[]): void {
|
|
||||||
const enabled = true;
|
|
||||||
if (enabled) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('[Extension]', ...args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProtocolCommand = {
|
|
||||||
id: number;
|
|
||||||
method: string;
|
|
||||||
params?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProtocolResponse = {
|
|
||||||
id?: number;
|
|
||||||
method?: string;
|
|
||||||
params?: any;
|
|
||||||
result?: any;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class RelayConnection {
|
|
||||||
private _debuggee: chrome.debugger.Debuggee;
|
|
||||||
private _rootSessionId: string;
|
|
||||||
private _ws: WebSocket;
|
|
||||||
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
|
|
||||||
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
|
|
||||||
|
|
||||||
constructor(tabId: number, ws: WebSocket) {
|
|
||||||
this._debuggee = { tabId };
|
|
||||||
this._rootSessionId = `pw-tab-${tabId}`;
|
|
||||||
this._ws = ws;
|
|
||||||
this._ws.onmessage = this._onMessage.bind(this);
|
|
||||||
// Store listeners for cleanup
|
|
||||||
this._eventListener = this._onDebuggerEvent.bind(this);
|
|
||||||
this._detachListener = this._onDebuggerDetach.bind(this);
|
|
||||||
chrome.debugger.onEvent.addListener(this._eventListener);
|
|
||||||
chrome.debugger.onDetach.addListener(this._detachListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
close(message?: string): void {
|
|
||||||
chrome.debugger.onEvent.removeListener(this._eventListener);
|
|
||||||
chrome.debugger.onDetach.removeListener(this._detachListener);
|
|
||||||
this._ws.close(1000, message || 'Connection closed');
|
|
||||||
}
|
|
||||||
|
|
||||||
async detachDebugger(): Promise<void> {
|
|
||||||
await chrome.debugger.detach(this._debuggee);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
|
|
||||||
if (source.tabId !== this._debuggee.tabId)
|
|
||||||
return;
|
|
||||||
debugLog('Forwarding CDP event:', method, params);
|
|
||||||
const sessionId = source.sessionId || this._rootSessionId;
|
|
||||||
this._sendMessage({
|
|
||||||
method: 'forwardCDPEvent',
|
|
||||||
params: {
|
|
||||||
sessionId,
|
|
||||||
method,
|
|
||||||
params,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
|
|
||||||
if (source.tabId !== this._debuggee.tabId)
|
|
||||||
return;
|
|
||||||
this._sendMessage({
|
|
||||||
method: 'detachedFromTab',
|
|
||||||
params: {
|
|
||||||
tabId: this._debuggee.tabId,
|
|
||||||
reason,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onMessage(event: MessageEvent): void {
|
|
||||||
this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _onMessageAsync(event: MessageEvent): Promise<void> {
|
|
||||||
let message: ProtocolCommand;
|
|
||||||
try {
|
|
||||||
message = JSON.parse(event.data);
|
|
||||||
} catch (error: any) {
|
|
||||||
debugLog('Error parsing message:', error);
|
|
||||||
this._sendError(-32700, `Error parsing message: ${error.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugLog('Received message:', message);
|
|
||||||
|
|
||||||
const response: ProtocolResponse = {
|
|
||||||
id: message.id,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
response.result = await this._handleCommand(message);
|
|
||||||
} catch (error: any) {
|
|
||||||
debugLog('Error handling command:', error);
|
|
||||||
response.error = error.message;
|
|
||||||
}
|
|
||||||
debugLog('Sending response:', response);
|
|
||||||
this._sendMessage(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _handleCommand(message: ProtocolCommand): Promise<any> {
|
|
||||||
if (message.method === 'attachToTab') {
|
|
||||||
debugLog('Attaching debugger to tab:', this._debuggee);
|
|
||||||
await chrome.debugger.attach(this._debuggee, '1.3');
|
|
||||||
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
|
|
||||||
return {
|
|
||||||
sessionId: this._rootSessionId,
|
|
||||||
targetInfo: result?.targetInfo,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (message.method === 'detachFromTab') {
|
|
||||||
debugLog('Detaching debugger from tab:', this._debuggee);
|
|
||||||
return await this.detachDebugger();
|
|
||||||
}
|
|
||||||
if (message.method === 'forwardCDPCommand') {
|
|
||||||
const { sessionId, method, params } = message.params;
|
|
||||||
debugLog('CDP command:', method, params);
|
|
||||||
const debuggerSession: chrome.debugger.DebuggerSession = { ...this._debuggee };
|
|
||||||
// Pass session id, unless it's the root session.
|
|
||||||
if (sessionId && sessionId !== this._rootSessionId)
|
|
||||||
debuggerSession.sessionId = sessionId;
|
|
||||||
// Forward CDP command to chrome.debugger
|
|
||||||
return await chrome.debugger.sendCommand(
|
|
||||||
debuggerSession,
|
|
||||||
method,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _sendError(code: number, message: string): void {
|
|
||||||
this._sendMessage({
|
|
||||||
error: {
|
|
||||||
code,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _sendMessage(message: any): void {
|
|
||||||
this._ws.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"strict": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "./lib",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src",
|
|
||||||
],
|
|
||||||
}
|
|
@ -17,18 +17,15 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"build:extension": "tsc --project extension",
|
|
||||||
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
||||||
"update-readme": "node utils/update-readme.js",
|
"update-readme": "node utils/update-readme.js",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"watch:extension": "tsc --watch --project extension",
|
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"ctest": "playwright test --project=chrome",
|
"ctest": "playwright test --project=chrome",
|
||||||
"ftest": "playwright test --project=firefox",
|
"ftest": "playwright test --project=firefox",
|
||||||
"wtest": "playwright test --project=webkit",
|
"wtest": "playwright test --project=webkit",
|
||||||
"etest": "playwright test --project=chromium-extension",
|
|
||||||
"run-server": "node lib/browserServer.js",
|
"run-server": "node lib/browserServer.js",
|
||||||
"clean": "rm -rf lib && rm -rf extension/lib",
|
"clean": "rm -rf lib",
|
||||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
|
@ -39,6 +39,5 @@ export default defineConfig<TestOptions>({
|
|||||||
}] : [],
|
}] : [],
|
||||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
{ name: 'chromium-extension', use: { mcpBrowser: 'chromium', mcpMode: 'extension' } },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
349
src/cdpRelay.ts
349
src/cdpRelay.ts
@ -1,349 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 debug from 'debug';
|
|
||||||
import { httpAddressToString } from './transport.js';
|
|
||||||
|
|
||||||
const debugLogger = debug('pw:mcp:relay');
|
|
||||||
|
|
||||||
const CDP_PATH = '/cdp';
|
|
||||||
const EXTENSION_PATH = '/extension';
|
|
||||||
|
|
||||||
type CDPCommand = {
|
|
||||||
id: number;
|
|
||||||
sessionId?: string;
|
|
||||||
method: string;
|
|
||||||
params?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CDPResponse = {
|
|
||||||
id?: number;
|
|
||||||
sessionId?: string;
|
|
||||||
method?: string;
|
|
||||||
params?: any;
|
|
||||||
result?: any;
|
|
||||||
error?: { code?: number; message: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
export class CDPRelayServer {
|
|
||||||
private _wss: WebSocketServer;
|
|
||||||
private _playwrightSocket: WebSocket | null = null;
|
|
||||||
private _extensionConnection: ExtensionConnection | null = null;
|
|
||||||
private _connectionInfo: {
|
|
||||||
targetInfo: any;
|
|
||||||
// Page sessionId that should be used by this connection.
|
|
||||||
sessionId: string;
|
|
||||||
} | undefined;
|
|
||||||
|
|
||||||
constructor(server: http.Server) {
|
|
||||||
this._wss = new WebSocketServer({ server });
|
|
||||||
this._wss.on('connection', this._onConnection.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
this._playwrightSocket?.close();
|
|
||||||
this._extensionConnection?.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 === CDP_PATH) {
|
|
||||||
this._handlePlaywrightConnection(ws);
|
|
||||||
} else if (url.pathname === 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', async data => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(data.toString());
|
|
||||||
await this._handlePlaywrightMessage(message);
|
|
||||||
} catch (error) {
|
|
||||||
debugLogger('Error parsing Playwright message:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ws.on('close', () => {
|
|
||||||
if (this._playwrightSocket === ws) {
|
|
||||||
void this._detachDebugger();
|
|
||||||
this._playwrightSocket = null;
|
|
||||||
}
|
|
||||||
debugLogger('Playwright MCP disconnected');
|
|
||||||
});
|
|
||||||
ws.on('error', error => {
|
|
||||||
debugLogger('Playwright WebSocket error:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _detachDebugger() {
|
|
||||||
this._connectionInfo = undefined;
|
|
||||||
await this._extensionConnection?.send('detachFromTab', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleExtensionConnection(ws: WebSocket): void {
|
|
||||||
if (this._extensionConnection)
|
|
||||||
this._extensionConnection.close('New connection established');
|
|
||||||
this._extensionConnection = new ExtensionConnection(ws);
|
|
||||||
this._extensionConnection.onclose = c => {
|
|
||||||
if (this._extensionConnection === c)
|
|
||||||
this._extensionConnection = null;
|
|
||||||
};
|
|
||||||
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleExtensionMessage(method: string, params: any) {
|
|
||||||
switch (method) {
|
|
||||||
case 'forwardCDPEvent':
|
|
||||||
this._sendToPlaywright({
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
method: params.method,
|
|
||||||
params: params.params
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'detachedFromTab':
|
|
||||||
debugLogger('← Debugger detached from tab:', params);
|
|
||||||
this._connectionInfo = undefined;
|
|
||||||
this._extensionConnection?.close();
|
|
||||||
this._extensionConnection = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
|
|
||||||
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
|
|
||||||
if (!this._extensionConnection) {
|
|
||||||
debugLogger('Extension not connected, sending error to Playwright');
|
|
||||||
this._sendToPlaywright({
|
|
||||||
id: message.id,
|
|
||||||
error: { message: 'Extension not connected' }
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (await this._interceptCDPCommand(message))
|
|
||||||
return;
|
|
||||||
await this._forwardToExtension(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _interceptCDPCommand(message: CDPCommand): Promise<boolean> {
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case 'Browser.setDownloadBehavior': {
|
|
||||||
this._sendToPlaywright({
|
|
||||||
id: message.id
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case 'Target.setAutoAttach': {
|
|
||||||
// Simulate auto-attach behavior with real target info
|
|
||||||
if (!message.sessionId) {
|
|
||||||
this._connectionInfo = await this._extensionConnection!.send('attachToTab');
|
|
||||||
debugLogger('Simulating auto-attach for target:', message);
|
|
||||||
this._sendToPlaywright({
|
|
||||||
method: 'Target.attachedToTarget',
|
|
||||||
params: {
|
|
||||||
sessionId: this._connectionInfo!.sessionId,
|
|
||||||
targetInfo: {
|
|
||||||
...this._connectionInfo!.targetInfo,
|
|
||||||
attached: true,
|
|
||||||
},
|
|
||||||
waitingForDebugger: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this._sendToPlaywright({
|
|
||||||
id: message.id
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this._forwardToExtension(message);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
case 'Target.getTargetInfo': {
|
|
||||||
debugLogger('Target.getTargetInfo', message);
|
|
||||||
this._sendToPlaywright({
|
|
||||||
id: message.id,
|
|
||||||
result: this._connectionInfo?.targetInfo
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _forwardToExtension(message: CDPCommand): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!this._extensionConnection)
|
|
||||||
throw new Error('Extension not connected');
|
|
||||||
const { id, sessionId, method, params } = message;
|
|
||||||
const result = await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
|
|
||||||
this._sendToPlaywright({ id, sessionId, result });
|
|
||||||
} catch (e) {
|
|
||||||
debugLogger('Error in the extension:', e);
|
|
||||||
this._sendToPlaywright({
|
|
||||||
id: message.id,
|
|
||||||
sessionId: message.sessionId,
|
|
||||||
error: { message: (e as Error).message }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _sendToPlaywright(message: CDPResponse): void {
|
|
||||||
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
|
|
||||||
this._playwrightSocket?.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startCDPRelayServer(httpServer: http.Server) {
|
|
||||||
const wsAddress = httpAddressToString(httpServer.address()).replace(/^http/, 'ws');
|
|
||||||
const cdpRelayServer = new CDPRelayServer(httpServer);
|
|
||||||
process.on('exit', () => cdpRelayServer.stop());
|
|
||||||
console.error(`CDP relay server started on ${wsAddress}${EXTENSION_PATH} - Connect to it using the browser extension.`);
|
|
||||||
const cdpEndpoint = `${wsAddress}${CDP_PATH}`;
|
|
||||||
return cdpEndpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 CDPRelayServer(httpServer);
|
|
||||||
|
|
||||||
console.error(`CDP Bridge Server listening on ws://localhost:${port}`);
|
|
||||||
console.error(`- Playwright MCP: ws://localhost:${port}${CDP_PATH}`);
|
|
||||||
console.error(`- Extension: ws://localhost:${port}${EXTENSION_PATH}`);
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
debugLogger('\nShutting down bridge server...');
|
|
||||||
server.stop();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExtensionConnection {
|
|
||||||
private readonly _ws: WebSocket;
|
|
||||||
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void }>();
|
|
||||||
private _lastId = 0;
|
|
||||||
|
|
||||||
onmessage?: (method: string, params: any) => void;
|
|
||||||
onclose?: (self: ExtensionConnection) => void;
|
|
||||||
|
|
||||||
constructor(ws: WebSocket) {
|
|
||||||
this._ws = ws;
|
|
||||||
this._ws.on('message', this._onMessage.bind(this));
|
|
||||||
this._ws.on('close', this._onClose.bind(this));
|
|
||||||
this._ws.on('error', this._onError.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(method: string, params?: any, sessionId?: string): Promise<any> {
|
|
||||||
if (this._ws.readyState !== WebSocket.OPEN)
|
|
||||||
throw new Error('WebSocket closed');
|
|
||||||
const id = ++this._lastId;
|
|
||||||
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this._callbacks.set(id, { resolve, reject });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
close(message?: string) {
|
|
||||||
debugLogger('closing extension connection:', message);
|
|
||||||
this._ws.close(1000, message ?? 'Connection closed');
|
|
||||||
this.onclose?.(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onMessage(event: WebSocket.RawData) {
|
|
||||||
const eventData = event.toString();
|
|
||||||
let parsedJson;
|
|
||||||
try {
|
|
||||||
parsedJson = JSON.parse(eventData);
|
|
||||||
} catch (e: any) {
|
|
||||||
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
|
|
||||||
this._ws.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this._handleParsedMessage(parsedJson);
|
|
||||||
} catch (e: any) {
|
|
||||||
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
|
|
||||||
this._ws.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleParsedMessage(object: any) {
|
|
||||||
if (object.id && this._callbacks.has(object.id)) {
|
|
||||||
const callback = this._callbacks.get(object.id)!;
|
|
||||||
this._callbacks.delete(object.id);
|
|
||||||
if (object.error)
|
|
||||||
callback.reject(new Error(object.error.message));
|
|
||||||
else
|
|
||||||
callback.resolve(object.result);
|
|
||||||
} else if (object.id) {
|
|
||||||
debugLogger('← Extension: unexpected response', object);
|
|
||||||
} else {
|
|
||||||
this.onmessage?.(object.method, object.params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onClose(event: WebSocket.CloseEvent) {
|
|
||||||
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
|
|
||||||
this._dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onError(event: WebSocket.ErrorEvent) {
|
|
||||||
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
|
|
||||||
this._dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _dispose() {
|
|
||||||
for (const callback of this._callbacks.values())
|
|
||||||
callback.reject(new Error('WebSocket closed'));
|
|
||||||
this._callbacks.clear();
|
|
||||||
}
|
|
||||||
}
|
|
@ -19,18 +19,10 @@ import os from 'os';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { devices } from 'playwright';
|
import { devices } from 'playwright';
|
||||||
|
|
||||||
import type { Config as PublicConfig, ToolCapability } from '../config.js';
|
import type { Config, ToolCapability } from '../config.js';
|
||||||
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||||
import { sanitizeForFilePath } from './tools/utils.js';
|
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 = {
|
export type CLIOptions = {
|
||||||
allowedOrigins?: string[];
|
allowedOrigins?: string[];
|
||||||
blockedOrigins?: string[];
|
blockedOrigins?: string[];
|
||||||
@ -58,7 +50,6 @@ export type CLIOptions = {
|
|||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
viewportSize?: string;
|
viewportSize?: string;
|
||||||
vision?: boolean;
|
vision?: boolean;
|
||||||
extension?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultConfig: FullConfig = {
|
const defaultConfig: FullConfig = {
|
||||||
@ -108,13 +99,6 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
|
|||||||
return result;
|
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> {
|
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||||
let channel: string | undefined;
|
let channel: string | undefined;
|
||||||
@ -160,8 +144,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
|
|
||||||
if (cliOptions.device && cliOptions.cdpEndpoint)
|
if (cliOptions.device && cliOptions.cdpEndpoint)
|
||||||
throw new Error('Device emulation is not supported with 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
|
// Context options
|
||||||
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
||||||
@ -204,7 +186,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
},
|
},
|
||||||
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||||
vision: !!cliOptions.vision,
|
vision: !!cliOptions.vision,
|
||||||
extension: !!cliOptions.extension,
|
|
||||||
network: {
|
network: {
|
||||||
allowedOrigins: cliOptions.allowedOrigins,
|
allowedOrigins: cliOptions.allowedOrigins,
|
||||||
blockedOrigins: cliOptions.blockedOrigins,
|
blockedOrigins: cliOptions.blockedOrigins,
|
||||||
|
@ -22,14 +22,13 @@ import { Context } from './context.js';
|
|||||||
import { snapshotTools, visionTools } from './tools.js';
|
import { snapshotTools, visionTools } from './tools.js';
|
||||||
import { packageJSON } from './package.js';
|
import { packageJSON } from './package.js';
|
||||||
|
|
||||||
import { FullConfig, validateConfig } from './config.js';
|
import { FullConfig } from './config.js';
|
||||||
|
|
||||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
||||||
const allTools = config.vision ? visionTools : snapshotTools;
|
const allTools = config.vision ? visionTools : snapshotTools;
|
||||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
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 context = new Context(tools, config, browserContextFactory);
|
||||||
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Option, program } from 'commander';
|
import { program } from 'commander';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||||
|
|
||||||
@ -22,7 +22,6 @@ import { startHttpServer, startHttpTransport, startStdioTransport } from './tran
|
|||||||
import { resolveCLIConfig } from './config.js';
|
import { resolveCLIConfig } from './config.js';
|
||||||
import { Server } from './server.js';
|
import { Server } from './server.js';
|
||||||
import { packageJSON } from './package.js';
|
import { packageJSON } from './package.js';
|
||||||
import { startCDPRelayServer } from './cdpRelay.js';
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
@ -53,22 +52,15 @@ program
|
|||||||
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
.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('--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)')
|
.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 => {
|
.action(async options => {
|
||||||
const config = await resolveCLIConfig(options);
|
const config = await resolveCLIConfig(options);
|
||||||
const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
|
const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
|
||||||
if (config.extension) {
|
|
||||||
if (!httpServer)
|
|
||||||
throw new Error('--port parameter is required for extension mode');
|
|
||||||
// Point CDP endpoint to the relay server.
|
|
||||||
config.browser.cdpEndpoint = await startCDPRelayServer(httpServer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = new Server(config);
|
const server = new Server(config);
|
||||||
server.setupExitWatchdog();
|
server.setupExitWatchdog();
|
||||||
|
|
||||||
if (httpServer)
|
if (httpServer)
|
||||||
await startHttpTransport(httpServer, server);
|
startHttpTransport(httpServer, server);
|
||||||
else
|
else
|
||||||
await startStdioTransport(server);
|
await startStdioTransport(server);
|
||||||
|
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 type { Context } from '../context.js';
|
|
||||||
|
|
||||||
export type ResourceSchema = {
|
|
||||||
uri: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
mimeType?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResourceResult = {
|
|
||||||
uri: string;
|
|
||||||
mimeType?: string;
|
|
||||||
text?: string;
|
|
||||||
blob?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Resource = {
|
|
||||||
schema: ResourceSchema;
|
|
||||||
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
|
|
||||||
};
|
|
@ -19,8 +19,6 @@ import path from 'node:path';
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { test, expect } from './fixtures.js';
|
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 }) => {
|
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
||||||
await cdpServer.start();
|
await cdpServer.start();
|
||||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
|
@ -20,7 +20,6 @@ import { Config } from '../config.js';
|
|||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('config user data dir', async ({ startClient, server, mcpMode }, 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('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
<body>Hello, world!</body>
|
<body>Hello, world!</body>
|
||||||
@ -47,7 +46,6 @@ test('config user data dir', async ({ startClient, server, mcpMode }, testInfo)
|
|||||||
test.describe(() => {
|
test.describe(() => {
|
||||||
test.use({ mcpBrowser: '' });
|
test.use({ mcpBrowser: '' });
|
||||||
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, 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 = {
|
const config: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
browserName: 'firefox',
|
browserName: 'firefox',
|
||||||
|
@ -17,7 +17,6 @@
|
|||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('--device should work', async ({ startClient, server, mcpMode }) => {
|
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({
|
const { client } = await startClient({
|
||||||
args: ['--device', 'iPhone 15'],
|
args: ['--device', 'iPhone 15'],
|
||||||
});
|
});
|
||||||
|
@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.');
|
|
||||||
});
|
|
@ -95,7 +95,6 @@ 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, mcpMode }, 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({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
@ -120,7 +119,6 @@ test('clicking on download link emits download', async ({ startClient, server, m
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, 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({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
|
@ -17,16 +17,12 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import url from 'url';
|
import url from 'url';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import net from 'net';
|
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
import { fork } from 'child_process';
|
|
||||||
|
|
||||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { TestServer } from './testserver/index.ts';
|
import { TestServer } from './testserver/index.ts';
|
||||||
import { ManualPromise } from '../src/manualPromise.js';
|
|
||||||
|
|
||||||
import type { Config } from '../config';
|
import type { Config } from '../config';
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
@ -35,7 +31,7 @@ import type { Stream } from 'stream';
|
|||||||
|
|
||||||
export type TestOptions = {
|
export type TestOptions = {
|
||||||
mcpBrowser: string | undefined;
|
mcpBrowser: string | undefined;
|
||||||
mcpMode: 'docker' | 'extension' | undefined;
|
mcpMode: 'docker' | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CDPServer = {
|
type CDPServer = {
|
||||||
@ -52,7 +48,6 @@ type TestFixtures = {
|
|||||||
server: TestServer;
|
server: TestServer;
|
||||||
httpsServer: TestServer;
|
httpsServer: TestServer;
|
||||||
mcpHeadless: boolean;
|
mcpHeadless: boolean;
|
||||||
startMcpExtension: (relayServerURL: string) => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
@ -71,7 +66,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
await use(client);
|
await use(client);
|
||||||
},
|
},
|
||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, startMcpExtension }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||||
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
||||||
const configDir = path.dirname(test.info().config.configFile!);
|
const configDir = path.dirname(test.info().config.configFile!);
|
||||||
let client: Client | undefined;
|
let client: Client | undefined;
|
||||||
@ -95,7 +90,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
||||||
const { transport, stderr, relayServerURL } = await createTransport(args, mcpMode);
|
const { transport, stderr } = await createTransport(args, mcpMode);
|
||||||
let stderrBuffer = '';
|
let stderrBuffer = '';
|
||||||
stderr?.on('data', data => {
|
stderr?.on('data', data => {
|
||||||
if (process.env.PWMCP_DEBUG)
|
if (process.env.PWMCP_DEBUG)
|
||||||
@ -103,8 +98,6 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
stderrBuffer += data.toString();
|
stderrBuffer += data.toString();
|
||||||
});
|
});
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
if (mcpMode === 'extension')
|
|
||||||
await startMcpExtension(relayServerURL!);
|
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return { client, stderr: () => stderrBuffer };
|
return { client, stderr: () => stderrBuffer };
|
||||||
});
|
});
|
||||||
@ -147,38 +140,6 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
|
|
||||||
mcpMode: [undefined, { option: true }],
|
mcpMode: [undefined, { option: true }],
|
||||||
|
|
||||||
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) => {
|
_workerServers: [async ({ }, use, workerInfo) => {
|
||||||
const port = 8907 + workerInfo.workerIndex * 4;
|
const port = 8907 + workerInfo.workerIndex * 4;
|
||||||
const server = await TestServer.create(port);
|
const server = await TestServer.create(port);
|
||||||
@ -208,7 +169,6 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
|
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
|
||||||
transport: Transport,
|
transport: Transport,
|
||||||
stderr: Stream | null,
|
stderr: Stream | null,
|
||||||
relayServerURL?: string,
|
|
||||||
}> {
|
}> {
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
@ -223,42 +183,6 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']):
|
|||||||
stderr: transport.stderr,
|
stderr: transport.stderr,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
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({
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
@ -332,17 +256,6 @@ 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[] {
|
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);
|
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
@ -18,8 +18,6 @@ import fs from 'fs';
|
|||||||
|
|
||||||
import { test, expect, formatOutput } from './fixtures.js';
|
import { test, expect, formatOutput } from './fixtures.js';
|
||||||
|
|
||||||
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 }) => {
|
test('test reopen browser', async ({ startClient, server, mcpMode }) => {
|
||||||
const { client, stderr } = await startClient();
|
const { client, stderr } = await startClient();
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
|
@ -29,8 +29,6 @@ import type { Config } from '../config.d.ts';
|
|||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
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 }> }>({
|
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
|
||||||
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
|
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
|
||||||
let cp: ChildProcess | undefined;
|
let cp: ChildProcess | undefined;
|
||||||
|
@ -27,8 +27,6 @@ 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 }) => {
|
test('list initial tabs', async ({ client }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
|
@ -20,8 +20,6 @@ import path from 'path';
|
|||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('check that trace is saved', async ({ startClient, server, mcpMode }, 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 outputDir = testInfo.outputPath('output');
|
||||||
|
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
|
Loading…
x
Reference in New Issue
Block a user