diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index e1c6ecac64e66..da94041c7213c 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -21,7 +21,6 @@ import { ConnectedTabGroup, cleanupStalePlaywrightGroups, isNonDebuggableUrl } f type PageMessage = { type: 'connectionRequested'; mcpRelayUrl: string; - protocolVersion: number; } | { type: 'getTabs'; } | { @@ -56,10 +55,9 @@ class PlaywrightExtension { private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) { switch (message.type) { case 'connectionRequested': - this._pendingConnections.create(sender.tab!.id!, message.mcpRelayUrl, message.protocolVersion).then( - () => sendResponse({ success: true }), - (error: any) => sendResponse({ success: false, error: error.message })); - return true; + this._pendingConnections.create(sender.tab!.id!, message.mcpRelayUrl); + sendResponse({ success: true }); + return false; case 'getTabs': this._getTabs().then( tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }), diff --git a/packages/extension/src/pendingConnection.ts b/packages/extension/src/pendingConnection.ts index 3393879084217..38a339e7e1805 100644 --- a/packages/extension/src/pendingConnection.ts +++ b/packages/extension/src/pendingConnection.ts @@ -16,88 +16,29 @@ import { RelayConnection, debugLog } from './relayConnection'; -interface PendingEntry { - connect(): Promise; - close(reason: string): void; -} - -class EagerPending implements PendingEntry { - private _connection: RelayConnection; - onclose?: () => void; - - static async create(mcpRelayUrl: string, protocolVersion: number): Promise { - const connection = await openRelayConnection(mcpRelayUrl, protocolVersion); - return new EagerPending(connection); - } - - private constructor(connection: RelayConnection) { - this._connection = connection; - this._connection.onclose = () => this.onclose?.(); - } - - async connect(): Promise { - return this._connection; - } - - close(reason: string): void { - this._connection.close(reason); - } -} - -class DeferredPending implements PendingEntry { - constructor(private _mcpRelayUrl: string, private _protocolVersion: number) {} - - async connect(): Promise { - return openRelayConnection(this._mcpRelayUrl, this._protocolVersion); - } - - close(_reason: string): void { - } -} - +// Relay URLs recorded by `connectionRequested`, keyed by the connect page tab +// id. The relay WebSocket opens lazily in `take` once the user clicks Allow. export class PendingConnections { - private _map = new Map(); + private _map = new Map(); constructor() { - chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this)); + chrome.tabs.onRemoved.addListener(tabId => this._map.delete(tabId)); } - // v1 opens the relay WS eagerly — the daemon expects a prompt connection. - // v2 records only the descriptor; the WS opens lazily in `take` once the - // user clicks Allow. - async create(selectorTabId: number, mcpRelayUrl: string, protocolVersion: number): Promise { - if (protocolVersion !== 1) { - this._map.set(selectorTabId, new DeferredPending(mcpRelayUrl, protocolVersion)); - return; - } - const entry = await EagerPending.create(mcpRelayUrl, protocolVersion); - entry.onclose = () => { - if (this._map.get(selectorTabId) !== entry) - return; - this._map.delete(selectorTabId); - chrome.tabs.sendMessage(selectorTabId, { type: 'pendingConnectionClosed' }).catch(() => {}); - }; - this._map.set(selectorTabId, entry); + create(selectorTabId: number, mcpRelayUrl: string): void { + this._map.set(selectorTabId, mcpRelayUrl); } async take(selectorTabId: number): Promise { - const entry = this._map.get(selectorTabId); - if (!entry) + const mcpRelayUrl = this._map.get(selectorTabId); + if (mcpRelayUrl === undefined) return undefined; this._map.delete(selectorTabId); - return entry.connect(); - } - - private _onTabRemoved(tabId: number): void { - const entry = this._map.get(tabId); - if (!entry) - return; - this._map.delete(tabId); - entry.close('Browser tab closed'); + return openRelayConnection(mcpRelayUrl); } } -async function openRelayConnection(mcpRelayUrl: string, protocolVersion: number): Promise { +async function openRelayConnection(mcpRelayUrl: string): Promise { try { const socket = new WebSocket(mcpRelayUrl); await new Promise((resolve, reject) => { @@ -105,7 +46,7 @@ async function openRelayConnection(mcpRelayUrl: string, protocolVersion: number) socket.onerror = () => reject(new Error('WebSocket error')); setTimeout(() => reject(new Error('Connection timeout')), 5000); }); - return new RelayConnection(socket, protocolVersion); + return new RelayConnection(socket); } catch (error: any) { const message = `Failed to connect to MCP relay: ${error.message}`; debugLog(message); diff --git a/packages/extension/src/protocolHandlers.ts b/packages/extension/src/protocolHandlers.ts deleted file mode 100644 index f7f7173ad441f..0000000000000 --- a/packages/extension/src/protocolHandlers.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export type ProtocolCommand = { - id: number; - method: string; - params?: any; -}; - -// The narrow surface of RelayConnection that protocol handlers use. -export interface RelayContext { - readonly attachedTabs: ReadonlySet; - sendMessage(message: any): void; - // Records that a tab's debugger is now attached. Fires ontabattached on the - // owning RelayConnection. - notifyTabAttached(tabId: number): void; - // Records that a tab's debugger is now detached. Fires ontabdetached on the - // owning RelayConnection. - notifyTabDetached(tabId: number): void; -} - -export interface ProtocolHandler { - handleCommand(message: ProtocolCommand): Promise; - // Forwards an already-filtered chrome.* event (concerning a currently-attached - // tab) to the relay. Shape is protocol-specific. - forwardChromeEvent(fullMethod: string, args: any[]): void; - // The UI added a tab to the Playwright group, whether as the initial pick - // from the connect page or from a later drag-in. Handler tells the relay - // the tab is now available; the relay attaches via the usual command path. - onUserAttachRequest(tab: chrome.tabs.Tab): void; - // The UI removed a tab. RelayConnection has already detached the debugger - // and called notifyTabDetached; the handler only sends the wire-level - // detach notification (if the protocol has one). - onUserDetachRequest(tabId: number): void; - // Signals that the initial set of `onUserAttachRequest` calls is complete. - // For v2 this sends `extension.initialized` so the relay can unblock CDP - // traffic from Playwright; v1 has no handshake and ignores it. - didInitialize(): void; -} - -// ─── Protocol v1 (legacy single-tab) ─────────────────────────────────────── - -export class ProtocolV1Handler implements ProtocolHandler { - private _context: RelayContext; - private _selectedTabPromise: Promise; - private _selectedTabResolve!: (tabId: number) => void; - - constructor(context: RelayContext) { - this._context = context; - this._selectedTabPromise = new Promise(resolve => this._selectedTabResolve = resolve); - } - - async handleCommand(message: ProtocolCommand): Promise { - if (message.method === 'attachToTab') { - const tabId = await this._selectedTabPromise; - const debuggee: chrome.debugger.Debuggee = { tabId }; - await chrome.debugger.attach(debuggee, '1.3'); - this._context.notifyTabAttached(tabId); - const result: any = await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'); - return { targetInfo: result?.targetInfo }; - } - if (message.method === 'forwardCDPCommand') { - const { sessionId, method, params } = message.params; - if (method === 'Target.createTarget') - throw new Error('Tab creation is not supported yet. Update Playwright MCP or CLI to the latest version.'); - const tabId = [...this._context.attachedTabs][0]; - if (tabId === undefined) - throw new Error('No tab is connected'); - const debuggerSession: chrome.debugger.DebuggerSession = { tabId, sessionId }; - return await chrome.debugger.sendCommand(debuggerSession, method, params); - } - throw new Error(`Unknown method: ${message.method}`); - } - - forwardChromeEvent(fullMethod: string, args: any[]): void { - // v1 only forwards CDP events from the single attached tab; all other - // chrome events have no v1 equivalent. - if (fullMethod !== 'chrome.debugger.onEvent') - return; - const [source, method, params] = args as [chrome.debugger.DebuggerSession, string, any]; - this._context.sendMessage({ - method: 'forwardCDPEvent', - params: { sessionId: source.sessionId, method, params }, - }); - } - - onUserAttachRequest(tab: chrome.tabs.Tab): void { - // v1 is single-tab by design: the first attach call determines the tab - // used by the pending `attachToTab` command. Later attach requests are - // silently ignored (Promise.resolve is a no-op once resolved). - if (tab.id !== undefined) - this._selectedTabResolve(tab.id); - } - - onUserDetachRequest(_tabId: number): void { - // v1 has no wire-level detach notification; when the last tab detaches the - // socket closes and the relay notices. - } - - didInitialize(): void { - // v1 has no initial-tab-list handshake. `_selectedTabPromise` is resolved - // by the first `onUserAttachRequest`, which already unblocks `attachToTab`. - } -} - -// ─── Protocol v2 (reflective chrome.*) ───────────────────────────────────── - -// Allow-listed chrome.* commands the relay may invoke. The handler resolves -// the method reflectively and spreads positional params. -const ALLOWED_CHROME_COMMANDS = new Set([ - 'chrome.debugger.attach', - 'chrome.debugger.detach', - 'chrome.debugger.sendCommand', - 'chrome.tabs.create', - 'chrome.tabs.remove', -]); - -export class ProtocolV2Handler implements ProtocolHandler { - private _context: RelayContext; - - constructor(context: RelayContext) { - this._context = context; - } - - async handleCommand(message: ProtocolCommand): Promise { - if (ALLOWED_CHROME_COMMANDS.has(message.method)) { - const args = (message.params ?? []) as any[]; - const result = await invokeChromeMethod(message.method, args); - // Attach bookkeeping; detach flows through the chrome.debugger.onDetach event. - if (message.method === 'chrome.debugger.attach') { - const target = args[0] as chrome.debugger.Debuggee | undefined; - if (target?.tabId !== undefined) - this._context.notifyTabAttached(target.tabId); - } - return result ?? {}; - } - throw new Error(`Unknown method: ${message.method}`); - } - - forwardChromeEvent(fullMethod: string, args: any[]): void { - this._context.sendMessage({ method: fullMethod, params: args }); - } - - onUserAttachRequest(tab: chrome.tabs.Tab): void { - // Simulate a "new tab opened" event; the relay responds by calling - // chrome.debugger.attach, which flows through handleCommand. - this._context.sendMessage({ method: 'chrome.tabs.onCreated', params: [tab] }); - } - - didInitialize(): void { - // Signals the end of the initial-tab handshake. The relay holds CDP - // traffic from Playwright until it sees this event, so that - // `Target.setAutoAttach` is answered from a populated tab model. - this._context.sendMessage({ method: 'extension.initialized', params: [] }); - } - - onUserDetachRequest(tabId: number): void { - // chrome.debugger.detach does not fire onDetach for the caller, so we - // synthesize one so the relay notices the tab is gone. - this._context.sendMessage({ - method: 'chrome.debugger.onDetach', - params: [{ tabId }, 'target_closed'], - }); - } -} - -// ─── Reflective chrome.* invocation ──────────────────────────────────────── - -// Resolves chrome... Exported so RelayConnection can install -// listeners on the same set of chrome events without duplicating the traversal. -export function resolveChromeMember(fullMethod: string): { obj: any; name: string } { - const parts = fullMethod.split('.'); - if (parts[0] !== 'chrome' || parts.length < 3) - throw new Error(`Invalid chrome method: ${fullMethod}`); - let obj: any = chrome; - for (let i = 1; i < parts.length - 1; i++) { - obj = obj?.[parts[i]]; - if (obj === undefined) - throw new Error(`Unknown chrome path: ${parts.slice(0, i + 1).join('.')}, calling ${fullMethod}`); - } - return { obj, name: parts[parts.length - 1] }; -} - -async function invokeChromeMethod(fullMethod: string, args: any[]): Promise { - const { obj, name } = resolveChromeMember(fullMethod); - const fn = obj[name] as (...a: any[]) => any; - if (typeof fn !== 'function') - throw new Error(`Not a function: ${fullMethod}`); - return await fn.apply(obj, args); -} diff --git a/packages/extension/src/relayConnection.ts b/packages/extension/src/relayConnection.ts index 72160b09fe857..7ef2646d3fb1d 100644 --- a/packages/extension/src/relayConnection.ts +++ b/packages/extension/src/relayConnection.ts @@ -22,10 +22,11 @@ export function debugLog(...args: unknown[]): void { } } -import { - ProtocolCommand, ProtocolHandler, ProtocolV1Handler, ProtocolV2Handler, - RelayContext, resolveChromeMember, -} from './protocolHandlers'; +type ProtocolCommand = { + id: number; + method: string; + params?: any; +}; type ProtocolResponse = { id?: number; @@ -35,6 +36,16 @@ type ProtocolResponse = { error?: string; }; +// Allow-listed chrome.* commands the relay may invoke. They are resolved +// reflectively and the positional params are spread into the call. +const ALLOWED_CHROME_COMMANDS = new Set([ + 'chrome.debugger.attach', + 'chrome.debugger.detach', + 'chrome.debugger.sendCommand', + 'chrome.tabs.create', + 'chrome.tabs.remove', +]); + // chrome.* events the extension forwards to the relay (positional params). const CHROME_EVENT_METHODS = [ 'chrome.debugger.onEvent', @@ -45,7 +56,6 @@ const CHROME_EVENT_METHODS = [ export class RelayConnection { private _ws: WebSocket; - private _handler: ProtocolHandler; // Tabs whose debugger we have explicitly attached for this connection. private _attachedTabs = new Set(); // Once we've attached at least one tab, detaching the last one closes the connection. @@ -61,27 +71,19 @@ export class RelayConnection { return this._attachedTabs; } - constructor(ws: WebSocket, protocolVersion: number) { + constructor(ws: WebSocket) { this._ws = ws; - const context: RelayContext = { - attachedTabs: this._attachedTabs, - sendMessage: msg => this._sendMessage(msg), - notifyTabAttached: tabId => this._notifyTabAttached(tabId), - notifyTabDetached: tabId => this._notifyTabDetached(tabId), - }; - this._handler = protocolVersion === 1 - ? new ProtocolV1Handler(context) - : new ProtocolV2Handler(context); this._installEventForwarders(); this._ws.onmessage = this._onMessage.bind(this); this._ws.onclose = () => this._onClose(); } // Signals the end of the initial-tab handshake — call after the initial - // round of `attachTab` invocations. For v2 this sends `extension.initialized` - // so the relay can unblock Playwright CDP traffic; v1 has no handshake. + // round of `attachTab` invocations. The relay holds CDP traffic from + // Playwright until it sees this event, so that `Target.setAutoAttach` is + // answered from a populated tab model. didInitialize(): void { - this._handler.didInitialize(); + this._sendMessage({ method: 'extension.initialized', params: [] }); } close(message: string): void { @@ -91,17 +93,21 @@ export class RelayConnection { this._onClose(); } - // Called when the UI adds a tab to the Playwright group. The handler asks - // the relay to attach; the normal command path fires ontabattached. + // Called when the UI adds a tab to the Playwright group, whether as the + // initial pick from the connect page or from a later drag-in. Simulates a + // "new tab opened" event; the relay responds by calling + // chrome.debugger.attach, which flows through _handleCommand and fires + // ontabattached. attachTab(tab: chrome.tabs.Tab): void { if (this._closed || this._attachedTabs.has(tab.id!)) return; - this._handler.onUserAttachRequest(tab); + this._sendMessage({ method: 'chrome.tabs.onCreated', params: [tab] }); } // Called when the UI removes a tab from the Playwright group. We detach the - // debugger and update bookkeeping; the handler emits the wire-level detach - // notification for protocols that have one. + // debugger and update bookkeeping. chrome.debugger.detach does not fire + // onDetach for the caller, so we synthesize one so the relay notices the + // tab is gone. detachTab(tabId: number): void { if (this._closed || !this._attachedTabs.has(tabId)) return; @@ -109,7 +115,10 @@ export class RelayConnection { debugLog('Error detaching tab:', error); }); this._notifyTabDetached(tabId); - this._handler.onUserDetachRequest(tabId); + this._sendMessage({ + method: 'chrome.debugger.onDetach', + params: [{ tabId }, 'target_closed'], + }); this._checkLastTabDetached(); } @@ -154,13 +163,13 @@ export class RelayConnection { this.close('All controlled tabs detached'); } - // Filters chrome.* events to attached tabs, delegates wire formatting to the - // handler, then runs shared detach bookkeeping. + // Forwards chrome.* events concerning attached tabs to the relay, then runs + // shared detach bookkeeping. private _onChromeEvent(fullMethod: string, args: any[]): void { const tabId = this._tabIdForEventArgs(fullMethod, args); if (tabId === undefined || !this._attachedTabs.has(tabId)) return; - this._handler.forwardChromeEvent(fullMethod, args); + this._sendMessage({ method: fullMethod, params: args }); // chrome.debugger.onDetach is the single source of truth for detach bookkeeping. if (fullMethod === 'chrome.debugger.onDetach') { this._notifyTabDetached(tabId); @@ -204,7 +213,7 @@ export class RelayConnection { id: message.id, }; try { - response.result = await this._handler.handleCommand(message); + response.result = await this._handleCommand(message); } catch (error: any) { debugLog(`Error handling command ${JSON.stringify(message)}:`, error); response.error = error.message; @@ -212,6 +221,20 @@ export class RelayConnection { this._sendMessage(response); } + private async _handleCommand(message: ProtocolCommand): Promise { + if (!ALLOWED_CHROME_COMMANDS.has(message.method)) + throw new Error(`Unknown method: ${message.method}`); + const args = (message.params ?? []) as any[]; + const result = await invokeChromeMethod(message.method, args); + // Attach bookkeeping; detach flows through the chrome.debugger.onDetach event. + if (message.method === 'chrome.debugger.attach') { + const target = args[0] as chrome.debugger.Debuggee | undefined; + if (target?.tabId !== undefined) + this._notifyTabAttached(target.tabId); + } + return result ?? {}; + } + private _sendError(code: number, message: string): void { this._sendMessage({ error: { @@ -226,3 +249,28 @@ export class RelayConnection { this._ws.send(JSON.stringify(message)); } } + +// ─── Reflective chrome.* invocation ──────────────────────────────────────── + +// Resolves chrome.., shared by command invocation and event +// listener installation. +function resolveChromeMember(fullMethod: string): { obj: any; name: string } { + const parts = fullMethod.split('.'); + if (parts[0] !== 'chrome' || parts.length < 3) + throw new Error(`Invalid chrome method: ${fullMethod}`); + let obj: any = chrome; + for (let i = 1; i < parts.length - 1; i++) { + obj = obj?.[parts[i]]; + if (obj === undefined) + throw new Error(`Unknown chrome path: ${parts.slice(0, i + 1).join('.')}, calling ${fullMethod}`); + } + return { obj, name: parts[parts.length - 1] }; +} + +async function invokeChromeMethod(fullMethod: string, args: any[]): Promise { + const { obj, name } = resolveChromeMember(fullMethod); + const fn = obj[name] as (...a: any[]) => any; + if (typeof fn !== 'function') + throw new Error(`Not a function: ${fullMethod}`); + return await fn.apply(obj, args); +} diff --git a/packages/extension/src/ui/connect.tsx b/packages/extension/src/ui/connect.tsx index 22c3ec632b69c..da32ceab198a3 100644 --- a/packages/extension/src/ui/connect.tsx +++ b/packages/extension/src/ui/connect.tsx @@ -85,14 +85,13 @@ const ConnectApp: React.FC = () => { }); return; } - // The background decides per protocolVersion: v1 opens the relay WS - // immediately (the daemon expects a prompt connection); v2 just records - // the descriptor and defers the WS until the user clicks Allow. - const response = await chrome.runtime.sendMessage({ type: 'connectionRequested', mcpRelayUrl: relayUrl, protocolVersion: requestedVersion }); - if (!response.success) { - setError(response.error); + if (requestedVersion < SUPPORTED_PROTOCOL_VERSION) { + setError('The client uses an unsupported protocol version. Update Playwright MCP or CLI to the latest version.'); return; } + // The background only records the relay URL; the WS to the relay opens + // once the user clicks Allow. + await chrome.runtime.sendMessage({ type: 'connectionRequested', mcpRelayUrl: relayUrl }); const expectedToken = getOrCreateAuthToken(); const token = params.get('token'); @@ -112,8 +111,9 @@ const ConnectApp: React.FC = () => { await loadTabs(); }; void runAsync(); - // Ping the background every 20s so the MV3 service worker (which owns the - // relay WebSocket) stays above its 30s idle timeout while the user decides. + // Ping the background every 20s so the MV3 service worker (which holds the + // pending connection state) stays above its 30s idle timeout while the + // user decides. const keepalive = setInterval(() => { chrome.runtime.sendMessage({ type: 'keepalive' }).catch(() => {}); }, 20_000); @@ -154,19 +154,6 @@ const ConnectApp: React.FC = () => { } }, [clientInfo]); - useEffect(() => { - const listener = (message: any) => { - if (message.type === 'pendingConnectionClosed') { - setError('Pending client connection closed.'); - document.title = 'Playwright Extension'; - } - }; - chrome.runtime.onMessage.addListener(listener); - return () => { - chrome.runtime.onMessage.removeListener(listener); - }; - }, [setError]); - return (
diff --git a/packages/playwright-core/src/tools/mcp/browserModel.ts b/packages/playwright-core/src/tools/mcp/browserModel.ts index bad7f646ce1a5..13269c8884a54 100644 --- a/packages/playwright-core/src/tools/mcp/browserModel.ts +++ b/packages/playwright-core/src/tools/mcp/browserModel.ts @@ -35,9 +35,20 @@ import { logUnhandledError } from './log'; -import type { CDPMessage, SendCommand, SendToCDPClient } from './cdpRelayHandler'; import type { DebuggerSession, Debuggee, Tab } from './protocol'; +export type CDPMessage = { + id?: number; + sessionId?: string; + method?: string; + params?: any; + result?: any; + error?: { code?: number; message: string }; +}; + +export type SendCommand = (method: string, params: any) => Promise; +export type SendToCDPClient = (message: CDPMessage) => void; + type TabSession = { tabId: number; sessionId: string; diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index b41fddf0bcc7a..60915e7e64b6f 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -21,9 +21,8 @@ * - /cdp/guid - Full CDP interface for Playwright MCP * - /extension/guid - Extension connection * - * Protocol version is controlled by PLAYWRIGHT_EXTENSION_PROTOCOL env variable: - * - v1: single-tab, extension manages debugger attachment - * - v2 (default): multi-tab, relay manages debugger via chrome.* APIs + * The protocol version advertised to the extension can be overridden with the + * PLAYWRIGHT_EXTENSION_PROTOCOL env variable (used in tests). */ import { spawn } from 'child_process'; @@ -38,13 +37,12 @@ import { registry } from '../../server/registry/index'; import { playwrightExtensionId } from '../utils/extension'; import { addressToString } from '../utils/mcp/http'; import { logUnhandledError } from './log'; -import { ExtensionProtocolV1 } from './cdpRelayV1'; import { ExtensionProtocolV2 } from './cdpRelayV2'; import * as protocol from './protocol'; import type websocket from 'ws'; -import type { ExtensionCommand, ExtensionEvents } from './protocol'; -import type { CDPMessage, ExtensionProtocolHandler } from './cdpRelayHandler'; +import type { ExtensionCommandV2, ExtensionEventsV2 } from './protocol'; +import type { CDPMessage } from './browserModel'; import type { WebSocket, WebSocketServer } from 'ws'; @@ -69,25 +67,21 @@ export class CDPRelayServer { private _cdpConnection: WebSocket | null = null; private _extensionConnection: ExtensionConnection | null = null; private _protocolVersion: number; - private _handler: ExtensionProtocolHandler; + private _handler: ExtensionProtocolV2; private _extensionConnectionPromise = new ManualPromise(); constructor(server: http.Server, browserChannel: string, executablePath?: string) { this._wsHost = addressToString(server.address(), { protocol: 'ws' }); this._browserChannel = browserChannel; this._executablePath = executablePath; - this._protocolVersion = parseInt(process.env.PLAYWRIGHT_EXTENSION_PROTOCOL ?? protocol.DEFAULT_VERSION.toString(), 10); + this._protocolVersion = parseInt(process.env.PLAYWRIGHT_EXTENSION_PROTOCOL ?? protocol.VERSION.toString(), 10); const sendCommand = (method: string, params: any): Promise => { if (!this._extensionConnection) throw new Error('Extension not connected'); - return this._extensionConnection.send(method as keyof ExtensionCommand, params); + return this._extensionConnection.send(method as keyof ExtensionCommandV2, params); }; - - if (this._protocolVersion >= 2) - this._handler = new ExtensionProtocolV2(sendCommand); - else - this._handler = new ExtensionProtocolV1(sendCommand); + this._handler = new ExtensionProtocolV2(sendCommand); const uuid = crypto.randomUUID(); this._cdpPath = `/cdp/${uuid}`; @@ -290,7 +284,7 @@ class ExtensionConnection { private readonly _callbacks = new Map void, reject: (e: Error) => void, error: Error }>(); private _lastId = 0; - onmessage?: (method: M, params: ExtensionEvents[M]['params']) => void; + onmessage?: (method: M, params: ExtensionEventsV2[M]['params']) => void; onclose?: (reason: string) => void; constructor(ws: WebSocket) { @@ -300,7 +294,7 @@ class ExtensionConnection { this._ws.on('error', this._onError.bind(this)); } - async send(method: M, params: ExtensionCommand[M]['params']): Promise { + async send(method: M, params: ExtensionCommandV2[M]['params']): Promise { if (this._ws.readyState !== ws.OPEN) throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`); const id = ++this._lastId; @@ -349,7 +343,7 @@ class ExtensionConnection { } else if (object.id) { debugLogger('← Extension: unexpected response', object); } else { - this.onmessage?.(object.method! as keyof ExtensionEvents, object.params); + this.onmessage?.(object.method! as keyof ExtensionEventsV2, object.params); } } diff --git a/packages/playwright-core/src/tools/mcp/cdpRelayHandler.ts b/packages/playwright-core/src/tools/mcp/cdpRelayHandler.ts deleted file mode 100644 index f6310e65d97c3..0000000000000 --- a/packages/playwright-core/src/tools/mcp/cdpRelayHandler.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export type CDPMessage = { - id?: number; - sessionId?: string; - method?: string; - params?: any; - result?: any; - error?: { code?: number; message: string }; -}; - -export type SendCommand = (method: string, params: any) => Promise; -export type SendToCDPClient = (message: CDPMessage) => void; - -export interface ExtensionProtocolHandler { - // Handle an event from the extension. Sends CDP events to Playwright as needed. - handleExtensionEvent(method: string, params: any): void; - // Handle a protocol-specific CDP command. - // Returns { result } if handled, undefined to fall through to forwarding. - handleCDPCommand(method: string, params: any, sessionId: string | undefined): Promise<{ result: any } | undefined>; - // Forward a CDP command to the extension. - forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise; - // Resolves once the extension has completed its initial handshake and the - // relay may start processing CDP commands from Playwright. - ready(): Promise; - // Wires up the sink through which the handler emits CDP events back to - // Playwright. Called once `ready()` has resolved and the Playwright ws is - // about to start draining — before this call the handler is a silent sink. - connectOverCDP(sendToCDPClient: SendToCDPClient): void; - // Called when the extension WebSocket closes. Handlers should reject any - // pending `ready()` promise so a blocked `establishExtensionConnection` - // bails out instead of hanging forever. - onExtensionDisconnect(reason: string): void; -} diff --git a/packages/playwright-core/src/tools/mcp/cdpRelayV1.ts b/packages/playwright-core/src/tools/mcp/cdpRelayV1.ts deleted file mode 100644 index 0926aba871957..0000000000000 --- a/packages/playwright-core/src/tools/mcp/cdpRelayV1.ts +++ /dev/null @@ -1,102 +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. - */ - -/** - * Protocol v1: single-tab interface. The extension manages debugger - * attachment and forwards CDP events/commands through a thin wrapper. - */ - -import type { ExtensionProtocolHandler, SendCommand, SendToCDPClient } from './cdpRelayHandler'; -import type { ExtensionEventsV1 } from './protocol'; - -export class ExtensionProtocolV1 implements ExtensionProtocolHandler { - private _sendCommand: SendCommand; - private _sendToCDPClient: SendToCDPClient | null = null; - private _connectedTabInfo: { targetInfo: any; sessionId: string } | undefined; - private _nextSessionId = 1; - - constructor(sendCommand: SendCommand) { - this._sendCommand = sendCommand; - } - - connectOverCDP(sendToCDPClient: SendToCDPClient): void { - this._sendToCDPClient = sendToCDPClient; - } - - handleExtensionEvent(method: string, params: any): void { - switch (method) { - case 'forwardCDPEvent': { - const p = params as ExtensionEventsV1['forwardCDPEvent']['params']; - const sessionId = p.sessionId || this._connectedTabInfo?.sessionId; - this._sendToCDPClient?.({ - sessionId, - method: p.method, - params: p.params, - }); - break; - } - } - } - - async handleCDPCommand(method: string, params: any, sessionId: string | undefined): Promise<{ result: any } | undefined> { - switch (method) { - case 'Target.setAutoAttach': { - if (sessionId) - return undefined; - const { targetInfo } = await this._sendCommand('attachToTab', {}); - this._connectedTabInfo = { - targetInfo, - sessionId: `pw-tab-${this._nextSessionId++}`, - }; - this._sendToCDPClient?.({ - method: 'Target.attachedToTarget', - params: { - sessionId: this._connectedTabInfo.sessionId, - targetInfo: { - ...this._connectedTabInfo.targetInfo, - attached: true, - }, - waitingForDebugger: false, - }, - }); - return { result: {} }; - } - case 'Target.getTargetInfo': { - return { result: this._connectedTabInfo?.targetInfo }; - } - case 'Target.createTarget': { - throw new Error('Tab creation is not supported yet.'); - } - } - return undefined; - } - - async forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise { - // Top level sessionId is only passed between the relay and the client. - if (this._connectedTabInfo?.sessionId === sessionId) - sessionId = undefined; - return await this._sendCommand('forwardCDPCommand', { sessionId, method, params }); - } - - ready(): Promise { - // v1 has no initial handshake; messages from Playwright are processed immediately. - return Promise.resolve(); - } - - onExtensionDisconnect(_reason: string): void { - // v1 has no pending ready() promise to reject. - } -} diff --git a/packages/playwright-core/src/tools/mcp/cdpRelayV2.ts b/packages/playwright-core/src/tools/mcp/cdpRelayV2.ts index 385c2be3f2b3f..013e4eea888ac 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelayV2.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelayV2.ts @@ -31,10 +31,10 @@ import { ManualPromise } from '@isomorphic/manualPromise'; import { logUnhandledError } from './log'; import { BrowserModel } from './browserModel'; -import type { ExtensionProtocolHandler, SendCommand, SendToCDPClient } from './cdpRelayHandler'; +import type { SendCommand, SendToCDPClient } from './browserModel'; import type { ExtensionEventsV2 } from './protocol'; -export class ExtensionProtocolV2 implements ExtensionProtocolHandler { +export class ExtensionProtocolV2 { private _model: BrowserModel; // Resolved by `extension.initialized`. Purely a handshake signal for the // relay — the model itself is oblivious to this phase. @@ -45,6 +45,8 @@ export class ExtensionProtocolV2 implements ExtensionProtocolHandler { void this._ready.catch(logUnhandledError); } + // Resolves once the extension has completed its initial handshake and the + // relay may start processing CDP commands from Playwright. ready(): Promise { return this._ready; } @@ -53,6 +55,9 @@ export class ExtensionProtocolV2 implements ExtensionProtocolHandler { this._model.connectOverCDP(sendToCDPClient); } + // Called when the extension WebSocket closes. Rejects a pending `ready()` + // promise so a blocked `establishExtensionConnection` bails out instead of + // hanging forever. onExtensionDisconnect(reason: string): void { if (!this._ready.isDone()) this._ready.reject(new Error(`Extension disconnected before initialization: ${reason}`)); @@ -87,6 +92,8 @@ export class ExtensionProtocolV2 implements ExtensionProtocolHandler { } } + // Handles a protocol-specific CDP command. Returns { result } if handled, + // undefined to fall through to forwarding to the extension. async handleCDPCommand(method: string, params: any, sessionId: string | undefined): Promise<{ result: any } | undefined> { switch (method) { case 'Target.setAutoAttach': { diff --git a/packages/playwright-core/src/tools/mcp/protocol.ts b/packages/playwright-core/src/tools/mcp/protocol.ts index 06c02525f7c54..a54b02d257407 100644 --- a/packages/playwright-core/src/tools/mcp/protocol.ts +++ b/packages/playwright-core/src/tools/mcp/protocol.ts @@ -14,14 +14,10 @@ * limitations under the License. */ -// The latest protocol version defined in this file. Bumped whenever the -// commands/events change. The latest extension version should remain -// compatible with older MCP clients. -export const LATEST_VERSION = 2; - -// The protocol version used by default when PLAYWRIGHT_EXTENSION_PROTOCOL is -// not set. May lag behind LATEST_VERSION while a new version is rolling out. -export const DEFAULT_VERSION = 2; +// The protocol version defined in this file. Bumped whenever the +// commands/events change. Sent to the extension, which rejects clients +// requesting a version it does not support. +export const VERSION = 2; // Structural mirrors of @types/chrome shapes used over the wire. The extension // imports the real chrome.* types and they are structurally compatible. @@ -103,33 +99,3 @@ export type ExtensionEventsV2 = { params: []; }; }; - -// Protocol v1: legacy single-tab interface. -export type ExtensionCommandV1 = { - 'attachToTab': { - params: {}; - result: { targetInfo: any }; - }; - 'forwardCDPCommand': { - params: { - method: string, - sessionId?: string - params?: any, - }; - result: any; - }; -}; - -export type ExtensionEventsV1 = { - 'forwardCDPEvent': { - params: { - method: string, - sessionId?: string - params?: any, - }; - }; -}; - -// Combined types for the relay which supports both protocol versions. -export type ExtensionCommand = ExtensionCommandV1 & ExtensionCommandV2; -export type ExtensionEvents = ExtensionEventsV1 & ExtensionEventsV2; diff --git a/tests/extension/cli.spec.ts b/tests/extension/cli.spec.ts index 11a6a7324287b..ee09ad2e5a3b5 100644 --- a/tests/extension/cli.spec.ts +++ b/tests/extension/cli.spec.ts @@ -37,34 +37,6 @@ const test = base.extend<{ }, }); -function isAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function expectDaemonExited(cliPromise: Promise): Promise { - const { error } = await cliPromise; - const pidMatch = error.match(/Daemon pid=(\d+)/); - expect(pidMatch, `expected daemon pid in cli error:\n${error}`).toBeTruthy(); - const pid = parseInt(pidMatch![1], 10); - await expect.poll(() => isAlive(pid)).toBe(false); -} - -test('daemon exits when user closes the connect tab', async ({ startAttach, protocolVersion }) => { - // v2 defers opening the relay WS until the user clicks Allow, so closing the - // tab before Allow never opens a relay WS in v2. - test.skip(protocolVersion === 2, 'v2 defers the relay connection until Allow'); - const { confirmationPage, cliPromise } = await startAttach(); - // Wait for the page to fully load and the connection to the relay to be established before closing it. - await expect(confirmationPage.locator('.tab-item').first()).toBeVisible(); - await confirmationPage.close(); - await expectDaemonExited(cliPromise); -}); - test('attach --extension', async ({ startAttach, cli, server }) => { const { confirmationPage, cliPromise } = await startAttach(); await clickAllowAndSelect(confirmationPage, 'Welcome'); diff --git a/tests/extension/extension-fixtures.ts b/tests/extension/extension-fixtures.ts index c3c79abe54bbd..b7e8e0420caa7 100644 --- a/tests/extension/extension-fixtures.ts +++ b/tests/extension/extension-fixtures.ts @@ -36,10 +36,6 @@ export type CliResult = { error: string; }; -export type ExtensionTestOptions = { - protocolVersion: 1 | 2; -}; - export type TestFixtures = { browserWithExtension: BrowserWithExtension, pathToExtension: string, @@ -47,24 +43,9 @@ export type TestFixtures = { cli: (args: string[], options?: { env?: Record }) => Promise; }; -type WorkerFixtures = { - _protocolEnv: void; -}; - export const extensionId = 'mmlmfjhmonkocbjadbfplnigmagldckm'; -export const test = base.extend({ - protocolVersion: [2, { option: true, scope: 'worker' }], - - _protocolEnv: [async ({ protocolVersion }, use) => { - // Default is 2. - if (protocolVersion === 1) - process.env.PLAYWRIGHT_EXTENSION_PROTOCOL = '1'; - else - delete process.env.PLAYWRIGHT_EXTENSION_PROTOCOL; - await use(); - }, { auto: true, scope: 'worker' }], - +export const test = base.extend({ pathToExtension: async ({}, use, testInfo) => { const extensionDir = testInfo.outputPath('extension'); const srcDir = path.resolve(__dirname, '../../packages/extension/dist'); diff --git a/tests/extension/extension.spec.ts b/tests/extension/extension.spec.ts index 98ba4643b71dd..b75129774322f 100644 --- a/tests/extension/extension.spec.ts +++ b/tests/extension/extension.spec.ts @@ -41,27 +41,7 @@ test(`navigate with extension`, async ({ startExtensionClient, server }) => { }); }); -test(`connect.html protocolVersion search param matches fixture option`, async ({ startExtensionClient, server, protocolVersion }) => { - const { browserContext, client } = await startExtensionClient(); - - const confirmationPagePromise = browserContext.waitForEvent('page', page => { - return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); - }); - - client.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - }).catch(() => {}); - - const selectorPage = await confirmationPagePromise; - const url = new URL(selectorPage.url()); - expect(url.searchParams.get('protocolVersion')).toBe(String(protocolVersion)); -}); - -test(`protocolVersion defaults to 2`, async ({ startExtensionClient, server, protocolVersion }) => { - const saved = process.env.PLAYWRIGHT_EXTENSION_PROTOCOL; - delete process.env.PLAYWRIGHT_EXTENSION_PROTOCOL; - +test(`connect.html requests protocol version 2`, async ({ startExtensionClient, server }) => { const { browserContext, client } = await startExtensionClient(); const confirmationPagePromise = browserContext.waitForEvent('page', page => { @@ -76,12 +56,9 @@ test(`protocolVersion defaults to 2`, async ({ startExtensionClient, server, pro const selectorPage = await confirmationPagePromise; const url = new URL(selectorPage.url()); expect(url.searchParams.get('protocolVersion')).toBe('2'); - - process.env.PLAYWRIGHT_EXTENSION_PROTOCOL = saved; }); -test(`browser_run_code_unsafe can evaluate in a web worker`, async ({ startExtensionClient, server, protocolVersion }) => { - test.skip(protocolVersion === 1, 'Multi-tab not supported in protocol v1'); +test(`browser_run_code_unsafe can evaluate in a web worker`, async ({ startExtensionClient, server }) => { server.setContent('/worker.js', ` self.onmessage = (e) => self.postMessage('echo:' + e.data); self.workerName = 'mcp-worker'; @@ -232,6 +209,23 @@ test(`extension needs update`, async ({ startExtensionClient, server }) => { await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright client trying to connect requires newer extension version`); }); +test(`extension rejects outdated client protocol version`, async ({ startExtensionClient, server }) => { + const { browserContext, client } = await startExtensionClient({ PLAYWRIGHT_EXTENSION_PROTOCOL: '1' }); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + + // The call hangs as the extension never connects to the relay. + client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }).catch(() => {}); + + const confirmationPage = await confirmationPagePromise; + await expect(confirmationPage.locator('.status-banner')).toContainText(`The client uses an unsupported protocol version. Update Playwright MCP or CLI to the latest version.`); +}); + test(`custom executablePath skips local extension check`, { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1590' }, }, async ({ startClient, server }) => { @@ -345,38 +339,3 @@ test(`bypass connection dialog with token`, async ({ browserWithExtension, start snapshot: expect.stringContaining(`- generic [active] [ref=f1e1]: Hello, world!`), }); }); - -test(`pending connection closed when client disconnects`, async ({ startExtensionClient, server, protocolVersion }) => { - // v2 does not open a WS to the relay before the user clicks Allow, so there - // is no pending connection to tear down when the client dies pre-Allow. - test.skip(protocolVersion === 2, 'v2 defers the relay connection until Allow'); - const { browserContext, client } = await startExtensionClient(); - - const confirmationPagePromise = browserContext.waitForEvent('page', page => { - return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); - }); - - client.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - }).catch(() => {}); - - const selectorPage = await confirmationPagePromise; - // Wait for the tab list to appear so we know the relay connection is established. - await selectorPage.locator('.tab-item').first().waitFor(); - - // Close the MCP client, which tears down the relay WebSocket. - await client.close(); - - await expect(selectorPage.locator('.status-banner')).toContainText('Pending client connection closed.'); - await expect(selectorPage).toHaveTitle('Playwright Extension'); - - // The connect tab should be removed from the Playwright group. - await expect.poll(async () => { - return selectorPage.evaluate(async () => { - const chrome = (window as any).chrome; - const tab = await chrome.tabs.getCurrent(); - return tab?.groupId ?? -1; - }); - }).toBe(-1); -}); diff --git a/tests/extension/playwright.config.ts b/tests/extension/playwright.config.ts index d0e5bc17a5372..ab62b39f8209b 100644 --- a/tests/extension/playwright.config.ts +++ b/tests/extension/playwright.config.ts @@ -17,9 +17,8 @@ import { defineConfig } from '@playwright/test'; import type { TestOptions } from '../mcp/fixtures'; -import type { ExtensionTestOptions } from './extension-fixtures'; -export default defineConfig({ +export default defineConfig({ testDir: './', fullyParallel: true, forbidOnly: !!process.env.CI, @@ -30,7 +29,6 @@ export default defineConfig({ ['../config/parquetReporter.ts'], ] : 'list', projects: [ - { name: 'chromium', use: { mcpBrowser: 'chromium', protocolVersion: 2 } }, - { name: 'chromium (legacy v1)', use: { mcpBrowser: 'chromium', protocolVersion: 1 } }, + { name: 'chromium', use: { mcpBrowser: 'chromium' } }, ], }); diff --git a/tests/extension/tab-grouping.spec.ts b/tests/extension/tab-grouping.spec.ts index 5db70496abeb1..7a80d52c0de34 100644 --- a/tests/extension/tab-grouping.spec.ts +++ b/tests/extension/tab-grouping.spec.ts @@ -77,9 +77,7 @@ test('connected tab is in green Playwright group, connect page is closed', async }).toEqual({ color: 'green', title: 'Playwright' }); }); -test('tab added to group gets auto-attached', async ({ browserWithExtension, startClient, server, protocolVersion }) => { - test.skip(protocolVersion === 1, 'Multi-tab not supported in protocol v1'); - +test('tab added to group gets auto-attached', async ({ browserWithExtension, startClient, server }) => { server.setContent('/extra', 'ExtraExtra content', 'text/html'); const browserContext = await browserWithExtension.launch(); @@ -127,9 +125,7 @@ test('tab added to group gets auto-attached', async ({ browserWithExtension, sta }).toContain('Extra'); }); -test('chrome:// tab dragged into group stays until it navigates to a debuggable URL', async ({ browserWithExtension, startClient, server, protocolVersion }) => { - test.skip(protocolVersion === 1, 'Multi-tab not supported in protocol v1'); - +test('chrome:// tab dragged into group stays until it navigates to a debuggable URL', async ({ browserWithExtension, startClient, server }) => { server.setContent('/second', 'SecondSecond', 'text/html'); const browserContext = await browserWithExtension.launch(); @@ -207,9 +203,7 @@ test('chrome:// tab dragged into group stays until it navigates to a debuggable }).toEqual({ groupId, badge: '✓' }); }); -test('tab removed from group gets auto-detached', async ({ browserWithExtension, startClient, server, protocolVersion }) => { - test.skip(protocolVersion === 1, 'Multi-tab not supported in protocol v1'); - +test('tab removed from group gets auto-detached', async ({ browserWithExtension, startClient, server }) => { server.setContent('/second', 'SecondSecond', 'text/html'); const browserContext = await browserWithExtension.launch(); diff --git a/tests/extension/tab-management.spec.ts b/tests/extension/tab-management.spec.ts index 3df8ba2267975..698baeb1a47c1 100644 --- a/tests/extension/tab-management.spec.ts +++ b/tests/extension/tab-management.spec.ts @@ -16,8 +16,6 @@ import { test, expect, connectAndNavigate } from './extension-fixtures'; -test.skip(({ protocolVersion }) => protocolVersion === 1, 'Multi-tab not supported in protocol v1'); - test(`browser_tabs new creates a new tab`, async ({ startExtensionClient, server }) => { server.setContent('/second.html', 'SecondSecond page', 'text/html'); const { browserContext, client } = await startExtensionClient();