From 80c9b93b7219d42f6236e2a57526dfa118f6e2ba Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 28 Apr 2025 20:17:16 -0700 Subject: [PATCH] chore: allow configuring raw Playwright options (#287) Fixes: https://github.com/microsoft/playwright-mcp/issues/272 --- config.d.ts | 23 ++++++--- src/config.ts | 116 ++++++++++++++++++++++++++++++++++++------- src/context.ts | 45 ++++------------- src/program.ts | 45 +---------------- src/tools/install.ts | 4 +- 5 files changed, 125 insertions(+), 108 deletions(-) diff --git a/config.d.ts b/config.d.ts index 5a0a6d7..4de3571 100644 --- a/config.d.ts +++ b/config.d.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import type * as playwright from 'playwright'; + export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install'; export type Config = { @@ -24,12 +26,7 @@ export type Config = { /** * The type of browser to use. */ - type?: 'chrome' | 'chrome-beta' | 'chrome-canary' | 'chrome-dev' | 'chromium' | 'msedge' | 'msedge-beta' | 'msedge-canary' | 'msedge-dev' | 'firefox' | 'webkit'; - - /** - * Path to a custom browser executable. - */ - executablePath?: string; + browserName?: 'chromium' | 'firefox' | 'webkit'; /** * Path to a user data directory for browser profile persistence. @@ -37,9 +34,19 @@ export type Config = { userDataDir?: string; /** - * Whether to run the browser in headless mode (default: true). + * Launch options passed to + * @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context + * + * This is useful for settings options like `channel`, `headless`, `executablePath`, etc. */ - headless?: boolean; + launchOptions?: playwright.BrowserLaunchOptions; + + /** + * Context options for the browser context. + * + * This is useful for settings options like `viewport`. + */ + contextOptions?: playwright.BrowserContextOptions; /** * Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers. diff --git a/src/config.ts b/src/config.ts index 858e7ed..4001655 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,19 +21,46 @@ import path from 'path'; import { sanitizeForFilePath } from './tools/utils'; -import type { Config } from '../config'; -import type { LaunchOptions, BrowserContextOptions } from 'playwright'; +import type { Config, ToolCapability } from '../config'; +import type { LaunchOptions } from 'playwright'; -export type BrowserOptions = { - browserName: 'chromium' | 'firefox' | 'webkit'; - launchOptions: LaunchOptions; - contextOptions: BrowserContextOptions; +export type CLIOptions = { + browser?: string; + caps?: string; + cdpEndpoint?: string; + executablePath?: string; + headless?: boolean; + userDataDir?: string; + port?: number; + host?: string; + vision?: boolean; + config?: string; }; -export async function toBrowserOptions(config: Config): Promise { +const defaultConfig: Config = { + browser: { + browserName: 'chromium', + userDataDir: os.tmpdir(), + launchOptions: { + channel: 'chrome', + headless: os.platform() === 'linux' && !process.env.DISPLAY, + }, + contextOptions: { + viewport: null, + }, + }, +}; + +export async function resolveConfig(cliOptions: CLIOptions): Promise { + const config = await loadConfig(cliOptions.config); + const cliOverrides = await configFromCLIOptions(cliOptions); + return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides)); +} + +export async function configFromCLIOptions(cliOptions: CLIOptions): Promise { let browserName: 'chromium' | 'firefox' | 'webkit'; let channel: string | undefined; - switch (config.browser?.type) { + switch (cliOptions.browser) { case 'chrome': case 'chrome-beta': case 'chrome-canary': @@ -44,7 +71,7 @@ export async function toBrowserOptions(config: Config): Promise case 'msedge-canary': case 'msedge-dev': browserName = 'chromium'; - channel = config.browser.type; + channel = cliOptions.browser; break; case 'firefox': browserName = 'firefox'; @@ -58,23 +85,26 @@ export async function toBrowserOptions(config: Config): Promise } const launchOptions: LaunchOptions = { - headless: !!(config.browser?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)), channel, - executablePath: config.browser?.executablePath, - ...{ assistantMode: true }, - }; - - const contextOptions: BrowserContextOptions = { - viewport: null, + executablePath: cliOptions.executablePath, }; if (browserName === 'chromium') (launchOptions as any).webSocketPort = await findFreePort(); return { - browserName, - launchOptions, - contextOptions, + browser: { + browserName, + userDataDir: cliOptions.userDataDir ?? await createUserDataDir(browserName), + launchOptions, + cdpEndpoint: cliOptions.cdpEndpoint, + }, + server: { + port: cliOptions.port, + host: cliOptions.host, + }, + capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability), + vision: !!cliOptions.vision, }; } @@ -89,9 +119,57 @@ async function findFreePort() { }); } +async function loadConfig(configFile: string | undefined): Promise { + if (!configFile) + return {}; + + try { + return JSON.parse(await fs.promises.readFile(configFile, 'utf8')); + } catch (error) { + throw new Error(`Failed to load config file: ${configFile}`); + } +} + +async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') { + let cacheDirectory: string; + if (process.platform === 'linux') + cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); + else if (process.platform === 'darwin') + cacheDirectory = path.join(os.homedir(), 'Library', 'Caches'); + else if (process.platform === 'win32') + cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + else + throw new Error('Unsupported platform: ' + process.platform); + const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`); + await fs.promises.mkdir(result, { recursive: true }); + return result; +} + export async function outputFile(config: Config, name: string): Promise { const result = config.outputDir ?? os.tmpdir(); await fs.promises.mkdir(result, { recursive: true }); const fileName = sanitizeForFilePath(name); return path.join(result, fileName); } + +function mergeConfig(base: Config, overrides: Config): Config { + const browser: Config['browser'] = { + ...base.browser, + ...overrides.browser, + launchOptions: { + ...base.browser?.launchOptions, + ...overrides.browser?.launchOptions, + ...{ assistantMode: true }, + }, + contextOptions: { + ...base.browser?.contextOptions, + ...overrides.browser?.contextOptions, + }, + }; + + return { + ...base, + ...overrides, + browser, + }; +} diff --git a/src/context.ts b/src/context.ts index 03d806b..80930f7 100644 --- a/src/context.ts +++ b/src/context.ts @@ -14,21 +14,15 @@ * limitations under the License. */ -import fs from 'fs'; -import path from 'path'; -import os from 'os'; - import * as playwright from 'playwright'; import { waitForCompletion } from './tools/utils'; import { ManualPromise } from './manualPromise'; -import { toBrowserOptions } from './config'; import { Tab } from './tab'; import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types'; import type { ModalState, Tool, ToolActionResult } from './tools/tool'; import type { Config } from '../config'; -import type { BrowserOptions } from './config'; type PendingAction = { dialogShown: ManualPromise; @@ -285,51 +279,32 @@ ${code.join('\n')} } private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> { - const browserOptions = await toBrowserOptions(this.config); - if (this.config.browser?.remoteEndpoint) { const url = new URL(this.config.browser?.remoteEndpoint); - if (browserOptions.browserName) - url.searchParams.set('browser', browserOptions.browserName); - if (browserOptions.launchOptions) - url.searchParams.set('launch-options', JSON.stringify(browserOptions.launchOptions)); - const browser = await playwright[browserOptions.browserName ?? 'chromium'].connect(String(url)); + if (this.config.browser.browserName) + url.searchParams.set('browser', this.config.browser.browserName); + if (this.config.browser.launchOptions) + url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions)); + const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url)); const browserContext = await browser.newContext(); return { browser, browserContext }; } if (this.config.browser?.cdpEndpoint) { - const browser = await playwright.chromium.connectOverCDP(this.config.browser?.cdpEndpoint); + const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint); const browserContext = browser.contexts()[0]; return { browser, browserContext }; } - const browserContext = await launchPersistentContext(this.config.browser?.userDataDir, browserOptions); + const browserContext = await launchPersistentContext(this.config.browser); return { browserContext }; } } -async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') { - let cacheDirectory: string; - if (process.platform === 'linux') - cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); - else if (process.platform === 'darwin') - cacheDirectory = path.join(os.homedir(), 'Library', 'Caches'); - else if (process.platform === 'win32') - cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); - else - throw new Error('Unsupported platform: ' + process.platform); - const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`); - await fs.promises.mkdir(result, { recursive: true }); - return result; -} - -async function launchPersistentContext(userDataDir: string | undefined, browserOptions: BrowserOptions): Promise { - userDataDir = userDataDir ?? await createUserDataDir(browserOptions.browserName); - +async function launchPersistentContext(browserConfig: Config['browser']): Promise { try { - const browserType = browserOptions.browserName ? playwright[browserOptions.browserName] : playwright.chromium; - return await browserType.launchPersistentContext(userDataDir, browserOptions.launchOptions); + const browserType = browserConfig?.browserName ? playwright[browserConfig.browserName] : playwright.chromium; + return await browserType.launchPersistentContext(browserConfig?.userDataDir || '', browserConfig?.launchOptions); } catch (error: any) { if (error.message.includes('Executable doesn\'t exist')) throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); diff --git a/src/program.ts b/src/program.ts index c1c45bb..d871fe2 100644 --- a/src/program.ts +++ b/src/program.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import fs from 'fs'; - import { program } from 'commander'; import { createServer } from './index'; @@ -23,7 +21,7 @@ import { ServerList } from './server'; import { startHttpTransport, startStdioTransport } from './transport'; -import type { Config, ToolCapability } from '../config'; +import { resolveConfig } from './config'; const packageJSON = require('../package.json'); @@ -41,22 +39,7 @@ program .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') .option('--config ', 'Path to the configuration file.') .action(async options => { - const cliOverrides: Config = { - browser: { - type: options.browser, - userDataDir: options.userDataDir, - headless: options.headless, - executablePath: options.executablePath, - cdpEndpoint: options.cdpEndpoint, - }, - server: { - port: options.port, - host: options.host, - }, - capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability), - vision: !!options.vision, - }; - const config = await loadConfig(options.config, cliOverrides); + const config = await resolveConfig(options); const serverList = new ServerList(() => createServer(config)); setupExitWatchdog(serverList); @@ -66,30 +49,6 @@ program await startStdioTransport(serverList); }); -async function loadConfig(configFile: string | undefined, cliOverrides: Config): Promise { - if (!configFile) - return cliOverrides; - - try { - const config = JSON.parse(await fs.promises.readFile(configFile, 'utf8')); - return { - ...config, - ...cliOverrides, - browser: { - ...config.browser, - ...cliOverrides.browser, - }, - server: { - ...config.server, - ...cliOverrides.server, - }, - }; - } catch (e) { - console.error(`Error loading config file ${configFile}: ${e}`); - process.exit(1); - } -} - function setupExitWatchdog(serverList: ServerList) { const handleExit = async () => { setTimeout(() => process.exit(0), 15000); diff --git a/src/tools/install.ts b/src/tools/install.ts index 30edfaa..174dab1 100644 --- a/src/tools/install.ts +++ b/src/tools/install.ts @@ -19,7 +19,6 @@ import path from 'path'; import { z } from 'zod'; import { defineTool } from './tool'; -import { toBrowserOptions } from '../config'; const install = defineTool({ capability: 'install', @@ -30,8 +29,7 @@ const install = defineTool({ }, handle: async context => { - const browserOptions = await toBrowserOptions(context.config); - const channel = browserOptions.launchOptions?.channel ?? browserOptions.browserName ?? 'chrome'; + const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.launchOptions.channel ?? context.config.browser?.launchOptions.browserName ?? 'chrome'; const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js'); const child = fork(cli, ['install', channel], { stdio: 'pipe',