From b90454697895be4ad8c4cba37563366860e34ec7 Mon Sep 17 00:00:00 2001 From: Kashish Gupta <90824921+kashish2508@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:19:42 +0530 Subject: [PATCH 1/2] fix(html-reporter): Prevent duplicate query parameters in artifact URLs (#39528) --- packages/html-reporter/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/html-reporter/src/utils.ts b/packages/html-reporter/src/utils.ts index ebeb93d2855e0..2737b28311de0 100644 --- a/packages/html-reporter/src/utils.ts +++ b/packages/html-reporter/src/utils.ts @@ -59,7 +59,7 @@ export function formatUrl(url: string | undefined): string | undefined { const parsed = new URL(url, window.location.href); if (parsed.origin === window.location.origin) { for (const [key, value] of new URLSearchParams(window.location.search)) - parsed.searchParams.append(key, value); + parsed.searchParams.set(key, value); return parsed.toString(); } return url; From 7624e7db0f52f28cf1c39cefac226c07e4bc283f Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 6 Mar 2026 08:33:38 -0800 Subject: [PATCH 2/2] chore(mcp): context factory => browser factory (#39538) --- .../playwright-core/src/cli/daemon/program.ts | 9 +- .../src/mcp/browserContextFactory.ts | 313 ------------------ .../playwright-core/src/mcp/browserFactory.ts | 195 +++++++++++ packages/playwright-core/src/mcp/config.d.ts | 13 - packages/playwright-core/src/mcp/config.ts | 29 -- .../src/mcp/extensionContextFactory.ts | 50 +-- packages/playwright-core/src/mcp/index.ts | 26 +- packages/playwright-core/src/mcp/program.ts | 20 +- tests/mcp/http.spec.ts | 15 +- tests/mcp/launch.spec.ts | 3 +- tests/mcp/profile-lock.spec.ts | 2 +- tests/mcp/roots.spec.ts | 25 -- tests/mcp/sse.spec.ts | 12 +- tests/mcp/tracing.spec.ts | 18 - tests/mcp/video.spec.ts | 42 --- 15 files changed, 240 insertions(+), 532 deletions(-) delete mode 100644 packages/playwright-core/src/mcp/browserContextFactory.ts create mode 100644 packages/playwright-core/src/mcp/browserFactory.ts diff --git a/packages/playwright-core/src/cli/daemon/program.ts b/packages/playwright-core/src/cli/daemon/program.ts index 796f5a7a3b55a..69784e23897f7 100644 --- a/packages/playwright-core/src/cli/daemon/program.ts +++ b/packages/playwright-core/src/cli/daemon/program.ts @@ -21,8 +21,7 @@ import path from 'path'; import { startCliDaemonServer } from './daemon'; import { setupExitWatchdog } from '../../mcp/watchdog'; -import { contextFactory } from '../../mcp/browserContextFactory'; -import { ExtensionContextFactory } from '../../mcp/extensionContextFactory'; +import { createBrowser } from '../../mcp/browserFactory'; import * as configUtils from '../../mcp/config'; import { ClientInfo, createClientInfo } from '../client/registry'; @@ -47,10 +46,8 @@ export function decorateCLICommand(command: Command, version: string) { const mcpClientInfo = { cwd: process.cwd() }; try { - const extensionContextFactory = new ExtensionContextFactory(mcpConfig.browser.launchOptions.channel || 'chrome', mcpConfig.browser.userDataDir, mcpConfig.browser.launchOptions.executablePath); - const browserContextFactory = contextFactory(mcpConfig); - const cf = mcpConfig.extension ? extensionContextFactory : browserContextFactory; - const browserContext = mcpConfig.browser.isolated ? await cf.createContext(mcpClientInfo) : (await cf.contexts(mcpClientInfo))[0]; + const browser = await createBrowser(mcpConfig, mcpClientInfo); + const browserContext = mcpConfig.browser.isolated ? await browser.newContext(mcpConfig.browser.contextOptions) : browser.contexts()[0]; const socketPath = await startCliDaemonServer(sessionName, browserContext, mcpConfig, clientInfo, { ...options, exitOnClose: true }); console.log(`### Success\nDaemon listening on ${socketPath}`); console.log(''); diff --git a/packages/playwright-core/src/mcp/browserContextFactory.ts b/packages/playwright-core/src/mcp/browserContextFactory.ts deleted file mode 100644 index 3a7d98faa9093..0000000000000 --- a/packages/playwright-core/src/mcp/browserContextFactory.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * 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 crypto from 'crypto'; -import fs from 'fs'; -import net from 'net'; -import path from 'path'; - -import * as playwright from '../..'; -import { registryDirectory } from '../server/registry/index'; -import { startTraceViewerServer } from '../server'; -import { testDebug } from './log'; -import { outputDir, outputFile } from '../tools/context'; - -import type { FullConfig } from './config'; -import type { LaunchOptions, BrowserContextOptions } from '../client/types'; -import type { ClientInfo } from './sdk/server'; - -export function contextFactory(config: FullConfig): BrowserContextFactory { - if (config.browser.remoteEndpoint) - return new RemoteContextFactory(config); - if (config.browser.cdpEndpoint) - return new CdpContextFactory(config); - if (config.browser.isolated) - return new IsolatedContextFactory(config); - return new PersistentContextFactory(config); -} - -export interface BrowserContextFactory { - contexts(clientInfo: ClientInfo): Promise; - createContext(clientInfo: ClientInfo): Promise; -} - -export function identityBrowserContextFactory(browserContext: playwright.BrowserContext): BrowserContextFactory { - return { - contexts: async (clientInfo: ClientInfo) => { - return [browserContext]; - }, - - createContext: async (clientInfo: ClientInfo) => { - return browserContext; - } - }; -} - -class BaseContextFactory implements BrowserContextFactory { - readonly config: FullConfig; - private _logName: string; - protected _browserPromise: Promise | undefined; - - constructor(name: string, config: FullConfig) { - this._logName = name; - this.config = config; - } - - protected async _obtainBrowser(clientInfo: ClientInfo): Promise { - if (this._browserPromise) - return this._browserPromise; - testDebug(`obtain browser (${this._logName})`); - this._browserPromise = this._doObtainBrowser(clientInfo); - void this._browserPromise.then(browser => { - browser.on('disconnected', () => { - this._browserPromise = undefined; - }); - }).catch(() => { - this._browserPromise = undefined; - }); - return this._browserPromise; - } - - protected async _doObtainBrowser(clientInfo: ClientInfo): Promise { - throw new Error('Not implemented'); - } - - async contexts(clientInfo: ClientInfo): Promise { - const browser = await this._obtainBrowser(clientInfo); - return browser.contexts(); - } - - async createContext(clientInfo: ClientInfo): Promise { - testDebug(`create browser context (${this._logName})`); - const browser = await this._obtainBrowser(clientInfo); - return await this._doCreateContext(browser, clientInfo); - } - - protected async _doCreateContext(browser: playwright.Browser, clientInfo: ClientInfo): Promise { - throw new Error('Not implemented'); - } -} - -class IsolatedContextFactory extends BaseContextFactory { - constructor(config: FullConfig) { - super('isolated', config); - } - - protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise { - await injectCdpPort(this.config.browser); - const browserType = playwright[this.config.browser.browserName]; - const tracesDir = await computeTracesDir(this.config, clientInfo); - if (tracesDir && this.config.saveTrace) - await startTraceServer(this.config, tracesDir); - return browserType.launch({ - tracesDir, - ...this.config.browser.launchOptions, - handleSIGINT: false, - handleSIGTERM: false, - }).catch(error => { - if (error.message.includes('Executable doesn\'t exist')) - throwBrowserIsNotInstalledError(this.config); - throw error; - }); - } - - protected override async _doCreateContext(browser: playwright.Browser, clientInfo: ClientInfo): Promise { - return browser.newContext(await browserContextOptionsFromConfig(this.config, clientInfo)); - } -} - -class CdpContextFactory extends BaseContextFactory { - constructor(config: FullConfig) { - super('cdp', config); - } - - protected override async _doObtainBrowser(): Promise { - return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint!, { - headers: this.config.browser.cdpHeaders, - timeout: this.config.browser.cdpTimeout - }); - } - - protected override async _doCreateContext(browser: playwright.Browser): Promise { - return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0]; - } -} - -class RemoteContextFactory extends BaseContextFactory { - constructor(config: FullConfig) { - super('remote', config); - } - - protected override async _doObtainBrowser(): Promise { - const url = new URL(this.config.browser.remoteEndpoint!); - url.searchParams.set('browser', this.config.browser.browserName); - if (this.config.browser.launchOptions) - url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions)); - return playwright[this.config.browser.browserName].connect(String(url)); - } - - protected override async _doCreateContext(browser: playwright.Browser): Promise { - return browser.newContext(); - } -} - -class PersistentContextFactory extends BaseContextFactory { - readonly name = 'persistent'; - readonly description = 'Create a new persistent browser context'; - - constructor(config: FullConfig) { - super('persistent', config); - } - - protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise { - await injectCdpPort(this.config.browser); - testDebug('create browser context (persistent)'); - const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo); - const tracesDir = await computeTracesDir(this.config, clientInfo); - if (tracesDir && this.config.saveTrace) - await startTraceServer(this.config, tracesDir); - - if (await isProfileLocked5Times(userDataDir)) - throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`); - - const browserType = playwright[this.config.browser.browserName]; - const launchOptions: LaunchOptions & BrowserContextOptions = { - tracesDir, - ...this.config.browser.launchOptions, - ...await browserContextOptionsFromConfig(this.config, clientInfo), - handleSIGINT: false, - handleSIGTERM: false, - ignoreDefaultArgs: [ - '--disable-extensions', - ], - assistantMode: true, - }; - try { - const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions); - return browserContext.browser()!; - } catch (error: any) { - if (error.message.includes('Executable doesn\'t exist')) - throwBrowserIsNotInstalledError(this.config); - if (error.message.includes('cannot open shared object file: No such file or directory')) { - const browserName = launchOptions.channel ?? this.config.browser.browserName; - throw new Error(`Missing system dependencies required to run browser ${browserName}. Install them with: sudo npx playwright install-deps ${browserName}`); - } - if (error.message.includes('ProcessSingleton') || error.message.includes('exitCode=21')) - throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`); - throw error; - } - } - - private async _createUserDataDir(clientInfo: ClientInfo) { - const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory; - const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName; - // Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead. - const rootPathToken = createHash(clientInfo.cwd); - const result = path.join(dir, `mcp-${browserToken}-${rootPathToken}`); - await fs.promises.mkdir(result, { recursive: true }); - return result; - } -} - -async function injectCdpPort(browserConfig: FullConfig['browser']) { - if (browserConfig.browserName === 'chromium') - (browserConfig.launchOptions as any).cdpPort = await findFreePort(); -} - -async function findFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, () => { - const { port } = server.address() as net.AddressInfo; - server.close(() => resolve(port)); - }); - server.on('error', reject); - }); -} - -async function startTraceServer(config: FullConfig, tracesDir: string): Promise { - if (!config.saveTrace) - return; - - const server = await startTraceViewerServer(); - const urlPrefix = server.urlPrefix('human-readable'); - const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json'; - // eslint-disable-next-line no-console - console.error('\nTrace viewer listening on ' + url); -} - -function createHash(data: string): string { - return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7); -} - -async function computeTracesDir(config: FullConfig, clientInfo: ClientInfo): Promise { - return path.resolve(outputDir({ config, cwd: clientInfo.cwd }), 'traces'); -} - -async function browserContextOptionsFromConfig(config: FullConfig, clientInfo: ClientInfo): Promise { - const result = { ...config.browser.contextOptions }; - if (config.saveVideo) { - const dir = await outputFile({ config, cwd: clientInfo.cwd }, `videos`, { origin: 'code' }); - result.recordVideo = { - dir, - size: config.saveVideo, - }; - } - return result; -} - -async function isProfileLocked5Times(userDataDir: string): Promise { - for (let i = 0; i < 5; i++) { - if (!isProfileLocked(userDataDir)) - return false; - await new Promise(f => setTimeout(f, 1000)); - } - return true; -} - -export function isProfileLocked(userDataDir: string): boolean { - const lockFile = process.platform === 'win32' ? 'lockfile' : 'SingletonLock'; - const lockPath = path.join(userDataDir, lockFile); - - if (process.platform === 'win32') { - try { - const fd = fs.openSync(lockPath, 'r+'); - fs.closeSync(fd); - return false; - } catch (e: any) { - return e.code !== 'ENOENT'; - } - } - - try { - const target = fs.readlinkSync(lockPath); - const pid = parseInt(target.split('-').pop() || '', 10); - if (isNaN(pid)) - return false; - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function throwBrowserIsNotInstalledError(config: FullConfig): never { - const channel = config.browser.launchOptions?.channel ?? config.browser.browserName; - if (config.skillMode) - throw new Error(`Browser "${channel}" is not installed. Run \`playwright-cli install-browser ${channel}\` to install`); - else - throw new Error(`Browser "${channel}" is not installed. Run \`npx @playwright/mcp install-browser ${channel}\` to install`); -} diff --git a/packages/playwright-core/src/mcp/browserFactory.ts b/packages/playwright-core/src/mcp/browserFactory.ts new file mode 100644 index 0000000000000..284de9c3f1e02 --- /dev/null +++ b/packages/playwright-core/src/mcp/browserFactory.ts @@ -0,0 +1,195 @@ +/** + * 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 crypto from 'crypto'; +import fs from 'fs'; +import net from 'net'; +import path from 'path'; + +import * as playwright from '../..'; +import { registryDirectory } from '../server/registry/index'; +import { testDebug } from './log'; +import { outputDir } from '../tools/context'; +import { createExtensionBrowser } from './extensionContextFactory'; + +import type { FullConfig } from './config'; +import type { LaunchOptions, BrowserContextOptions } from '../client/types'; +import type { ClientInfo } from './sdk/server'; + +export async function createBrowser(config: FullConfig, clientInfo: ClientInfo): Promise { + if (config.browser.remoteEndpoint) + return await createRemoteBrowser(config); + if (config.browser.cdpEndpoint) + return await createCDPBrowser(config); + if (config.browser.isolated) + return await createIsolatedBrowser(config, clientInfo); + if (config.extension) + return await createExtensionBrowser(config, clientInfo); + return await createPersistentBrowser(config, clientInfo); +} + +export interface BrowserContextFactory { + contexts(clientInfo: ClientInfo): Promise; + createContext(clientInfo: ClientInfo): Promise; +} + +async function createIsolatedBrowser(config: FullConfig, clientInfo: ClientInfo): Promise { + testDebug('create browser (isolated)'); + await injectCdpPort(config.browser); + const browserType = playwright[config.browser.browserName]; + const tracesDir = await computeTracesDir(config, clientInfo); + return await browserType.launch({ + tracesDir, + ...config.browser.launchOptions, + handleSIGINT: false, + handleSIGTERM: false, + }).catch(error => { + if (error.message.includes('Executable doesn\'t exist')) + throwBrowserIsNotInstalledError(config); + throw error; + }); +} + +async function createCDPBrowser(config: FullConfig): Promise { + testDebug('create browser (cdp)'); + return playwright.chromium.connectOverCDP(config.browser.cdpEndpoint!, { + headers: config.browser.cdpHeaders, + timeout: config.browser.cdpTimeout + }); +} + +async function createRemoteBrowser(config: FullConfig): Promise { + testDebug('create browser (remote)'); + const url = new URL(config.browser.remoteEndpoint!); + url.searchParams.set('browser', config.browser.browserName); + if (config.browser.launchOptions) + url.searchParams.set('launch-options', JSON.stringify(config.browser.launchOptions)); + return playwright[config.browser.browserName].connect(String(url)); +} + +async function createPersistentBrowser(config: FullConfig, clientInfo: ClientInfo): Promise { + testDebug('create browser (persistent)'); + await injectCdpPort(config.browser); + const userDataDir = config.browser.userDataDir ?? await createUserDataDir(config, clientInfo); + const tracesDir = await computeTracesDir(config, clientInfo); + + if (await isProfileLocked5Times(userDataDir)) + throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`); + + const browserType = playwright[config.browser.browserName]; + const launchOptions: LaunchOptions & BrowserContextOptions = { + tracesDir, + ...config.browser.launchOptions, + ...config.browser.contextOptions, + handleSIGINT: false, + handleSIGTERM: false, + ignoreDefaultArgs: [ + '--disable-extensions', + ], + assistantMode: true, + }; + try { + const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions); + return browserContext.browser()!; + } catch (error: any) { + if (error.message.includes('Executable doesn\'t exist')) + throwBrowserIsNotInstalledError(config); + if (error.message.includes('cannot open shared object file: No such file or directory')) { + const browserName = launchOptions.channel ?? config.browser.browserName; + throw new Error(`Missing system dependencies required to run browser ${browserName}. Install them with: sudo npx playwright install-deps ${browserName}`); + } + if (error.message.includes('ProcessSingleton') || error.message.includes('exitCode=21')) + throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`); + throw error; + } +} + +async function createUserDataDir(config: FullConfig, clientInfo: ClientInfo) { + const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory; + const browserToken = config.browser.launchOptions?.channel ?? config.browser?.browserName; + // Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead. + const rootPathToken = createHash(clientInfo.cwd); + const result = path.join(dir, `mcp-${browserToken}-${rootPathToken}`); + await fs.promises.mkdir(result, { recursive: true }); + return result; +} + +async function injectCdpPort(browserConfig: FullConfig['browser']) { + if (browserConfig.browserName === 'chromium') + (browserConfig.launchOptions as any).cdpPort = await findFreePort(); +} + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + +function createHash(data: string): string { + return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7); +} + +async function computeTracesDir(config: FullConfig, clientInfo: ClientInfo): Promise { + return path.resolve(outputDir({ config, cwd: clientInfo.cwd }), 'traces'); +} + +async function isProfileLocked5Times(userDataDir: string): Promise { + for (let i = 0; i < 5; i++) { + if (!isProfileLocked(userDataDir)) + return false; + await new Promise(f => setTimeout(f, 1000)); + } + return true; +} + +export function isProfileLocked(userDataDir: string): boolean { + const lockFile = process.platform === 'win32' ? 'lockfile' : 'SingletonLock'; + const lockPath = path.join(userDataDir, lockFile); + + if (process.platform === 'win32') { + try { + const fd = fs.openSync(lockPath, 'r+'); + fs.closeSync(fd); + return false; + } catch (e: any) { + return e.code !== 'ENOENT'; + } + } + + try { + const target = fs.readlinkSync(lockPath); + const pid = parseInt(target.split('-').pop() || '', 10); + if (isNaN(pid)) + return false; + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function throwBrowserIsNotInstalledError(config: FullConfig): never { + const channel = config.browser.launchOptions?.channel ?? config.browser.browserName; + if (config.skillMode) + throw new Error(`Browser "${channel}" is not installed. Run \`playwright-cli install-browser ${channel}\` to install`); + else + throw new Error(`Browser "${channel}" is not installed. Run \`npx @playwright/mcp install-browser ${channel}\` to install`); +} diff --git a/packages/playwright-core/src/mcp/config.d.ts b/packages/playwright-core/src/mcp/config.d.ts index d270a6fdcebb4..35c5ca61fb225 100644 --- a/packages/playwright-core/src/mcp/config.d.ts +++ b/packages/playwright-core/src/mcp/config.d.ts @@ -137,19 +137,6 @@ export type Config = { */ saveSession?: boolean; - /** - * Whether to save the Playwright trace of the session into the output directory. - */ - saveTrace?: boolean; - - /** - * If specified, saves the Playwright video of the session into the output directory. - */ - saveVideo?: { - width: number; - height: number; - }; - /** * Reuse the same browser context between all connected HTTP clients. */ diff --git a/packages/playwright-core/src/mcp/config.ts b/packages/playwright-core/src/mcp/config.ts index f0d96d693bb28..67a8a21be8f2c 100644 --- a/packages/playwright-core/src/mcp/config.ts +++ b/packages/playwright-core/src/mcp/config.ts @@ -17,7 +17,6 @@ import fs from 'fs'; import os from 'os'; -import { registry } from '../server'; import { devices } from '../..'; import { dotenv } from '../utilsBundle'; @@ -64,8 +63,6 @@ export type CLIOptions = { proxyBypass?: string; proxyServer?: string; saveSession?: boolean; - saveTrace?: boolean; - saveVideo?: ViewportSize; secrets?: Record; sharedBrowserContext?: boolean; snapshotMode?: 'incremental' | 'full' | 'none'; @@ -138,17 +135,6 @@ export async function validateConfig(config: FullConfig): Promise { config.browser.launchOptions.chromiumSandbox = true; } - if (config.saveVideo && !checkFfmpeg()) { - // eslint-disable-next-line no-console - console.error(`\nError: ffmpeg required to save the video is not installed.`); - // eslint-disable-next-line no-console - console.error(`\nPlease run the command below. It will install a local copy of ffmpeg and will not change any system-wide settings.`); - // eslint-disable-next-line no-console - console.error(`\n npx playwright install ffmpeg\n`); - // eslint-disable-next-line no-restricted-properties - process.exit(1); - } - if (config.browser.initScript) { for (const script of config.browser.initScript) { if (!await fileExistsAsync(script)) @@ -161,8 +147,6 @@ export async function validateConfig(config: FullConfig): Promise { throw new Error(`Init page file does not exist: ${page}`); } } - if (config.sharedBrowserContext && config.saveVideo) - throw new Error('saveVideo is not supported when sharedBrowserContext is true'); } export function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: string } { @@ -266,8 +250,6 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config & { configF allowUnrestrictedFileAccess: cliOptions.allowUnrestrictedFileAccess, codegen: cliOptions.codegen, saveSession: cliOptions.saveSession, - saveTrace: cliOptions.saveTrace, - saveVideo: cliOptions.saveVideo, secrets: cliOptions.secrets, sharedBrowserContext: cliOptions.sharedBrowserContext, snapshot: cliOptions.snapshotMode ? { mode: cliOptions.snapshotMode } : undefined, @@ -320,8 +302,6 @@ export function configFromEnv(): Config & { configFile?: string } { options.port = numberParser(process.env.PLAYWRIGHT_MCP_PORT); options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS); options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER); - options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE); - options.saveVideo = resolutionParser('--save-video', process.env.PLAYWRIGHT_MCP_SAVE_VIDEO); options.secrets = dotenvFileLoader(process.env.PLAYWRIGHT_MCP_SECRETS_FILE); options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE); options.testIdAttribute = envToString(process.env.PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE); @@ -472,12 +452,3 @@ function envToBoolean(value: string | undefined): boolean | undefined { function envToString(value: string | undefined): string | undefined { return value ? value.trim() : undefined; } - -function checkFfmpeg(): boolean { - try { - const executable = registry.findExecutable('ffmpeg')!; - return fs.existsSync(executable.executablePath()!); - } catch (error) { - return false; - } -} diff --git a/packages/playwright-core/src/mcp/extensionContextFactory.ts b/packages/playwright-core/src/mcp/extensionContextFactory.ts index b4b52ebba447d..a2715130ddc09 100644 --- a/packages/playwright-core/src/mcp/extensionContextFactory.ts +++ b/packages/playwright-core/src/mcp/extensionContextFactory.ts @@ -17,47 +17,23 @@ import * as playwright from '../..'; import { debug } from '../utilsBundle'; import { createHttpServer, startHttpServer } from '../server/utils/network'; - import { CDPRelayServer } from './cdpRelay'; -import type { BrowserContextFactory } from './browserContextFactory'; import type { ClientInfo } from './sdk/server'; +import type { FullConfig } from './config'; const debugLogger = debug('pw:mcp:relay'); -export class ExtensionContextFactory implements BrowserContextFactory { - private _browserChannel: string; - private _userDataDir?: string; - private _executablePath?: string; - - constructor(browserChannel: string, userDataDir: string | undefined, executablePath: string | undefined) { - this._browserChannel = browserChannel; - this._userDataDir = userDataDir; - this._executablePath = executablePath; - } - - async contexts(clientInfo: ClientInfo): Promise { - const browser = await this._obtainBrowser(clientInfo); - return browser.contexts(); - } - - async createContext(clientInfo: ClientInfo): Promise { - throw new Error('Creating a new context is not supported in extension mode. Please use the shared context instead.'); - } - - private async _obtainBrowser(clientInfo: ClientInfo): Promise { - const relay = await this._startRelay(); - await relay.ensureExtensionConnectionForMCPContext(clientInfo, /* forceNewTab */ false); - return await playwright.chromium.connectOverCDP(relay.cdpEndpoint(), { isLocal: true }); - } - - private async _startRelay() { - const httpServer = createHttpServer(); - // Listen to the loopback interface only. The extension will disallow - // connections to other hosts anyway. - await startHttpServer(httpServer, {}); - const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir, this._executablePath); - debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`); - return cdpRelayServer; - } +export async function createExtensionBrowser(config: FullConfig, clientInfo: ClientInfo): Promise { + const httpServer = createHttpServer(); + await startHttpServer(httpServer, {}); + const relay = new CDPRelayServer( + httpServer, + config.browser.launchOptions.channel || 'chrome', + config.browser.userDataDir, + config.browser.launchOptions.executablePath); + debugLogger(`CDP relay server started, extension endpoint: ${relay.extensionEndpoint()}.`); + + await relay.ensureExtensionConnectionForMCPContext(clientInfo, /* forceNewTab */ false); + return await playwright.chromium.connectOverCDP(relay.cdpEndpoint(), { isLocal: true }); } diff --git a/packages/playwright-core/src/mcp/index.ts b/packages/playwright-core/src/mcp/index.ts index e0179e986084a..750bd8d315832 100644 --- a/packages/playwright-core/src/mcp/index.ts +++ b/packages/playwright-core/src/mcp/index.ts @@ -16,11 +16,10 @@ import { resolveConfig } from './config'; import { filteredTools } from '../tools/tools'; -import { contextFactory } from './browserContextFactory'; +import { createBrowser } from './browserFactory'; import { BrowserServerBackend } from '../tools/browserServerBackend'; import { createServer } from './sdk/server'; -import type { BrowserContextFactory } from './browserContextFactory'; import type { BrowserContext } from 'playwright'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { ClientInfo, ServerBackendFactory } from './sdk/server'; @@ -37,30 +36,27 @@ export async function createConnection(userConfig: Config = {}, contextGetter?: version: packageJSON.version, toolSchemas: tools.map(tool => tool.schema), create: async (clientInfo: ClientInfo) => { - const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config); - return new BrowserServerBackend(config, await factory.createContext(clientInfo), tools); + const browser = contextGetter ? new SimpleBrowser(await contextGetter()) : await createBrowser(config, clientInfo); + const context = config.browser.isolated ? await browser.newContext(config.browser.contextOptions) : browser.contexts()[0]; + return new BrowserServerBackend(config, context, tools); }, disposed: async () => { } }; return createServer('api', packageJSON.version, backendFactory, false); } -class SimpleBrowserContextFactory implements BrowserContextFactory { - name = 'custom'; - description = 'Connect to a browser using a custom context getter'; +class SimpleBrowser { + private _context: BrowserContext; - private readonly _contextGetter: () => Promise; - - constructor(contextGetter: () => Promise) { - this._contextGetter = contextGetter; + constructor(context: BrowserContext) { + this._context = context; } - async contexts(): Promise { - const browserContext = await this._contextGetter(); - return [browserContext]; + contexts(): BrowserContext[] { + return [this._context]; } - async createContext(): Promise { + async newContext(): Promise { throw new Error('Creating a new context is not supported in SimpleBrowserContextFactory.'); } } diff --git a/packages/playwright-core/src/mcp/program.ts b/packages/playwright-core/src/mcp/program.ts index b165d3db38f74..c60b789c088c5 100644 --- a/packages/playwright-core/src/mcp/program.ts +++ b/packages/playwright-core/src/mcp/program.ts @@ -19,9 +19,8 @@ import { ProgramOption } from '../utilsBundle'; import * as mcpServer from './sdk/server'; import { commaSeparatedList, dotenvFileLoader, enumParser, headerParser, numberParser, resolutionParser, resolveCLIConfig, semicolonSeparatedList } from './config'; import { setupExitWatchdog } from './watchdog'; -import { contextFactory } from './browserContextFactory'; +import { createBrowser } from './browserFactory'; import { BrowserServerBackend } from '../tools/browserServerBackend'; -import { ExtensionContextFactory } from './extensionContextFactory'; import { filteredTools } from '../tools/tools'; import { testDebug } from './log'; @@ -62,8 +61,6 @@ export function decorateMCPCommand(command: Command, version: string) { .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') .option('--sandbox', 'enable the sandbox for all process types that are normally not sandboxed.') .option('--save-session', 'Whether to save the Playwright MCP session into the output directory.') - .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.') - .option('--save-video ', 'Whether to save the video of the session into the output directory. For example "--save-video=800x600"', resolutionParser.bind(null, '--save-video')) .option('--secrets ', 'path to a file containing secrets in the dotenv format', dotenvFileLoader) .option('--shared-browser-context', 'reuse the same browser context between all connected HTTP clients.') .option('--snapshot-mode ', 'when taking snapshots for responses, specifies the mode to use. Can be "incremental", "full", or "none". Default is incremental.') @@ -100,11 +97,8 @@ export function decorateMCPCommand(command: Command, version: string) { version, toolSchemas: tools.map(tool => tool.schema), create: async (clientInfo: ClientInfo) => { - const extensionContextFactory = new ExtensionContextFactory( - config.browser.launchOptions.channel || 'chrome', - config.browser.userDataDir, - config.browser.launchOptions.executablePath); - const browserContext = await extensionContextFactory.createContext(clientInfo); + const browser = await createBrowser(config, clientInfo); + const browserContext = browser.contexts()[0]; return new BrowserServerBackend(config, browserContext, tools); }, disposed: async () => { } @@ -113,7 +107,7 @@ export function decorateMCPCommand(command: Command, version: string) { return; } - const sharedContextFactory = config.sharedBrowserContext ? contextFactory(config) : undefined; + const sharedBrowser = config.sharedBrowserContext ? await createBrowser(config, { cwd: process.cwd() }) : undefined; let clientCount = 0; const factory: mcpServer.ServerBackendFactory = { name: 'Playwright', @@ -122,13 +116,13 @@ export function decorateMCPCommand(command: Command, version: string) { toolSchemas: tools.map(tool => tool.schema), create: async (clientInfo: ClientInfo) => { clientCount++; - const browserContextFactory = sharedContextFactory || contextFactory(config); - const browserContext = config.browser.isolated ? await browserContextFactory.createContext(clientInfo) : (await browserContextFactory.contexts(clientInfo))[0]; + const browser = sharedBrowser || await createBrowser(config, clientInfo); + const browserContext = config.browser.isolated ? await browser.newContext(config.browser.contextOptions) : browser.contexts()[0]; return new BrowserServerBackend(config, browserContext, tools); }, disposed: async backend => { clientCount--; - if (sharedContextFactory && clientCount > 0) + if (sharedBrowser && clientCount > 0) return; testDebug('close browser'); diff --git a/tests/mcp/http.spec.ts b/tests/mcp/http.spec.ts index 495d68198e6fa..555bf67c64733 100644 --- a/tests/mcp/http.spec.ts +++ b/tests/mcp/http.spec.ts @@ -134,9 +134,8 @@ test('http transport browser lifecycle (isolated)', async ({ serverEndpoint, ser await expect.poll(() => formatLog(stderr())).toEqual({ 'create http session': 2, 'delete http session': 2, - 'create browser context \(isolated\)': 2, + 'create browser \(isolated\)': 2, 'create context': 2, - 'obtain browser \(isolated\)': 2, 'close browser': 2, }); }); @@ -155,10 +154,9 @@ test('http transport browser sigint', async ({ serverEndpoint, server }) => { await fetch(new URL('/killkillkill', url).href).catch(() => {}); await expect.poll(() => formatLog(stderr())).toEqual({ - 'create browser context (isolated)': 1, + 'create browser (isolated)': 1, 'create context': 1, 'create http session': 1, - 'obtain browser (isolated)': 1, 'gracefully closing 1': 1, }); }); @@ -201,8 +199,7 @@ test('http transport browser lifecycle (isolated, multiclient)', async ({ server 'create http session': 3, 'delete http session': 3, 'create context': 3, - 'create browser context (isolated)': 3, - 'obtain browser (isolated)': 3, + 'create browser (isolated)': 3, 'close browser': 3, }); }); @@ -235,8 +232,7 @@ test('http transport browser lifecycle (persistent)', async ({ serverEndpoint, s 'delete http session': 2, 'create context': 2, 'close browser': 2, - 'obtain browser (persistent)': 2, - 'create browser context (persistent)': 2, + 'create browser (persistent)': 2, }); }); @@ -304,10 +300,9 @@ test('http transport shared context', async ({ serverEndpoint, server }) => { await client2.close(); await expect.poll(() => formatLog(stderr())).toEqual({ - 'create browser context (persistent)': 1, + 'create browser (persistent)': 1, 'create http session': 2, 'delete http session': 2, - 'obtain browser (persistent)': 1, 'create context': 2, 'close browser': 1, }); diff --git a/tests/mcp/launch.spec.ts b/tests/mcp/launch.spec.ts index 063297f101f20..f5bc62c9b85b8 100644 --- a/tests/mcp/launch.spec.ts +++ b/tests/mcp/launch.spec.ts @@ -43,9 +43,8 @@ test('test reopen browser', async ({ startClient, server }) => { }); await expect.poll(() => formatLog(stderr()), { timeout: 0 }).toEqual({ - 'obtain browser (persistent)': 2, + 'create browser (persistent)': 2, 'create context': 2, - 'create browser context (persistent)': 2, 'close browser': 1, }); }); diff --git a/tests/mcp/profile-lock.spec.ts b/tests/mcp/profile-lock.spec.ts index c5e0e173834f1..ddb95e35c4bc6 100644 --- a/tests/mcp/profile-lock.spec.ts +++ b/tests/mcp/profile-lock.spec.ts @@ -17,7 +17,7 @@ import { chromium } from 'playwright'; import { test, expect } from './fixtures'; -import { isProfileLocked } from '../../packages/playwright-core/lib/mcp/browserContextFactory'; +import { isProfileLocked } from '../../packages/playwright-core/lib/mcp/browserFactory'; test('isProfileLocked returns false for empty directory', async ({ mcpBrowser }, testInfo) => { test.skip(!['chromium', 'chrome', 'msedge'].includes(mcpBrowser!), 'Chromium-only'); diff --git a/tests/mcp/roots.spec.ts b/tests/mcp/roots.spec.ts index 322e72d1667b6..412763f51f394 100644 --- a/tests/mcp/roots.spec.ts +++ b/tests/mcp/roots.spec.ts @@ -16,7 +16,6 @@ import crypto from 'crypto'; import fs from 'fs'; -import path from 'path'; import { pathToFileURL } from 'url'; import { test, expect } from './fixtures'; @@ -44,30 +43,6 @@ test('should use separate user data by root path', async ({ startClient, server expect(file).toContain(hash); }); -test('check that trace is saved in workspace', async ({ startClient, server }, testInfo) => { - const rootPath = testInfo.outputPath('workspace'); - const { client } = await startClient({ - args: ['--save-trace'], - clientName: 'My client', - roots: [ - { - name: 'workspace', - uri: pathToFileURL(rootPath).toString(), - }, - ], - }); - - expect(await client.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - })).toHaveResponse({ - code: expect.stringContaining(`page.goto('http://localhost`), - }); - - const files = await fs.promises.readdir(path.join(rootPath, '.playwright-mcp')); - expect(files).toContain('traces'); -}); - test('should list all tools when listRoots is slow', async ({ startClient }) => { const { client } = await startClient({ clientName: 'Another custom client', diff --git a/tests/mcp/sse.spec.ts b/tests/mcp/sse.spec.ts index 13c0ce63c67d5..8af6c1a1e9e40 100644 --- a/tests/mcp/sse.spec.ts +++ b/tests/mcp/sse.spec.ts @@ -110,8 +110,7 @@ test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, serv 'create SSE session': 2, 'delete SSE session': 2, 'create context': 2, - 'create browser context (isolated)': 2, - 'obtain browser (isolated)': 2, + 'create browser (isolated)': 2, 'close browser': 2, }); }); @@ -151,8 +150,7 @@ test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverE 'create SSE session': 3, 'delete SSE session': 3, 'create context': 3, - 'obtain browser (isolated)': 3, - 'create browser context (isolated)': 3, + 'create browser (isolated)': 3, 'close browser': 3, }); }); @@ -181,9 +179,8 @@ test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, se await expect.poll(() => formatLog(stderr())).toEqual({ 'create SSE session': 2, 'delete SSE session': 2, - 'obtain browser (persistent)': 2, 'create context': 2, - 'create browser context (persistent)': 2, + 'create browser (persistent)': 2, 'close browser': 2, }); }); @@ -252,8 +249,7 @@ test('sse transport shared context', async ({ serverEndpoint, server }) => { await expect.poll(() => formatLog(stderr())).toEqual({ 'create SSE session': 2, 'delete SSE session': 2, - 'obtain browser (persistent)': 1, - 'create browser context (persistent)': 1, + 'create browser (persistent)': 1, 'create context': 2, 'close browser': 1, }); diff --git a/tests/mcp/tracing.spec.ts b/tests/mcp/tracing.spec.ts index 34aa9cb097064..a386bc1af50cf 100644 --- a/tests/mcp/tracing.spec.ts +++ b/tests/mcp/tracing.spec.ts @@ -18,24 +18,6 @@ import fs from 'fs'; import path from 'path'; import { test, expect } from './fixtures'; -test('check that trace is saved with --save-trace', async ({ startClient, server }, testInfo) => { - const outputDir = testInfo.outputPath('output'); - - const { client } = await startClient({ - args: ['--save-trace', `--output-dir=${outputDir}`], - }); - - expect(await client.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - })).toHaveResponse({ - code: expect.stringContaining(`page.goto('http://localhost`), - }); - - const files = await fs.promises.readdir(outputDir); - expect(files).toContain('traces'); -}); - test('check that trace is saved with browser_start_tracing', async ({ startClient, server }, testInfo) => { const outputDir = testInfo.outputPath('output'); diff --git a/tests/mcp/video.spec.ts b/tests/mcp/video.spec.ts index 3ee00efee88b9..6481bdbb124db 100644 --- a/tests/mcp/video.spec.ts +++ b/tests/mcp/video.spec.ts @@ -14,52 +14,10 @@ * limitations under the License. */ -import fs from 'fs'; -import path from 'path'; import { test, expect } from './fixtures'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; for (const mode of ['isolated', 'persistent']) { - test(`should work with --save-video (${mode})`, async ({ startClient, server }, testInfo) => { - const outputDir = testInfo.outputPath('output'); - - const { client } = await startClient({ - args: [ - '--save-video=800x600', - ...(mode === 'isolated' ? ['--isolated'] : []), - '--output-dir', outputDir, - ], - }); - - await navigateToTestPage(client, server); - await produceFrames(client); - await closeBrowser(client); - - const videosDir = path.join(outputDir, 'videos'); - const [file] = await fs.promises.readdir(videosDir); - expect(file).toMatch(/.*\.webm/); - }); - - test(`should work with { saveVideo } (${mode})`, async ({ startClient, server }, testInfo) => { - const outputDir = testInfo.outputPath('output'); - - const { client } = await startClient({ - config: { - browser: { isolated: mode === 'isolated' }, - saveVideo: { width: 800, height: 600 }, - outputDir, - } - }); - - await navigateToTestPage(client, server); - await produceFrames(client); - await closeBrowser(client); - - const videosDir = path.join(outputDir, 'videos'); - const [file] = await fs.promises.readdir(videosDir); - expect(file).toMatch(/.*\.webm/); - }); - test(`should work with recordVideo (${mode})`, async ({ startClient, server }, testInfo) => { const videosDir = testInfo.outputPath('videos');