Skip to content

Commit

Permalink
feat(chromium): connect to a browser over cdp
Browse files Browse the repository at this point in the history
  • Loading branch information
JoelEinbinder committed Jan 28, 2021
1 parent 16249cc commit 4f7018c
Show file tree
Hide file tree
Showing 14 changed files with 223 additions and 73 deletions.
5 changes: 5 additions & 0 deletions docs/src/api/class-browsertype.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]>
Expand Down
151 changes: 87 additions & 64 deletions src/client/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,77 +113,100 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
async connect(params: ConnectOptions): Promise<Browser> {
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<Browser> {
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<Browser> {
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<Browser>(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<Browser>(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'));
});
});
}
}

Expand Down
1 change: 1 addition & 0 deletions src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type ConnectOptions = {
slowMo?: number,
timeout?: number,
logger?: Logger,
protocol?: 'playwright'|'cdp',
};
export type LaunchServerOptions = {
executablePath?: string,
Expand Down
8 changes: 8 additions & 0 deletions src/dispatchers/browserTypeDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,12 @@ export class BrowserTypeDispatcher extends Dispatcher<BrowserType, channels.Brow
const browserContext = await this._object.launchPersistentContext(params.userDataDir, params);
return { context: new BrowserContextDispatcher(this._scope, browserContext) };
}

async cdpConnect(params: channels.BrowserTypeCdpConnectParams): Promise<channels.BrowserTypeCdpConnectResult> {
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!),
};
}
}
14 changes: 14 additions & 0 deletions src/protocol/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export type BrowserTypeInitializer = {
export interface BrowserTypeChannel extends Channel {
launch(params: BrowserTypeLaunchParams, metadata?: Metadata): Promise<BrowserTypeLaunchResult>;
launchPersistentContext(params: BrowserTypeLaunchPersistentContextParams, metadata?: Metadata): Promise<BrowserTypeLaunchPersistentContextResult>;
cdpConnect(params: BrowserTypeCdpConnectParams, metadata?: Metadata): Promise<BrowserTypeCdpConnectResult>;
}
export type BrowserTypeLaunchParams = {
executablePath?: string,
Expand Down Expand Up @@ -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 = {
Expand Down
8 changes: 8 additions & 0 deletions src/protocol/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,14 @@ BrowserType:
returns:
context: BrowserContext

cdpConnect:
parameters:
wsEndpoint: string
slowMo: number?
timeout: number?
returns:
browser: Browser
defaultContext: BrowserContext

Browser:
type: interface
Expand Down
5 changes: 5 additions & 0 deletions src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/server/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
close(): Promise<void>;
Expand Down
4 changes: 4 additions & 0 deletions src/server/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ export abstract class BrowserType {
return { browserProcess, downloadsPath, transport };
}

async cdpConnect(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 _connectToTransport(transport: ConnectionTransport, options: BrowserOptions): Promise<Browser>;
abstract _amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env;
Expand Down
36 changes: 34 additions & 2 deletions src/server/chromium/chromium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'));
}
Expand Down
5 changes: 3 additions & 2 deletions src/server/chromium/crBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/server/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}

0 comments on commit 4f7018c

Please sign in to comment.