diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c8ba2c..4aa7066 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file + 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 diff --git a/extension/package.json b/extension/package.json index 6f858cd..f2bc50b 100644 --- a/extension/package.json +++ b/extension/package.json @@ -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": { diff --git a/extension/playwright.config.ts b/extension/playwright.config.ts new file mode 100644 index 0000000..4af9827 --- /dev/null +++ b/extension/playwright.config.ts @@ -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({ + 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' } }, + ], +}); diff --git a/extension/tests/extension.spec.ts b/extension/tests/extension.spec.ts new file mode 100644 index 0000000..683d1d6 --- /dev/null +++ b/extension/tests/extension.spec.ts @@ -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; +}; + +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!`), + }); +}); diff --git a/src/extension/cdpRelay.ts b/src/extension/cdpRelay.ts index 39350a7..dc99fc0 100644 --- a/src/extension/cdpRelay.ts +++ b/src/extension/cdpRelay.ts @@ -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; - 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, diff --git a/src/extension/extensionContextFactory.ts b/src/extension/extensionContextFactory.ts index 1453a0c..95a4f72 100644 --- a/src/extension/extensionContextFactory.ts +++ b/src/extension/extensionContextFactory.ts @@ -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 }> { @@ -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; diff --git a/src/extension/main.ts b/src/extension/main.ts index bb3a197..4a209a6 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -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); } diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 38b78ca..3c91edd 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -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,