Compare commits
16 Commits
v0.0.28
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37973f0876 | ||
|
|
a5ffc9671b | ||
|
|
9066988098 | ||
|
|
1dc4977ff9 | ||
|
|
96e234012d | ||
|
|
6c3f3b6576 | ||
|
|
0df6d7a441 | ||
|
|
4ea7041ba9 | ||
|
|
7dae68de78 | ||
|
|
60495ed9b0 | ||
|
|
0aaef661b1 | ||
|
|
abbe7858a2 | ||
|
|
767af21e02 | ||
|
|
27c498e0e7 | ||
|
|
0fb9646c4d | ||
|
|
9728527900 |
40
README.md
40
README.md
@@ -52,6 +52,12 @@ After installation, the Playwright MCP server will be available for use with you
|
||||
<details>
|
||||
<summary><b>Install in Cursor</b></summary>
|
||||
|
||||
#### Click the button to install:
|
||||
|
||||
[](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||
|
||||
#### Or install manually:
|
||||
|
||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||
|
||||
```js
|
||||
@@ -106,6 +112,37 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install in Claude Code</b></summary>
|
||||
|
||||
Use the Claude Code CLI to add the Playwright MCP server:
|
||||
|
||||
```bash
|
||||
claude mcp add playwright npx @playwright/mcp@latest
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install in Qodo Gen</b></summary>
|
||||
|
||||
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the following configuration:
|
||||
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Click <code>Save</code>.
|
||||
</details>
|
||||
|
||||
### Configuration
|
||||
|
||||
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
|
||||
@@ -124,6 +161,7 @@ Playwright MCP server supports following arguments. They can be provided in the
|
||||
--block-service-workers block service workers
|
||||
--browser <browser> browser or chrome channel to use, possible
|
||||
values: chrome, firefox, webkit, msedge.
|
||||
--browser-agent <endpoint> Use browser agent (experimental).
|
||||
--caps <caps> comma-separated list of capabilities to enable,
|
||||
possible values: tabs, pdf, history, wait, files,
|
||||
install. Default is all.
|
||||
@@ -354,7 +392,7 @@ http.createServer(async (req, res) => {
|
||||
// Creates a headless Playwright MCP server with SSE transport
|
||||
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
await connection.connect(transport);
|
||||
await connection.sever.connect(transport);
|
||||
|
||||
// ...
|
||||
});
|
||||
|
||||
5
config.d.ts
vendored
5
config.d.ts
vendored
@@ -23,6 +23,11 @@ export type Config = {
|
||||
* The browser to use.
|
||||
*/
|
||||
browser?: {
|
||||
/**
|
||||
* Use browser agent (experimental).
|
||||
*/
|
||||
browserAgent?: string;
|
||||
|
||||
/**
|
||||
* The type of browser to use.
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
4
index.d.ts
vendored
4
index.d.ts
vendored
@@ -16,13 +16,11 @@
|
||||
*/
|
||||
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { Config } from './config';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Config } from './config.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
|
||||
export type Connection = {
|
||||
server: Server;
|
||||
connect(transport: Transport): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
|
||||
349
package-lock.json
generated
349
package-lock.json
generated
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.28",
|
||||
"version": "0.0.29",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.28",
|
||||
"version": "0.0.29",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"commander": "^13.1.0",
|
||||
"debug": "^4.4.1",
|
||||
"playwright": "1.53.0-alpha-2025-05-27",
|
||||
"mime": "^4.0.7",
|
||||
"playwright": "1.53.0",
|
||||
"ws": "^8.18.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
"bin": {
|
||||
@@ -21,10 +23,12 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||
"@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",
|
||||
@@ -288,13 +292,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.53.0-alpha-2025-05-27",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-05-27.tgz",
|
||||
"integrity": "sha512-G2zG56kEQOWhk3nQyPKH5u41jyQw5jx+Kga5huUi7RjBjPEnNtiCMNXMNGCh6dDYCIyQkLJvz/o1H/QN26HLsg==",
|
||||
"version": "1.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
|
||||
"integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.53.0-alpha-2025-05-27"
|
||||
"playwright": "1.53.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -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",
|
||||
@@ -853,16 +901,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz",
|
||||
"integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.5.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.0",
|
||||
"raw-body": "^3.0.0",
|
||||
@@ -872,21 +920,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
@@ -1220,16 +1253,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -1765,46 +1788,45 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz",
|
||||
"integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.0.1",
|
||||
"body-parser": "^2.2.0",
|
||||
"content-disposition": "^1.0.0",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"cookie-signature": "^1.2.1",
|
||||
"debug": "4.3.6",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "^2.0.0",
|
||||
"fresh": "2.0.0",
|
||||
"http-errors": "2.0.0",
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"merge-descriptors": "^2.0.0",
|
||||
"methods": "~1.1.2",
|
||||
"mime-types": "^3.0.0",
|
||||
"on-finished": "2.4.1",
|
||||
"once": "1.4.0",
|
||||
"parseurl": "~1.3.3",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"router": "^2.0.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"on-finished": "^2.4.1",
|
||||
"once": "^1.4.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"qs": "^6.14.0",
|
||||
"range-parser": "^1.2.1",
|
||||
"router": "^2.2.0",
|
||||
"send": "^1.1.0",
|
||||
"serve-static": "^2.1.0",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"type-is": "^2.0.0",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
"serve-static": "^2.2.0",
|
||||
"statuses": "^2.0.1",
|
||||
"type-is": "^2.0.1",
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
@@ -1822,29 +1844,6 @@
|
||||
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
||||
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2033,7 +2032,6 @@
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -2308,12 +2306,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz",
|
||||
"integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==",
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -2924,15 +2922,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/metric-lcs": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
|
||||
@@ -2954,6 +2943,21 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
|
||||
"integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
@@ -2964,12 +2968,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz",
|
||||
"integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.53.0"
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -3294,12 +3298,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.53.0-alpha-2025-05-27",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-05-27.tgz",
|
||||
"integrity": "sha512-CD0BTwV5javEJ3hf3rhFJEvR3ZoWsu4HUQFfLH2mtVVe+grGPCP55FnlOjpDnJ5pP4Kibe/ZcmgPDg56ic/y9g==",
|
||||
"license": "Apache-2.0",
|
||||
"version": "1.53.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
|
||||
"integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.53.0-alpha-2025-05-27"
|
||||
"playwright-core": "1.53.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3312,10 +3315,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.53.0-alpha-2025-05-27",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-05-27.tgz",
|
||||
"integrity": "sha512-uVxs7YjENoBMFyQhsZWImIBuo/oX7Mu63djhQN3qFz/NdXA/rOAnP73XzfB+VJNwRMKgIOtqHQgjOG3Rl/lm0A==",
|
||||
"license": "Apache-2.0",
|
||||
"version": "1.53.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
|
||||
"integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
@@ -3367,12 +3369,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -3426,18 +3428,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -3525,11 +3515,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz",
|
||||
"integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"is-promise": "^4.0.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"path-to-regexp": "^8.0.0"
|
||||
@@ -3657,19 +3649,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz",
|
||||
"integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"destroy": "^1.2.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^0.5.2",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"mime-types": "^3.0.1",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
@@ -3679,46 +3670,16 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
|
||||
"integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"parseurl": "^1.3.3",
|
||||
"send": "^1.0.0"
|
||||
"send": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -4051,9 +4012,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz",
|
||||
"integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
@@ -4201,15 +4162,6 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -4339,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",
|
||||
|
||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.28",
|
||||
"version": "0.0.29",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@@ -24,6 +24,8 @@
|
||||
"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"
|
||||
},
|
||||
@@ -38,16 +40,20 @@
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"commander": "^13.1.0",
|
||||
"debug": "^4.4.1",
|
||||
"playwright": "1.53.0-alpha-2025-05-27",
|
||||
"mime": "^4.0.7",
|
||||
"playwright": "1.53.0",
|
||||
"ws": "^8.18.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||
"@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' } },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
import { userDataDir } from './fileUtils.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.js';
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
@@ -32,6 +35,8 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon
|
||||
return new CdpContextFactory(browserConfig);
|
||||
if (browserConfig.isolated)
|
||||
return new IsolatedContextFactory(browserConfig);
|
||||
if (browserConfig.browserAgent)
|
||||
return new BrowserServerContextFactory(browserConfig);
|
||||
return new PersistentContextFactory(browserConfig);
|
||||
}
|
||||
|
||||
@@ -97,6 +102,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
await injectCdpPort(this.browserConfig);
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
return browserType.launch({
|
||||
...this.browserConfig.launchOptions,
|
||||
@@ -155,6 +161,7 @@ class PersistentContextFactory implements BrowserContextFactory {
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
await injectCdpPort(this.browserConfig);
|
||||
testDebug('create browser context (persistent)');
|
||||
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
||||
|
||||
@@ -209,3 +216,51 @@ class PersistentContextFactory implements BrowserContextFactory {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserServerContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('persistent', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
browserType: this.browserConfig.browserName,
|
||||
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
|
||||
launchOptions: this.browserConfig.launchOptions,
|
||||
contextOptions: this.browserConfig.contextOptions,
|
||||
} as LaunchBrowserRequest),
|
||||
});
|
||||
const info = await response.json() as BrowserInfo;
|
||||
if (info.error)
|
||||
throw new Error(info.error);
|
||||
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||
}
|
||||
|
||||
private async _createUserDataDir() {
|
||||
const dir = await userDataDir(this.browserConfig);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
||||
if (browserConfig.browserName === 'chromium')
|
||||
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
||||
}
|
||||
|
||||
async function findFreePort() {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
197
src/browserServer.ts
Normal file
197
src/browserServer.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import net from 'net';
|
||||
|
||||
import { program } from 'commander';
|
||||
import playwright from 'playwright';
|
||||
|
||||
import { HttpServer } from './httpServer.js';
|
||||
import { packageJSON } from './package.js';
|
||||
|
||||
import type http from 'http';
|
||||
|
||||
export type LaunchBrowserRequest = {
|
||||
browserType: string;
|
||||
userDataDir: string;
|
||||
launchOptions: playwright.LaunchOptions;
|
||||
contextOptions: playwright.BrowserContextOptions;
|
||||
};
|
||||
|
||||
export type BrowserInfo = {
|
||||
browserType: string;
|
||||
userDataDir: string;
|
||||
cdpPort: number;
|
||||
launchOptions: playwright.LaunchOptions;
|
||||
contextOptions: playwright.BrowserContextOptions;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type BrowserEntry = {
|
||||
browser?: playwright.Browser;
|
||||
info: BrowserInfo;
|
||||
};
|
||||
|
||||
class BrowserServer {
|
||||
private _server = new HttpServer();
|
||||
private _entries: BrowserEntry[] = [];
|
||||
|
||||
constructor() {
|
||||
this._setupExitHandler();
|
||||
}
|
||||
|
||||
async start(port: number) {
|
||||
await this._server.start({ port });
|
||||
this._server.routePath('/json/list', (req, res) => {
|
||||
this._handleJsonList(res);
|
||||
});
|
||||
this._server.routePath('/json/launch', async (req, res) => {
|
||||
void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
|
||||
});
|
||||
this._setEntries([]);
|
||||
}
|
||||
|
||||
private _handleJsonList(res: http.ServerResponse) {
|
||||
const list = this._entries.map(browser => browser.info);
|
||||
res.end(JSON.stringify(list));
|
||||
}
|
||||
|
||||
private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const request = await readBody<LaunchBrowserRequest>(req);
|
||||
let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
|
||||
if (!info || info.error)
|
||||
info = await this._newBrowser(request);
|
||||
res.end(JSON.stringify(info));
|
||||
}
|
||||
|
||||
private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
|
||||
const cdpPort = await findFreePort();
|
||||
(request.launchOptions as any).cdpPort = cdpPort;
|
||||
const info: BrowserInfo = {
|
||||
browserType: request.browserType,
|
||||
userDataDir: request.userDataDir,
|
||||
cdpPort,
|
||||
launchOptions: request.launchOptions,
|
||||
contextOptions: request.contextOptions,
|
||||
};
|
||||
|
||||
const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
|
||||
const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
|
||||
...request.launchOptions,
|
||||
...request.contextOptions,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
}).then(context => {
|
||||
return { browser: context.browser()!, error: undefined };
|
||||
}).catch(error => {
|
||||
return { browser: undefined, error: error.message };
|
||||
});
|
||||
this._setEntries([...this._entries, {
|
||||
browser,
|
||||
info: {
|
||||
browserType: request.browserType,
|
||||
userDataDir: request.userDataDir,
|
||||
cdpPort,
|
||||
launchOptions: request.launchOptions,
|
||||
contextOptions: request.contextOptions,
|
||||
error,
|
||||
},
|
||||
}]);
|
||||
browser?.on('disconnected', () => {
|
||||
this._setEntries(this._entries.filter(entry => entry.browser !== browser));
|
||||
});
|
||||
return info;
|
||||
}
|
||||
|
||||
private _updateReport() {
|
||||
// Clear the current line and move cursor to top of screen
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`);
|
||||
process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
|
||||
|
||||
if (this._entries.length === 0) {
|
||||
process.stdout.write('No browsers currently running\n');
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write('Running browsers:\n');
|
||||
for (const entry of this._entries) {
|
||||
const status = entry.browser ? 'running' : 'error';
|
||||
const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
|
||||
process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
|
||||
if (entry.info.error)
|
||||
process.stdout.write(` Error: ${entry.info.error}\n`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private _setEntries(entries: BrowserEntry[]) {
|
||||
this._entries = entries;
|
||||
this._updateReport();
|
||||
}
|
||||
|
||||
private _setupExitHandler() {
|
||||
let isExiting = false;
|
||||
const handleExit = async () => {
|
||||
if (isExiting)
|
||||
return;
|
||||
isExiting = true;
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
for (const entry of this._entries)
|
||||
await entry.browser?.close().catch(() => {});
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.stdin.on('close', handleExit);
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.name('browser-agent')
|
||||
.option('-p, --port <port>', 'Port to listen on', '9224')
|
||||
.action(async options => {
|
||||
await main(options);
|
||||
});
|
||||
|
||||
void program.parseAsync(process.argv);
|
||||
|
||||
async function main(options: { port: string }) {
|
||||
const server = new BrowserServer();
|
||||
await server.start(+options.port);
|
||||
}
|
||||
|
||||
function readBody<T>(req: http.IncomingMessage): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
317
src/cdpRelay.ts
Normal file
317
src/cdpRelay.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 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';
|
||||
import { httpAddressToString } from './transport.js';
|
||||
|
||||
const debugLogger = debug('pw:mcp:relay');
|
||||
|
||||
const CDP_PATH = '/cdp';
|
||||
const EXTENSION_PATH = '/extension';
|
||||
|
||||
export class CDPRelayServer extends EventEmitter {
|
||||
private _wss: WebSocketServer;
|
||||
private _playwrightSocket: WebSocket | null = null;
|
||||
private _extensionSocket: WebSocket | null = null;
|
||||
private _connectionInfo: {
|
||||
targetInfo: any;
|
||||
sessionId: string;
|
||||
} | undefined;
|
||||
|
||||
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 === 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', 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
// eslint-disable-next-line no-console
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -15,20 +15,28 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
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[];
|
||||
blockServiceWorkers?: boolean;
|
||||
browser?: string;
|
||||
browserAgent?: string;
|
||||
caps?: string;
|
||||
cdpEndpoint?: string;
|
||||
config?: string;
|
||||
@@ -50,6 +58,7 @@ export type CLIOptions = {
|
||||
userDataDir?: string;
|
||||
viewportSize?: string;
|
||||
vision?: boolean;
|
||||
extension?: boolean;
|
||||
};
|
||||
|
||||
const defaultConfig: FullConfig = {
|
||||
@@ -96,11 +105,16 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
|
||||
// Derive artifact output directory from config.outputDir
|
||||
if (result.saveTrace)
|
||||
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
||||
if (result.browser.browserName === 'chromium')
|
||||
(result.browser.launchOptions as any).cdpPort = await findFreePort();
|
||||
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;
|
||||
@@ -144,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)
|
||||
@@ -171,6 +190,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
|
||||
const result: Config = {
|
||||
browser: {
|
||||
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
|
||||
browserName,
|
||||
isolated: cliOptions.isolated,
|
||||
userDataDir: cliOptions.userDataDir,
|
||||
@@ -184,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,
|
||||
@@ -196,17 +217,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
return result;
|
||||
}
|
||||
|
||||
async function findFreePort() {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||
if (!configFile)
|
||||
return {};
|
||||
@@ -232,6 +242,8 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||
|
||||
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
||||
const browser: FullConfig['browser'] = {
|
||||
...pickDefined(base.browser),
|
||||
...pickDefined(overrides.browser),
|
||||
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
||||
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
||||
launchOptions: {
|
||||
@@ -243,9 +255,6 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
||||
...pickDefined(base.browser?.contextOptions),
|
||||
...pickDefined(overrides.browser?.contextOptions),
|
||||
},
|
||||
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
|
||||
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
|
||||
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
|
||||
};
|
||||
|
||||
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -89,7 +89,7 @@ export class Context {
|
||||
|
||||
currentTabOrDie(): Tab {
|
||||
if (!this._currentTab)
|
||||
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
|
||||
throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
|
||||
return this._currentTab;
|
||||
}
|
||||
|
||||
|
||||
37
src/fileUtils.ts
Normal file
37
src/fileUtils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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 os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
export function cacheDir() {
|
||||
let cacheDirectory: string;
|
||||
if (process.platform === 'linux')
|
||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||
else if (process.platform === 'darwin')
|
||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||
else if (process.platform === 'win32')
|
||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||
else
|
||||
throw new Error('Unsupported platform: ' + process.platform);
|
||||
return path.join(cacheDirectory, 'ms-playwright');
|
||||
}
|
||||
|
||||
export async function userDataDir(browserConfig: FullConfig['browser']) {
|
||||
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||
}
|
||||
232
src/httpServer.ts
Normal file
232
src/httpServer.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 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 fs from 'fs';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
|
||||
import mime from 'mime';
|
||||
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
|
||||
|
||||
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
|
||||
|
||||
export type Transport = {
|
||||
sendEvent?: (method: string, params: any) => void;
|
||||
close?: () => void;
|
||||
onconnect: () => void;
|
||||
dispatch: (method: string, params: any) => Promise<any>;
|
||||
onclose: () => void;
|
||||
};
|
||||
|
||||
export class HttpServer {
|
||||
private _server: http.Server;
|
||||
private _urlPrefixPrecise: string = '';
|
||||
private _urlPrefixHumanReadable: string = '';
|
||||
private _port: number = 0;
|
||||
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
|
||||
|
||||
constructor() {
|
||||
this._server = http.createServer(this._onRequest.bind(this));
|
||||
decorateServer(this._server);
|
||||
}
|
||||
|
||||
server() {
|
||||
return this._server;
|
||||
}
|
||||
|
||||
routePrefix(prefix: string, handler: ServerRouteHandler) {
|
||||
this._routes.push({ prefix, handler });
|
||||
}
|
||||
|
||||
routePath(path: string, handler: ServerRouteHandler) {
|
||||
this._routes.push({ exact: path, handler });
|
||||
}
|
||||
|
||||
port(): number {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
private async _tryStart(port: number | undefined, host: string) {
|
||||
const errorPromise = new ManualPromise();
|
||||
const errorListener = (error: Error) => errorPromise.reject(error);
|
||||
this._server.on('error', errorListener);
|
||||
|
||||
try {
|
||||
this._server.listen(port, host);
|
||||
await Promise.race([
|
||||
new Promise(cb => this._server!.once('listening', cb)),
|
||||
errorPromise,
|
||||
]);
|
||||
} finally {
|
||||
this._server.removeListener('error', errorListener);
|
||||
}
|
||||
}
|
||||
|
||||
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
|
||||
const host = options.host || 'localhost';
|
||||
if (options.preferredPort) {
|
||||
try {
|
||||
await this._tryStart(options.preferredPort, host);
|
||||
} catch (e: any) {
|
||||
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
|
||||
throw e;
|
||||
await this._tryStart(undefined, host);
|
||||
}
|
||||
} else {
|
||||
await this._tryStart(options.port, host);
|
||||
}
|
||||
|
||||
const address = this._server.address();
|
||||
if (typeof address === 'string') {
|
||||
this._urlPrefixPrecise = address;
|
||||
this._urlPrefixHumanReadable = address;
|
||||
} else {
|
||||
this._port = address!.port;
|
||||
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
|
||||
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
|
||||
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await new Promise(cb => this._server!.close(cb));
|
||||
}
|
||||
|
||||
urlPrefix(purpose: 'human-readable' | 'precise'): string {
|
||||
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
|
||||
}
|
||||
|
||||
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
|
||||
try {
|
||||
for (const [name, value] of Object.entries(headers || {}))
|
||||
response.setHeader(name, value);
|
||||
if (request.headers.range)
|
||||
this._serveRangeFile(request, response, absoluteFilePath);
|
||||
else
|
||||
this._serveFile(response, absoluteFilePath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
|
||||
const content = fs.readFileSync(absoluteFilePath);
|
||||
response.statusCode = 200;
|
||||
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
|
||||
response.setHeader('Content-Type', contentType);
|
||||
response.setHeader('Content-Length', content.byteLength);
|
||||
response.end(content);
|
||||
}
|
||||
|
||||
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
|
||||
const range = request.headers.range;
|
||||
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
|
||||
response.statusCode = 400;
|
||||
return response.end('Bad request');
|
||||
}
|
||||
|
||||
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
|
||||
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||||
|
||||
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
|
||||
let start: number;
|
||||
let end: number;
|
||||
const size = fs.statSync(absoluteFilePath).size;
|
||||
if (startStr !== '' && endStr === '') {
|
||||
// No end specified: use the whole file
|
||||
start = +startStr;
|
||||
end = size - 1;
|
||||
} else if (startStr === '' && endStr !== '') {
|
||||
// No start specified: calculate start manually
|
||||
start = size - +endStr;
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = +startStr;
|
||||
end = +endStr;
|
||||
}
|
||||
|
||||
// Handle unavailable range request
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
|
||||
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
|
||||
response.writeHead(416, {
|
||||
'Content-Range': `bytes */${size}`
|
||||
});
|
||||
return response.end();
|
||||
}
|
||||
|
||||
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
|
||||
response.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
|
||||
});
|
||||
|
||||
const readable = fs.createReadStream(absoluteFilePath, { start, end });
|
||||
readable.pipe(response);
|
||||
}
|
||||
|
||||
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(200);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
request.on('error', () => response.end());
|
||||
try {
|
||||
if (!request.url) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const url = new URL('http://localhost' + request.url);
|
||||
for (const route of this._routes) {
|
||||
if (route.exact && url.pathname === route.exact) {
|
||||
route.handler(request, response);
|
||||
return;
|
||||
}
|
||||
if (route.prefix && url.pathname.startsWith(route.prefix)) {
|
||||
route.handler(request, response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
} catch (e) {
|
||||
response.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decorateServer(server: net.Server) {
|
||||
const sockets = new Set<net.Socket>();
|
||||
server.on('connection', socket => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
const close = server.close;
|
||||
server.close = (callback?: (err?: Error) => void) => {
|
||||
for (const socket of sockets)
|
||||
socket.destroy();
|
||||
sockets.clear();
|
||||
return close.call(server, callback);
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
||||
import { createConnection as createConnectionImpl } from './connection.js';
|
||||
import type { Connection } from '../index.js';
|
||||
import { resolveConfig } from './config.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
|
||||
|
||||
@@ -14,14 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { program } from 'commander';
|
||||
import { Option, program } from 'commander';
|
||||
// @ts-ignore
|
||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||
|
||||
import { startHttpTransport, startStdioTransport } from './transport.js';
|
||||
import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
|
||||
import { resolveCLIConfig } from './config.js';
|
||||
import { Server } from './server.js';
|
||||
import { packageJSON } from './package.js';
|
||||
import { startCDPRelayServer } from './cdpRelay.js';
|
||||
|
||||
program
|
||||
.version('Version ' + packageJSON.version)
|
||||
@@ -30,6 +31,7 @@ program
|
||||
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
||||
.option('--block-service-workers', 'block service workers')
|
||||
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||
.option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
|
||||
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||
.option('--config <path>', 'path to the configuration file.')
|
||||
@@ -51,13 +53,22 @@ 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 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);
|
||||
server.setupExitWatchdog();
|
||||
|
||||
if (config.server.port !== undefined)
|
||||
startHttpTransport(server);
|
||||
if (httpServer)
|
||||
await startHttpTransport(httpServer, server);
|
||||
else
|
||||
await startStdioTransport(server);
|
||||
|
||||
|
||||
@@ -83,11 +83,10 @@ export class Tab {
|
||||
|| e.message.includes('Download is starting'); // firefox + webkit
|
||||
if (!mightBeDownload)
|
||||
throw e;
|
||||
|
||||
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
||||
const download = await Promise.race([
|
||||
downloadEvent,
|
||||
new Promise(resolve => setTimeout(resolve, 500)),
|
||||
new Promise(resolve => setTimeout(resolve, 1000)),
|
||||
]);
|
||||
if (!download)
|
||||
throw e;
|
||||
|
||||
@@ -34,6 +34,12 @@ const close = defineTool({
|
||||
code: [`await page.close()`],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
resultOverride: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Browser closed successfully.',
|
||||
}],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,7 +78,13 @@ export function sanitizeForFilePath(s: string) {
|
||||
}
|
||||
|
||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||
return (locator as any)._generateLocatorString();
|
||||
try {
|
||||
return await (locator as any)._generateLocatorString();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && /locator._generateLocatorString: Timeout .* exceeded/.test(e.message))
|
||||
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
|
||||
|
||||
@@ -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,43 +97,53 @@ async function handleStreamable(server: Server, req: http.IncomingMessage, res:
|
||||
res.end('Invalid request');
|
||||
}
|
||||
|
||||
export function startHttpTransport(server: Server) {
|
||||
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
|
||||
const { host, port } = config;
|
||||
const httpServer = http.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.on('error', reject);
|
||||
httpServer.listen(port, host, () => {
|
||||
resolve();
|
||||
httpServer.removeListener('error', reject);
|
||||
});
|
||||
});
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
export function startHttpTransport(httpServer: http.Server, mcpServer: Server) {
|
||||
const sseSessions = new Map<string, SSEServerTransport>();
|
||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
httpServer.on('request', async (req, res) => {
|
||||
const url = new URL(`http://localhost${req.url}`);
|
||||
if (url.pathname.startsWith('/mcp'))
|
||||
await handleStreamable(server, req, res, streamableSessions);
|
||||
await handleStreamable(mcpServer, req, res, streamableSessions);
|
||||
else
|
||||
await handleSSE(server, req, res, url, sseSessions);
|
||||
await handleSSE(mcpServer, 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`
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
77
tests/browser-server.spec.ts
Normal file
77
tests/browser-server.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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 path from 'path';
|
||||
import url from 'node:url';
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { test as baseTest, expect } from './fixtures.js';
|
||||
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
|
||||
const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({
|
||||
agentEndpoint: async ({}, use) => {
|
||||
let cp: ChildProcess | undefined;
|
||||
await use(async (options?: { args?: string[] }) => {
|
||||
if (cp)
|
||||
throw new Error('Process already running');
|
||||
|
||||
cp = spawn('node', [
|
||||
path.join(path.dirname(__filename), '../lib/browserServer.js'),
|
||||
...(options?.args || []),
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
DEBUG: 'pw:mcp:test',
|
||||
DEBUG_COLORS: '0',
|
||||
DEBUG_HIDE_DATE: '1',
|
||||
},
|
||||
});
|
||||
let stdout = '';
|
||||
const url = await new Promise<string>(resolve => cp!.stdout?.on('data', data => {
|
||||
stdout += data.toString();
|
||||
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
||||
if (match)
|
||||
resolve(match[1]);
|
||||
}));
|
||||
|
||||
return { url: new URL(url), stdout: () => stdout };
|
||||
});
|
||||
cp?.kill('SIGTERM');
|
||||
},
|
||||
});
|
||||
|
||||
test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now');
|
||||
|
||||
test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => {
|
||||
const { url: agentUrl } = await agentEndpoint();
|
||||
const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
|
||||
expect(await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent('Hello, world!');
|
||||
|
||||
const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
|
||||
expect(await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent('Hello, world!');
|
||||
|
||||
await client1.close();
|
||||
await client2.close();
|
||||
});
|
||||
@@ -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}`] });
|
||||
@@ -38,7 +43,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||
element: 'Hello, world!',
|
||||
ref: 'f0',
|
||||
},
|
||||
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`);
|
||||
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
@@ -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',
|
||||
|
||||
@@ -237,3 +237,41 @@ await page.setViewportSize({ width: 390, height: 780 });
|
||||
\`\`\``);
|
||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
|
||||
});
|
||||
|
||||
test('old locator error message', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<button>Button 1</button>
|
||||
<button>Button 2</button>
|
||||
<script>
|
||||
document.querySelector('button').addEventListener('click', () => {
|
||||
document.querySelectorAll('button')[1].remove();
|
||||
});
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
})).toContainTextContent(`
|
||||
- button "Button 1" [ref=e2]
|
||||
- button "Button 2" [ref=e3]
|
||||
`.trim());
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button 1',
|
||||
ref: 'e2',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button 2',
|
||||
ref: 'e3',
|
||||
},
|
||||
})).toContainTextContent('Ref not found');
|
||||
});
|
||||
|
||||
@@ -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,13 +71,15 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
await use(client);
|
||||
},
|
||||
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||
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;
|
||||
|
||||
await use(async options => {
|
||||
const args = ['--user-data-dir', path.relative(configDir, userDataDir)];
|
||||
const args: string[] = [];
|
||||
if (userDataDir)
|
||||
args.push('--user-data-dir', userDataDir);
|
||||
if (process.env.CI && process.platform === 'linux')
|
||||
args.push('--no-sandbox');
|
||||
if (mcpHeadless)
|
||||
@@ -86,14 +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 => {
|
||||
stderr += data.toString();
|
||||
const { transport, stderr, relayServerURL } = await createTransport(args, mcpMode);
|
||||
let stderrBuffer = '';
|
||||
stderr?.on('data', data => {
|
||||
if (process.env.PWMCP_DEBUG)
|
||||
process.stderr.write(data);
|
||||
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();
|
||||
@@ -134,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);
|
||||
|
||||
@@ -160,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), '..'),
|
||||
@@ -182,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']>>;
|
||||
@@ -238,6 +332,17 @@ export const expect = baseExpect.extend({
|
||||
},
|
||||
});
|
||||
|
||||
export function formatOutput(output: string): string[] {
|
||||
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/test-results.*/, '').trim()).filter(Boolean);
|
||||
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',
|
||||
@@ -27,7 +29,7 @@ test('test reopen browser', async ({ startClient, server }) => {
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_close',
|
||||
})).toContainTextContent('No open pages available');
|
||||
})).toContainTextContent('Browser closed successfully.');
|
||||
|
||||
expect(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({
|
||||
|
||||
Reference in New Issue
Block a user