Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions extension/src/ui/connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TabInfo[]>([]);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }) => {
return (
<div className={`status-banner ${status.type}`}>
{status.message}
{'versionMismatch' in status ? (
<VersionMismatchError
extensionVersion={status.versionMismatch.extensionVersion}
/>
) : (
status.message
)}
</div>
);
};
Expand Down
34 changes: 34 additions & 0 deletions extension/tests/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type TestFixtures = {
browserWithExtension: BrowserWithExtension,
pathToExtension: string,
useShortConnectionTimeout: (timeoutMs: number) => void
overrideProtocolVersion: (version: number) => void
};

const test = base.extend<TestFixtures>({
Expand Down Expand Up @@ -80,6 +81,12 @@ const test = base.extend<TestFixtures>({
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> {
Expand Down Expand Up @@ -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,
});
});

}
19 changes: 9 additions & 10 deletions src/extension/cdpRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -229,7 +232,7 @@ export class CDPRelayServer {
this._extensionConnectionPromise.resolve();
}

private _handleExtensionMessage(method: string, params: any) {
private _handleExtensionMessage<M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) {
switch (method) {
case 'forwardCDPEvent':
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
Expand All @@ -239,10 +242,6 @@ export class CDPRelayServer {
params: params.params
});
break;
case 'detachedFromTab':
debugLogger('← Debugger detached from tab:', params);
this._connectedTabInfo = undefined;
break;
}
}

Expand Down Expand Up @@ -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++}`,
Expand Down Expand Up @@ -333,7 +332,7 @@ class ExtensionConnection {
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
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;

constructor(ws: WebSocket) {
Expand All @@ -343,11 +342,11 @@ class ExtensionConnection {
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)
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 });
Expand Down Expand Up @@ -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);
}
}

Expand Down
42 changes: 42 additions & 0 deletions src/extension/protocol.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
};
Loading