diff --git a/extension/src/ui/connect.tsx b/extension/src/ui/connect.tsx index 0a80a61..0277562 100644 --- a/extension/src/ui/connect.tsx +++ b/extension/src/ui/connect.tsx @@ -22,7 +22,10 @@ import type { TabInfo } from './tabItem.js'; type Status = | { type: 'connecting'; message: string } | { type: 'connected'; message: string } - | { type: 'error'; message: string }; + | { type: 'error'; message: string } + | { type: 'error'; versionMismatch: { extensionVersion: string; } }; + +const SUPPORTED_PROTOCOL_VERSION = 1; const ConnectApp: React.FC = () => { const [tabs, setTabs] = useState([]); @@ -58,6 +61,21 @@ const ConnectApp: React.FC = () => { return; } + const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10); + const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion; + if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) { + const extensionVersion = chrome.runtime.getManifest().version; + setShowButtons(false); + setShowTabList(false); + setStatus({ + type: 'error', + versionMismatch: { + extensionVersion, + } + }); + return; + } + void connectToMCPRelay(relayUrl); // If this is a browser_navigate command, hide the tab list and show simple allow/reject @@ -181,11 +199,28 @@ const ConnectApp: React.FC = () => { ); }; +const VersionMismatchError: React.FC<{ extensionVersion: string }> = ({ extensionVersion }) => { + const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md'; + const latestReleaseUrl = 'https://github.com/microsoft/playwright-mcp/releases/latest'; + return ( +
+ Playwright MCP version trying to connect requires newer extension version (current version: {extensionVersion}).{' '} + Click here to download latest version of the extension, then drag and drop it into the Chrome Extensions page.{' '} + See installation instructions for more details. +
+ ); +}; const StatusBanner: React.FC<{ status: Status }> = ({ status }) => { return (
- {status.message} + {'versionMismatch' in status ? ( + + ) : ( + status.message + )}
); }; diff --git a/extension/tests/extension.spec.ts b/extension/tests/extension.spec.ts index cff28d6..07ddf6d 100644 --- a/extension/tests/extension.spec.ts +++ b/extension/tests/extension.spec.ts @@ -33,6 +33,7 @@ type TestFixtures = { browserWithExtension: BrowserWithExtension, pathToExtension: string, useShortConnectionTimeout: (timeoutMs: number) => void + overrideProtocolVersion: (version: number) => void }; const test = base.extend({ @@ -80,6 +81,12 @@ const test = base.extend({ process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined; }, + overrideProtocolVersion: async ({}, use) => { + await use((version: number) => { + process.env.PWMCP_TEST_PROTOCOL_VERSION = version.toString(); + }); + process.env.PWMCP_TEST_PROTOCOL_VERSION = undefined; + } }); async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise { @@ -241,4 +248,31 @@ for (const [mode, startClientMethod] of [ }); }); + test(`extension needs update (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout, overrideProtocolVersion }) => { + useShortConnectionTimeout(500); + overrideProtocolVersion(1000); + + // Prelaunch the browser, so that it is properly closed after the test. + const browserContext = await browserWithExtension.launch(); + + const client = await startClientMethod(browserWithExtension, startClient); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + const navigateResponse = client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const confirmationPage = await confirmationPagePromise; + await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright MCP version trying to connect requires newer extension version`); + + expect(await navigateResponse).toHaveResponse({ + result: expect.stringContaining('Extension connection timeout.'), + isError: true, + }); + }); + } diff --git a/src/extension/cdpRelay.ts b/src/extension/cdpRelay.ts index c7791db..dcd1980 100644 --- a/src/extension/cdpRelay.ts +++ b/src/extension/cdpRelay.ts @@ -29,9 +29,11 @@ import { WebSocket, WebSocketServer } from 'ws'; import { httpAddressToString } from '../mcp/http.js'; import { logUnhandledError } from '../utils/log.js'; import { ManualPromise } from '../mcp/manualPromise.js'; +import * as protocol from './protocol.js'; import type websocket from 'ws'; import type { ClientInfo } from '../browserContextFactory.js'; +import type { ExtensionCommand, ExtensionEvents } from './protocol.js'; // @ts-ignore const { registry } = await import('playwright-core/lib/server/registry/index'); @@ -119,6 +121,7 @@ export class CDPRelayServer { 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 href = url.toString(); @@ -229,7 +232,7 @@ export class CDPRelayServer { this._extensionConnectionPromise.resolve(); } - private _handleExtensionMessage(method: string, params: any) { + private _handleExtensionMessage(method: M, params: ExtensionEvents[M]['params']) { switch (method) { case 'forwardCDPEvent': const sessionId = params.sessionId || this._connectedTabInfo?.sessionId; @@ -239,10 +242,6 @@ export class CDPRelayServer { params: params.params }); break; - case 'detachedFromTab': - debugLogger('← Debugger detached from tab:', params); - this._connectedTabInfo = undefined; - break; } } @@ -279,7 +278,7 @@ export class CDPRelayServer { if (sessionId) break; // Simulate auto-attach behavior with real target info - const { targetInfo } = await this._extensionConnection!.send('attachToTab'); + const { targetInfo } = await this._extensionConnection!.send('attachToTab', { }); this._connectedTabInfo = { targetInfo, sessionId: `pw-tab-${this._nextSessionId++}`, @@ -333,7 +332,7 @@ class ExtensionConnection { private readonly _callbacks = new Map void, reject: (e: Error) => void, error: Error }>(); private _lastId = 0; - onmessage?: (method: string, params: any) => void; + onmessage?: (method: M, params: ExtensionEvents[M]['params']) => void; onclose?: (self: ExtensionConnection, reason: string) => void; constructor(ws: WebSocket) { @@ -343,11 +342,11 @@ class ExtensionConnection { this._ws.on('error', this._onError.bind(this)); } - async send(method: string, params?: any, sessionId?: string): Promise { + async send(method: M, params: ExtensionCommand[M]['params']): Promise { if (this._ws.readyState !== WebSocket.OPEN) throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`); const id = ++this._lastId; - this._ws.send(JSON.stringify({ id, method, params, sessionId })); + this._ws.send(JSON.stringify({ id, method, params })); const error = new Error(`Protocol error: ${method}`); return new Promise((resolve, reject) => { this._callbacks.set(id, { resolve, reject, error }); @@ -392,7 +391,7 @@ class ExtensionConnection { } else if (object.id) { debugLogger('← Extension: unexpected response', object); } else { - this.onmessage?.(object.method!, object.params); + this.onmessage?.(object.method! as keyof ExtensionEvents, object.params); } } diff --git a/src/extension/protocol.ts b/src/extension/protocol.ts new file mode 100644 index 0000000..7b0cb26 --- /dev/null +++ b/src/extension/protocol.ts @@ -0,0 +1,42 @@ +/** + * 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. + */ + +// Whenever the commands/events change, the version must be updated. The latest +// extension version should be compatible with the old MCP clients. +export const VERSION = 1; + +export type ExtensionCommand = { + 'attachToTab': { + params: {}; + }; + 'forwardCDPCommand': { + params: { + method: string, + sessionId?: string + params?: any, + }; + }; +}; + +export type ExtensionEvents = { + 'forwardCDPEvent': { + params: { + method: string, + sessionId?: string + params?: any, + }; + }; +};