diff --git a/extension/src/background.ts b/extension/src/background.ts index 8c6b49ec4..67b81b8ad 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -19,6 +19,7 @@ import { RelayConnection, debugLog } from './relayConnection.js'; type PageMessage = { type: 'connectToMCPRelay'; mcpRelayUrl: string; + pwMcpVersion: string | null; } | { type: 'getTabs'; } | { @@ -49,7 +50,7 @@ class TabShareExtension { private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) { switch (message.type) { case 'connectToMCPRelay': - this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then( + this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl, message.pwMcpVersion).then( () => sendResponse({ success: true }), (error: any) => sendResponse({ success: false, error: error.message })); return true; @@ -77,7 +78,11 @@ class TabShareExtension { return false; } - private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise { + private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string, pwMcpVersion: string | null): Promise { + const version = chrome.runtime.getManifest().version; + if (pwMcpVersion !== version) + throw new Error(`Incompatible Playwright MCP version: ${pwMcpVersion} (extension version: ${version}). Please install the latest version of the extension.`); + try { debugLog(`Connecting to relay at ${mcpRelayUrl}`); const socket = new WebSocket(mcpRelayUrl); @@ -96,8 +101,9 @@ class TabShareExtension { this._pendingTabSelection.set(selectorTabId, { connection }); debugLog(`Connected to MCP relay`); } catch (error: any) { - debugLog(`Failed to connect to MCP relay:`, error.message); - throw error; + const message = `Failed to connect to MCP relay: ${error.message}`; + debugLog(message); + throw new Error(message); } } diff --git a/extension/src/ui/connect.tsx b/extension/src/ui/connect.tsx index c9a818096..956a4beec 100644 --- a/extension/src/ui/connect.tsx +++ b/extension/src/ui/connect.tsx @@ -54,16 +54,22 @@ const ConnectApp: React.FC = () => { return; } - void connectToMCPRelay(relayUrl); + void connectToMCPRelay(relayUrl, params.get('pwMcpVersion')); void loadTabs(); }, []); - const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => { - const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl }); - if (!response.success) - setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error }); + const handleReject = useCallback((message: string) => { + setShowButtons(false); + setShowTabList(false); + setStatus({ type: 'error', message }); }, []); + const connectToMCPRelay = useCallback(async (mcpRelayUrl: string, pwMcpVersion: string | null) => { + const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl, pwMcpVersion }); + if (!response.success) + handleReject(response.error); + }, [handleReject]); + const loadTabs = useCallback(async () => { const response = await chrome.runtime.sendMessage({ type: 'getTabs' }); if (response.success) @@ -100,22 +106,16 @@ const ConnectApp: React.FC = () => { } }, [clientInfo, mcpRelayUrl]); - const handleReject = useCallback(() => { - setShowButtons(false); - setShowTabList(false); - setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' }); - }, []); - useEffect(() => { const listener = (message: any) => { if (message.type === 'connectionTimeout') - handleReject(); + handleReject('Connection timed out.'); }; chrome.runtime.onMessage.addListener(listener); return () => { chrome.runtime.onMessage.removeListener(listener); }; - }, []); + }, [handleReject]); return (
@@ -124,7 +124,7 @@ const ConnectApp: React.FC = () => {
{showButtons && ( - )} diff --git a/extension/tests/extension.spec.ts b/extension/tests/extension.spec.ts index 6ec47bfb5..a79ce731a 100644 --- a/extension/tests/extension.spec.ts +++ b/extension/tests/extension.spec.ts @@ -14,12 +14,15 @@ * limitations under the License. */ +import fs from 'fs'; +import path from 'path'; import { fileURLToPath } from 'url'; import { chromium } from 'playwright'; +import packageJSON from '../../package.json' assert { type: 'json' }; import { test as base, expect } from '../../tests/fixtures.js'; -import type { BrowserContext } from 'playwright'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { BrowserContext } from 'playwright'; import type { StartClient } from '../../tests/fixtures.js'; type BrowserWithExtension = { @@ -27,14 +30,22 @@ type BrowserWithExtension = { launch: (mode?: 'disable-extension') => Promise; }; -const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({ - browserWithExtension: async ({ mcpBrowser }, use, testInfo) => { +type TestFixtures = { + browserWithExtension: BrowserWithExtension, + pathToExtension: string, + useShortConnectionTimeout: (timeoutMs: number) => void +}; + +const test = base.extend({ + pathToExtension: async ({}, use) => { + await use(fileURLToPath(new URL('../dist', import.meta.url))); + }, + + browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => { // The flags no longer work in Chrome since // https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1# test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium'); - const pathToExtension = fileURLToPath(new URL('../dist', import.meta.url)); - let browserContext: BrowserContext | undefined; const userDataDir = testInfo.outputPath('extension-user-data-dir'); await use({ @@ -60,9 +71,16 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({ return browserContext; } }); - await browserContext?.close(); }, + + useShortConnectionTimeout: async ({}, use) => { + await use((timeoutMs: number) => { + process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString(); + }); + process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined; + }, + }); async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise { @@ -99,6 +117,21 @@ async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension return client; } +const testWithOldVersion = test.extend({ + pathToExtension: async ({}, use, testInfo) => { + const extensionDir = testInfo.outputPath('extension'); + const oldPath = fileURLToPath(new URL('../dist', import.meta.url)); + + await fs.promises.cp(oldPath, extensionDir, { recursive: true }); + const manifestPath = path.join(extensionDir, 'manifest.json'); + const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8')); + manifest.version = '0.0.1'; + await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + + await use(extensionDir); + }, +}); + for (const [mode, startClientMethod] of [ ['connect-tool', startAndCallConnectTool], ['extension-flag', startWithExtensionFlag], @@ -160,8 +193,8 @@ for (const [mode, startClientMethod] of [ expect(browserContext.pages()).toHaveLength(4); }); - test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => { - process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100'; + test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => { + useShortConnectionTimeout(100); const browserContext = await browserWithExtension.launch(); @@ -180,8 +213,32 @@ for (const [mode, startClientMethod] of [ }); await confirmationPagePromise; + }); - process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined; + testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => { + useShortConnectionTimeout(500); + + // 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')).toHaveText(`Incompatible Playwright MCP version: ${packageJSON.version} (extension version: 0.0.1). Please install the latest version of the extension.`); + + 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 c042b0f20..1aa7119a9 100644 --- a/src/extension/cdpRelay.ts +++ b/src/extension/cdpRelay.ts @@ -29,6 +29,8 @@ import { WebSocket, WebSocketServer } from 'ws'; import { httpAddressToString } from '../utils/httpServer.js'; import { logUnhandledError } from '../utils/log.js'; import { ManualPromise } from '../utils/manualPromise.js'; +import { packageJSON } from '../utils/package.js'; + import type websocket from 'ws'; import type { ClientInfo } from '../browserContextFactory.js'; @@ -113,7 +115,12 @@ export class CDPRelayServer { // Need to specify "key" in the manifest.json to make the id stable when loading from file. const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint); - url.searchParams.set('client', JSON.stringify(clientInfo)); + const client = { + name: clientInfo.name, + version: clientInfo.version, + }; + url.searchParams.set('client', JSON.stringify(client)); + url.searchParams.set('pwMcpVersion', packageJSON.version); const href = url.toString(); const executableInfo = registry.findExecutable(this._browserChannel); if (!executableInfo)