simplifying shit playwright code

This commit is contained in:
Tommy D. Rossi
2025-11-13 20:37:19 +01:00
parent ca2485f3d5
commit a47b3bd99d
10 changed files with 153 additions and 2242 deletions
-48
View File
@@ -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.
+4 -12
View File
@@ -1,14 +1,12 @@
{
"manifest_version": 3,
"name": "Playwright MCP Bridge",
"version": "0.0.46",
"description": "Share browser tabs with Playwright MCP server",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
"version": "0.0.47",
"description": "Click to attach debugger to current tab",
"permissions": [
"debugger",
"activeTab",
"tabs",
"storage"
"tabs"
],
"host_permissions": [
"<all_urls>"
@@ -18,13 +16,7 @@
"type": "module"
},
"action": {
"default_title": "Playwright MCP Bridge",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
"default_title": "Click to attach debugger"
},
"icons": {
"16": "icons/icon-16.png",
File diff suppressed because it is too large Load Diff
+2 -7
View File
@@ -16,18 +16,13 @@
},
"license": "Apache-2.0",
"scripts": {
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build && 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",
"build": "tsc --project . && vite build --config vite.sw.config.mts",
"watch": "tsc --watch --project . & vite build --watch --config vite.sw.config.mts",
"test": "playwright test",
"clean": "rm -rf dist"
},
"devDependencies": {
"@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",
"vite": "^5.4.21",
"vite-plugin-static-copy": "^3.1.1"
-31
View File
@@ -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' } },
],
});
+116 -158
View File
@@ -16,207 +16,165 @@
import { RelayConnection, debugLog } from './relayConnection';
type PageMessage = {
type: 'connectToMCPRelay';
mcpRelayUrl: string;
} | {
type: 'getTabs';
} | {
type: 'connectToTab';
tabId?: number;
windowId?: number;
mcpRelayUrl: string;
} | {
type: 'getConnectionStatus';
} | {
type: 'disconnect';
};
// Relay URL - fixed port for MCP bridge
const RELAY_URL = 'ws://localhost:9988';
class TabShareExtension {
private _activeConnection: RelayConnection | undefined;
class SimplifiedExtension {
private _connection: RelayConnection | undefined;
private _connectedTabId: number | null = null;
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
constructor() {
debugLog(`Using relay URL: ${RELAY_URL}`);
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.runtime.onMessage.addListener(this._onMessage.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 _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) {
case 'connectToMCPRelay':
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
case 'getTabs':
this._getTabs().then(
tabs => sendResponse({ success: true, tabs, currentTabId: sender.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;
private async _onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
if (!tab.id) {
debugLog('No tab ID available');
return;
}
// Toggle: if connected to this tab, disconnect; otherwise connect
if (this._connectedTabId === tab.id) {
await this._disconnect();
} else {
await this._connect(tab.id);
}
return false;
}
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
private async _connect(tabId: number): Promise<void> {
try {
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
const socket = new WebSocket(mcpRelayUrl);
debugLog(`Connecting to tab ${tabId}`);
// 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) => {
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);
});
const connection = new RelayConnection(socket);
connection.onclose = () => {
// Create relay connection
this._connection = new RelayConnection(socket);
this._connection.onclose = () => {
debugLog('Connection closed');
this._pendingTabSelection.delete(selectorTabId);
// 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;
this._connection = undefined;
void this._setConnectedTabId(null);
};
await Promise.all([
this._setConnectedTabId(tabId),
chrome.tabs.update(tabId, { active: true }),
chrome.windows.update(windowId, { focused: true }),
]);
debugLog(`Connected to MCP bridge`);
// Set the tab ID (this will attach debugger)
this._connection.setTabId(tabId);
// Update state
await this._setConnectedTabId(tabId);
debugLog(`Successfully connected to tab ${tabId}`);
} catch (error: any) {
await this._setConnectedTabId(null);
debugLog(`Failed to connect tab ${tabId}:`, error.message);
throw error;
debugLog(`Failed to connect: ${error.message}`);
await this._updateIcon(tabId, 'disconnected');
// 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> {
const oldTabId = this._connectedTabId;
this._connectedTabId = tabId;
if (oldTabId && oldTabId !== tabId)
await this._updateBadge(oldTabId, { text: '' });
if (tabId)
await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' });
// Clear old tab icon
if (oldTabId && oldTabId !== tabId) {
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 {
await chrome.action.setBadgeText({ tabId, text });
await chrome.action.setTitle({ tabId, title: title || '' });
if (color)
await chrome.action.setBadgeBackgroundColor({ tabId, color });
switch (state) {
case 'connected':
await chrome.action.setBadgeText({ tabId, text: '✓' });
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) {
// 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> {
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
if (pendingConnection) {
this._pendingTabSelection.delete(tabId);
pendingConnection.close('Browser tab closed');
if (this._connectedTabId !== tabId) {
return;
}
if (this._connectedTabId !== tabId)
return;
this._activeConnection?.close('Browser tab closed');
this._activeConnection = undefined;
debugLog(`Connected tab ${tabId} was closed`);
this._connection?.close('Browser tab closed');
this._connection = undefined;
this._connectedTabId = null;
}
private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) {
for (const [tabId, pending] of this._pendingTabSelection) {
if (tabId === activeInfo.tabId) {
if (pending.timerId) {
clearTimeout(pending.timerId);
pending.timerId = undefined;
}
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 async _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo): Promise<void> {
// Update icon for the newly active tab
if (this._connectedTabId === activeInfo.tabId) {
await this._updateIcon(activeInfo.tabId, 'connected');
} else {
await this._updateIcon(activeInfo.tabId, 'disconnected');
}
}
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();
+10
View File
@@ -0,0 +1,10 @@
import { createExtensionContext } from '../src/extension/extensionContextFactory'
async function main() {
const controller = new AbortController()
const { browserContext, close } = await createExtensionContext(
controller.signal,
)
}
+3 -63
View File
@@ -60,9 +60,7 @@ type CDPResponse = {
export class CDPRelayServer {
private _wsHost: string;
private _browserChannel: string;
private _userDataDir?: string;
private _executablePath?: string;
private _cdpPath: string;
private _extensionPath: string;
private _wss: WebSocketServer;
@@ -76,11 +74,9 @@ export class CDPRelayServer {
private _nextSessionId: number = 1;
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._browserChannel = browserChannel;
this._userDataDir = userDataDir;
this._executablePath = executablePath;
const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`;
@@ -99,62 +95,6 @@ export class CDPRelayServer {
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 {
this.closeConnections('Server stopped');
@@ -27,45 +27,28 @@ import { BrowserContextFactory, ClientInfo } from './types.js';
const debugLogger = debug('pw:mcp:relay');
export class ExtensionContextFactory implements BrowserContextFactory {
private _browserChannel: string;
private _userDataDir?: string;
private _executablePath?: string;
constructor(browserChannel: string, userDataDir: string | undefined, executablePath: string | undefined) {
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
this._executablePath = executablePath;
export async function createExtensionContext(abortSignal: AbortSignal, ): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
// Merge obtainBrowser into this function
const httpServer = await startHttpServer({ port: 9988 });
if (abortSignal.aborted) {
httpServer.close();
throw new Error(abortSignal.reason);
}
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> }> {
const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName);
return {
browserContext: browser.contexts()[0],
close: async () => {
debugLogger('close() called for browser context');
await browser.close();
}
};
}
// TODO simply call fetch. it was creatign a full fledged browser just to fetch an url previously. CRAZY
// await cdpRelayServer.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
const browser = await playwright.chromium.connectOverCDP(cdpRelayServer.cdpEndpoint());
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<playwright.Browser> {
const relay = await this._startRelay(abortSignal);
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
}
private async _startRelay(abortSignal: AbortSignal) {
const httpServer = await startHttpServer({});
if (abortSignal.aborted) {
httpServer.close();
throw new Error(abortSignal.reason);
return {
browserContext: browser.contexts()[0],
close: async () => {
debugLogger('close() called for browser context');
await browser.close();
}
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;
}
};
}
-3
View File
@@ -13,8 +13,5 @@ export interface BrowserContextFactory {
export type ClientInfo = {
name: string;
version: string;
roots: Root[];
timestamp: number;
};
type Root = never // something mcp