From 01229869e8a4ade57ee48b54d98251cd0c33140a Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 4 Nov 2020 20:21:15 +0100 Subject: [PATCH 1/5] feat: add support for using existing cdp websocket in launchServer --- docs/api.md | 1 + src/browserServerImpl.ts | 20 +++++++++++--------- src/client/browserType.ts | 4 +++- src/client/types.ts | 1 + src/server/browser.ts | 4 ++-- src/server/browserType.ts | 4 ++++ src/server/transport.ts | 2 ++ src/server/types.ts | 1 + test/browsertype-connect.spec.ts | 11 +++++++++++ test/browsertype-launch-server.spec.ts | 15 ++++++++++++++- utils/testserver/index.js | 3 ++- 11 files changed, 52 insertions(+), 14 deletions(-) diff --git a/docs/api.md b/docs/api.md index a0af7c6fff8a3..93e669a1a1914 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4524,6 +4524,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. diff --git a/src/browserServerImpl.ts b/src/browserServerImpl.ts index fd5307bb10cbd..acd1e5e90d85e 100644 --- a/src/browserServerImpl.ts +++ b/src/browserServerImpl.ts @@ -56,7 +56,7 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer { private _browserType: BrowserType; private _browser: Browser; private _wsEndpoint: string; - private _process: ChildProcess; + private _process?: ChildProcess; constructor(browserType: BrowserType, browser: Browser, port: number = 0) { super(); @@ -68,7 +68,7 @@ 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; + this._process = browser._options.browserProcess?.process; this._server.on('connection', (socket: ws, req) => { if (req.url !== '/' + token) { @@ -78,13 +78,15 @@ 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 { + process(): ChildProcess | undefined { return this._process; } @@ -93,11 +95,11 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer { } async close(): Promise { - await this._browser._options.browserProcess.close(); + await this._browser._options.browserProcess?.close(); } async kill(): Promise { - await this._browser._options.browserProcess.kill(); + await this._browser._options.browserProcess?.kill(); } private _clientAttached(socket: ws) { diff --git a/src/client/browserType.ts b/src/client/browserType.ts index 594df1d07a33d..e0dc9860745a7 100644 --- a/src/client/browserType.ts +++ b/src/client/browserType.ts @@ -32,13 +32,14 @@ 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; } export interface BrowserServer { - process(): ChildProcess; + process(): ChildProcess | undefined; wsEndpoint(): string; close(): Promise; kill(): Promise; @@ -117,6 +118,7 @@ export class BrowserType extends ChannelOwner this.once(Browser.Events.Disconnected, x)); diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 2c70586e1b846..c6a698ca7be48 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -83,6 +83,10 @@ export abstract class BrowserType { } async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, userDataDir?: string): Promise { + 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) diff --git a/src/server/transport.ts b/src/server/transport.ts index eda689da2316a..96487c2a457c1 100644 --- a/src/server/transport.ts +++ b/src/server/transport.ts @@ -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; @@ -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. diff --git a/src/server/types.ts b/src/server/types.ts index ed4f9b7d47d71..8bbeae01ee8e1 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -255,6 +255,7 @@ export type EnvArray = { name: string, value: string }[]; type LaunchOptionsBase = { executablePath?: string, + cdpWebsocketEndpoint?: string, args?: string[], ignoreDefaultArgs?: string[], ignoreAllDefaultArgs?: boolean, diff --git a/test/browsertype-connect.spec.ts b/test/browsertype-connect.spec.ts index 9a07456a537cb..c8333e205b932 100644 --- a/test/browsertype-connect.spec.ts +++ b/test/browsertype-connect.spec.ts @@ -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/'); + }); }); diff --git a/test/browsertype-launch-server.spec.ts b/test/browsertype-launch-server.spec.ts index 758c5b924e3b1..25a8455ffc60f 100644 --- a/test/browsertype-launch-server.spec.ts +++ b/test/browsertype-launch-server.spec.ts @@ -17,7 +17,7 @@ import { it, expect, describe } from './fixtures'; -describe('lauch server', (suite, { wire }) => { +describe('launch server', (suite, { wire }) => { suite.skip(wire); }, () => { it('should work', async ({browserType, browserOptions}) => { @@ -62,4 +62,17 @@ describe('lauch server', (suite, { wire }) => { expect(result['exitCode']).toBe(0); expect(result['signal']).toBe(null); }); + + it('should allow using an existing cdp endpoint', 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/'); + }); }); diff --git a/utils/testserver/index.js b/utils/testserver/index.js index 3f5773b1e1f92..93ab31ad3338c 100644 --- a/utils/testserver/index.js +++ b/utils/testserver/index.js @@ -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._onRequest.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)); @@ -89,7 +90,7 @@ class TestServer { this.PREFIX = `${protocol}://localhost:${port}`; this.CROSS_PROCESS_PREFIX = `${protocol}://127.0.0.1:${port}`; this.EMPTY_PAGE = `${protocol}://localhost:${port}/empty.html`; - + } _onSocket(socket) { From 2e8f6500366a104f08e709f4d6caf7d19845cb05 Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 4 Nov 2020 20:53:27 +0100 Subject: [PATCH 2/5] fix: remove optional chaining --- src/browserServerImpl.ts | 9 ++++++--- src/server/browser.ts | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/browserServerImpl.ts b/src/browserServerImpl.ts index acd1e5e90d85e..ba3794a5b181a 100644 --- a/src/browserServerImpl.ts +++ b/src/browserServerImpl.ts @@ -68,7 +68,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) { @@ -95,11 +96,13 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer { } async close(): Promise { - await this._browser._options.browserProcess?.close(); + if (this._browser._options.browserProcess) + await this._browser._options.browserProcess.close(); } async kill(): Promise { - await this._browser._options.browserProcess?.kill(); + if (this._browser._options.browserProcess) + await this._browser._options.browserProcess.kill(); } private _clientAttached(socket: ws) { diff --git a/src/server/browser.ts b/src/server/browser.ts index 1005f3e9c57a1..27f2757c3cacc 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -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)); From fe4c9d30b27ef1341633d058a31fa03d690256ec Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 4 Nov 2020 21:08:38 +0100 Subject: [PATCH 3/5] fix: switch to dedicated onUpgrade handler --- utils/testserver/index.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/utils/testserver/index.js b/utils/testserver/index.js index 3e71e3a691e24..32fb3d41c01f4 100644 --- a/utils/testserver/index.js +++ b/utils/testserver/index.js @@ -61,7 +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._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)); @@ -232,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 From e4f57a33b6289cec1de4abbcd17d9cc25c14cab1 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 5 Nov 2020 12:27:47 +0100 Subject: [PATCH 4/5] test: add smoke test for cdp websocket connection --- test/browsertype-launch-server.spec.ts | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/test/browsertype-launch-server.spec.ts b/test/browsertype-launch-server.spec.ts index 25a8455ffc60f..df2b26211c81e 100644 --- a/test/browsertype-launch-server.spec.ts +++ b/test/browsertype-launch-server.spec.ts @@ -16,6 +16,7 @@ */ import { it, expect, describe } from './fixtures'; +import http from 'http'; describe('launch server', (suite, { wire }) => { suite.skip(wire); @@ -63,7 +64,7 @@ describe('launch server', (suite, { wire }) => { expect(result['signal']).toBe(null); }); - it('should allow using an existing cdp endpoint', async ({ browserType, server}) => { + 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']); @@ -75,4 +76,29 @@ describe('launch server', (suite, { wire }) => { 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 => 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); + }); }); From 1f2e6cca49e982409f712619423ab3e593482a05 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 5 Nov 2020 13:58:53 +0100 Subject: [PATCH 5/5] feat: add dedicated browserType.connectServer api --- docs/api.md | 11 ++++++++++- src/browserServerImpl.ts | 11 +++++++++-- src/client/browserType.ts | 11 +++++++++-- src/client/types.ts | 6 ++++++ src/server/browserType.ts | 12 ++++++++++++ src/server/types.ts | 6 ++++++ 6 files changed, 52 insertions(+), 5 deletions(-) diff --git a/docs/api.md b/docs/api.md index 93e669a1a1914..558482070f91e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4397,6 +4397,7 @@ const { chromium } = require('playwright'); // Or 'firefox' or 'webkit'. - [browserType.connect(options)](#browsertypeconnectoptions) +- [browserType.connectServer([options])](#browsertypeconnectserveroptions) - [browserType.executablePath()](#browsertypeexecutablepath) - [browserType.launch([options])](#browsertypelaunchoptions) - [browserType.launchPersistentContext(userDataDir, [options])](#browsertypelaunchpersistentcontextuserdatadir-options) @@ -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. @@ -4559,7 +4569,6 @@ const { chromium } = require('playwright'); // Or 'webkit' or 'firefox'. })(); ``` - #### browserType.name() - returns: <[string]> diff --git a/src/browserServerImpl.ts b/src/browserServerImpl.ts index ba3794a5b181a..26e72d920b525 100644 --- a/src/browserServerImpl.ts +++ b/src/browserServerImpl.ts @@ -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'; @@ -49,6 +49,11 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { }); return new BrowserServerImpl(this._browserType, browser, options.port); } + + async connectServer(options: ConnectServerOptions = {}): Promise { + const browser = await this._browserType.connect(options); + return new BrowserServerImpl(this._browserType, browser, options.port); + } } export class BrowserServerImpl extends EventEmitter implements BrowserServer { @@ -87,7 +92,9 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer { } } - process(): ChildProcess | undefined { + process(): ChildProcess { + if (!this._process) + throw new Error('Process not available.'); return this._process; } diff --git a/src/client/browserType.ts b/src/client/browserType.ts index e0dc9860745a7..f68cc39f92172 100644 --- a/src/client/browserType.ts +++ b/src/client/browserType.ts @@ -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'; @@ -36,10 +36,11 @@ const packageVersion = require('../../package.json').version; export interface BrowserServerLauncher { launchServer(options?: LaunchServerOptions): Promise; + connectServer(options?: ConnectServerOptions): Promise; } export interface BrowserServer { - process(): ChildProcess | undefined; + process(): ChildProcess; wsEndpoint(): string; close(): Promise; kill(): Promise; @@ -90,6 +91,12 @@ export class BrowserType extends ChannelOwner { + if (!this._serverLauncher) + throw new Error('Launching server is not supported'); + return this._serverLauncher.connectServer(options); + } + async launchPersistentContext(userDataDir: string, options: LaunchPersistentContextOptions = {}): Promise { return this._wrapApiCall('browserType.launchPersistentContext', async () => { assert(!(options as any).port, 'Cannot specify a port without launching as a server.'); diff --git a/src/client/types.ts b/src/client/types.ts index f3e76d9eefcea..867955e29a3b6 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -90,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. diff --git a/src/server/browserType.ts b/src/server/browserType.ts index c6a698ca7be48..f297a15960a62 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -213,6 +213,18 @@ export abstract class BrowserType { return { browserProcess, downloadsPath, transport }; } + async connect(options: types.ConnectOptions = {}): Promise { + 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; abstract _amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env; diff --git a/src/server/types.ts b/src/server/types.ts index 8bbeae01ee8e1..43243a7befedc 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -276,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,