chore: version extension-relay protocol (#939)

This commit is contained in:
Yury Semikhatsky
2025-08-25 11:48:23 -07:00
committed by GitHub
parent 22043cb3ef
commit 1a64a51812
4 changed files with 122 additions and 12 deletions

View File

@@ -22,7 +22,10 @@ import type { TabInfo } from './tabItem.js';
type Status = type Status =
| { type: 'connecting'; message: string } | { type: 'connecting'; message: string }
| { type: 'connected'; 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 ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]); const [tabs, setTabs] = useState<TabInfo[]>([]);
@@ -58,6 +61,21 @@ const ConnectApp: React.FC = () => {
return; 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); void connectToMCPRelay(relayUrl);
// If this is a browser_navigate command, hide the tab list and show simple allow/reject // 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 (
<div>
Playwright MCP version trying to connect requires newer extension version (current version: {extensionVersion}).{' '}
<a href={latestReleaseUrl}>Click here</a> to download latest version of the extension, then drag and drop it into the Chrome Extensions page.{' '}
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a> for more details.
</div>
);
};
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => { const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
return ( return (
<div className={`status-banner ${status.type}`}> <div className={`status-banner ${status.type}`}>
{status.message} {'versionMismatch' in status ? (
<VersionMismatchError
extensionVersion={status.versionMismatch.extensionVersion}
/>
) : (
status.message
)}
</div> </div>
); );
}; };

View File

@@ -33,6 +33,7 @@ type TestFixtures = {
browserWithExtension: BrowserWithExtension, browserWithExtension: BrowserWithExtension,
pathToExtension: string, pathToExtension: string,
useShortConnectionTimeout: (timeoutMs: number) => void useShortConnectionTimeout: (timeoutMs: number) => void
overrideProtocolVersion: (version: number) => void
}; };
const test = base.extend<TestFixtures>({ const test = base.extend<TestFixtures>({
@@ -80,6 +81,12 @@ const test = base.extend<TestFixtures>({
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined; 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<Client> { async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
@@ -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,
});
});
} }

View File

@@ -29,9 +29,11 @@ import { WebSocket, WebSocketServer } from 'ws';
import { httpAddressToString } from '../mcp/http.js'; import { httpAddressToString } from '../mcp/http.js';
import { logUnhandledError } from '../utils/log.js'; import { logUnhandledError } from '../utils/log.js';
import { ManualPromise } from '../mcp/manualPromise.js'; import { ManualPromise } from '../mcp/manualPromise.js';
import * as protocol from './protocol.js';
import type websocket from 'ws'; import type websocket from 'ws';
import type { ClientInfo } from '../browserContextFactory.js'; import type { ClientInfo } from '../browserContextFactory.js';
import type { ExtensionCommand, ExtensionEvents } from './protocol.js';
// @ts-ignore // @ts-ignore
const { registry } = await import('playwright-core/lib/server/registry/index'); const { registry } = await import('playwright-core/lib/server/registry/index');
@@ -119,6 +121,7 @@ export class CDPRelayServer {
version: clientInfo.version, version: clientInfo.version,
}; };
url.searchParams.set('client', JSON.stringify(client)); url.searchParams.set('client', JSON.stringify(client));
url.searchParams.set('protocolVersion', process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
if (toolName) if (toolName)
url.searchParams.set('newTab', String(toolName === 'browser_navigate')); url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
const href = url.toString(); const href = url.toString();
@@ -229,7 +232,7 @@ export class CDPRelayServer {
this._extensionConnectionPromise.resolve(); this._extensionConnectionPromise.resolve();
} }
private _handleExtensionMessage(method: string, params: any) { private _handleExtensionMessage<M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) {
switch (method) { switch (method) {
case 'forwardCDPEvent': case 'forwardCDPEvent':
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId; const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
@@ -239,10 +242,6 @@ export class CDPRelayServer {
params: params.params params: params.params
}); });
break; break;
case 'detachedFromTab':
debugLogger('← Debugger detached from tab:', params);
this._connectedTabInfo = undefined;
break;
} }
} }
@@ -279,7 +278,7 @@ export class CDPRelayServer {
if (sessionId) if (sessionId)
break; break;
// Simulate auto-attach behavior with real target info // Simulate auto-attach behavior with real target info
const { targetInfo } = await this._extensionConnection!.send('attachToTab'); const { targetInfo } = await this._extensionConnection!.send('attachToTab', { });
this._connectedTabInfo = { this._connectedTabInfo = {
targetInfo, targetInfo,
sessionId: `pw-tab-${this._nextSessionId++}`, sessionId: `pw-tab-${this._nextSessionId++}`,
@@ -333,7 +332,7 @@ class ExtensionConnection {
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>(); private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
private _lastId = 0; private _lastId = 0;
onmessage?: (method: string, params: any) => void; onmessage?: <M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) => void;
onclose?: (self: ExtensionConnection, reason: string) => void; onclose?: (self: ExtensionConnection, reason: string) => void;
constructor(ws: WebSocket) { constructor(ws: WebSocket) {
@@ -343,11 +342,11 @@ class ExtensionConnection {
this._ws.on('error', this._onError.bind(this)); this._ws.on('error', this._onError.bind(this));
} }
async send(method: string, params?: any, sessionId?: string): Promise<any> { async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): Promise<any> {
if (this._ws.readyState !== WebSocket.OPEN) if (this._ws.readyState !== WebSocket.OPEN)
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`); throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
const id = ++this._lastId; 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}`); const error = new Error(`Protocol error: ${method}`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject, error }); this._callbacks.set(id, { resolve, reject, error });
@@ -392,7 +391,7 @@ class ExtensionConnection {
} else if (object.id) { } else if (object.id) {
debugLogger('← Extension: unexpected response', object); debugLogger('← Extension: unexpected response', object);
} else { } else {
this.onmessage?.(object.method!, object.params); this.onmessage?.(object.method! as keyof ExtensionEvents, object.params);
} }
} }

42
src/extension/protocol.ts Normal file
View File

@@ -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,
};
};
};