chore(extension): connection timeout when extension not installed (#896)

This commit is contained in:
Yury Semikhatsky
2025-08-15 09:09:35 -07:00
committed by GitHub
parent 2fc4e88048
commit ba726fb44a
3 changed files with 102 additions and 61 deletions

View File

@@ -19,10 +19,12 @@ import { chromium } from 'playwright';
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 { BrowserContext } from 'playwright';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { StartClient } from '../../tests/fixtures.js';
type BrowserWithExtension = { type BrowserWithExtension = {
userDataDir: string; userDataDir: string;
launch: () => Promise<BrowserContext>; launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
}; };
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
@@ -37,14 +39,14 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
const userDataDir = testInfo.outputPath('extension-user-data-dir'); const userDataDir = testInfo.outputPath('extension-user-data-dir');
await use({ await use({
userDataDir, userDataDir,
launch: async () => { launch: async (mode?: 'disable-extension') => {
browserContext = await chromium.launchPersistentContext(userDataDir, { browserContext = await chromium.launchPersistentContext(userDataDir, {
channel: mcpBrowser, channel: mcpBrowser,
// Opening the browser singleton only works in headed. // Opening the browser singleton only works in headed.
headless: false, headless: false,
// Automation disables singleton browser process behavior, which is necessary for the extension. // Automation disables singleton browser process behavior, which is necessary for the extension.
ignoreDefaultArgs: ['--enable-automation'], ignoreDefaultArgs: ['--enable-automation'],
args: [ args: mode === 'disable-extension' ? [] : [
`--disable-extensions-except=${pathToExtension}`, `--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`, `--load-extension=${pathToExtension}`,
], ],
@@ -63,9 +65,7 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
}, },
}); });
test('navigate with extension', async ({ browserWithExtension, startClient, server }) => { async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
const browserContext = await browserWithExtension.launch();
const { client } = await startClient({ const { client } = await startClient({
args: [`--connect-tool`], args: [`--connect-tool`],
config: { config: {
@@ -84,69 +84,104 @@ test('navigate with extension', async ({ browserWithExtension, startClient, serv
result: 'Successfully changed connection method.', result: 'Successfully changed connection method.',
}); });
const confirmationPagePromise = browserContext.waitForEvent('page', page => { return client;
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);
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
const { client } = await startClient({ const { client } = await startClient({
args: [`--connect-tool`], args: [`--extension`],
config: { config: {
browser: { browser: {
userDataDir: browserWithExtension.userDataDir, userDataDir: browserWithExtension.userDataDir,
} }
}, },
}); });
return client;
}
expect(await client.callTool({ for (const [mode, startClientMethod] of [
name: 'browser_connect', ['connect-tool', startAndCallConnectTool],
arguments: { ['extension-flag', startWithExtensionFlag],
name: 'extension' ] as const) {
}
})).toHaveResponse({
result: 'Successfully changed connection method.',
});
expect(browserContext.pages()).toHaveLength(3);
const confirmationPagePromise = browserContext.waitForEvent('page', page => { test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); 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({ test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => {
name: 'browser_snapshot', const browserContext = await browserWithExtension.launch();
arguments: { },
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; test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => {
expect(browserContext.pages()).toHaveLength(4); 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({ const client = await startClientMethod(browserWithExtension, startClient);
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
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); }
});

View File

@@ -100,6 +100,9 @@ export class CDPRelayServer {
debugLogger('Waiting for incoming extension connection'); debugLogger('Waiting for incoming extension connection');
await Promise.race([ await Promise.race([
this._extensionConnectionPromise, 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)) new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
]); ]);
debugLogger('Extension connection established'); debugLogger('Extension connection established');

View File

@@ -40,15 +40,18 @@ type CDPServer = {
start: () => Promise<BrowserContext>; start: () => Promise<BrowserContext>;
}; };
export type StartClient = (options?: {
clientName?: string,
args?: string[],
config?: Config,
roots?: { name: string, uri: string }[],
rootsResponseDelay?: number,
}) => Promise<{ client: Client, stderr: () => string }>;
type TestFixtures = { type TestFixtures = {
client: Client; client: Client;
startClient: (options?: { startClient: StartClient;
clientName?: string,
args?: string[],
config?: Config,
roots?: { name: string, uri: string }[],
rootsResponseDelay?: number,
}) => Promise<{ client: Client, stderr: () => string }>;
wsEndpoint: string; wsEndpoint: string;
cdpServer: CDPServer; cdpServer: CDPServer;
server: TestServer; server: TestServer;