chore: version extension-relay protocol (#939)
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
42
src/extension/protocol.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user