chore: check extension version on connect (#907)
This commit is contained in:
@@ -19,6 +19,7 @@ import { RelayConnection, debugLog } from './relayConnection.js';
|
|||||||
type PageMessage = {
|
type PageMessage = {
|
||||||
type: 'connectToMCPRelay';
|
type: 'connectToMCPRelay';
|
||||||
mcpRelayUrl: string;
|
mcpRelayUrl: string;
|
||||||
|
pwMcpVersion: string | null;
|
||||||
} | {
|
} | {
|
||||||
type: 'getTabs';
|
type: 'getTabs';
|
||||||
} | {
|
} | {
|
||||||
@@ -49,7 +50,7 @@ class TabShareExtension {
|
|||||||
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'connectToMCPRelay':
|
case 'connectToMCPRelay':
|
||||||
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
|
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl, message.pwMcpVersion).then(
|
||||||
() => sendResponse({ success: true }),
|
() => sendResponse({ success: true }),
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
return true;
|
return true;
|
||||||
@@ -77,7 +78,11 @@ class TabShareExtension {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
|
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string, pwMcpVersion: string | null): Promise<void> {
|
||||||
|
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 {
|
try {
|
||||||
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
|
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
|
||||||
const socket = new WebSocket(mcpRelayUrl);
|
const socket = new WebSocket(mcpRelayUrl);
|
||||||
@@ -96,8 +101,9 @@ class TabShareExtension {
|
|||||||
this._pendingTabSelection.set(selectorTabId, { connection });
|
this._pendingTabSelection.set(selectorTabId, { connection });
|
||||||
debugLog(`Connected to MCP relay`);
|
debugLog(`Connected to MCP relay`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
debugLog(`Failed to connect to MCP relay:`, error.message);
|
const message = `Failed to connect to MCP relay: ${error.message}`;
|
||||||
throw error;
|
debugLog(message);
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,16 +54,22 @@ const ConnectApp: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void connectToMCPRelay(relayUrl);
|
void connectToMCPRelay(relayUrl, params.get('pwMcpVersion'));
|
||||||
void loadTabs();
|
void loadTabs();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
const handleReject = useCallback((message: string) => {
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
setShowButtons(false);
|
||||||
if (!response.success)
|
setShowTabList(false);
|
||||||
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
|
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 loadTabs = useCallback(async () => {
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
||||||
if (response.success)
|
if (response.success)
|
||||||
@@ -100,22 +106,16 @@ const ConnectApp: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [clientInfo, mcpRelayUrl]);
|
}, [clientInfo, mcpRelayUrl]);
|
||||||
|
|
||||||
const handleReject = useCallback(() => {
|
|
||||||
setShowButtons(false);
|
|
||||||
setShowTabList(false);
|
|
||||||
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (message: any) => {
|
const listener = (message: any) => {
|
||||||
if (message.type === 'connectionTimeout')
|
if (message.type === 'connectionTimeout')
|
||||||
handleReject();
|
handleReject('Connection timed out.');
|
||||||
};
|
};
|
||||||
chrome.runtime.onMessage.addListener(listener);
|
chrome.runtime.onMessage.addListener(listener);
|
||||||
return () => {
|
return () => {
|
||||||
chrome.runtime.onMessage.removeListener(listener);
|
chrome.runtime.onMessage.removeListener(listener);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [handleReject]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='app-container'>
|
<div className='app-container'>
|
||||||
@@ -124,7 +124,7 @@ const ConnectApp: React.FC = () => {
|
|||||||
<div className='status-container'>
|
<div className='status-container'>
|
||||||
<StatusBanner type={status.type} message={status.message} />
|
<StatusBanner type={status.type} message={status.message} />
|
||||||
{showButtons && (
|
{showButtons && (
|
||||||
<Button variant='reject' onClick={handleReject}>
|
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,12 +14,15 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
import packageJSON from '../../package.json' assert { type: 'json' };
|
||||||
import { test as base, expect } from '../../tests/fixtures.js';
|
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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
import type { StartClient } from '../../tests/fixtures.js';
|
import type { StartClient } from '../../tests/fixtures.js';
|
||||||
|
|
||||||
type BrowserWithExtension = {
|
type BrowserWithExtension = {
|
||||||
@@ -27,14 +30,22 @@ type BrowserWithExtension = {
|
|||||||
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
|
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
|
type TestFixtures = {
|
||||||
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => {
|
browserWithExtension: BrowserWithExtension,
|
||||||
|
pathToExtension: string,
|
||||||
|
useShortConnectionTimeout: (timeoutMs: number) => void
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = base.extend<TestFixtures>({
|
||||||
|
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
|
// 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#
|
// 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');
|
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;
|
let browserContext: BrowserContext | undefined;
|
||||||
const userDataDir = testInfo.outputPath('extension-user-data-dir');
|
const userDataDir = testInfo.outputPath('extension-user-data-dir');
|
||||||
await use({
|
await use({
|
||||||
@@ -60,9 +71,16 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
|
|||||||
return browserContext;
|
return browserContext;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await browserContext?.close();
|
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<Client> {
|
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||||
@@ -99,6 +117,21 @@ async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension
|
|||||||
return client;
|
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 [
|
for (const [mode, startClientMethod] of [
|
||||||
['connect-tool', startAndCallConnectTool],
|
['connect-tool', startAndCallConnectTool],
|
||||||
['extension-flag', startWithExtensionFlag],
|
['extension-flag', startWithExtensionFlag],
|
||||||
@@ -160,8 +193,8 @@ for (const [mode, startClientMethod] of [
|
|||||||
expect(browserContext.pages()).toHaveLength(4);
|
expect(browserContext.pages()).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||||
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100';
|
useShortConnectionTimeout(100);
|
||||||
|
|
||||||
const browserContext = await browserWithExtension.launch();
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
@@ -180,8 +213,32 @@ for (const [mode, startClientMethod] of [
|
|||||||
});
|
});
|
||||||
|
|
||||||
await confirmationPagePromise;
|
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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ 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 '../utils/manualPromise.js';
|
import { ManualPromise } from '../utils/manualPromise.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
import type websocket from 'ws';
|
import type websocket from 'ws';
|
||||||
import type { ClientInfo } from '../browserContextFactory.js';
|
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.
|
// 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');
|
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
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 href = url.toString();
|
||||||
const executableInfo = registry.findExecutable(this._browserChannel);
|
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||||
if (!executableInfo)
|
if (!executableInfo)
|
||||||
|
|||||||
Reference in New Issue
Block a user