simplifying shit playwright code
This commit is contained in:
@@ -1,48 +0,0 @@
|
|||||||
# Playwright MCP Chrome Extension
|
|
||||||
|
|
||||||
## Introduction
|
|
||||||
|
|
||||||
The Playwright MCP Chrome Extension allows you to connect to pages in your existing browser and leverage the state of your default user profile. This means the AI assistant can interact with websites where you're already logged in, using your existing cookies, sessions, and browser state, providing a seamless experience without requiring separate authentication or setup.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Chrome/Edge/Chromium browser
|
|
||||||
|
|
||||||
## Installation Steps
|
|
||||||
|
|
||||||
### Download the Extension
|
|
||||||
|
|
||||||
Download the latest Chrome extension from GitHub:
|
|
||||||
- **Download link**: https://github.com/microsoft/playwright-mcp/releases
|
|
||||||
|
|
||||||
### Load Chrome Extension
|
|
||||||
|
|
||||||
1. Open Chrome and navigate to `chrome://extensions/`
|
|
||||||
2. Enable "Developer mode" (toggle in the top right corner)
|
|
||||||
3. Click "Load unpacked" and select the extension directory
|
|
||||||
|
|
||||||
### Configure Playwright MCP server
|
|
||||||
|
|
||||||
Configure Playwright MCP server to connect to the browser using the extension by passing the `--extension` option when running the MCP server:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"playwright-extension": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"@playwright/mcp@latest",
|
|
||||||
"--extension"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Browser Tab Selection
|
|
||||||
|
|
||||||
When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session.
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Playwright MCP Bridge",
|
"name": "Playwright MCP Bridge",
|
||||||
"version": "0.0.46",
|
"version": "0.0.47",
|
||||||
"description": "Share browser tabs with Playwright MCP server",
|
"description": "Click to attach debugger to current tab",
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"debugger",
|
"debugger",
|
||||||
"activeTab",
|
"activeTab",
|
||||||
"tabs",
|
"tabs"
|
||||||
"storage"
|
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
@@ -18,13 +16,7 @@
|
|||||||
"type": "module"
|
"type": "module"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"default_title": "Playwright MCP Bridge",
|
"default_title": "Click to attach debugger"
|
||||||
"default_icon": {
|
|
||||||
"16": "icons/icon-16.png",
|
|
||||||
"32": "icons/icon-32.png",
|
|
||||||
"48": "icons/icon-48.png",
|
|
||||||
"128": "icons/icon-128.png"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon-16.png",
|
"16": "icons/icon-16.png",
|
||||||
|
|||||||
Generated
-1885
File diff suppressed because it is too large
Load Diff
@@ -16,18 +16,13 @@
|
|||||||
},
|
},
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build && vite build --config vite.sw.config.mts",
|
"build": "tsc --project . && vite build --config vite.sw.config.mts",
|
||||||
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch & vite build --watch --config vite.sw.config.mts",
|
"watch": "tsc --watch --project . & vite build --watch --config vite.sw.config.mts",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"clean": "rm -rf dist"
|
"clean": "rm -rf dist"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chrome": "^0.0.315",
|
"@types/chrome": "^0.0.315",
|
||||||
"@types/react": "^18.2.66",
|
|
||||||
"@types/react-dom": "^18.2.22",
|
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^5.4.21",
|
"vite": "^5.4.21",
|
||||||
"vite-plugin-static-copy": "^3.1.1"
|
"vite-plugin-static-copy": "^3.1.1"
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { defineConfig } from '@playwright/test';
|
|
||||||
|
|
||||||
import type { TestOptions } from '../tests/fixtures';
|
|
||||||
|
|
||||||
export default defineConfig<TestOptions>({
|
|
||||||
testDir: './tests',
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: 'list',
|
|
||||||
projects: [
|
|
||||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -16,207 +16,165 @@
|
|||||||
|
|
||||||
import { RelayConnection, debugLog } from './relayConnection';
|
import { RelayConnection, debugLog } from './relayConnection';
|
||||||
|
|
||||||
type PageMessage = {
|
// Relay URL - fixed port for MCP bridge
|
||||||
type: 'connectToMCPRelay';
|
const RELAY_URL = 'ws://localhost:9988';
|
||||||
mcpRelayUrl: string;
|
|
||||||
} | {
|
|
||||||
type: 'getTabs';
|
|
||||||
} | {
|
|
||||||
type: 'connectToTab';
|
|
||||||
tabId?: number;
|
|
||||||
windowId?: number;
|
|
||||||
mcpRelayUrl: string;
|
|
||||||
} | {
|
|
||||||
type: 'getConnectionStatus';
|
|
||||||
} | {
|
|
||||||
type: 'disconnect';
|
|
||||||
};
|
|
||||||
|
|
||||||
class TabShareExtension {
|
class SimplifiedExtension {
|
||||||
private _activeConnection: RelayConnection | undefined;
|
private _connection: RelayConnection | undefined;
|
||||||
private _connectedTabId: number | null = null;
|
private _connectedTabId: number | null = null;
|
||||||
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
debugLog(`Using relay URL: ${RELAY_URL}`);
|
||||||
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
|
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
|
||||||
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
|
|
||||||
chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
|
chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
|
||||||
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
|
|
||||||
chrome.action.onClicked.addListener(this._onActionClicked.bind(this));
|
chrome.action.onClicked.addListener(this._onActionClicked.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
|
private async _onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
|
||||||
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
if (!tab.id) {
|
||||||
switch (message.type) {
|
debugLog('No tab ID available');
|
||||||
case 'connectToMCPRelay':
|
return;
|
||||||
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
|
}
|
||||||
() => sendResponse({ success: true }),
|
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
// Toggle: if connected to this tab, disconnect; otherwise connect
|
||||||
return true;
|
if (this._connectedTabId === tab.id) {
|
||||||
case 'getTabs':
|
await this._disconnect();
|
||||||
this._getTabs().then(
|
} else {
|
||||||
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
|
await this._connect(tab.id);
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
|
||||||
return true;
|
|
||||||
case 'connectToTab':
|
|
||||||
const tabId = message.tabId || sender.tab?.id!;
|
|
||||||
const windowId = message.windowId || sender.tab?.windowId!;
|
|
||||||
this._connectTab(sender.tab!.id!, tabId, windowId, message.mcpRelayUrl!).then(
|
|
||||||
() => sendResponse({ success: true }),
|
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
|
||||||
return true; // Return true to indicate that the response will be sent asynchronously
|
|
||||||
case 'getConnectionStatus':
|
|
||||||
sendResponse({
|
|
||||||
connectedTabId: this._connectedTabId
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
case 'disconnect':
|
|
||||||
this._disconnect().then(
|
|
||||||
() => sendResponse({ success: true }),
|
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
|
private async _connect(tabId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
|
debugLog(`Connecting to tab ${tabId}`);
|
||||||
const socket = new WebSocket(mcpRelayUrl);
|
|
||||||
|
// Disconnect from any existing connection
|
||||||
|
if (this._connection) {
|
||||||
|
this._connection.close('Switching to new tab');
|
||||||
|
this._connection = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update icon to show connecting state
|
||||||
|
await this._updateIcon(tabId, 'connecting');
|
||||||
|
|
||||||
|
// Connect to WebSocket relay
|
||||||
|
const socket = new WebSocket(RELAY_URL);
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
socket.onopen = () => resolve();
|
socket.onopen = () => resolve();
|
||||||
socket.onerror = () => reject(new Error('WebSocket error'));
|
socket.onerror = () => reject(new Error('WebSocket connection failed'));
|
||||||
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = new RelayConnection(socket);
|
// Create relay connection
|
||||||
connection.onclose = () => {
|
this._connection = new RelayConnection(socket);
|
||||||
|
this._connection.onclose = () => {
|
||||||
debugLog('Connection closed');
|
debugLog('Connection closed');
|
||||||
this._pendingTabSelection.delete(selectorTabId);
|
this._connection = undefined;
|
||||||
// TODO: show error in the selector tab?
|
|
||||||
};
|
|
||||||
this._pendingTabSelection.set(selectorTabId, { connection });
|
|
||||||
debugLog(`Connected to MCP relay`);
|
|
||||||
} catch (error: any) {
|
|
||||||
const message = `Failed to connect to MCP relay: ${error.message}`;
|
|
||||||
debugLog(message);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`);
|
|
||||||
try {
|
|
||||||
this._activeConnection?.close('Another connection is requested');
|
|
||||||
} catch (error: any) {
|
|
||||||
debugLog(`Error closing active connection:`, error);
|
|
||||||
}
|
|
||||||
await this._setConnectedTabId(null);
|
|
||||||
|
|
||||||
this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection;
|
|
||||||
if (!this._activeConnection)
|
|
||||||
throw new Error('No active MCP relay connection');
|
|
||||||
this._pendingTabSelection.delete(selectorTabId);
|
|
||||||
|
|
||||||
this._activeConnection.setTabId(tabId);
|
|
||||||
this._activeConnection.onclose = () => {
|
|
||||||
debugLog('MCP connection closed');
|
|
||||||
this._activeConnection = undefined;
|
|
||||||
void this._setConnectedTabId(null);
|
void this._setConnectedTabId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
await Promise.all([
|
// Set the tab ID (this will attach debugger)
|
||||||
this._setConnectedTabId(tabId),
|
this._connection.setTabId(tabId);
|
||||||
chrome.tabs.update(tabId, { active: true }),
|
|
||||||
chrome.windows.update(windowId, { focused: true }),
|
// Update state
|
||||||
]);
|
await this._setConnectedTabId(tabId);
|
||||||
debugLog(`Connected to MCP bridge`);
|
|
||||||
|
debugLog(`Successfully connected to tab ${tabId}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await this._setConnectedTabId(null);
|
debugLog(`Failed to connect: ${error.message}`);
|
||||||
debugLog(`Failed to connect tab ${tabId}:`, error.message);
|
await this._updateIcon(tabId, 'disconnected');
|
||||||
throw error;
|
|
||||||
|
// Show error notification
|
||||||
|
chrome.action.setBadgeText({ tabId, text: '!' });
|
||||||
|
chrome.action.setBadgeBackgroundColor({ tabId, color: '#f44336' });
|
||||||
|
chrome.action.setTitle({ tabId, title: `Error: ${error.message}` });
|
||||||
|
|
||||||
|
// Clear error after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this._connectedTabId !== tabId) {
|
||||||
|
chrome.action.setBadgeText({ tabId, text: '' });
|
||||||
|
chrome.action.setTitle({ tabId, title: 'Click to attach debugger' });
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _disconnect(): Promise<void> {
|
||||||
|
debugLog('Disconnecting');
|
||||||
|
|
||||||
|
const tabId = this._connectedTabId;
|
||||||
|
|
||||||
|
this._connection?.close('User disconnected');
|
||||||
|
this._connection = undefined;
|
||||||
|
|
||||||
|
await this._setConnectedTabId(null);
|
||||||
|
|
||||||
|
if (tabId) {
|
||||||
|
await this._updateIcon(tabId, 'disconnected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _setConnectedTabId(tabId: number | null): Promise<void> {
|
private async _setConnectedTabId(tabId: number | null): Promise<void> {
|
||||||
const oldTabId = this._connectedTabId;
|
const oldTabId = this._connectedTabId;
|
||||||
this._connectedTabId = tabId;
|
this._connectedTabId = tabId;
|
||||||
if (oldTabId && oldTabId !== tabId)
|
|
||||||
await this._updateBadge(oldTabId, { text: '' });
|
// Clear old tab icon
|
||||||
if (tabId)
|
if (oldTabId && oldTabId !== tabId) {
|
||||||
await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' });
|
await this._updateIcon(oldTabId, 'disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new tab icon
|
||||||
|
if (tabId) {
|
||||||
|
await this._updateIcon(tabId, 'connected');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise<void> {
|
private async _updateIcon(tabId: number, state: 'connected' | 'disconnected' | 'connecting'): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await chrome.action.setBadgeText({ tabId, text });
|
switch (state) {
|
||||||
await chrome.action.setTitle({ tabId, title: title || '' });
|
case 'connected':
|
||||||
if (color)
|
await chrome.action.setBadgeText({ tabId, text: '✓' });
|
||||||
await chrome.action.setBadgeBackgroundColor({ tabId, color });
|
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#4CAF50' });
|
||||||
|
await chrome.action.setTitle({ tabId, title: 'Connected - Click to disconnect' });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'connecting':
|
||||||
|
await chrome.action.setBadgeText({ tabId, text: '...' });
|
||||||
|
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#FF9800' });
|
||||||
|
await chrome.action.setTitle({ tabId, title: 'Connecting...' });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'disconnected':
|
||||||
|
default:
|
||||||
|
await chrome.action.setBadgeText({ tabId, text: '' });
|
||||||
|
await chrome.action.setTitle({ tabId, title: 'Click to attach debugger' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Ignore errors as the tab may be closed already.
|
// Ignore errors (tab may be closed)
|
||||||
|
debugLog(`Error updating icon: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _onTabRemoved(tabId: number): Promise<void> {
|
private async _onTabRemoved(tabId: number): Promise<void> {
|
||||||
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
|
if (this._connectedTabId !== tabId) {
|
||||||
if (pendingConnection) {
|
|
||||||
this._pendingTabSelection.delete(tabId);
|
|
||||||
pendingConnection.close('Browser tab closed');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this._connectedTabId !== tabId)
|
|
||||||
return;
|
debugLog(`Connected tab ${tabId} was closed`);
|
||||||
this._activeConnection?.close('Browser tab closed');
|
this._connection?.close('Browser tab closed');
|
||||||
this._activeConnection = undefined;
|
this._connection = undefined;
|
||||||
this._connectedTabId = null;
|
this._connectedTabId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) {
|
private async _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo): Promise<void> {
|
||||||
for (const [tabId, pending] of this._pendingTabSelection) {
|
// Update icon for the newly active tab
|
||||||
if (tabId === activeInfo.tabId) {
|
if (this._connectedTabId === activeInfo.tabId) {
|
||||||
if (pending.timerId) {
|
await this._updateIcon(activeInfo.tabId, 'connected');
|
||||||
clearTimeout(pending.timerId);
|
} else {
|
||||||
pending.timerId = undefined;
|
await this._updateIcon(activeInfo.tabId, 'disconnected');
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!pending.timerId) {
|
|
||||||
pending.timerId = setTimeout(() => {
|
|
||||||
const existed = this._pendingTabSelection.delete(tabId);
|
|
||||||
if (existed) {
|
|
||||||
pending.connection.close('Tab has been inactive for 5 seconds');
|
|
||||||
chrome.tabs.sendMessage(tabId, { type: 'connectionTimeout' });
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
|
|
||||||
if (this._connectedTabId === tabId)
|
|
||||||
void this._setConnectedTabId(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
|
|
||||||
const tabs = await chrome.tabs.query({});
|
|
||||||
return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _onActionClicked(): Promise<void> {
|
|
||||||
await chrome.tabs.create({
|
|
||||||
url: chrome.runtime.getURL('status.html'),
|
|
||||||
active: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _disconnect(): Promise<void> {
|
|
||||||
this._activeConnection?.close('User disconnected');
|
|
||||||
this._activeConnection = undefined;
|
|
||||||
await this._setConnectedTabId(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
new TabShareExtension();
|
new SimplifiedExtension();
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createExtensionContext } from '../src/extension/extensionContextFactory'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const { browserContext, close } = await createExtensionContext(
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -60,9 +60,7 @@ type CDPResponse = {
|
|||||||
|
|
||||||
export class CDPRelayServer {
|
export class CDPRelayServer {
|
||||||
private _wsHost: string;
|
private _wsHost: string;
|
||||||
private _browserChannel: string;
|
|
||||||
private _userDataDir?: string;
|
|
||||||
private _executablePath?: string;
|
|
||||||
private _cdpPath: string;
|
private _cdpPath: string;
|
||||||
private _extensionPath: string;
|
private _extensionPath: string;
|
||||||
private _wss: WebSocketServer;
|
private _wss: WebSocketServer;
|
||||||
@@ -76,11 +74,9 @@ export class CDPRelayServer {
|
|||||||
private _nextSessionId: number = 1;
|
private _nextSessionId: number = 1;
|
||||||
private _extensionConnectionPromise!: ManualPromise<void>;
|
private _extensionConnectionPromise!: ManualPromise<void>;
|
||||||
|
|
||||||
constructor(server: http.Server, browserChannel: string, userDataDir?: string, executablePath?: string) {
|
constructor(server: http.Server, ) {
|
||||||
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
||||||
this._browserChannel = browserChannel;
|
|
||||||
this._userDataDir = userDataDir;
|
|
||||||
this._executablePath = executablePath;
|
|
||||||
|
|
||||||
const uuid = crypto.randomUUID();
|
const uuid = crypto.randomUUID();
|
||||||
this._cdpPath = `/cdp/${uuid}`;
|
this._cdpPath = `/cdp/${uuid}`;
|
||||||
@@ -99,62 +95,6 @@ export class CDPRelayServer {
|
|||||||
return `${this._wsHost}${this._extensionPath}`;
|
return `${this._wsHost}${this._extensionPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) {
|
|
||||||
debugLogger('Ensuring extension connection for MCP context');
|
|
||||||
if (this._extensionConnection)
|
|
||||||
return;
|
|
||||||
this._connectBrowser(clientInfo, toolName);
|
|
||||||
debugLogger('Waiting for incoming extension connection');
|
|
||||||
await Promise.race([
|
|
||||||
this._extensionConnectionPromise,
|
|
||||||
new Promise((_, reject) => setTimeout(() => {
|
|
||||||
reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
|
|
||||||
}, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
|
|
||||||
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
|
||||||
]);
|
|
||||||
debugLogger('Extension connection established');
|
|
||||||
}
|
|
||||||
|
|
||||||
private _connectBrowser(clientInfo: ClientInfo, toolName: string | undefined) {
|
|
||||||
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
|
||||||
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
|
||||||
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
|
||||||
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
|
||||||
const client = {
|
|
||||||
name: clientInfo.name,
|
|
||||||
version: clientInfo.version,
|
|
||||||
};
|
|
||||||
url.searchParams.set('client', JSON.stringify(client));
|
|
||||||
url.searchParams.set('protocolVersion', process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
|
|
||||||
if (toolName)
|
|
||||||
url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
|
|
||||||
const token = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
||||||
if (token)
|
|
||||||
url.searchParams.set('token', token);
|
|
||||||
const href = url.toString();
|
|
||||||
|
|
||||||
let executablePath = this._executablePath;
|
|
||||||
if (!executablePath) {
|
|
||||||
const executableInfo = registry.findExecutable(this._browserChannel);
|
|
||||||
if (!executableInfo)
|
|
||||||
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
|
|
||||||
executablePath = executableInfo.executablePath('javascript');
|
|
||||||
if (!executablePath)
|
|
||||||
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const args: string[] = [];
|
|
||||||
if (this._userDataDir)
|
|
||||||
args.push(`--user-data-dir=${this._userDataDir}`);
|
|
||||||
args.push(href);
|
|
||||||
|
|
||||||
spawn(executablePath, args, {
|
|
||||||
windowsHide: true,
|
|
||||||
detached: true,
|
|
||||||
shell: false,
|
|
||||||
stdio: 'ignore',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.closeConnections('Server stopped');
|
this.closeConnections('Server stopped');
|
||||||
|
|||||||
@@ -27,45 +27,28 @@ import { BrowserContextFactory, ClientInfo } from './types.js';
|
|||||||
|
|
||||||
const debugLogger = debug('pw:mcp:relay');
|
const debugLogger = debug('pw:mcp:relay');
|
||||||
|
|
||||||
export class ExtensionContextFactory implements BrowserContextFactory {
|
export async function createExtensionContext(abortSignal: AbortSignal, ): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
private _browserChannel: string;
|
// Merge obtainBrowser into this function
|
||||||
private _userDataDir?: string;
|
const httpServer = await startHttpServer({ port: 9988 });
|
||||||
private _executablePath?: string;
|
if (abortSignal.aborted) {
|
||||||
|
httpServer.close();
|
||||||
constructor(browserChannel: string, userDataDir: string | undefined, executablePath: string | undefined) {
|
throw new Error(abortSignal.reason);
|
||||||
this._browserChannel = browserChannel;
|
|
||||||
this._userDataDir = userDataDir;
|
|
||||||
this._executablePath = executablePath;
|
|
||||||
}
|
}
|
||||||
|
const cdpRelayServer = new CDPRelayServer(httpServer, );
|
||||||
|
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||||
|
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||||
|
|
||||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
// TODO simply call fetch. it was creatign a full fledged browser just to fetch an url previously. CRAZY
|
||||||
const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName);
|
// await cdpRelayServer.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
|
||||||
return {
|
const browser = await playwright.chromium.connectOverCDP(cdpRelayServer.cdpEndpoint());
|
||||||
browserContext: browser.contexts()[0],
|
|
||||||
close: async () => {
|
|
||||||
debugLogger('close() called for browser context');
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<playwright.Browser> {
|
return {
|
||||||
const relay = await this._startRelay(abortSignal);
|
browserContext: browser.contexts()[0],
|
||||||
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
|
close: async () => {
|
||||||
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
debugLogger('close() called for browser context');
|
||||||
}
|
await browser.close();
|
||||||
|
|
||||||
private async _startRelay(abortSignal: AbortSignal) {
|
|
||||||
const httpServer = await startHttpServer({});
|
|
||||||
if (abortSignal.aborted) {
|
|
||||||
httpServer.close();
|
|
||||||
throw new Error(abortSignal.reason);
|
|
||||||
}
|
}
|
||||||
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir, this._executablePath);
|
};
|
||||||
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
|
||||||
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
|
||||||
return cdpRelayServer;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,5 @@ export interface BrowserContextFactory {
|
|||||||
export type ClientInfo = {
|
export type ClientInfo = {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
roots: Root[];
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Root = never // something mcp
|
|
||||||
|
|||||||
Reference in New Issue
Block a user