diff --git a/docs/src/api/class-browsertype.md b/docs/src/api/class-browsertype.md index cef7e0e3bda026..9f7d02654ef156 100644 --- a/docs/src/api/class-browsertype.md +++ b/docs/src/api/class-browsertype.md @@ -63,6 +63,27 @@ This methods attaches Playwright to an existing browser instance. - `timeout` <[float]> Maximum time in milliseconds to wait for the connection to be established. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. +## async method: BrowserType.connectOverCDP +* langs: js +- returns: <[Browser]> + +This methods attaches Playwright to an existing browser instance using the Chrome DevTools Protocol. + +The default browser context is accessible via [`method: Browser.contexts`]. + +:::note +Connecting over the Chrome DevTools Protocol is only supported for Chromium-based browsers. +::: + +### param: BrowserType.connectOverCDP.params +- `params` <[Object]> + - `wsEndpoint` <[string]> A browser websocket endpoint to connect to. + - `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. + - `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. + ## method: BrowserType.executablePath - returns: <[string]> diff --git a/src/client/browserType.ts b/src/client/browserType.ts index 1ed28ea08ac438..84bdcaf3eee27d 100644 --- a/src/client/browserType.ts +++ b/src/client/browserType.ts @@ -185,6 +185,25 @@ export class BrowserType extends ChannelOwner { + if (this.name() !== 'chromium') + throw new Error('Connecting over CDP is only supported in Chromium.'); + const logger = params.logger; + return this._wrapApiCall('browserType.connect', async () => { + const result = await this._channel.cdpConnect({ + wsEndpoint: params.wsEndpoint, + slowMo: params.slowMo, + timeout: params.timeout + }); + const browser = Browser.from(result.browser); + if (result.defaultContext) + browser._contexts.add(BrowserContext.from(result.defaultContext)); + browser._isRemote = true; + browser._logger = logger; + return browser; + }, logger); + } } export class RemoteBrowser extends ChannelOwner { diff --git a/src/dispatchers/browserTypeDispatcher.ts b/src/dispatchers/browserTypeDispatcher.ts index 39469762e9eb41..00860b8145f307 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: browser._defaultContext ? new BrowserContextDispatcher(this._scope, browser._defaultContext!) : undefined, + }; + } } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 762e249700bac5..89e75c9e5e0852 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 cfd66fc2cff6f9..a32af7e3cfe093 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 65757b8fa1ac56..e94f634876494b 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 ed2736ddb7e569..b7dc89dcf57295 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; @@ -35,7 +35,7 @@ export type PlaywrightOptions = { isInternal: boolean }; -export type BrowserOptions = PlaywrightOptions & { +export type BrowserOptions = PlaywrightOptions & types.UIOptions & { name: string, isChromium: boolean, downloadsPath?: string, @@ -45,7 +45,6 @@ export type BrowserOptions = PlaywrightOptions & { proxy?: ProxySettings, protocolLogger: types.ProtocolLogger, browserLogsCollector: RecentLogsCollector, - slowMo?: number, }; export abstract class Browser extends EventEmitter { diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 005607432694b8..8ee657430c7745 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -225,6 +225,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 a0a9a043b2b11e..7d190ac796fd5d 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 * as browserPaths from '../../utils/browserPaths'; import { CRDevTools } from './crDevTools'; -import { BrowserOptions, PlaywrightOptions } from '../browser'; +import { BrowserOptions, BrowserProcess, PlaywrightOptions } 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; @@ -42,6 +46,35 @@ 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 = { + ...this._playwrightOptions, + ...uiOptions, + name: 'chromium', + isChromium: true, + headful: true, + persistent: { noDefaultViewport: true }, + browserProcess, + protocolLogger: helper.debugProtocolLogger(), + browserLogsCollector, + }; + return await CRBrowser.connect(chromeTransport, browserOptions, null); + }, 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 ad3a7e5bb1091b..2a1ef69a38bf26 100644 --- a/src/server/chromium/crBrowser.ts +++ b/src/server/chromium/crBrowser.ts @@ -158,8 +158,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/src/server/types.ts b/src/server/types.ts index dd4da26659f8af..cd6493a7864319 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -254,7 +254,7 @@ export type BrowserContextOptions = { export type EnvArray = { name: string, value: string }[]; -type LaunchOptionsBase = { +type LaunchOptionsBase = UIOptions & { executablePath?: string, args?: string[], ignoreDefaultArgs?: string[], @@ -269,7 +269,6 @@ type LaunchOptionsBase = { proxy?: ProxySettings, downloadsPath?: string, chromiumSandbox?: boolean, - slowMo?: number, }; export type LaunchOptions = LaunchOptionsBase & { firefoxUserPrefs?: { [key: string]: string | number | boolean }, @@ -345,3 +344,7 @@ export type SetStorageState = { cookies?: SetNetworkCookieParam[], origins?: OriginStorage[] } + +export type UIOptions = { + slowMo?: number; +} diff --git a/test/chromium/chromium.spec.ts b/test/chromium/chromium.spec.ts index 01ac984d1ac96b..c172142cca1eeb 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,44 @@ 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.connectOverCDP({ + wsEndpoint: match[1], + }); + 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 66c7b48ec290d9..41e53a2bfd94db 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -6258,6 +6258,39 @@ export interface BrowserType { */ connect(params: ConnectOptions): Promise; + /** + * This methods attaches Playwright to an existing browser instance using the Chrome DevTools Protocol. + * + * The default browser context is accessible via + * [browser.contexts()](https://playwright.dev/docs/api/class-browser#browsercontexts). + * + * > NOTE: Connecting over the Chrome DevTools Protocol is only supported for Chromium-based browsers. + * @param params + */ + connectOverCDP(params: { + /** + * A browser websocket endpoint to connect to. + */ + wsEndpoint: string; + + /** + * Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going on. + * Defaults to 0. + */ + slowMo?: number; + + /** + * Logger sink for Playwright logging. Optional. + */ + logger?: Logger; + + /** + * Maximum time in milliseconds to wait for the connection to be established. Defaults to `30000` (30 seconds). Pass `0` to + * disable timeout. + */ + timeout?: number; + }): Promise; + /** * A path where Playwright expects to find a bundled browser executable. */