feat(chromium): connect to a browser over cdp (#5207)

This commit is contained in:
Joel Einbinder 2021-02-10 14:00:02 -08:00 committed by GitHub
parent a8ebe4d888
commit dca70abbd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 189 additions and 12 deletions

View File

@ -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 - `timeout` <[float]> Maximum time in milliseconds to wait for the connection to be established. Defaults to
`30000` (30 seconds). Pass `0` to disable timeout. `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 ## method: BrowserType.executablePath
- returns: <[string]> - returns: <[string]>

View File

@ -185,6 +185,25 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
}); });
}, logger); }, logger);
} }
async connectOverCDP(params: ConnectOptions): Promise<Browser> {
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<channels.RemoteBrowserChannel, channels.RemoteBrowserInitializer> { export class RemoteBrowser extends ChannelOwner<channels.RemoteBrowserChannel, channels.RemoteBrowserInitializer> {

View File

@ -38,4 +38,12 @@ export class BrowserTypeDispatcher extends Dispatcher<BrowserType, channels.Brow
const browserContext = await this._object.launchPersistentContext(metadata, params.userDataDir, params); const browserContext = await this._object.launchPersistentContext(metadata, params.userDataDir, params);
return { context: new BrowserContextDispatcher(this._scope, browserContext) }; return { context: new BrowserContextDispatcher(this._scope, browserContext) };
} }
async connectOverCDP(params: channels.BrowserTypeConnectOverCDPParams, metadata: CallMetadata): Promise<channels.BrowserTypeConnectOverCDPResult> {
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,
};
}
} }

View File

@ -197,6 +197,7 @@ export type BrowserTypeInitializer = {
export interface BrowserTypeChannel extends Channel { export interface BrowserTypeChannel extends Channel {
launch(params: BrowserTypeLaunchParams, metadata?: Metadata): Promise<BrowserTypeLaunchResult>; launch(params: BrowserTypeLaunchParams, metadata?: Metadata): Promise<BrowserTypeLaunchResult>;
launchPersistentContext(params: BrowserTypeLaunchPersistentContextParams, metadata?: Metadata): Promise<BrowserTypeLaunchPersistentContextResult>; launchPersistentContext(params: BrowserTypeLaunchPersistentContextParams, metadata?: Metadata): Promise<BrowserTypeLaunchPersistentContextResult>;
connectOverCDP(params: BrowserTypeConnectOverCDPParams, metadata?: Metadata): Promise<BrowserTypeConnectOverCDPResult>;
} }
export type BrowserTypeLaunchParams = { export type BrowserTypeLaunchParams = {
executablePath?: string, executablePath?: string,
@ -377,6 +378,19 @@ export type BrowserTypeLaunchPersistentContextOptions = {
export type BrowserTypeLaunchPersistentContextResult = { export type BrowserTypeLaunchPersistentContextResult = {
context: BrowserContextChannel, 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 ----------- // ----------- Browser -----------
export type BrowserInitializer = { export type BrowserInitializer = {

View File

@ -391,6 +391,14 @@ BrowserType:
returns: returns:
context: BrowserContext context: BrowserContext
connectOverCDP:
parameters:
wsEndpoint: string
slowMo: number?
timeout: number?
returns:
browser: Browser
defaultContext: BrowserContext?
Browser: Browser:
type: interface type: interface

View File

@ -225,6 +225,11 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
path: tString, path: tString,
})), })),
}); });
scheme.BrowserTypeConnectOverCDPParams = tObject({
wsEndpoint: tString,
slowMo: tOptional(tNumber),
timeout: tOptional(tNumber),
});
scheme.BrowserCloseParams = tOptional(tObject({})); scheme.BrowserCloseParams = tOptional(tObject({}));
scheme.BrowserNewContextParams = tObject({ scheme.BrowserNewContextParams = tObject({
noDefaultViewport: tOptional(tBoolean), noDefaultViewport: tOptional(tBoolean),

View File

@ -25,7 +25,7 @@ import * as registry from '../utils/registry';
import { SdkObject } from './instrumentation'; import { SdkObject } from './instrumentation';
export interface BrowserProcess { export interface BrowserProcess {
onclose: ((exitCode: number | null, signal: string | null) => void) | undefined; onclose?: ((exitCode: number | null, signal: string | null) => void);
process?: ChildProcess; process?: ChildProcess;
kill(): Promise<void>; kill(): Promise<void>;
close(): Promise<void>; close(): Promise<void>;
@ -37,7 +37,7 @@ export type PlaywrightOptions = {
rootSdkObject: SdkObject, rootSdkObject: SdkObject,
}; };
export type BrowserOptions = PlaywrightOptions & { export type BrowserOptions = PlaywrightOptions & types.UIOptions & {
name: string, name: string,
isChromium: boolean, isChromium: boolean,
downloadsPath?: string, downloadsPath?: string,
@ -47,7 +47,6 @@ export type BrowserOptions = PlaywrightOptions & {
proxy?: ProxySettings, proxy?: ProxySettings,
protocolLogger: types.ProtocolLogger, protocolLogger: types.ProtocolLogger,
browserLogsCollector: RecentLogsCollector, browserLogsCollector: RecentLogsCollector,
slowMo?: number,
}; };
export abstract class Browser extends SdkObject { export abstract class Browser extends SdkObject {

View File

@ -223,6 +223,10 @@ export abstract class BrowserType extends SdkObject {
return { browserProcess, downloadsPath, transport }; return { browserProcess, downloadsPath, transport };
} }
async connectOverCDP(metadata: CallMetadata, wsEndpoint: string, uiOptions: types.UIOptions, timeout?: number): Promise<Browser> {
throw new Error('CDP connections are only supported by Chromium');
}
abstract _defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[]; abstract _defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[];
abstract _connectToTransport(transport: ConnectionTransport, options: BrowserOptions): Promise<Browser>; abstract _connectToTransport(transport: ConnectionTransport, options: BrowserOptions): Promise<Browser>;
abstract _amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env; abstract _amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env;

View File

@ -21,11 +21,16 @@ import { Env } from '../processLauncher';
import { kBrowserCloseMessageId } from './crConnection'; import { kBrowserCloseMessageId } from './crConnection';
import { rewriteErrorMessage } from '../../utils/stackTrace'; import { rewriteErrorMessage } from '../../utils/stackTrace';
import { BrowserType } from '../browserType'; import { BrowserType } from '../browserType';
import { ConnectionTransport, ProtocolRequest } from '../transport'; import { ConnectionTransport, ProtocolRequest, WebSocketTransport } from '../transport';
import { CRDevTools } from './crDevTools'; import { CRDevTools } from './crDevTools';
import { BrowserOptions, PlaywrightOptions } from '../browser'; import { BrowserOptions, BrowserProcess, PlaywrightOptions } from '../browser';
import * as types from '../types'; import * as types from '../types';
import { isDebugMode } from '../../utils/utils'; 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 { export class Chromium extends BrowserType {
private _devtools: CRDevTools | undefined; private _devtools: CRDevTools | undefined;
@ -37,6 +42,34 @@ export class Chromium extends BrowserType {
this._devtools = this._createDevTools(); 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() { private _createDevTools() {
return new CRDevTools(path.join(this._registry.browserDirectory('chromium'), 'devtools-preferences.json')); return new CRDevTools(path.join(this._registry.browserDirectory('chromium'), 'devtools-preferences.json'));
} }

View File

@ -156,7 +156,8 @@ export class CRBrowser extends Browser {
if (targetInfo.type === 'background_page') { if (targetInfo.type === 'background_page') {
const backgroundPage = new CRPage(session, targetInfo.targetId, context, null, false); const backgroundPage = new CRPage(session, targetInfo.targetId, context, null, false);
this._backgroundPages.set(targetInfo.targetId, backgroundPage); this._backgroundPages.set(targetInfo.targetId, backgroundPage);
backgroundPage.pageOrError().then(() => { backgroundPage.pageOrError().then(pageOrError => {
if (pageOrError instanceof Page)
context!.emit(CRBrowserContext.CREvents.BackgroundPage, backgroundPage._page); context!.emit(CRBrowserContext.CREvents.BackgroundPage, backgroundPage._page);
}); });
return; return;

View File

@ -113,8 +113,8 @@ export class WebSocketTransport implements ConnectionTransport {
} }
async closeAndWait() { async closeAndWait() {
const promise = new Promise(f => this.onclose = f); const promise = new Promise(f => this._ws.once('close', f));
this.close(); this.close();
return promise; // Make sure to await the actual disconnect. await promise; // Make sure to await the actual disconnect.
} }
} }

View File

@ -254,7 +254,7 @@ export type BrowserContextOptions = {
export type EnvArray = { name: string, value: string }[]; export type EnvArray = { name: string, value: string }[];
type LaunchOptionsBase = { type LaunchOptionsBase = UIOptions & {
executablePath?: string, executablePath?: string,
args?: string[], args?: string[],
ignoreDefaultArgs?: string[], ignoreDefaultArgs?: string[],
@ -269,7 +269,6 @@ type LaunchOptionsBase = {
proxy?: ProxySettings, proxy?: ProxySettings,
downloadsPath?: string, downloadsPath?: string,
chromiumSandbox?: boolean, chromiumSandbox?: boolean,
slowMo?: number,
}; };
export type LaunchOptions = LaunchOptionsBase & { export type LaunchOptions = LaunchOptionsBase & {
firefoxUserPrefs?: { [key: string]: string | number | boolean }, firefoxUserPrefs?: { [key: string]: string | number | boolean },
@ -345,3 +344,7 @@ export type SetStorageState = {
cookies?: SetNetworkCookieParam[], cookies?: SetNetworkCookieParam[],
origins?: OriginStorage[] origins?: OriginStorage[]
} }
export type UIOptions = {
slowMo?: number;
}

View File

@ -1,5 +1,6 @@
/** /**
* Copyright 2018 Google Inc. All rights reserved. * Copyright 2018 Google Inc. All rights reserved.
* Modifications copyright (c) Microsoft Corporation.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 { it, expect, describe } from '../fixtures';
import type { ChromiumBrowserContext } from '../..'; import type { ChromiumBrowserContext } from '../..';
import http from 'http';
describe('chromium', (suite, { browserName }) => { describe('chromium', (suite, { browserName }) => {
suite.skip(browserName !== 'chromium'); suite.skip(browserName !== 'chromium');
@ -88,4 +90,31 @@ describe('chromium', (suite, { browserName }) => {
// make it work with Edgium. // make it work with Edgium.
expect(serverRequest.headers.intervention).toContain('feature/5718547946799104'); 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<string>((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();
}
});
}); });

33
types/types.d.ts vendored
View File

@ -6262,6 +6262,39 @@ export interface BrowserType<Browser> {
*/ */
connect(params: ConnectOptions): Promise<Browser>; connect(params: ConnectOptions): Promise<Browser>;
/**
* 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<Browser>;
/** /**
* A path where Playwright expects to find a bundled browser executable. * A path where Playwright expects to find a bundled browser executable.
*/ */