From ba726fb44a3bd5c39cf2d59d60b6f05c867c5495 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 Aug 2025 09:09:35 -0700 Subject: [PATCH] chore(extension): connection timeout when extension not installed (#896) --- extension/tests/extension.spec.ts | 143 +++++++++++++++++++----------- src/extension/cdpRelay.ts | 3 + tests/fixtures.ts | 17 ++-- 3 files changed, 102 insertions(+), 61 deletions(-) diff --git a/extension/tests/extension.spec.ts b/extension/tests/extension.spec.ts index 5de2c19..6ec47bf 100644 --- a/extension/tests/extension.spec.ts +++ b/extension/tests/extension.spec.ts @@ -19,10 +19,12 @@ import { chromium } from 'playwright'; 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 { StartClient } from '../../tests/fixtures.js'; type BrowserWithExtension = { userDataDir: string; - launch: () => Promise; + launch: (mode?: 'disable-extension') => Promise; }; const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({ @@ -37,14 +39,14 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({ const userDataDir = testInfo.outputPath('extension-user-data-dir'); await use({ userDataDir, - launch: async () => { + launch: async (mode?: 'disable-extension') => { browserContext = await chromium.launchPersistentContext(userDataDir, { channel: mcpBrowser, // Opening the browser singleton only works in headed. headless: false, // Automation disables singleton browser process behavior, which is necessary for the extension. ignoreDefaultArgs: ['--enable-automation'], - args: [ + args: mode === 'disable-extension' ? [] : [ `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`, ], @@ -63,9 +65,7 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({ }, }); -test('navigate with extension', async ({ browserWithExtension, startClient, server }) => { - const browserContext = await browserWithExtension.launch(); - +async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise { const { client } = await startClient({ args: [`--connect-tool`], config: { @@ -84,69 +84,104 @@ test('navigate with extension', async ({ browserWithExtension, startClient, serv result: 'Successfully changed connection method.', }); - 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 selectorPage = await confirmationPagePromise; - await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click(); - - expect(await navigateResponse).toHaveResponse({ - pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), - }); -}); - -test('snapshot of an existing page', async ({ browserWithExtension, startClient, server }) => { - const browserContext = await browserWithExtension.launch(); - - const page = await browserContext.newPage(); - await page.goto(server.HELLO_WORLD); - - // Another empty page. - await browserContext.newPage(); - expect(browserContext.pages()).toHaveLength(3); + return client; +} +async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise { const { client } = await startClient({ - args: [`--connect-tool`], + args: [`--extension`], config: { browser: { userDataDir: browserWithExtension.userDataDir, } }, }); + return client; +} - expect(await client.callTool({ - name: 'browser_connect', - arguments: { - name: 'extension' - } - })).toHaveResponse({ - result: 'Successfully changed connection method.', - }); - expect(browserContext.pages()).toHaveLength(3); +for (const [mode, startClientMethod] of [ + ['connect-tool', startAndCallConnectTool], + ['extension-flag', startWithExtensionFlag], +] as const) { - const confirmationPagePromise = browserContext.waitForEvent('page', page => { - return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => { + 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 selectorPage = await confirmationPagePromise; + await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click(); + + expect(await navigateResponse).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); }); - const navigateResponse = client.callTool({ - name: 'browser_snapshot', - arguments: { }, + test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + // Another empty page. + await browserContext.newPage(); + expect(browserContext.pages()).toHaveLength(3); + + const client = await startClientMethod(browserWithExtension, startClient); + expect(browserContext.pages()).toHaveLength(3); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + const navigateResponse = client.callTool({ + name: 'browser_snapshot', + arguments: { }, + }); + + const selectorPage = await confirmationPagePromise; + expect(browserContext.pages()).toHaveLength(4); + + await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + + expect(await navigateResponse).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); + + expect(browserContext.pages()).toHaveLength(4); }); - const selectorPage = await confirmationPagePromise; - expect(browserContext.pages()).toHaveLength(4); + test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => { + process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100'; - await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + const browserContext = await browserWithExtension.launch(); - expect(await navigateResponse).toHaveResponse({ - pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + const client = await startClientMethod(browserWithExtension, startClient); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'), + isError: true, + }); + + await confirmationPagePromise; + + process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined; }); - expect(browserContext.pages()).toHaveLength(4); -}); +} diff --git a/src/extension/cdpRelay.ts b/src/extension/cdpRelay.ts index 53e639e..c042b0f 100644 --- a/src/extension/cdpRelay.ts +++ b/src/extension/cdpRelay.ts @@ -100,6 +100,9 @@ export class CDPRelayServer { debugLogger('Waiting for incoming extension connection'); await Promise.race([ this._extensionConnectionPromise, + new Promise((_, reject) => setTimeout(() => { + reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`)); + }, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)), new Promise((_, reject) => abortSignal.addEventListener('abort', reject)) ]); debugLogger('Extension connection established'); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index ef686b8..966dc9d 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -40,15 +40,18 @@ type CDPServer = { start: () => Promise; }; +export type StartClient = (options?: { + clientName?: string, + args?: string[], + config?: Config, + roots?: { name: string, uri: string }[], + rootsResponseDelay?: number, +}) => Promise<{ client: Client, stderr: () => string }>; + + type TestFixtures = { client: Client; - startClient: (options?: { - clientName?: string, - args?: string[], - config?: Config, - roots?: { name: string, uri: string }[], - rootsResponseDelay?: number, - }) => Promise<{ client: Client, stderr: () => string }>; + startClient: StartClient; wsEndpoint: string; cdpServer: CDPServer; server: TestServer;