diff --git a/docs/src/api/class-browsertype.md b/docs/src/api/class-browsertype.md index 8a71b6019b..54ae53f454 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 CDP 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 1ed28ea08a..33eb6bc4fa 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.connectOverCDP', async () => { + const result = await this._channel.connectOverCDP({ + 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 ccf3bd14d6..e865ee2a44 100644 --- a/src/dispatchers/browserTypeDispatcher.ts +++ b/src/dispatchers/browserTypeDispatcher.ts @@ -38,4 +38,12 @@ export class BrowserTypeDispatcher extends Dispatcher { + const browser = await this._object.connectOverCDP(metadata, 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 762e249700..b76e58c504 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; + connectOverCDP(params: BrowserTypeConnectOverCDPParams, metadata?: Metadata): Promise; } export type BrowserTypeLaunchParams = { executablePath?: string, @@ -377,6 +378,19 @@ export type BrowserTypeLaunchPersistentContextOptions = { export type BrowserTypeLaunchPersistentContextResult = { context: BrowserContextChannel, }; +export type BrowserTypeConnectOverCDPParams = { + wsEndpoint: string, + slowMo?: number, + timeout?: number, +}; +export type BrowserTypeConnectOverCDPOptions = { + slowMo?: number, + timeout?: number, +}; +export type BrowserTypeConnectOverCDPResult = { + browser: BrowserChannel, + defaultContext?: BrowserContextChannel, +}; // ----------- Browser ----------- export type BrowserInitializer = { diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index cfd66fc2cf..da41f56579 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -391,6 +391,14 @@ BrowserType: returns: context: BrowserContext + connectOverCDP: + parameters: + wsEndpoint: string + slowMo: number? + timeout: number? + returns: + browser: Browser + defaultContext: BrowserContext? Browser: type: interface @@ -1147,7 +1155,7 @@ Frame: evaluateExpression: parameters: expression: string - isFunction: boolean? + isFunction: boolean? arg: SerializedArgument world: type: enum? diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index 65757b8fa1..7699dfe123 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.BrowserTypeConnectOverCDPParams = 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 88f3eac5d8..47064f2233 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -25,7 +25,7 @@ import * as registry from '../utils/registry'; import { SdkObject } from './instrumentation'; 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; @@ -37,7 +37,7 @@ export type PlaywrightOptions = { rootSdkObject: SdkObject, }; -export type BrowserOptions = PlaywrightOptions & { +export type BrowserOptions = PlaywrightOptions & types.UIOptions & { name: string, isChromium: boolean, downloadsPath?: string, @@ -47,7 +47,6 @@ export type BrowserOptions = PlaywrightOptions & { proxy?: ProxySettings, protocolLogger: types.ProtocolLogger, browserLogsCollector: RecentLogsCollector, - slowMo?: number, }; export abstract class Browser extends SdkObject { diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 8536365f03..52caba7582 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -223,6 +223,10 @@ export abstract class BrowserType extends SdkObject { return { browserProcess, downloadsPath, transport }; } + async connectOverCDP(metadata: CallMetadata, 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 dc6f46c5a9..49dddfa991 100644 --- a/src/server/chromium/chromium.ts +++ b/src/server/chromium/chromium.ts @@ -21,11 +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 { 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'; +import { CallMetadata } from '../instrumentation'; export class Chromium extends BrowserType { private _devtools: CRDevTools | undefined; @@ -37,6 +42,34 @@ export class Chromium extends BrowserType { this._devtools = this._createDevTools(); } + async connectOverCDP(metadata: CallMetadata, wsEndpoint: string, uiOptions: types.UIOptions, timeout?: number) { + const controller = new ProgressController(metadata, this); + 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, + 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._registry.browserDirectory('chromium'), 'devtools-preferences.json')); } diff --git a/src/server/chromium/crBrowser.ts b/src/server/chromium/crBrowser.ts index 794f987339..006e9653fb 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 9070d0d07f..de35f73863 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 dd4da26659..cd6493a786 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 01ac984d1a..55e0f0240d 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. @@ -15,6 +16,7 @@ */ import { it, expect, describe } from '../fixtures'; import type { ChromiumBrowserContext } from '../..'; +import http from 'http'; describe('chromium', (suite, { browserName }) => { suite.skip(browserName !== 'chromium'); @@ -88,4 +90,31 @@ describe('chromium', (suite, { browserName }) => { // make it work with Edgium. expect(serverRequest.headers.intervention).toContain('feature/5718547946799104'); }); + + it('should connect to an existing cdp session 2', (test, {headful}) => { + test.skip(headful, 'Chromium currently doesn\'t support --remote-debugging-port and --remote-debugging-pipe at the same time.'); + }, async ({browserType, testWorkerIndex, browserOptions, createUserDataDir }) => { + const port = 9339 + testWorkerIndex; + const browserServer = await browserType.launch({ + ...browserOptions, + args: ['--remote-debugging-port=' + port] + }); + try { + const json = await new Promise((resolve, reject) => { + http.get(`http://localhost:${port}/json/version/`, resp => { + let data = ''; + resp.on('data', chunk => data += chunk); + resp.on('end', () => resolve(data)); + }).on('error', reject); + }); + const cdpBrowser = await browserType.connectOverCDP({ + wsEndpoint: JSON.parse(json).webSocketDebuggerUrl, + }); + const contexts = cdpBrowser.contexts(); + expect(contexts.length).toBe(1); + await cdpBrowser.close(); + } finally { + await browserServer.close(); + } + }); }); diff --git a/types/types.d.ts b/types/types.d.ts index 8f07e87d60..6294d91c88 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -6262,6 +6262,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 CDP 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. */