Compare commits
5 Commits
v0.0.30
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a233080f7 | ||
|
|
f4bc6447eb | ||
|
|
708aa6d6a5 | ||
|
|
c82a17ddfd | ||
|
|
8e0ccf770b |
15
.github/workflows/publish.yml
vendored
15
.github/workflows/publish.yml
vendored
@@ -44,7 +44,6 @@ jobs:
|
|||||||
- name: Login to ACR
|
- name: Login to ACR
|
||||||
run: az acr login --name playwright
|
run: az acr login --name playwright
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-push
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -54,17 +53,3 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
||||||
playwright.azurecr.io/public/playwright/mcp:latest
|
playwright.azurecr.io/public/playwright/mcp:latest
|
||||||
- uses: oras-project/setup-oras@v1
|
|
||||||
- name: Set oras tags
|
|
||||||
run: |
|
|
||||||
attach_eol_manifest() {
|
|
||||||
local image="$1"
|
|
||||||
local today=$(date -u +'%Y-%m-%d')
|
|
||||||
# oras is re-using Docker credentials, so we don't need to login.
|
|
||||||
# Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary
|
|
||||||
oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image
|
|
||||||
}
|
|
||||||
# for each tag, attach the eol manifest
|
|
||||||
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
|
||||||
attach_eol_manifest $tag
|
|
||||||
done
|
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -10,7 +10,7 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
- Node.js 18 or newer
|
- Node.js 18 or newer
|
||||||
- VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
|
- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
// Generate using:
|
// Generate using:
|
||||||
@@ -77,7 +77,7 @@ Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, u
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>Install in Windsurf</b></summary>
|
<summary><b>Install in Windsurf</b></summary>
|
||||||
|
|
||||||
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
|
Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
@@ -122,18 +122,6 @@ claude mcp add playwright npx @playwright/mcp@latest
|
|||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Install in Goose</b></summary>
|
|
||||||
|
|
||||||
#### Click the button to install:
|
|
||||||
|
|
||||||
[](https://block.github.io/goose/extension?cmd=npx&arg=%40playwright%2Fmcp%40latest&id=playwright&name=Playwright&description=Interact%20with%20web%20pages%20through%20structured%20accessibility%20snapshots%20using%20Playwright)
|
|
||||||
|
|
||||||
#### Or install manually:
|
|
||||||
|
|
||||||
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Install in Qodo Gen</b></summary>
|
<summary><b>Install in Qodo Gen</b></summary>
|
||||||
|
|
||||||
@@ -155,25 +143,6 @@ Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in
|
|||||||
Click <code>Save</code>.
|
Click <code>Save</code>.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Install in Gemini CLI</b></summary>
|
|
||||||
|
|
||||||
Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#configure-the-mcp-server-in-settingsjson), use following configuration:
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"playwright": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"@playwright/mcp@latest"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
|
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
|
||||||
@@ -357,10 +326,9 @@ npx @playwright/mcp@latest --config path/to/config.json
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to send image responses to the client. Can be "allow", "omit", or "auto".
|
* Do not send image responses to the client.
|
||||||
* Defaults to "auto", images are omitted for Cursor clients and sent for all other clients.
|
|
||||||
*/
|
*/
|
||||||
imageResponses?: 'allow' | 'omit' | 'auto';
|
noImageResponses?: boolean;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
@@ -478,7 +446,6 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
|
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
34
package-lock.json
generated
34
package-lock.json
generated
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.30",
|
"version": "0.0.29",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.30",
|
"version": "0.0.29",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
"mime": "^4.0.7",
|
"mime": "^4.0.7",
|
||||||
"playwright": "1.54.1",
|
"playwright": "1.53.0",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.54.1",
|
"@playwright/test": "1.53.0",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/chrome": "^0.0.315",
|
"@types/chrome": "^0.0.315",
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
@@ -292,13 +292,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.54.1",
|
"version": "1.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
|
||||||
"integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
|
"integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.54.1"
|
"playwright": "1.53.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -2033,7 +2032,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -3300,12 +3298,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.54.1",
|
"version": "1.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
|
||||||
"integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
|
"integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.54.1"
|
"playwright-core": "1.53.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3318,10 +3315,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.54.1",
|
"version": "1.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
|
||||||
"integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
|
"integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.30",
|
"version": "0.0.29",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"ctest": "playwright test --project=chrome",
|
"ctest": "playwright test --project=chrome",
|
||||||
"ftest": "playwright test --project=firefox",
|
"ftest": "playwright test --project=firefox",
|
||||||
"wtest": "playwright test --project=webkit",
|
"wtest": "playwright test --project=webkit",
|
||||||
|
"etest": "playwright test --project=chromium-extension",
|
||||||
"run-server": "node lib/browserServer.js",
|
"run-server": "node lib/browserServer.js",
|
||||||
"clean": "rm -rf lib",
|
"clean": "rm -rf lib",
|
||||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||||
@@ -40,14 +41,14 @@
|
|||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
"mime": "^4.0.7",
|
"mime": "^4.0.7",
|
||||||
"playwright": "1.54.1",
|
"playwright": "1.53.0",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.54.1",
|
"@playwright/test": "1.53.0",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/chrome": "^0.0.315",
|
"@types/chrome": "^0.0.315",
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
|
|||||||
@@ -39,5 +39,6 @@ export default defineConfig<TestOptions>({
|
|||||||
}] : [],
|
}] : [],
|
||||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
|
{ name: 'chromium-extension', use: { mcpBrowser: 'chromium', mcpMode: 'extension' } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,10 +19,18 @@ import os from 'os';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { devices } from 'playwright';
|
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 type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||||
import { sanitizeForFilePath } from './tools/utils.js';
|
import { sanitizeForFilePath } from './tools/utils.js';
|
||||||
|
|
||||||
|
type Config = PublicConfig & {
|
||||||
|
/**
|
||||||
|
* TODO: Move to PublicConfig once we are ready to release this feature.
|
||||||
|
* Run server that is able to connect to the 'Playwright MCP' Chrome extension.
|
||||||
|
*/
|
||||||
|
extension?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CLIOptions = {
|
export type CLIOptions = {
|
||||||
allowedOrigins?: string[];
|
allowedOrigins?: string[];
|
||||||
blockedOrigins?: string[];
|
blockedOrigins?: string[];
|
||||||
@@ -50,6 +58,7 @@ export type CLIOptions = {
|
|||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
viewportSize?: string;
|
viewportSize?: string;
|
||||||
vision?: boolean;
|
vision?: boolean;
|
||||||
|
extension?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultConfig: FullConfig = {
|
const defaultConfig: FullConfig = {
|
||||||
@@ -99,6 +108,13 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateConfig(config: Config) {
|
||||||
|
if (config.extension) {
|
||||||
|
if (config.browser?.browserName !== 'chromium')
|
||||||
|
throw new Error('Extension mode is only supported for Chromium browsers.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||||
let channel: string | undefined;
|
let channel: string | undefined;
|
||||||
@@ -144,6 +160,8 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
|
|
||||||
if (cliOptions.device && cliOptions.cdpEndpoint)
|
if (cliOptions.device && cliOptions.cdpEndpoint)
|
||||||
throw new Error('Device emulation is not supported with cdpEndpoint.');
|
throw new Error('Device emulation is not supported with cdpEndpoint.');
|
||||||
|
if (cliOptions.device && cliOptions.extension)
|
||||||
|
throw new Error('Device emulation is not supported with extension mode.');
|
||||||
|
|
||||||
// Context options
|
// Context options
|
||||||
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
||||||
@@ -186,6 +204,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
},
|
},
|
||||||
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||||
vision: !!cliOptions.vision,
|
vision: !!cliOptions.vision,
|
||||||
|
extension: !!cliOptions.extension,
|
||||||
network: {
|
network: {
|
||||||
allowedOrigins: cliOptions.allowedOrigins,
|
allowedOrigins: cliOptions.allowedOrigins,
|
||||||
blockedOrigins: cliOptions.blockedOrigins,
|
blockedOrigins: cliOptions.blockedOrigins,
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ import { Context } from './context.js';
|
|||||||
import { snapshotTools, visionTools } from './tools.js';
|
import { snapshotTools, visionTools } from './tools.js';
|
||||||
import { packageJSON } from './package.js';
|
import { packageJSON } from './package.js';
|
||||||
|
|
||||||
import { FullConfig } from './config.js';
|
import { FullConfig, validateConfig } from './config.js';
|
||||||
|
|
||||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
||||||
const allTools = config.vision ? visionTools : snapshotTools;
|
const allTools = config.vision ? visionTools : snapshotTools;
|
||||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||||
|
validateConfig(config);
|
||||||
const context = new Context(tools, config, browserContextFactory);
|
const context = new Context(tools, config, browserContextFactory);
|
||||||
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async selectTab(index: number) {
|
async selectTab(index: number) {
|
||||||
this._currentTab = this._tabs[index - 1];
|
this._currentTab = this._tabs[index];
|
||||||
await this._currentTab.page.bringToFront();
|
await this._currentTab.page.bringToFront();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,13 +121,13 @@ export class Context {
|
|||||||
const title = await tab.title();
|
const title = await tab.title();
|
||||||
const url = tab.page.url();
|
const url = tab.page.url();
|
||||||
const current = tab === this._currentTab ? ' (current)' : '';
|
const current = tab === this._currentTab ? ' (current)' : '';
|
||||||
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
||||||
}
|
}
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeTab(index: number | undefined) {
|
async closeTab(index: number | undefined) {
|
||||||
const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
|
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
||||||
await tab?.page.close();
|
await tab?.page.close();
|
||||||
return await this.listTabsMarkdown();
|
return await this.listTabsMarkdown();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { program } from 'commander';
|
import { Option, program } from 'commander';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ import { startHttpServer, startHttpTransport, startStdioTransport } from './tran
|
|||||||
import { resolveCLIConfig } from './config.js';
|
import { resolveCLIConfig } from './config.js';
|
||||||
import { Server } from './server.js';
|
import { Server } from './server.js';
|
||||||
import { packageJSON } from './package.js';
|
import { packageJSON } from './package.js';
|
||||||
|
import { startCDPRelayServer } from './cdpRelay.js';
|
||||||
|
|
||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
@@ -52,15 +53,22 @@ program
|
|||||||
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
||||||
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
|
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
|
||||||
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
||||||
|
.addOption(new Option('--extension', 'Allow connecting to a running browser instance (Edge/Chrome only). Requires the \'Playwright MCP\' browser extension to be installed.').hideHelp())
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
const config = await resolveCLIConfig(options);
|
const config = await resolveCLIConfig(options);
|
||||||
const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
|
const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
|
||||||
|
if (config.extension) {
|
||||||
|
if (!httpServer)
|
||||||
|
throw new Error('--port parameter is required for extension mode');
|
||||||
|
// Point CDP endpoint to the relay server.
|
||||||
|
config.browser.cdpEndpoint = await startCDPRelayServer(httpServer);
|
||||||
|
}
|
||||||
|
|
||||||
const server = new Server(config);
|
const server = new Server(config);
|
||||||
server.setupExitWatchdog();
|
server.setupExitWatchdog();
|
||||||
|
|
||||||
if (httpServer)
|
if (httpServer)
|
||||||
startHttpTransport(httpServer, server);
|
await startHttpTransport(httpServer, server);
|
||||||
else
|
else
|
||||||
await startStdioTransport(server);
|
await startStdioTransport(server);
|
||||||
|
|
||||||
|
|||||||
36
src/resources/resource.ts
Normal file
36
src/resources/resource.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from '../context.js';
|
||||||
|
|
||||||
|
export type ResourceSchema = {
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceResult = {
|
||||||
|
uri: string;
|
||||||
|
mimeType?: string;
|
||||||
|
text?: string;
|
||||||
|
blob?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Resource = {
|
||||||
|
schema: ResourceSchema;
|
||||||
|
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
|
||||||
|
};
|
||||||
@@ -46,17 +46,13 @@ const elementSchema = z.object({
|
|||||||
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const clickSchema = elementSchema.extend({
|
|
||||||
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const click = defineTool({
|
const click = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
title: 'Click',
|
title: 'Click',
|
||||||
description: 'Perform click on a web page',
|
description: 'Perform click on a web page',
|
||||||
inputSchema: clickSchema,
|
inputSchema: elementSchema,
|
||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -64,18 +60,14 @@ const click = defineTool({
|
|||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const locator = tab.snapshotOrDie().refLocator(params);
|
const locator = tab.snapshotOrDie().refLocator(params);
|
||||||
|
|
||||||
const code: string[] = [];
|
const code = [
|
||||||
if (params.doubleClick) {
|
`// Click ${params.element}`,
|
||||||
code.push(`// Double click ${params.element}`);
|
`await page.${await generateLocator(locator)}.click();`
|
||||||
code.push(`await page.${await generateLocator(locator)}.dblclick();`);
|
];
|
||||||
} else {
|
|
||||||
code.push(`// Click ${params.element}`);
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.click();`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
action: () => params.doubleClick ? locator.dblclick() : locator.click(),
|
action: () => locator.click(),
|
||||||
captureSnapshot: true,
|
captureSnapshot: true,
|
||||||
waitForNetwork: true,
|
waitForNetwork: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export async function generateLocator(locator: playwright.Locator): Promise<stri
|
|||||||
try {
|
try {
|
||||||
return await (locator as any)._generateLocatorString();
|
return await (locator as any)._generateLocatorString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && /locator._generateLocatorString: No element matching locator/.test(e.message))
|
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 new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ import path from 'node:path';
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test.skip(({ mcpMode }) => mcpMode === 'extension', 'Connecting to CDP server is not supported in combination with --extension');
|
||||||
|
|
||||||
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
||||||
await cdpServer.start();
|
await cdpServer.start();
|
||||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||||
@@ -55,7 +57,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
|||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Hello, world!
|
- generic [ref=e1]: Hello, world!
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -76,7 +78,7 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Config } from '../config.js';
|
|||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
|
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||||
|
test.skip(mcpMode === 'extension', 'Connecting to CDP server does not use user data dir');
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
<body>Hello, world!</body>
|
<body>Hello, world!</body>
|
||||||
@@ -46,6 +47,7 @@ test('config user data dir', async ({ startClient, server, mcpMode }, testInfo)
|
|||||||
test.describe(() => {
|
test.describe(() => {
|
||||||
test.use({ mcpBrowser: '' });
|
test.use({ mcpBrowser: '' });
|
||||||
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => {
|
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => {
|
||||||
|
test.skip(mcpMode === 'extension', 'Extension mode only supports Chromium');
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
browserName: 'firefox',
|
browserName: 'firefox',
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ await page.goto('${server.HELLO_WORLD}');
|
|||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Hello, world!
|
- generic [ref=e1]: Hello, world!
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_click', async ({ client, server, mcpBrowser }) => {
|
test('browser_click', async ({ client, server }) => {
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
<button>Submit</button>
|
<button>Submit</button>
|
||||||
@@ -65,46 +65,7 @@ await page.getByRole('button', { name: 'Submit' }).click();
|
|||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]
|
- button "Submit" [ref=e2]
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_click (double)', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<script>
|
|
||||||
function handle() {
|
|
||||||
document.querySelector('h1').textContent = 'Double clicked';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<h1 ondblclick="handle()">Click me</h1>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Click me',
|
|
||||||
ref: 'e2',
|
|
||||||
doubleClick: true,
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// Double click Click me
|
|
||||||
await page.getByRole('heading', { name: 'Click me' }).dblclick();
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- heading "Double clicked" [level=1] [ref=e3]
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -314,22 +275,3 @@ test('old locator error message', async ({ client, server }) => {
|
|||||||
},
|
},
|
||||||
})).toContainTextContent('Ref not found');
|
})).toContainTextContent('Ref not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<div style="visibility: hidden;">
|
|
||||||
<div style="visibility: visible;">
|
|
||||||
<button>Button</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_snapshot'
|
|
||||||
})).toContainTextContent('- button "Button"');
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('--device should work', async ({ startClient, server, mcpMode }) => {
|
test('--device should work', async ({ startClient, server, mcpMode }) => {
|
||||||
|
test.skip(mcpMode === 'extension', 'Viewport is not supported when connecting via CDP. There we re-use the browser viewport.');
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--device', 'iPhone 15'],
|
args: ['--device', 'iPhone 15'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ await page.getByRole('button', { name: 'Button' }).click();
|
|||||||
- Page Title:
|
- Page Title:
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- button "Button" [active] [ref=e2]
|
- button "Button" [ref=e2]
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -136,7 +136,7 @@ test('confirm dialog (true)', async ({ client, server }) => {
|
|||||||
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: "true"
|
- generic [ref=e1]: "true"
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ test('confirm dialog (false)', async ({ client, server }) => {
|
|||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: "false"
|
- generic [ref=e1]: "false"
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,6 +207,6 @@ test('prompt dialog', async ({ client, server }) => {
|
|||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot
|
expect(result).toContainTextContent(`- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Answer
|
- generic [ref=e1]: Answer
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|||||||
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.');
|
||||||
|
});
|
||||||
@@ -28,7 +28,7 @@ test('browser_file_upload', async ({ client, server }, testInfo) => {
|
|||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`
|
})).toContainTextContent(`
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]:
|
- generic [ref=e1]:
|
||||||
- button "Choose File" [ref=e2]
|
- button "Choose File" [ref=e2]
|
||||||
- button "Button" [ref=e3]
|
- button "Button" [ref=e3]
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
@@ -65,6 +65,12 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response).not.toContainTextContent('### Modal state');
|
expect(response).not.toContainTextContent('### Modal state');
|
||||||
|
expect(response).toContainTextContent(`
|
||||||
|
\`\`\`yaml
|
||||||
|
- generic [ref=e1]:
|
||||||
|
- button "Choose File" [ref=e2]
|
||||||
|
- button "Button" [ref=e3]
|
||||||
|
\`\`\``);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -95,6 +101,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => {
|
test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||||
|
test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension');
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
@@ -119,6 +126,7 @@ test('clicking on download link emits download', async ({ startClient, server, m
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
|
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
|
||||||
|
test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension');
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,12 +17,16 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import url from 'url';
|
import url from 'url';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import net from 'net';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
import { fork } from 'child_process';
|
||||||
|
|
||||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { TestServer } from './testserver/index.ts';
|
import { TestServer } from './testserver/index.ts';
|
||||||
|
import { ManualPromise } from '../src/manualPromise.js';
|
||||||
|
|
||||||
import type { Config } from '../config';
|
import type { Config } from '../config';
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
@@ -31,7 +35,7 @@ import type { Stream } from 'stream';
|
|||||||
|
|
||||||
export type TestOptions = {
|
export type TestOptions = {
|
||||||
mcpBrowser: string | undefined;
|
mcpBrowser: string | undefined;
|
||||||
mcpMode: 'docker' | undefined;
|
mcpMode: 'docker' | 'extension' | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CDPServer = {
|
type CDPServer = {
|
||||||
@@ -48,6 +52,7 @@ type TestFixtures = {
|
|||||||
server: TestServer;
|
server: TestServer;
|
||||||
httpsServer: TestServer;
|
httpsServer: TestServer;
|
||||||
mcpHeadless: boolean;
|
mcpHeadless: boolean;
|
||||||
|
startMcpExtension: (relayServerURL: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
@@ -66,7 +71,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
await use(client);
|
await use(client);
|
||||||
},
|
},
|
||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, startMcpExtension }, use, testInfo) => {
|
||||||
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
||||||
const configDir = path.dirname(test.info().config.configFile!);
|
const configDir = path.dirname(test.info().config.configFile!);
|
||||||
let client: Client | undefined;
|
let client: Client | undefined;
|
||||||
@@ -90,7 +95,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
||||||
const { transport, stderr } = await createTransport(args, mcpMode);
|
const { transport, stderr, relayServerURL } = await createTransport(args, mcpMode);
|
||||||
let stderrBuffer = '';
|
let stderrBuffer = '';
|
||||||
stderr?.on('data', data => {
|
stderr?.on('data', data => {
|
||||||
if (process.env.PWMCP_DEBUG)
|
if (process.env.PWMCP_DEBUG)
|
||||||
@@ -98,6 +103,8 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
stderrBuffer += data.toString();
|
stderrBuffer += data.toString();
|
||||||
});
|
});
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
|
if (mcpMode === 'extension')
|
||||||
|
await startMcpExtension(relayServerURL!);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return { client, stderr: () => stderrBuffer };
|
return { client, stderr: () => stderrBuffer };
|
||||||
});
|
});
|
||||||
@@ -140,6 +147,38 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
|
|
||||||
mcpMode: [undefined, { option: true }],
|
mcpMode: [undefined, { option: true }],
|
||||||
|
|
||||||
|
startMcpExtension: async ({ mcpMode, mcpHeadless }, use) => {
|
||||||
|
let context: BrowserContext | undefined;
|
||||||
|
await use(async (relayServerURL: string) => {
|
||||||
|
if (mcpMode !== 'extension')
|
||||||
|
throw new Error('Must be running in MCP extension mode to use this fixture.');
|
||||||
|
const cdpPort = await findFreePort();
|
||||||
|
const pathToExtension = path.join(url.fileURLToPath(import.meta.url), '../../extension');
|
||||||
|
context = await chromium.launchPersistentContext('', {
|
||||||
|
headless: mcpHeadless,
|
||||||
|
args: [
|
||||||
|
`--disable-extensions-except=${pathToExtension}`,
|
||||||
|
`--load-extension=${pathToExtension}`,
|
||||||
|
'--enable-features=AllowContentInitiatedDataUrlNavigations',
|
||||||
|
],
|
||||||
|
channel: 'chromium',
|
||||||
|
...{ assistantMode: true, cdpPort },
|
||||||
|
});
|
||||||
|
const popupPage = await context.newPage();
|
||||||
|
const page = context.pages()[0];
|
||||||
|
await page.bringToFront();
|
||||||
|
// Do not auto dismiss dialogs.
|
||||||
|
page.on('dialog', () => { });
|
||||||
|
await expect.poll(() => context?.serviceWorkers()).toHaveLength(1);
|
||||||
|
// Connect to the relay server.
|
||||||
|
await popupPage.goto(new URL('/popup.html', context.serviceWorkers()[0].url()).toString());
|
||||||
|
await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).clear();
|
||||||
|
await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(relayServerURL);
|
||||||
|
await popupPage.getByRole('button', { name: 'Share This Tab' }).click();
|
||||||
|
});
|
||||||
|
await context?.close();
|
||||||
|
},
|
||||||
|
|
||||||
_workerServers: [async ({ }, use, workerInfo) => {
|
_workerServers: [async ({ }, use, workerInfo) => {
|
||||||
const port = 8907 + workerInfo.workerIndex * 4;
|
const port = 8907 + workerInfo.workerIndex * 4;
|
||||||
const server = await TestServer.create(port);
|
const server = await TestServer.create(port);
|
||||||
@@ -169,6 +208,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
|
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
|
||||||
transport: Transport,
|
transport: Transport,
|
||||||
stderr: Stream | null,
|
stderr: Stream | null,
|
||||||
|
relayServerURL?: string,
|
||||||
}> {
|
}> {
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
@@ -183,6 +223,42 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']):
|
|||||||
stderr: transport.stderr,
|
stderr: transport.stderr,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (mcpMode === 'extension') {
|
||||||
|
const relay = fork(path.join(__filename, '../../cli.js'), [...args, '--extension', '--port=0'], {
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
const cdpRelayServerReady = new ManualPromise<string>();
|
||||||
|
const sseEndpointPromise = new ManualPromise<string>();
|
||||||
|
let stderrBuffer = '';
|
||||||
|
relay.stderr!.on('data', data => {
|
||||||
|
stderrBuffer += data.toString();
|
||||||
|
const match = stderrBuffer.match(/Listening on (http:\/\/.*)/);
|
||||||
|
if (match)
|
||||||
|
sseEndpointPromise.resolve(match[1].toString());
|
||||||
|
const extensionMatch = stderrBuffer.match(/CDP relay server started on (ws:\/\/.*\/extension)/);
|
||||||
|
if (extensionMatch)
|
||||||
|
cdpRelayServerReady.resolve(extensionMatch[1].toString());
|
||||||
|
});
|
||||||
|
relay.on('exit', () => {
|
||||||
|
sseEndpointPromise.reject(new Error(`Process exited`));
|
||||||
|
cdpRelayServerReady.reject(new Error(`Process exited`));
|
||||||
|
});
|
||||||
|
const relayServerURL = await cdpRelayServerReady;
|
||||||
|
const sseEndpoint = await sseEndpointPromise;
|
||||||
|
|
||||||
|
const transport = new SSEClientTransport(new URL(sseEndpoint));
|
||||||
|
// We cannot just add transport.onclose here as Client.connect() overrides it.
|
||||||
|
const origClose = transport.close;
|
||||||
|
transport.close = async () => {
|
||||||
|
await origClose.call(transport);
|
||||||
|
relay.kill();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
transport,
|
||||||
|
stderr: relay.stderr!,
|
||||||
|
relayServerURL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
@@ -256,6 +332,17 @@ export const expect = baseExpect.extend({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function findFreePort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address() as net.AddressInfo;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function formatOutput(output: string): string[] {
|
export function formatOutput(output: string): string[] {
|
||||||
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
},
|
},
|
||||||
})).toContainTextContent(`
|
})).toContainTextContent(`
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]:
|
- generic [ref=e1]:
|
||||||
- heading "Hello" [level=1] [ref=e2]
|
- heading "Hello" [level=1] [ref=e2]
|
||||||
- iframe [ref=e3]:
|
- iframe [ref=e3]:
|
||||||
- generic [active] [ref=f1e1]:
|
- generic [ref=f1e1]:
|
||||||
- button "World" [ref=f1e2]
|
- button "World" [ref=f1e2]
|
||||||
- main [ref=f1e3]:
|
- main [ref=f1e3]:
|
||||||
- iframe [ref=f1e4]:
|
- iframe [ref=f1e4]:
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import fs from 'fs';
|
|||||||
|
|
||||||
import { test, expect, formatOutput } from './fixtures.js';
|
import { test, expect, formatOutput } from './fixtures.js';
|
||||||
|
|
||||||
|
test.skip(({ mcpMode }) => mcpMode === 'extension', 'launch scenarios are not supported with --extension - the browser is already launched');
|
||||||
|
|
||||||
test('test reopen browser', async ({ startClient, server, mcpMode }) => {
|
test('test reopen browser', async ({ startClient, server, mcpMode }) => {
|
||||||
const { client, stderr } = await startClient();
|
const { client, stderr } = await startClient();
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
@@ -32,7 +34,7 @@ test('test reopen browser', async ({ startClient, server, mcpMode }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
|
|
||||||
await client.close();
|
await client.close();
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
|
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
@@ -58,7 +58,7 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
|
|||||||
@@ -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.
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
baseTest.skip(({ mcpMode }) => mcpMode === 'extension', 'Extension tests run via SSE anyways');
|
||||||
|
|
||||||
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
|
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
|
||||||
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
|
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
|
||||||
let cp: ChildProcess | undefined;
|
let cp: ChildProcess | undefined;
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ async function createTab(client: Client, title: string, body: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test.skip(({ mcpMode }) => mcpMode === 'extension', 'Multi-tab scenarios are not supported with --extension');
|
||||||
|
|
||||||
test('list initial tabs', async ({ client }) => {
|
test('list initial tabs', async ({ client }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
})).toHaveTextContent(`### Open tabs
|
})).toHaveTextContent(`### Open tabs
|
||||||
- 1: (current) [] (about:blank)`);
|
- 0: (current) [] (about:blank)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('list first tab', async ({ client }) => {
|
test('list first tab', async ({ client }) => {
|
||||||
@@ -39,8 +41,8 @@ test('list first tab', async ({ client }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
})).toHaveTextContent(`### Open tabs
|
})).toHaveTextContent(`### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 0: [] (about:blank)
|
||||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create new tab', async ({ client }) => {
|
test('create new tab', async ({ client }) => {
|
||||||
@@ -51,15 +53,15 @@ test('create new tab', async ({ client }) => {
|
|||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
### Open tabs
|
### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 0: [] (about:blank)
|
||||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
|
|
||||||
### Current tab
|
### Current tab
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Body one
|
- generic [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||||
@@ -69,16 +71,16 @@ test('create new tab', async ({ client }) => {
|
|||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
### Open tabs
|
### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 0: [] (about:blank)
|
||||||
- 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
- 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||||
|
|
||||||
### Current tab
|
### Current tab
|
||||||
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
||||||
- Page Title: Tab two
|
- Page Title: Tab two
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Body two
|
- generic [ref=e1]: Body two
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,25 +90,25 @@ test('select tab', async ({ client }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_select',
|
name: 'browser_tab_select',
|
||||||
arguments: {
|
arguments: {
|
||||||
index: 2,
|
index: 1,
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
- Ran Playwright code:
|
- Ran Playwright code:
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
// <internal code to select tab 2>
|
// <internal code to select tab 1>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
### Open tabs
|
### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 0: [] (about:blank)
|
||||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
- 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
- 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||||
|
|
||||||
### Current tab
|
### Current tab
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Body one
|
- generic [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,24 +118,24 @@ test('close tab', async ({ client }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_close',
|
name: 'browser_tab_close',
|
||||||
arguments: {
|
arguments: {
|
||||||
index: 3,
|
index: 2,
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
- Ran Playwright code:
|
- Ran Playwright code:
|
||||||
\`\`\`js
|
\`\`\`js
|
||||||
// <internal code to close tab 3>
|
// <internal code to close tab 2>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
### Open tabs
|
### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 0: [] (about:blank)
|
||||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
|
|
||||||
### Current tab
|
### Current tab
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Body one
|
- generic [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import path from 'path';
|
|||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => {
|
test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||||
|
test.fixme(mcpMode === 'extension', 'Tracing is not supported via CDP');
|
||||||
|
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
|
|||||||
Reference in New Issue
Block a user