/** * 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 path from 'path'; import { chromium } from 'playwright'; import { spawn } from 'child_process'; import { test as base, expect } from '../../playwright-mcp/tests/fixtures'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { BrowserContext } from 'playwright'; import type { StartClient } from '../../playwright-mcp/tests/fixtures'; type BrowserWithExtension = { userDataDir: string; launch: (mode?: 'disable-extension') => Promise; }; type CliResult = { output: string; error: string; }; type TestFixtures = { browserWithExtension: BrowserWithExtension, pathToExtension: string, useShortConnectionTimeout: (timeoutMs: number) => void overrideProtocolVersion: (version: number) => void cli: (...args: string[]) => Promise; }; const test = base.extend({ pathToExtension: async ({}, use) => { await use(path.resolve(__dirname, '../dist')); }, 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'); 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}`, ], }); // for manifest v3: let [serviceWorker] = browserContext.serviceWorkers(); if (!serviceWorker) serviceWorker = await browserContext.waitForEvent('serviceworker'); return browserContext; } }); await browserContext?.close(); // Free up disk space. await fs.rm(userDataDir, { recursive: true, force: true }).catch(() => {}); }, useShortConnectionTimeout: async ({}, use) => { await use((timeoutMs: number) => { process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString(); }); process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined; }, overrideProtocolVersion: async ({}, use) => { await use((version: number) => { process.env.PWMCP_TEST_PROTOCOL_VERSION = version.toString(); }); process.env.PWMCP_TEST_PROTOCOL_VERSION = undefined; }, cli: async ({ mcpBrowser }, use, testInfo) => { await use(async (...args: string[]) => { return await runCli(args, { mcpBrowser, testInfo }); }); // Cleanup sessions await runCli(['session-stop-all'], { mcpBrowser, testInfo }).catch(() => {}); const daemonDir = path.join(testInfo.outputDir, 'daemon'); await fs.rm(daemonDir, { recursive: true, force: true }).catch(() => {}); }, }); async function runCli( args: string[], options: { mcpBrowser?: string, testInfo: any }, ): Promise { const stepTitle = `cli ${args.join(' ')}`; return await test.step(stepTitle, async () => { const testInfo = options.testInfo; // Path to the terminal CLI const cliPath = path.join(__dirname, '../../../node_modules/playwright/lib/mcp/terminal/cli.js'); return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; const childProcess = spawn(process.execPath, [cliPath, ...args], { cwd: testInfo.outputPath(), env: { ...process.env, PLAYWRIGHT_DAEMON_INSTALL_DIR: testInfo.outputPath(), PLAYWRIGHT_DAEMON_SESSION_DIR: testInfo.outputPath('daemon'), PLAYWRIGHT_DAEMON_SOCKETS_DIR: path.join(testInfo.project.outputDir, 'daemon-sockets'), PLAYWRIGHT_MCP_BROWSER: options.mcpBrowser, PLAYWRIGHT_MCP_HEADLESS: 'false', }, 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); }); }); } async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise { const { client } = await startClient({ args: [`--extension`], config: { browser: { userDataDir: browserWithExtension.userDataDir, } }, }); return client; } const testWithOldExtensionVersion = test.extend({ pathToExtension: async ({}, use, testInfo) => { const extensionDir = testInfo.outputPath('extension'); const oldPath = path.resolve(__dirname, '../dist'); await fs.cp(oldPath, extensionDir, { recursive: true }); const manifestPath = path.join(extensionDir, '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) + '\n'); await use(extensionDir); }, }); test(`navigate with extension`, async ({ browserWithExtension, startClient, server }) => { const browserContext = await browserWithExtension.launch(); const client = await startWithExtensionFlag(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; // For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector await selectorPage.getByRole('button', { name: 'Allow' }).click(); expect(await navigateResponse).toHaveResponse({ snapshot: 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); const client = await startWithExtensionFlag(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({ snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), }); expect(browserContext.pages()).toHaveLength(4); }); test(`extension not installed timeout`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => { useShortConnectionTimeout(100); const browserContext = await browserWithExtension.launch(); const client = await startWithExtensionFlag(browserWithExtension, startClient); 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({ error: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'), isError: true, }); await confirmationPagePromise; }); testWithOldExtensionVersion(`works with old extension version`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => { useShortConnectionTimeout(500); // Prelaunch the browser, so that it is properly closed after the test. const browserContext = await browserWithExtension.launch(); const client = await startWithExtensionFlag(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; // For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector await selectorPage.getByRole('button', { name: 'Allow' }).click(); expect(await navigateResponse).toHaveResponse({ snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), }); }); test(`extension needs update`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout, overrideProtocolVersion }) => { useShortConnectionTimeout(500); overrideProtocolVersion(1000); // Prelaunch the browser, so that it is properly closed after the test. const browserContext = await browserWithExtension.launch(); const client = await startWithExtensionFlag(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 confirmationPage = await confirmationPagePromise; await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright MCP version trying to connect requires newer extension version`); expect(await navigateResponse).toHaveResponse({ error: expect.stringContaining('Extension connection timeout.'), isError: true, }); }); test(`custom executablePath`, async ({ startClient, server, useShortConnectionTimeout }) => { useShortConnectionTimeout(1000); const executablePath = test.info().outputPath('echo.sh'); await fs.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 }); const { client } = await startClient({ args: [`--extension`], config: { browser: { launchOptions: { executablePath, }, } }, }); const navigateResponse = await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); expect(await navigateResponse).toHaveResponse({ error: expect.stringContaining('Extension connection timeout.'), isError: true, }); expect(await fs.readFile(test.info().outputPath('output.txt'), 'utf8')).toMatch(/Custom exec args.*chrome-extension:\/\/jakfalbnbhgkpmoaakfflhflbfpkailf\/connect\.html\?/); }); test(`bypass connection dialog with token`, async ({ browserWithExtension, startClient, server }) => { const browserContext = await browserWithExtension.launch(); const page = await browserContext.newPage(); await page.goto('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/status.html'); const token = await page.locator('.auth-token-code').textContent(); const [name, value] = token?.split('=') || []; const { client } = await startClient({ args: [`--extension`], extensionToken: value, config: { browser: { userDataDir: browserWithExtension.userDataDir, } }, }); const navigateResponse = await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); expect(await navigateResponse).toHaveResponse({ snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), }); }); test.describe('CLI with extension', () => { test('open --extension', async ({ browserWithExtension, cli, server }, testInfo) => { const browserContext = await browserWithExtension.launch(); // Write config file with userDataDir const configPath = testInfo.outputPath('cli-config.json'); await fs.writeFile(configPath, JSON.stringify({ browser: { userDataDir: browserWithExtension.userDataDir, } }, null, 2)); const confirmationPagePromise = browserContext.waitForEvent('page', page => { return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); }); // Start the CLI command in the background const cliPromise = cli('open', server.HELLO_WORLD, '--extension', `--config=cli-config.json`); // Wait for the confirmation page to appear const confirmationPage = await confirmationPagePromise; // Click the Allow button await confirmationPage.getByRole('button', { name: 'Allow' }).click(); // Wait for the CLI command to complete const { output } = await cliPromise; // Verify the output expect(output).toContain(`### Page`); expect(output).toContain(`- Page URL: ${server.HELLO_WORLD}`); expect(output).toContain(`- Page Title: Title`); }); });