diff --git a/docs/src/api/class-browsertype.md b/docs/src/api/class-browsertype.md index 49dcd1d25fa185..4ccaad7c09456c 100644 --- a/docs/src/api/class-browsertype.md +++ b/docs/src/api/class-browsertype.md @@ -62,6 +62,11 @@ This methods attaches Playwright to an existing browser instance. - `logger` <[Logger]> Logger sink for Playwright logging. Optional. - `timeout` <[float]> Maximum time in milliseconds to wait for the connection to be established. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. + - `protocol` <"playwright"|"cdp"> To use a Chrome DevTools Protocol endpoint, pass "cdp". Defaults to "playwright". + +:::note +Connecting over the Chrome DevTools Protocol is only supported for Chromium-based browsers. +::: ## method: BrowserType.executablePath - returns: <[string]> diff --git a/src/client/browserType.ts b/src/client/browserType.ts index 1ed28ea08ac438..f15788e63e1b24 100644 --- a/src/client/browserType.ts +++ b/src/client/browserType.ts @@ -113,77 +113,100 @@ export class BrowserType extends ChannelOwner { const logger = params.logger; return this._wrapApiCall('browserType.connect', async () => { - const connection = new Connection(); + if (!params.protocol || params.protocol === 'playwright') + return this._connectPlaywright(params); + if (params.protocol === 'cdp') + return this._connectCDP(params); + throw new Error(`Unsupported connection protocol: ${params.protocol}`); + }, logger); + } - const ws = new WebSocket(params.wsEndpoint, [], { - perMessageDeflate: false, - maxPayload: 256 * 1024 * 1024, // 256Mb, - handshakeTimeout: this._timeoutSettings.timeout(params), - }); + async _connectCDP(params: ConnectOptions): Promise { + if (this.name() !== 'chromium') + throw new Error('Connecting over CDP is onlly supported for the Chromium BrowserType'); + const result = await this._channel.cdpConnect({ + wsEndpoint: params.wsEndpoint, + slowMo: params.slowMo, + timeout: params.timeout + }); + const browser = Browser.from(result.browser); + browser._contexts.add(BrowserContext.from(result.defaultContext)); + browser._isRemote = true; + browser._logger = params.logger; + return browser; + } - // The 'ws' module in node sometimes sends us multiple messages in a single task. - const waitForNextTask = params.slowMo - ? (cb: () => any) => setTimeout(cb, params.slowMo) - : makeWaitForNextTask(); - connection.onmessage = message => { - if (ws.readyState !== WebSocket.OPEN) { - setTimeout(() => { - connection.dispatch({ id: (message as any).id, error: serializeError(new Error(kBrowserClosedError)) }); - }, 0); - return; + async _connectPlaywright(params: ConnectOptions): Promise { + const connection = new Connection(); + + const ws = new WebSocket(params.wsEndpoint, [], { + perMessageDeflate: false, + maxPayload: 256 * 1024 * 1024, // 256Mb, + handshakeTimeout: this._timeoutSettings.timeout(params), + }); + + // The 'ws' module in node sometimes sends us multiple messages in a single task. + const waitForNextTask = params.slowMo + ? (cb: () => any) => setTimeout(cb, params.slowMo) + : makeWaitForNextTask(); + connection.onmessage = message => { + if (ws.readyState !== WebSocket.OPEN) { + setTimeout(() => { + connection.dispatch({ id: (message as any).id, error: serializeError(new Error(kBrowserClosedError)) }); + }, 0); + return; + } + ws.send(JSON.stringify(message)); + }; + ws.addEventListener('message', event => { + waitForNextTask(() => connection.dispatch(JSON.parse(event.data))); + }); + + return await new Promise(async (fulfill, reject) => { + if ((params as any).__testHookBeforeCreateBrowser) { + try { + await (params as any).__testHookBeforeCreateBrowser(); + } catch (e) { + reject(e); } - ws.send(JSON.stringify(message)); - }; - ws.addEventListener('message', event => { - waitForNextTask(() => connection.dispatch(JSON.parse(event.data))); - }); - - return await new Promise(async (fulfill, reject) => { - if ((params as any).__testHookBeforeCreateBrowser) { - try { - await (params as any).__testHookBeforeCreateBrowser(); - } catch (e) { - reject(e); + } + ws.addEventListener('open', async () => { + const prematureCloseListener = (event: { reason: string }) => { + reject(new Error('Server disconnected: ' + event.reason)); + }; + ws.addEventListener('close', prematureCloseListener); + const remoteBrowser = await connection.waitForObjectWithKnownName('remoteBrowser') as RemoteBrowser; + + // Inherit shared selectors for connected browser. + const selectorsOwner = SelectorsOwner.from(remoteBrowser._initializer.selectors); + sharedSelectors._addChannel(selectorsOwner); + + const browser = Browser.from(remoteBrowser._initializer.browser); + browser._logger = params.logger; + browser._isRemote = true; + const closeListener = () => { + // Emulate all pages, contexts and the browser closing upon disconnect. + for (const context of browser.contexts()) { + for (const page of context.pages()) + page._onClose(); + context._onClose(); } - } - ws.addEventListener('open', async () => { - const prematureCloseListener = (event: { reason: string }) => { - reject(new Error('Server disconnected: ' + event.reason)); - }; - ws.addEventListener('close', prematureCloseListener); - const remoteBrowser = await connection.waitForObjectWithKnownName('remoteBrowser') as RemoteBrowser; - - // Inherit shared selectors for connected browser. - const selectorsOwner = SelectorsOwner.from(remoteBrowser._initializer.selectors); - sharedSelectors._addChannel(selectorsOwner); - - const browser = Browser.from(remoteBrowser._initializer.browser); - browser._logger = logger; - browser._isRemote = true; - const closeListener = () => { - // Emulate all pages, contexts and the browser closing upon disconnect. - for (const context of browser.contexts()) { - for (const page of context.pages()) - page._onClose(); - context._onClose(); - } - browser._didClose(); - }; - ws.removeEventListener('close', prematureCloseListener); - ws.addEventListener('close', closeListener); - browser.on(Events.Browser.Disconnected, () => { - sharedSelectors._removeChannel(selectorsOwner); - ws.removeEventListener('close', closeListener); - ws.close(); - }); - fulfill(browser); - }); - ws.addEventListener('error', event => { + browser._didClose(); + }; + ws.removeEventListener('close', prematureCloseListener); + ws.addEventListener('close', closeListener); + browser.on(Events.Browser.Disconnected, () => { + sharedSelectors._removeChannel(selectorsOwner); + ws.removeEventListener('close', closeListener); ws.close(); - reject(new Error(event.message + '. Most likely ws endpoint is incorrect')); }); + fulfill(browser); }); - }, logger); + ws.addEventListener('error', event => { + ws.close(); + reject(new Error(event.message + '. Most likely ws endpoint is incorrect')); + }); + }); } } diff --git a/src/client/types.ts b/src/client/types.ts index d393c3a278cbd6..3abb74a53465ce 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -75,6 +75,7 @@ export type ConnectOptions = { slowMo?: number, timeout?: number, logger?: Logger, + protocol?: 'playwright'|'cdp', }; export type LaunchServerOptions = { executablePath?: string, diff --git a/src/dispatchers/browserTypeDispatcher.ts b/src/dispatchers/browserTypeDispatcher.ts index 39469762e9eb41..68d83086bcd8ba 100644 --- a/src/dispatchers/browserTypeDispatcher.ts +++ b/src/dispatchers/browserTypeDispatcher.ts @@ -37,4 +37,12 @@ export class BrowserTypeDispatcher extends Dispatcher { + const browser = await this._object.cdpConnect(params.wsEndpoint, params, params.timeout); + return { + browser: new BrowserDispatcher(this._scope, browser), + defaultContext: new BrowserContextDispatcher(this._scope, browser._defaultContext!), + }; + } } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 582238705ed610..ed28ac34e05598 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -197,6 +197,7 @@ export type BrowserTypeInitializer = { export interface BrowserTypeChannel extends Channel { launch(params: BrowserTypeLaunchParams, metadata?: Metadata): Promise; launchPersistentContext(params: BrowserTypeLaunchPersistentContextParams, metadata?: Metadata): Promise; + cdpConnect(params: BrowserTypeCdpConnectParams, metadata?: Metadata): Promise; } export type BrowserTypeLaunchParams = { executablePath?: string, @@ -377,6 +378,19 @@ export type BrowserTypeLaunchPersistentContextOptions = { export type BrowserTypeLaunchPersistentContextResult = { context: BrowserContextChannel, }; +export type BrowserTypeCdpConnectParams = { + wsEndpoint: string, + slowMo?: number, + timeout?: number, +}; +export type BrowserTypeCdpConnectOptions = { + slowMo?: number, + timeout?: number, +}; +export type BrowserTypeCdpConnectResult = { + browser: BrowserChannel, + defaultContext: BrowserContextChannel, +}; // ----------- Browser ----------- export type BrowserInitializer = { diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index afb0948384414f..9d06473aace0d1 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -391,6 +391,14 @@ BrowserType: returns: context: BrowserContext + cdpConnect: + parameters: + wsEndpoint: string + slowMo: number? + timeout: number? + returns: + browser: Browser + defaultContext: BrowserContext Browser: type: interface diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index ceb75d7e7abae4..0c6dae7ca307d2 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -225,6 +225,11 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { path: tString, })), }); + scheme.BrowserTypeCdpConnectParams = tObject({ + wsEndpoint: tString, + slowMo: tOptional(tNumber), + timeout: tOptional(tNumber), + }); scheme.BrowserCloseParams = tOptional(tObject({})); scheme.BrowserNewContextParams = tObject({ noDefaultViewport: tOptional(tBoolean), diff --git a/src/server/browser.ts b/src/server/browser.ts index c37501dca7c444..4ff00ec284fd62 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -24,7 +24,7 @@ import { ChildProcess } from 'child_process'; import { RecentLogsCollector } from '../utils/debugLogger'; export interface BrowserProcess { - onclose: ((exitCode: number | null, signal: string | null) => void) | undefined; + onclose?: ((exitCode: number | null, signal: string | null) => void); process?: ChildProcess; kill(): Promise; close(): Promise; diff --git a/src/server/browserType.ts b/src/server/browserType.ts index b24dbf7220ab82..8b712b5db7937b 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -208,6 +208,10 @@ export abstract class BrowserType { return { browserProcess, downloadsPath, transport }; } + async cdpConnect(wsEndpoint: string, uiOptions: types.UIOptions, timeout?: number): Promise { + throw new Error('CDP connections are only supported by Chromium'); + } + abstract _defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[]; abstract _connectToTransport(transport: ConnectionTransport, options: BrowserOptions): Promise; abstract _amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env; diff --git a/src/server/chromium/chromium.ts b/src/server/chromium/chromium.ts index f7b3ab52f2da44..5c6f584fce9eec 100644 --- a/src/server/chromium/chromium.ts +++ b/src/server/chromium/chromium.ts @@ -21,12 +21,16 @@ import { Env } from '../processLauncher'; import { kBrowserCloseMessageId } from './crConnection'; import { rewriteErrorMessage } from '../../utils/stackTrace'; import { BrowserType } from '../browserType'; -import { ConnectionTransport, ProtocolRequest } from '../transport'; +import { ConnectionTransport, ProtocolRequest, WebSocketTransport } from '../transport'; import type { BrowserDescriptor } from '../../utils/browserPaths'; import { CRDevTools } from './crDevTools'; -import { BrowserOptions } from '../browser'; +import { BrowserOptions, BrowserProcess } from '../browser'; import * as types from '../types'; import { isDebugMode } from '../../utils/utils'; +import { RecentLogsCollector } from '../../utils/debugLogger'; +import { ProgressController } from '../progress'; +import { TimeoutSettings } from '../../utils/timeoutSettings'; +import { helper } from '../helper'; export class Chromium extends BrowserType { private _devtools: CRDevTools | undefined; @@ -37,6 +41,34 @@ export class Chromium extends BrowserType { this._devtools = this._createDevTools(); } + async cdpConnect(wsEndpoint: string, uiOptions: types.UIOptions, timeout?: number) { + const controller = new ProgressController(); + controller.setLogName('browser'); + const browserLogsCollector = new RecentLogsCollector(); + return controller.run(async progress => { + const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint); + const browserProcess: BrowserProcess = { + close: async () => { + await chromeTransport.closeAndWait(); + }, + kill: async () => { + await chromeTransport.closeAndWait(); + } + }; + const browserOptions: BrowserOptions = { + ...uiOptions, + name: 'chromium', + isChromium: true, + headful: true, + persistent: { noDefaultViewport: true }, + browserProcess, + protocolLogger: helper.debugProtocolLogger(), + browserLogsCollector, + }; + return await CRBrowser.connect(chromeTransport, browserOptions); + }, TimeoutSettings.timeout({timeout})); + } + private _createDevTools() { return new CRDevTools(path.join(this._browserPath, 'devtools-preferences.json')); } diff --git a/src/server/chromium/crBrowser.ts b/src/server/chromium/crBrowser.ts index a1c394d28fecef..b47b646599a421 100644 --- a/src/server/chromium/crBrowser.ts +++ b/src/server/chromium/crBrowser.ts @@ -156,8 +156,9 @@ export class CRBrowser extends Browser { if (targetInfo.type === 'background_page') { const backgroundPage = new CRPage(session, targetInfo.targetId, context, null, false); this._backgroundPages.set(targetInfo.targetId, backgroundPage); - backgroundPage.pageOrError().then(() => { - context!.emit(CRBrowserContext.CREvents.BackgroundPage, backgroundPage._page); + backgroundPage.pageOrError().then(pageOrError => { + if (pageOrError instanceof Page) + context!.emit(CRBrowserContext.CREvents.BackgroundPage, backgroundPage._page); }); return; } diff --git a/src/server/transport.ts b/src/server/transport.ts index eda689da2316a2..2f166b58bcd6b0 100644 --- a/src/server/transport.ts +++ b/src/server/transport.ts @@ -113,8 +113,8 @@ export class WebSocketTransport implements ConnectionTransport { } async closeAndWait() { - const promise = new Promise(f => this.onclose = f); + const promise = new Promise(f => this._ws.once('close', f)); this.close(); - return promise; // Make sure to await the actual disconnect. + await promise; // Make sure to await the actual disconnect. } } diff --git a/test/chromium/chromium.spec.ts b/test/chromium/chromium.spec.ts index 01ac984d1ac96b..0944109a4e85e6 100644 --- a/test/chromium/chromium.spec.ts +++ b/test/chromium/chromium.spec.ts @@ -1,5 +1,6 @@ /** * Copyright 2018 Google Inc. All rights reserved. + * Modifications 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. @@ -14,7 +15,9 @@ * limitations under the License. */ import { it, expect, describe } from '../fixtures'; -import type { ChromiumBrowserContext } from '../..'; +import type { ChromiumBrowserContext, chromium } from '../..'; +import { ChildProcess, spawn } from 'child_process'; +import readline from 'readline'; describe('chromium', (suite, { browserName }) => { suite.skip(browserName !== 'chromium'); @@ -88,4 +91,45 @@ describe('chromium', (suite, { browserName }) => { // make it work with Edgium. expect(serverRequest.headers.intervention).toContain('feature/5718547946799104'); }); + + it('should connect to an existing cdp session', async ({browserType, testWorkerIndex, browserOptions, createUserDataDir }) => { + const port = 9339 + testWorkerIndex; + const spawnedProcess = spawn( + browserType.executablePath(), + [`--remote-debugging-port=${port}`, `--user-data-dir=${await createUserDataDir()}`], + { + // On non-windows platforms, `detached: true` makes child process a leader of a new + // process group, making it possible to kill child process tree with `.kill(-pid)` command. + // @see https://nodejs.org/api/child_process.html#child_process_options_detached + detached: process.platform !== 'win32', + } + ); + try { + const match = await waitForLine(spawnedProcess, /^DevTools listening on (ws:\/\/.*)$/); + const crBrowserType = browserType as typeof chromium; + const cdpBrowser = await crBrowserType.connect({ + wsEndpoint: match[1], + protocol: 'cdp' + }); + const contexts = cdpBrowser.contexts(); + expect(contexts.length).toBe(1); + await cdpBrowser.close(); + } finally { + spawnedProcess.kill(); + } + }); }); + +function waitForLine(process: ChildProcess, regex: RegExp): Promise { + return new Promise(resolve => { + const rl = readline.createInterface({ input: process.stderr }); + rl.on('line', onLine); + + function onLine(line: string) { + const match = line.match(regex); + if (!match) + return; + resolve(match); + } + }); +} diff --git a/types/types.d.ts b/types/types.d.ts index 2f2b8ac75495f5..b8d653e593908d 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -6276,7 +6276,7 @@ export interface BrowserType { /** * This methods attaches Playwright to an existing browser instance. - * @param params + * @param params > NOTE: Connecting over the Chrome DevTools Protocol is only supported for Chromium-based browsers. */ connect(params: ConnectOptions): Promise; @@ -9621,6 +9621,11 @@ export interface ConnectOptions { * disable timeout. */ timeout?: number; + + /** + * To use a Chrome DevTools Protocol endpoint, pass "cdp". Defaults to "playwright". + */ + protocol?: "playwright"|"cdp"; } interface ElementHandleWaitForSelectorOptions {