/** * 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 fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { chromium } from 'playwright'; import { spawn } from 'child_process'; import { test as base, expect } from '../mcp/fixtures'; import { kTargetClosedErrorMessage } from '../config/errors'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { BrowserContext, Page } from 'playwright'; import type { StartClient } from '../mcp/fixtures'; export type BrowserWithExtension = { userDataDir: string; launch: (mode?: 'disable-extension') => Promise; }; export type CliResult = { output: string; error: string; }; export type ExtensionTestOptions = { protocolVersion: 1 | 2; }; export type TestFixtures = { browserWithExtension: BrowserWithExtension, pathToExtension: string, startExtensionClient: (env?: Record) => Promise<{ browserContext: BrowserContext, client: Client }>, cli: (args: string[], options?: { env?: Record }) => Promise; }; type WorkerFixtures = { _protocolEnv: void; }; export const extensionId = 'mmlmfjhmonkocbjadbfplnigmagldckm'; export const test = base.extend({ protocolVersion: [2, { option: true, scope: 'worker' }], _protocolEnv: [async ({ protocolVersion }, use) => { // Default is 2. if (protocolVersion === 1) process.env.PLAYWRIGHT_EXTENSION_PROTOCOL = '1'; else delete process.env.PLAYWRIGHT_EXTENSION_PROTOCOL; await use(); }, { auto: true, scope: 'worker' }], pathToExtension: async ({}, use, testInfo) => { const extensionDir = testInfo.outputPath('extension'); const srcDir = path.resolve(__dirname, '../../packages/extension/dist'); await fs.cp(srcDir, extensionDir, { recursive: true }); await use(extensionDir); }, browserWithExtension: async ({ mcpBrowser, pathToExtension }, 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'); let browserContext: BrowserContext | undefined; const userDataDir = testInfo.outputPath('extension-user-data-dir'); // --load-extension does not populate Default/Extensions/; create the folder // so the relay's extension-installed check recognizes the test profile. await fs.mkdir(path.join(userDataDir, 'Default', 'Extensions', extensionId), { recursive: true }); await use({ userDataDir, 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: mode === 'disable-extension' ? [] : [ `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`, ], }); // MV3 service workers start lazily; wait for the extension's // background to be ready so tests can reach `chrome.*` via it. if (!browserContext.serviceWorkers().length) await browserContext.waitForEvent('serviceworker'); return browserContext; } }); await browserContext?.close(); // Free up disk space. await fs.rm(userDataDir, { recursive: true, force: true }).catch(() => {}); }, startExtensionClient: async ({ browserWithExtension, startClient }, use) => { await use(async (env?: Record) => { const browserContext = await browserWithExtension.launch(); const client = await startWithExtensionFlag(browserWithExtension, startClient, env); return { browserContext, client }; }); }, cli: async ({ mcpBrowser }, use, testInfo) => { await use(async (args, options) => { return await runCli(args, { mcpBrowser, testInfo, env: options?.env }); }); // Cleanup sessions await runCli(['close-all'], { mcpBrowser, testInfo }).catch(() => {}); const daemonDir = path.join(testInfo.outputDir, 'daemon'); await fs.rm(daemonDir, { recursive: true, force: true }).catch(() => {}); }, }); export { expect }; export const testWithOldExtensionVersion = test.extend({ pathToExtension: async ({ pathToExtension }, use) => { const manifestPath = path.join(pathToExtension, 'manifest.json'); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); manifest.version = '0.0.1'; await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); await use(pathToExtension); }, }); function cliEnv() { return { PLAYWRIGHT_SERVER_REGISTRY: test.info().outputPath('registry'), PLAYWRIGHT_DAEMON_SESSION_DIR: test.info().outputPath('daemon'), // Short path because macOS caps unix socket paths at 104 chars; the // long `project.outputDir` path overflows and causes EADDRINUSE. PLAYWRIGHT_SOCKETS_DIR: path.join(os.tmpdir(), 'pwmcp-sock', String(test.info().parallelIndex)), }; } async function runCli( args: string[], options: { mcpBrowser?: string, testInfo: any, env?: Record }, ): Promise { const stepTitle = `cli ${args.join(' ')}`; return await test.step(stepTitle, async () => { const testInfo = options.testInfo; // Path to the terminal CLI const cliPath = require.resolve('../../packages/playwright-core/lib/tools/cli-client/cli.js'); return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; const childProcess = spawn(process.execPath, [cliPath, ...args], { cwd: testInfo.outputPath(), env: { ...process.env, ...cliEnv(), PLAYWRIGHT_MCP_BROWSER: options.mcpBrowser, PLAYWRIGHT_MCP_HEADLESS: 'false', ...options.env, }, detached: true, }); childProcess.stdout?.on('data', data => { stdout += data.toString(); }); childProcess.stderr?.on('data', data => { if (process.env.PWMCP_DEBUG) process.stderr.write(data); stderr += data.toString(); }); childProcess.on('close', async code => { await testInfo.attach(stepTitle, { body: stdout, contentType: 'text/plain' }); resolve({ output: stdout.trim(), error: stderr.trim(), }); }); childProcess.on('error', reject); }); }); } export async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient, env?: Record): Promise { const { client } = await startClient({ args: [`--extension`], env: { ...env, PWTEST_EXTENSION_USER_DATA_DIR: browserWithExtension.userDataDir, }, }); return client; } // The connect page closes itself once a different tab is selected, which races // with the click — the request reaches the background while the page is being // torn down. Swallow the resulting "Target closed" error. export async function clickAllowAndSelect(connectPage: Page, tabTitle: RegExp | string): Promise { await connectPage.locator('.tab-item', { hasText: tabTitle }).getByRole('button', { name: 'Allow & select' }).click().catch(e => { if (!e?.message?.includes(kTargetClosedErrorMessage)) throw e; }); } export async function connectAndNavigate( browserContext: BrowserContext, client: Client, url: string, ): Promise>> { const confirmationPagePromise = browserContext.waitForEvent('page', page => page.url().startsWith(`chrome-extension://${extensionId}/connect.html`) ); const navigatePromise = client.callTool({ name: 'browser_navigate', arguments: { url } }); const selectorPage = await confirmationPagePromise; await clickAllowAndSelect(selectorPage, 'Welcome'); return await navigatePromise; }