diff --git a/docs/src/api/class-browsertype.md b/docs/src/api/class-browsertype.md index 1d658a5b5f915..ccb2af7cb72ef 100644 --- a/docs/src/api/class-browsertype.md +++ b/docs/src/api/class-browsertype.md @@ -211,12 +211,12 @@ A CDP websocket endpoint or http url to connect to. For example `http://localhos Additional HTTP headers to be sent with connect request. Optional. -### option: BrowserType.connectOverCDP.slowMo -* since: v1.11 -- `slowMo` <[float]> +### option: BrowserType.connectOverCDP.isLocal +* since: v1.58 +- `isLocal` <[boolean]> -Slows down Playwright operations by the specified amount of milliseconds. Useful so that you -can see what is going on. Defaults to 0. +Tells Playwright that it runs on the same host as the CDP server. It will enable certain optimizations that rely upon +the file system being the same between Playwright and the Browser. ### option: BrowserType.connectOverCDP.logger * since: v1.14 @@ -226,6 +226,13 @@ can see what is going on. Defaults to 0. Logger sink for Playwright logging. Optional. +### option: BrowserType.connectOverCDP.slowMo +* since: v1.11 +- `slowMo` <[float]> + +Slows down Playwright operations by the specified amount of milliseconds. Useful so that you +can see what is going on. Defaults to 0. + ### option: BrowserType.connectOverCDP.timeout * since: v1.11 - `timeout` <[float]> diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 6788988908458..471274b115811 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -22046,6 +22046,12 @@ export interface ConnectOverCDPOptions { */ headers?: { [key: string]: string; }; + /** + * Tells Playwright that it runs on the same host as the CDP server. It will enable certain optimizations that rely + * upon the file system being the same between Playwright and the Browser. + */ + isLocal?: boolean; + /** * Logger sink for Playwright logging. Optional. * @deprecated The logs received by the logger are incomplete. Please use tracing instead. diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index b13037d356197..45be8e2f20166 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -201,6 +201,7 @@ export class BrowserType extends ChannelOwner imple headers, slowMo: params.slowMo, timeout: new TimeoutSettings(this._platform).timeout(params), + isLocal: params.isLocal, }); const browser = Browser.from(result.browser); browser._connectToBrowserType(this, {}, params.logger); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index d75cb62722b0c..f54c9a256384e 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -623,6 +623,7 @@ scheme.BrowserTypeConnectOverCDPParams = tObject({ headers: tOptional(tArray(tType('NameValue'))), slowMo: tOptional(tFloat), timeout: tFloat, + isLocal: tOptional(tBoolean), }); scheme.BrowserTypeConnectOverCDPResult = tObject({ browser: tChannel(['Browser']), diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index b4f843f22ae93..8bb1fb4bb23a2 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -284,7 +284,7 @@ export abstract class BrowserType extends SdkObject { await fs.promises.mkdir(options.tracesDir, { recursive: true }); } - async connectOverCDP(progress: Progress, endpointURL: string, options: { slowMo?: number, timeout?: number, headers?: types.HeadersArray }): Promise { + async connectOverCDP(progress: Progress, endpointURL: string, options: { slowMo?: number, timeout?: number, headers?: types.HeadersArray, isLocal?: boolean }): Promise { throw new Error('CDP connections are only supported by Chromium'); } diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index d782868fd5a64..ceca33eaab797 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -76,11 +76,11 @@ export class Chromium extends BrowserType { return super.launchPersistentContext(progress, userDataDir, options); } - override async connectOverCDP(progress: Progress, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray }) { + override async connectOverCDP(progress: Progress, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray, isLocal?: boolean }) { return await this._connectOverCDPInternal(progress, endpointURL, options); } - async _connectOverCDPInternal(progress: Progress, endpointURL: string, options: types.LaunchOptions & { headers?: types.HeadersArray }, onClose?: () => Promise) { + async _connectOverCDPInternal(progress: Progress, endpointURL: string, options: types.LaunchOptions & { headers?: types.HeadersArray, isLocal?: boolean }, onClose?: () => Promise) { let headersMap: { [key: string]: string; } | undefined; if (options.headers) headersMap = headersArrayToObject(options.headers, false); @@ -125,7 +125,8 @@ export class Chromium extends BrowserType { }; validateBrowserContextOptions(persistent, browserOptions); const browser = await progress.race(CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions)); - browser._isCollocatedWithServer = false; + if (!options.isLocal) + browser._isCollocatedWithServer = false; browser.on(Browser.Events.Disconnected, doCleanup); return browser; } catch (error) { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 6788988908458..471274b115811 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -22046,6 +22046,12 @@ export interface ConnectOverCDPOptions { */ headers?: { [key: string]: string; }; + /** + * Tells Playwright that it runs on the same host as the CDP server. It will enable certain optimizations that rely + * upon the file system being the same between Playwright and the Browser. + */ + isLocal?: boolean; + /** * Logger sink for Playwright logging. Optional. * @deprecated The logs received by the logger are incomplete. Please use tracing instead. diff --git a/packages/playwright/src/mcp/extension/extensionContextFactory.ts b/packages/playwright/src/mcp/extension/extensionContextFactory.ts index c59cff05c9376..a3b9d54e94c22 100644 --- a/packages/playwright/src/mcp/extension/extensionContextFactory.ts +++ b/packages/playwright/src/mcp/extension/extensionContextFactory.ts @@ -50,7 +50,7 @@ export class ExtensionContextFactory implements BrowserContextFactory { private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise { const relay = await this._startRelay(abortSignal); await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName); - return await playwright.chromium.connectOverCDP(relay.cdpEndpoint()); + return await playwright.chromium.connectOverCDP(relay.cdpEndpoint(), { isLocal: true }); } private async _startRelay(abortSignal: AbortSignal) { diff --git a/packages/playwright/src/mcp/program.ts b/packages/playwright/src/mcp/program.ts index 1c0389dcfb7eb..d26c6fa8032fe 100644 --- a/packages/playwright/src/mcp/program.ts +++ b/packages/playwright/src/mcp/program.ts @@ -25,12 +25,10 @@ import * as mcpServer from './sdk/server'; import { commaSeparatedList, dotenvFileLoader, enumParser, headerParser, numberParser, resolutionParser, resolveCLIConfig, semicolonSeparatedList } from './browser/config'; import { setupExitWatchdog } from './browser/watchdog'; import { contextFactory } from './browser/browserContextFactory'; -import { ProxyBackend } from './sdk/proxyBackend'; import { BrowserServerBackend } from './browser/browserServerBackend'; import { ExtensionContextFactory } from './extension/extensionContextFactory'; import type { Command } from 'playwright-core/lib/utilsBundle'; -import type { MCPProvider } from './sdk/proxyBackend'; export function decorateCommand(command: Command, version: string) { command @@ -73,7 +71,6 @@ export function decorateCommand(command: Command, version: string) { .option('--user-agent ', 'specify user agent string') .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280x720"', resolutionParser.bind(null, '--viewport-size')) - .addOption(new ProgramOption('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp()) .addOption(new ProgramOption('--vision', 'Legacy option, use --caps=vision instead').hideHelp()) .action(async options => { setupExitWatchdog(); @@ -108,29 +105,6 @@ export function decorateCommand(command: Command, version: string) { return; } - if (options.connectTool) { - const providers: MCPProvider[] = [ - { - name: 'default', - description: 'Starts standalone browser', - connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, browserContextFactory)), - }, - { - name: 'extension', - description: 'Connect to a browser using the Playwright MCP extension', - connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, extensionContextFactory)), - }, - ]; - const factory: mcpServer.ServerBackendFactory = { - name: 'Playwright w/ switch', - nameInConfig: 'playwright-switch', - version, - create: () => new ProxyBackend(providers), - }; - await mcpServer.start(factory, config.server); - return; - } - const factory: mcpServer.ServerBackendFactory = { name: 'Playwright', nameInConfig: 'playwright', diff --git a/packages/playwright/src/mcp/sdk/exports.ts b/packages/playwright/src/mcp/sdk/exports.ts index b30e94b9be501..939b3c230817e 100644 --- a/packages/playwright/src/mcp/sdk/exports.ts +++ b/packages/playwright/src/mcp/sdk/exports.ts @@ -15,7 +15,6 @@ */ export * from './inProcessTransport'; -export * from './proxyBackend'; export * from './server'; export * from './tool'; export * from './http'; diff --git a/packages/playwright/src/mcp/sdk/proxyBackend.ts b/packages/playwright/src/mcp/sdk/proxyBackend.ts deleted file mode 100644 index 46e1d061f30e8..0000000000000 --- a/packages/playwright/src/mcp/sdk/proxyBackend.ts +++ /dev/null @@ -1,134 +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 { debug } from 'playwright-core/lib/utilsBundle'; -import * as mcpBundle from 'playwright-core/lib/mcpBundle'; - -import type { ServerBackend, ClientInfo } from './server'; -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; -import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; -import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; - -export type MCPProvider = { - name: string; - description: string; - connect(): Transport; -}; - -const errorsDebug = debug('pw:mcp:errors'); -const { z, zodToJsonSchema } = mcpBundle; - -export class ProxyBackend implements ServerBackend { - private _mcpProviders: MCPProvider[]; - private _currentClient: Client | undefined; - private _contextSwitchTool: Tool; - private _clientInfo: ClientInfo | undefined; - - constructor(mcpProviders: MCPProvider[]) { - this._mcpProviders = mcpProviders; - this._contextSwitchTool = this._defineContextSwitchTool(); - } - - async initialize(clientInfo: ClientInfo): Promise { - this._clientInfo = clientInfo; - } - - async listTools(): Promise { - const currentClient = await this._ensureCurrentClient(); - const response = await currentClient.listTools(); - if (this._mcpProviders.length === 1) - return response.tools; - return [ - ...response.tools, - this._contextSwitchTool, - ]; - } - - async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise { - if (name === this._contextSwitchTool.name) - return this._callContextSwitchTool(args); - const currentClient = await this._ensureCurrentClient(); - return await currentClient.callTool({ - name, - arguments: args, - }) as CallToolResult; - } - - serverClosed?(): void { - void this._currentClient?.close().catch(errorsDebug); - } - - private async _callContextSwitchTool(params: any): Promise { - try { - const factory = this._mcpProviders.find(factory => factory.name === params.name); - if (!factory) - throw new Error('Unknown connection method: ' + params.name); - - await this._setCurrentClient(factory); - return { - content: [{ type: 'text', text: '### Result\nSuccessfully changed connection method.\n' }], - }; - } catch (error) { - return { - content: [{ type: 'text', text: `### Result\nError: ${error}\n` }], - isError: true, - }; - } - } - - private _defineContextSwitchTool(): Tool { - return { - name: 'browser_connect', - description: [ - 'Connect to a browser using one of the available methods:', - ...this._mcpProviders.map(factory => `- "${factory.name}": ${factory.description}`), - ].join('\n'), - inputSchema: zodToJsonSchema(z.object({ - name: z.enum(this._mcpProviders.map(factory => factory.name) as [string, ...string[]]).default(this._mcpProviders[0].name).describe('The method to use to connect to the browser'), - }), { strictUnions: true }) as Tool['inputSchema'], - annotations: { - title: 'Connect to a browser context', - readOnlyHint: true, - openWorldHint: false, - }, - }; - } - - private async _ensureCurrentClient(): Promise { - if (this._currentClient) - return this._currentClient; - return await this._setCurrentClient(this._mcpProviders[0]); - } - - private async _setCurrentClient(factory: MCPProvider) { - await this._currentClient?.close(); - this._currentClient = undefined; - - const client = new mcpBundle.Client({ name: 'Playwright MCP Proxy', version: '0.0.0' }); - client.registerCapabilities({ - roots: { - listRoots: true, - }, - }); - client.setRequestHandler(mcpBundle.ListRootsRequestSchema, () => ({ roots: this._clientInfo?.roots || [] })); - client.setRequestHandler(mcpBundle.PingRequestSchema, () => ({})); - - const transport = factory.connect(); - await client.connect(transport); - this._currentClient = client; - return client; - } -} diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 190e39174e32c..c17f6022c8770 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1119,10 +1119,12 @@ export type BrowserTypeConnectOverCDPParams = { headers?: NameValue[], slowMo?: number, timeout: number, + isLocal?: boolean, }; export type BrowserTypeConnectOverCDPOptions = { headers?: NameValue[], slowMo?: number, + isLocal?: boolean, }; export type BrowserTypeConnectOverCDPResult = { browser: BrowserChannel, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index e2a194629d6f2..c6bd21db330ff 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1007,6 +1007,7 @@ BrowserType: items: NameValue slowMo: float? timeout: float + isLocal: boolean? returns: browser: Browser defaultContext: BrowserContext? diff --git a/tests/library/chromium/connect-over-cdp.spec.ts b/tests/library/chromium/connect-over-cdp.spec.ts index b5347e2ab74bf..ad0a3c2948dc3 100644 --- a/tests/library/chromium/connect-over-cdp.spec.ts +++ b/tests/library/chromium/connect-over-cdp.spec.ts @@ -553,6 +553,22 @@ test('setInputFiles should preserve lastModified timestamp', async ({ browserTyp } }); +test('setInputFiles should use local path when isLocal is set', async ({ browserType, toImpl }) => { + const port = 9339 + test.info().workerIndex; + const browserServer = await browserType.launch({ + args: ['--remote-debugging-port=' + port] + }); + try { + const cdpBrowser1 = await browserType.connectOverCDP(`http://127.0.0.1:${port}/`); + expect(toImpl(cdpBrowser1)._isCollocatedWithServer).toBe(false); + + const cdpBrowser2 = await browserType.connectOverCDP(`http://127.0.0.1:${port}/`, { isLocal: true }); + expect(toImpl(cdpBrowser2)._isCollocatedWithServer).toBe(true); + } finally { + await browserServer.close(); + } +}); + test('should print custom ws close error', async ({ browserType, server }) => { server.onceWebSocketConnection((ws, request) => { ws.on('message', message => { diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index f7bc620a7842c..b5272a0ea395a 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -44,38 +44,6 @@ test('test snapshot tool list', async ({ client }) => { ])); }); -test('test tool list proxy mode', async ({ startClient }) => { - const { client } = await startClient({ - args: ['--connect-tool'], - }); - const { tools } = await client.listTools(); - expect(new Set(tools.map(t => t.name))).toEqual(new Set([ - 'browser_click', - 'browser_connect', // the extra tool - 'browser_console_messages', - 'browser_drag', - 'browser_evaluate', - 'browser_file_upload', - 'browser_fill_form', - 'browser_handle_dialog', - 'browser_hover', - 'browser_select_option', - 'browser_type', - 'browser_close', - 'browser_install', - 'browser_navigate_back', - 'browser_navigate', - 'browser_network_requests', - 'browser_press_key', - 'browser_resize', - 'browser_run_code', - 'browser_snapshot', - 'browser_tabs', - 'browser_take_screenshot', - 'browser_wait_for', - ])); -}); - test('test capabilities (pdf)', async ({ startClient }) => { const { client } = await startClient({ args: ['--caps=pdf'],