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:
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@@ -84,19 +84,19 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
MCP_IN_DOCKER: 1
|
MCP_IN_DOCKER: 1
|
||||||
|
|
||||||
build_extension:
|
test_extension:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
runs-on: ubuntu-latest
|
runs-on: macos-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./extension
|
working-directory: ./extension
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20' # crypto.randomUUID(); stalls in v18.20.8
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -108,3 +108,17 @@ jobs:
|
|||||||
name: extension
|
name: extension
|
||||||
path: ./extension/dist
|
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
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
||||||
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
||||||
|
"test": "playwright test",
|
||||||
"clean": "rm -rf dist"
|
"clean": "rm -rf dist"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
31
extension/playwright.config.ts
Normal file
31
extension/playwright.config.ts
Normal 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' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
102
extension/tests/extension.spec.ts
Normal file
102
extension/tests/extension.spec.ts
Normal 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!`),
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,7 @@ type CDPResponse = {
|
|||||||
export class CDPRelayServer {
|
export class CDPRelayServer {
|
||||||
private _wsHost: string;
|
private _wsHost: string;
|
||||||
private _browserChannel: string;
|
private _browserChannel: string;
|
||||||
|
private _userDataDir?: string;
|
||||||
private _cdpPath: string;
|
private _cdpPath: string;
|
||||||
private _extensionPath: string;
|
private _extensionPath: string;
|
||||||
private _wss: WebSocketServer;
|
private _wss: WebSocketServer;
|
||||||
@@ -69,9 +70,10 @@ export class CDPRelayServer {
|
|||||||
private _nextSessionId: number = 1;
|
private _nextSessionId: number = 1;
|
||||||
private _extensionConnectionPromise!: ManualPromise<void>;
|
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._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
||||||
this._browserChannel = browserChannel;
|
this._browserChannel = browserChannel;
|
||||||
|
this._userDataDir = userDataDir;
|
||||||
|
|
||||||
const uuid = crypto.randomUUID();
|
const uuid = crypto.randomUUID();
|
||||||
this._cdpPath = `/cdp/${uuid}`;
|
this._cdpPath = `/cdp/${uuid}`;
|
||||||
@@ -117,7 +119,12 @@ export class CDPRelayServer {
|
|||||||
if (!executablePath)
|
if (!executablePath)
|
||||||
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
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,
|
windowsHide: true,
|
||||||
detached: true,
|
detached: true,
|
||||||
shell: false,
|
shell: false,
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ export class ExtensionContextFactory implements BrowserContextFactory {
|
|||||||
description = 'Connect to a browser using the Playwright MCP extension';
|
description = 'Connect to a browser using the Playwright MCP extension';
|
||||||
|
|
||||||
private _browserChannel: string;
|
private _browserChannel: string;
|
||||||
|
private _userDataDir?: string;
|
||||||
|
|
||||||
constructor(browserChannel: string) {
|
constructor(browserChannel: string, userDataDir: string | undefined) {
|
||||||
this._browserChannel = browserChannel;
|
this._browserChannel = browserChannel;
|
||||||
|
this._userDataDir = userDataDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
@@ -56,7 +58,7 @@ export class ExtensionContextFactory implements BrowserContextFactory {
|
|||||||
httpServer.close();
|
httpServer.close();
|
||||||
throw new Error(abortSignal.reason);
|
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());
|
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||||
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||||
return cdpRelayServer;
|
return cdpRelayServer;
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import * as mcpTransport from '../mcp/transport.js';
|
|||||||
import type { FullConfig } from '../config.js';
|
import type { FullConfig } from '../config.js';
|
||||||
|
|
||||||
export async function runWithExtension(config: FullConfig) {
|
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]);
|
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
await mcpTransport.start(serverBackendFactory, config.server);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createExtensionContextFactory(config: FullConfig) {
|
export function createExtensionContextFactory(config: FullConfig) {
|
||||||
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
|
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'],
|
|||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||||
cwd: path.join(path.dirname(__filename), '..'),
|
cwd: path.dirname(test.info().config.configFile!),
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|||||||
Reference in New Issue
Block a user