Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for using existing cdp websocket in launchServer #4344

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4397,6 +4397,7 @@ const { chromium } = require('playwright'); // Or 'firefox' or 'webkit'.

<!-- GEN:toc -->
- [browserType.connect(options)](#browsertypeconnectoptions)
- [browserType.connectServer([options])](#browsertypeconnectserveroptions)
- [browserType.executablePath()](#browsertypeexecutablepath)
- [browserType.launch([options])](#browsertypelaunchoptions)
- [browserType.launchPersistentContext(userDataDir, [options])](#browsertypelaunchpersistentcontextuserdatadir-options)
Expand All @@ -4414,6 +4415,15 @@ const { chromium } = require('playwright'); // Or 'firefox' or 'webkit'.

This methods attaches Playwright to an existing browser instance.

#### browserType.connectServer([options])
- `options` <[Object]> Set of configurable options to set on the browser. Can have the following fields:
- `cdpWebsocketEndpoint` <[string]> Existing CDP websocket endpoint to use.-
- `port` <[number]> Port to use for the web socket. Defaults to 0 that picks any available port.
- `timeout` <[number]> Maximum time in milliseconds to wait for the browser instance to start. Defaults to `30000` (30 seconds). Pass `0` to disable timeout.
- returns: <[Promise]<[BrowserServer]>> Promise which resolves to the browser app instance.

Launches browser server that client can connect to. Will connect to an existing CDP websocket endpoint instead of launching a browser process.

#### browserType.executablePath()
- returns: <[string]> A path where Playwright expects to find a bundled browser executable.

Expand Down Expand Up @@ -4524,6 +4534,7 @@ Launches browser that uses persistent storage located at `userDataDir` and retur
- `headless` <[boolean]> Whether to run browser in headless mode. More details for [Chromium](https://developers.google.com/web/updates/2017/04/headless-chrome) and [Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Headless_mode). Defaults to `true` unless the `devtools` option is `true`.
- `port` <[number]> Port to use for the web socket. Defaults to 0 that picks any available port.
- `executablePath` <[string]> Path to a browser executable to run instead of the bundled one. If `executablePath` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). **BEWARE**: Playwright is only guaranteed to work with the bundled Chromium, Firefox or WebKit, use at your own risk.
- `cdpWebsocketEndpoint` <[string]> Connect to an existing CDP websocket instead of launching a browser process.
- `args` <[Array]<[string]>> Additional arguments to pass to the browser instance. The list of Chromium flags can be found [here](http://peter.sh/experiments/chromium-command-line-switches/).
- `ignoreDefaultArgs` <[boolean]|[Array]<[string]>> If `true`, then do not use any of the default arguments. If an array is given, then filter out the given default arguments. Dangerous option; use with care. Defaults to `false`.
- `proxy` <[Object]> Network proxy settings.
Expand Down Expand Up @@ -4558,7 +4569,6 @@ const { chromium } = require('playwright'); // Or 'webkit' or 'firefox'.
})();
```


#### browserType.name()
- returns: <[string]>

Expand Down
30 changes: 21 additions & 9 deletions src/browserServerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { LaunchServerOptions } from './client/types';
import { LaunchServerOptions, ConnectServerOptions } from './client/types';
import { BrowserType } from './server/browserType';
import * as ws from 'ws';
import * as fs from 'fs';
Expand Down Expand Up @@ -49,14 +49,19 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher {
});
return new BrowserServerImpl(this._browserType, browser, options.port);
}

async connectServer(options: ConnectServerOptions = {}): Promise<BrowserServerImpl> {
const browser = await this._browserType.connect(options);
return new BrowserServerImpl(this._browserType, browser, options.port);
}
}

export class BrowserServerImpl extends EventEmitter implements BrowserServer {
private _server: ws.Server;
private _browserType: BrowserType;
private _browser: Browser;
private _wsEndpoint: string;
private _process: ChildProcess;
private _process?: ChildProcess;

constructor(browserType: BrowserType, browser: Browser, port: number = 0) {
super();
Expand All @@ -68,7 +73,8 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer {
this._server = new ws.Server({ port });
const address = this._server.address();
this._wsEndpoint = typeof address === 'string' ? `${address}/${token}` : `ws://127.0.0.1:${address.port}/${token}`;
this._process = browser._options.browserProcess.process;
if (browser._options.browserProcess)
this._process = browser._options.browserProcess.process;

this._server.on('connection', (socket: ws, req) => {
if (req.url !== '/' + token) {
Expand All @@ -78,13 +84,17 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer {
this._clientAttached(socket);
});

browser._options.browserProcess.onclose = (exitCode, signal) => {
this._server.close();
this.emit('close', exitCode, signal);
};
if (browser._options.browserProcess) {
browser._options.browserProcess.onclose = (exitCode, signal) => {
this._server.close();
this.emit('close', exitCode, signal);
};
}
}

process(): ChildProcess {
if (!this._process)
throw new Error('Process not available.');
return this._process;
}

Expand All @@ -93,11 +103,13 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer {
}

async close(): Promise<void> {
await this._browser._options.browserProcess.close();
if (this._browser._options.browserProcess)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the right way is to connect right here, in the BrowserServerImpl, instead of going all the way to _innerLaunch and back.

Now that I think about this more: when connecting over existing cdp, all other launch parameters are actually ignored. This hints at using a different method, something like chromium.connectServer that only takes the websocket url. WDYT?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, when connecting to an existing cdp websocket virtually all of the launch code isn't needed. Also the flag feels a bit shoehorned into there, so it might be cleaner to keep it separate. If promoting this feature to it's own top-level API is fine then I'm happy to work on the changes 😄

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a dedicated API implementation for comparison

await this._browser._options.browserProcess.close();
}

async kill(): Promise<void> {
await this._browser._options.browserProcess.kill();
if (this._browser._options.browserProcess)
await this._browser._options.browserProcess.kill();
}

private _clientAttached(socket: ws) {
Expand Down
11 changes: 10 additions & 1 deletion src/client/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import * as channels from '../protocol/channels';
import { Browser } from './browser';
import { BrowserContext, validateBrowserContextOptions } from './browserContext';
import { ChannelOwner } from './channelOwner';
import { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions } from './types';
import { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions, ConnectServerOptions } from './types';
import * as WebSocket from 'ws';
import * as path from 'path';
import * as fs from 'fs';
Expand All @@ -32,9 +32,11 @@ import { assert, makeWaitForNextTask, mkdirIfNeeded } from '../utils/utils';
import { SelectorsOwner, sharedSelectors } from './selectors';
import { kBrowserClosedError } from '../utils/errors';
import { Stream } from './stream';
const packageVersion = require('../../package.json').version;

export interface BrowserServerLauncher {
launchServer(options?: LaunchServerOptions): Promise<BrowserServer>;
connectServer(options?: ConnectServerOptions): Promise<BrowserServer>;
}

export interface BrowserServer {
Expand Down Expand Up @@ -89,6 +91,12 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
return this._serverLauncher.launchServer(options);
}

async connectServer(options: ConnectServerOptions = {}): Promise<BrowserServer> {
if (!this._serverLauncher)
throw new Error('Launching server is not supported');
return this._serverLauncher.connectServer(options);
}

async launchPersistentContext(userDataDir: string, options: LaunchPersistentContextOptions = {}): Promise<BrowserContext> {
return this._wrapApiCall('browserType.launchPersistentContext', async () => {
assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
Expand Down Expand Up @@ -117,6 +125,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
perMessageDeflate: false,
maxPayload: 256 * 1024 * 1024, // 256Mb,
handshakeTimeout: this._timeoutSettings.timeout(options),
headers: { 'user-agent': `playwright/${packageVersion}` },
});

// The 'ws' module in node sometimes sends us multiple messages in a single task.
Expand Down
7 changes: 7 additions & 0 deletions src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export type ConnectOptions = {
};
export type LaunchServerOptions = {
executablePath?: string,
cdpWebsocketEndpoint?: string,
args?: string[],
ignoreDefaultArgs?: boolean | string[],
handleSIGINT?: boolean,
Expand All @@ -89,6 +90,12 @@ export type LaunchServerOptions = {
logger?: Logger,
} & FirefoxUserPrefs;

export type ConnectServerOptions = {
cdpWebsocketEndpoint?: string,
timeout?: number,
port?: number,
};

export type SelectorEngine = {
/**
* Creates a selector that matches given target when queried at the root.
Expand Down
5 changes: 3 additions & 2 deletions src/server/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export type BrowserOptions = types.UIOptions & {
downloadsPath?: string,
headful?: boolean,
persistent?: types.BrowserContextOptions, // Undefined means no persistent context.
browserProcess: BrowserProcess,
browserProcess?: BrowserProcess,
proxy?: ProxySettings,
};

Expand Down Expand Up @@ -113,7 +113,8 @@ export abstract class Browser extends EventEmitter {
async close() {
if (!this._startedClosing) {
this._startedClosing = true;
await this._options.browserProcess.close();
if (this._options.browserProcess)
await this._options.browserProcess.close();
}
if (this.isConnected())
await new Promise(x => this.once(Browser.Events.Disconnected, x));
Expand Down
16 changes: 16 additions & 0 deletions src/server/browserType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export abstract class BrowserType {
}

async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, userDataDir?: string): Promise<Browser> {
if (options.cdpWebsocketEndpoint) {
const transport = await WebSocketTransport.connect(progress, options.cdpWebsocketEndpoint);
return this._connectToTransport(transport, options as BrowserOptions);
}
options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined;
const { browserProcess, downloadsPath, transport } = await this._launchProcess(progress, options, !!persistent, userDataDir);
if ((options as any).__testHookBeforeCreateBrowser)
Expand Down Expand Up @@ -209,6 +213,18 @@ export abstract class BrowserType {
return { browserProcess, downloadsPath, transport };
}

async connect(options: types.ConnectOptions = {}): Promise<Browser> {
if (!options.cdpWebsocketEndpoint)
throw new Error(`No cdpWebsocketEndpoint url is specified.`);
const controller = new ProgressController();
controller.setLogName('browser');
const browser = await controller.run(async progress => {
const transport = await WebSocketTransport.connect(progress, options.cdpWebsocketEndpoint as string);
return this._connectToTransport(transport, options as BrowserOptions);
}, TimeoutSettings.timeout(options));
return browser;
}

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
2 changes: 2 additions & 0 deletions src/server/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import * as WebSocket from 'ws';
import { Progress } from './progress';
import { makeWaitForNextTask } from '../utils/utils';
const packageVersion = require('../../package.json').version;

export type ProtocolRequest = {
id: number;
Expand Down Expand Up @@ -79,6 +80,7 @@ export class WebSocketTransport implements ConnectionTransport {
perMessageDeflate: false,
maxPayload: 256 * 1024 * 1024, // 256Mb,
handshakeTimeout: progress.timeUntilDeadline(),
headers: { 'user-agent': `playwright/${packageVersion}` },
});
this._progress = progress;
// The 'ws' module in node sometimes sends us multiple messages in a single task.
Expand Down
7 changes: 7 additions & 0 deletions src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export type EnvArray = { name: string, value: string }[];

type LaunchOptionsBase = {
executablePath?: string,
cdpWebsocketEndpoint?: string,
args?: string[],
ignoreDefaultArgs?: string[],
ignoreAllDefaultArgs?: boolean,
Expand All @@ -275,6 +276,12 @@ export type LaunchOptions = LaunchOptionsBase & UIOptions & {
};
export type LaunchPersistentOptions = LaunchOptionsBase & BrowserContextOptions;

export type ConnectOptions = {
cdpWebsocketEndpoint?: string,
timeout?: number,
port?: number,
};

export type SerializedAXNode = {
role: string,
name: string,
Expand Down
11 changes: 11 additions & 0 deletions test/browsertype-connect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,15 @@ describe('connect', (suite, { wire }) => {
const files = fs.readdirSync(videosPath);
expect(files.some(file => file.endsWith('webm'))).toBe(true);
});

it('should add user-agent to websocket request', async ({ browserType, server}) => {
const getUserAgent = () => new Promise(async resolve => {
server.setRoute('/websocket', async (req, res) => {
resolve(req.headers['user-agent']);
});
browserType.connect({ wsEndpoint: server.PREFIX + '/websocket', });
});
const ua = await getUserAgent();
expect(ua).toContain('playwright/');
});
});
41 changes: 40 additions & 1 deletion test/browsertype-launch-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
*/

import { it, expect, describe } from './fixtures';
import http from 'http';

describe('lauch server', (suite, { wire }) => {
describe('launch server', (suite, { wire }) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😄

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought I fix that while I'm there 😄

suite.skip(wire);
}, () => {
it('should work', async ({browserType, browserOptions}) => {
Expand Down Expand Up @@ -62,4 +63,42 @@ describe('lauch server', (suite, { wire }) => {
expect(result['exitCode']).toBe(0);
expect(result['signal']).toBe(null);
});

it('should add user-agent to websocket request', async ({ browserType, server}) => {
const getUserAgent = () => new Promise(async resolve => {
server.setRoute('/websocket', async (req, res) => {
resolve(req.headers['user-agent']);
});
browserType.launchServer({
cdpWebsocketEndpoint: server.PREFIX + '/websocket'
});
});
const ua = await getUserAgent();
expect(ua).toContain('playwright/');
});

it('should allow using an existing cdp endpoint', async ({ testWorkerIndex, browserType, server}) => {
const fetchUrl = (url: string): Promise<string> => new Promise((resolve, reject) => {
http.get(url, resp => {
let data = '';
resp.on('data', chunk => { data += chunk; });
resp.on('end', () => { resolve(data); });
}).on('error', (err: Error) => { reject(err); });
});
const debuggingPort = 8100 + testWorkerIndex;
await browserType.launchServer({
args: [`--remote-debugging-port=${debuggingPort}`]
});
const version = await fetchUrl(`http://localhost:${debuggingPort}/json/version`);
const cdpWebsocketEndpoint = JSON.parse(version).webSocketDebuggerUrl;
const browserServer = await browserType.launchServer({ cdpWebsocketEndpoint });
const wsEndpoint = browserServer.wsEndpoint();
const browser = await browserType.connect({ wsEndpoint });
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
expect(page.url()).toContain('empty.html');
const answer = await page.evaluate(() => 6 * 7);
expect(answer).toBe(42);
});
});
13 changes: 13 additions & 0 deletions utils/testserver/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class TestServer {
this._server = https.createServer(sslOptions, this._onRequest.bind(this));
else
this._server = http.createServer(this._onRequest.bind(this));
this._server.on('upgrade', this._onUpgrade.bind(this));
this._server.on('connection', socket => this._onSocket(socket));
this._wsServer = new WebSocketServer({server: this._server, path: '/ws'});
this._wsServer.on('connection', this._onWebSocketConnection.bind(this));
Expand Down Expand Up @@ -231,6 +232,18 @@ class TestServer {
}
}

/**
* @param {http.IncomingMessage} request
* @param {http.ServerResponse} response
*/
_onUpgrade(request, response) {
const pathName = url.parse(request.url).path;
this.debugServer(`upgrade ${request.method} ${pathName}`);
const handler = this._routes.get(pathName);
if (handler)
handler.call(null, request, response);
}

/**
* @param {!http.IncomingMessage} request
* @param {!http.ServerResponse} response
Expand Down