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;
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');