chore(extension): add test (#842)

* On Linux headed mode under xvfb-run fails to properly launch the
process. It works fine without xvfb-run, we don't have environment for
that on CI, so run on macOS instead.
* Node v18.20.8 stalls on `const uuid = crypto.randomUUID();`, so use
v20 for the extension tests.
This commit is contained in:
Yury Semikhatsky
2025-08-06 16:27:39 -07:00
committed by GitHub
parent 5dbb1504ba
commit 8ecc46c905
8 changed files with 169 additions and 12 deletions

View File

@@ -84,19 +84,19 @@ jobs:
env:
MCP_IN_DOCKER: 1
build_extension:
test_extension:
strategy:
fail-fast: false
runs-on: ubuntu-latest
runs-on: macos-latest
defaults:
run:
working-directory: ./extension
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20' # crypto.randomUUID(); stalls in v18.20.8
cache: 'npm'
- name: Install dependencies
run: npm ci
@@ -107,4 +107,18 @@ jobs:
with:
name: extension
path: ./extension/dist
retention-days: 7
retention-days: 7
- name: Install and build MCP server
run: |
cd ..
npm ci
npm run build
npx playwright install chromium
- name: Run tests
run: |
if [[ "$(uname)" == "Linux" ]]; then
xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test
else
npm run test
fi
shell: bash

View File

@@ -19,6 +19,7 @@
"scripts": {
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
"test": "playwright test",
"clean": "rm -rf dist"
},
"devDependencies": {

View File

@@ -0,0 +1,31 @@
/**
* 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.
*/
import { defineConfig } from '@playwright/test';
import type { TestOptions } from '../tests/fixtures.js';
export default defineConfig<TestOptions>({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
projects: [
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
],
});

View File

@@ -0,0 +1,102 @@
/**
* 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.
*/
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import { test as base, expect } from '../../tests/fixtures.js';
import type { BrowserContext } from 'playwright';
type BrowserWithExtension = {
userDataDir: string;
launch: () => Promise<BrowserContext>;
};
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
browserWithExtension: async ({ mcpBrowser }, 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({
userDataDir,
launch: async () => {
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: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
// for manifest v3:
let [serviceWorker] = browserContext.serviceWorkers();
if (!serviceWorker)
serviceWorker = await browserContext.waitForEvent('serviceworker');
return browserContext;
}
});
await browserContext?.close();
},
});
test('navigate with extension', async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const { client } = await startClient({
args: [`--connect-tool`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
expect(await client.callTool({
name: 'browser_connect',
arguments: {
method: 'extension'
}
})).toHaveResponse({
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.getByRole('button', { name: 'Continue' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});

View File

@@ -56,6 +56,7 @@ type CDPResponse = {
export class CDPRelayServer {
private _wsHost: string;
private _browserChannel: string;
private _userDataDir?: string;
private _cdpPath: string;
private _extensionPath: string;
private _wss: WebSocketServer;
@@ -69,9 +70,10 @@ export class CDPRelayServer {
private _nextSessionId: number = 1;
private _extensionConnectionPromise!: ManualPromise<void>;
constructor(server: http.Server, browserChannel: string) {
constructor(server: http.Server, browserChannel: string, userDataDir?: string) {
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`;
@@ -117,7 +119,12 @@ export class CDPRelayServer {
if (!executablePath)
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
spawn(executablePath, [href], {
const args: string[] = [];
if (this._userDataDir)
args.push(`--user-data-dir=${this._userDataDir}`);
args.push(href);
spawn(executablePath, args, {
windowsHide: true,
detached: true,
shell: false,

View File

@@ -28,9 +28,11 @@ export class ExtensionContextFactory implements BrowserContextFactory {
description = 'Connect to a browser using the Playwright MCP extension';
private _browserChannel: string;
private _userDataDir?: string;
constructor(browserChannel: string) {
constructor(browserChannel: string, userDataDir: string | undefined) {
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
}
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
@@ -56,7 +58,7 @@ export class ExtensionContextFactory implements BrowserContextFactory {
httpServer.close();
throw new Error(abortSignal.reason);
}
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel);
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
return cdpRelayServer;

View File

@@ -21,11 +21,11 @@ import * as mcpTransport from '../mcp/transport.js';
import type { FullConfig } from '../config.js';
export async function runWithExtension(config: FullConfig) {
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
await mcpTransport.start(serverBackendFactory, config.server);
}
export function createExtensionContextFactory(config: FullConfig) {
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
}

View File

@@ -191,7 +191,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'],
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'),
cwd: path.dirname(test.info().config.configFile!),
stderr: 'pipe',
env: {
...process.env,