From 66b5cf5ae1159ace3db19641334220a2f4dc55e3 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 10 Feb 2022 16:36:23 -0800 Subject: [PATCH] feat(remote): make PlaywrightServer work with browserType.connect (#11849) This changes PlaywrigtServer to serve connections like `ws://localhost:3333/?browser=chromium`: - launches the browser; - talks `browserType.connect`-style protocol over websocket; - compatible with `connectOptions` fixture. ```js await playwright.chromium.connect({ wsEndpoint: 'ws://localhost:3333/?browser=chrome' }); ``` --- .../playwright-core/src/browserServerImpl.ts | 101 +------- packages/playwright-core/src/cli/driver.ts | 2 +- .../playwright-core/src/client/browserType.ts | 2 + .../src/dispatchers/browserDispatcher.ts | 64 ++++- .../src/dispatchers/playwrightDispatcher.ts | 32 ++- .../playwright-core/src/grid/gridWorker.ts | 7 +- .../src/remote/playwrightClient.ts | 2 + .../src/remote/playwrightServer.ts | 243 ++++++++++++------ tests/config/default.playwright.config.ts | 22 +- tests/config/testMode.ts | 2 +- tests/config/testModeFixtures.ts | 1 + tests/port-forwarding-server.spec.ts | 21 +- utils/testserver/index.js | 2 +- 13 files changed, 292 insertions(+), 209 deletions(-) diff --git a/packages/playwright-core/src/browserServerImpl.ts b/packages/playwright-core/src/browserServerImpl.ts index 4e0af7b209..99ec42ed7a 100644 --- a/packages/playwright-core/src/browserServerImpl.ts +++ b/packages/playwright-core/src/browserServerImpl.ts @@ -15,25 +15,14 @@ */ import { LaunchServerOptions, Logger } from './client/types'; -import { Browser } from './server/browser'; import { EventEmitter } from 'ws'; -import { Dispatcher, DispatcherConnection, DispatcherScope, Root } from './dispatchers/dispatcher'; -import { BrowserContextDispatcher } from './dispatchers/browserContextDispatcher'; -import * as channels from './protocol/channels'; import { BrowserServerLauncher, BrowserServer } from './client/browserType'; import { envObjectToArray } from './client/clientHelper'; import { createGuid } from './utils/utils'; -import { SelectorsDispatcher } from './dispatchers/selectorsDispatcher'; -import { Selectors } from './server/selectors'; import { ProtocolLogger } from './server/types'; -import { CallMetadata, internalCallMetadata } from './server/instrumentation'; -import { createPlaywright, Playwright } from './server/playwright'; -import { PlaywrightDispatcher } from './dispatchers/playwrightDispatcher'; -import { PlaywrightServer, PlaywrightServerDelegate } from './remote/playwrightServer'; -import { BrowserContext } from './server/browserContext'; -import { CRBrowser } from './server/chromium/crBrowser'; -import { CDPSessionDispatcher } from './dispatchers/cdpSessionDispatcher'; -import { PageDispatcher } from './dispatchers/pageDispatcher'; +import { internalCallMetadata } from './server/instrumentation'; +import { createPlaywright } from './server/playwright'; +import { PlaywrightServer } from './remote/playwrightServer'; import { helper } from './server/helper'; import { rewriteErrorMessage } from './utils/stackTrace'; @@ -64,13 +53,7 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { path = options.wsPath.startsWith('/') ? options.wsPath : `/${options.wsPath}`; // 2. Start the server - const delegate: PlaywrightServerDelegate = { - path, - allowMultipleClients: true, - onClose: () => {}, - onConnect: this._onConnect.bind(this, playwright, browser), - }; - const server = new PlaywrightServer(delegate); + const server = new PlaywrightServer(path, Infinity, browser); const wsEndpoint = await server.listen(options.port); // 3. Return the BrowserServer interface @@ -86,82 +69,6 @@ export class BrowserServerLauncherImpl implements BrowserServerLauncher { }; return browserServer; } - - private async _onConnect(playwright: Playwright, browser: Browser, connection: DispatcherConnection, forceDisconnect: () => void) { - let browserDispatcher: ConnectedBrowserDispatcher | undefined; - new Root(connection, async (scope: DispatcherScope): Promise => { - const selectors = new Selectors(); - const selectorsDispatcher = new SelectorsDispatcher(scope, selectors); - browserDispatcher = new ConnectedBrowserDispatcher(scope, browser, selectors); - browser.on(Browser.Events.Disconnected, () => { - // Underlying browser did close for some reason - force disconnect the client. - forceDisconnect(); - }); - return new PlaywrightDispatcher(scope, playwright, selectorsDispatcher, browserDispatcher); - }); - return () => { - // Cleanup contexts upon disconnect. - browserDispatcher?.cleanupContexts().catch(e => {}); - }; - } -} - -// This class implements multiplexing browser dispatchers over a single Browser instance. -class ConnectedBrowserDispatcher extends Dispatcher implements channels.BrowserChannel { - _type_Browser = true; - private _contexts = new Set(); - private _selectors: Selectors; - - constructor(scope: DispatcherScope, browser: Browser, selectors: Selectors) { - super(scope, browser, 'Browser', { version: browser.version(), name: browser.options.name }, true); - this._selectors = selectors; - } - - async newContext(params: channels.BrowserNewContextParams, metadata: CallMetadata): Promise { - if (params.recordVideo) - params.recordVideo.dir = this._object.options.artifactsDir; - const context = await this._object.newContext(params); - this._contexts.add(context); - context._setSelectors(this._selectors); - context.on(BrowserContext.Events.Close, () => this._contexts.delete(context)); - if (params.storageState) - await context.setStorageState(metadata, params.storageState); - return { context: new BrowserContextDispatcher(this._scope, context) }; - } - - async close(): Promise { - // Client should not send us Browser.close. - } - - async killForTests(): Promise { - // Client should not send us Browser.killForTests. - } - - async newBrowserCDPSession(): Promise { - if (!this._object.options.isChromium) - throw new Error(`CDP session is only available in Chromium`); - const crBrowser = this._object as CRBrowser; - return { session: new CDPSessionDispatcher(this._scope, await crBrowser.newBrowserCDPSession()) }; - } - - async startTracing(params: channels.BrowserStartTracingParams): Promise { - if (!this._object.options.isChromium) - throw new Error(`Tracing is only available in Chromium`); - const crBrowser = this._object as CRBrowser; - await crBrowser.startTracing(params.page ? (params.page as PageDispatcher)._object : undefined, params); - } - - async stopTracing(): Promise { - if (!this._object.options.isChromium) - throw new Error(`Tracing is only available in Chromium`); - const crBrowser = this._object as CRBrowser; - const buffer = await crBrowser.stopTracing(); - return { binary: buffer.toString('base64') }; - } - - async cleanupContexts() { - await Promise.all(Array.from(this._contexts).map(context => context.close(internalCallMetadata()))); - } } function toProtocolLogger(logger: Logger | undefined): ProtocolLogger | undefined { diff --git a/packages/playwright-core/src/cli/driver.ts b/packages/playwright-core/src/cli/driver.ts index 3a64be7cca..2315682b15 100644 --- a/packages/playwright-core/src/cli/driver.ts +++ b/packages/playwright-core/src/cli/driver.ts @@ -53,7 +53,7 @@ export function runDriver() { } export async function runServer(port: number | undefined) { - const server = await PlaywrightServer.startDefault(); + const server = await PlaywrightServer.startDefault({ path: '/', maxClients: Infinity }); const wsEndpoint = await server.listen(port); process.on('exit', () => server.close().catch(console.error)); console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 07e86998cd..d0635f3ecf 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -168,6 +168,8 @@ export class BrowserType extends ChannelOwner imple throw new Error('Malformed endpoint. Did you use launchServer method?'); } playwright._setSelectors(this._playwright.selectors); + if ((params as any).__testHookPortForwarding) + playwright._enablePortForwarding((params as any).__testHookPortForwarding.redirectPortForTest); browser = Browser.from(playwright._initializer.preLaunchedBrowser!); browser._logger = logger; browser._shouldCloseConnectionOnClose = true; diff --git a/packages/playwright-core/src/dispatchers/browserDispatcher.ts b/packages/playwright-core/src/dispatchers/browserDispatcher.ts index 479947a745..e2a5f02112 100644 --- a/packages/playwright-core/src/dispatchers/browserDispatcher.ts +++ b/packages/playwright-core/src/dispatchers/browserDispatcher.ts @@ -21,7 +21,9 @@ import { CDPSessionDispatcher } from './cdpSessionDispatcher'; import { Dispatcher, DispatcherScope } from './dispatcher'; import { CRBrowser } from '../server/chromium/crBrowser'; import { PageDispatcher } from './pageDispatcher'; -import { CallMetadata } from '../server/instrumentation'; +import { CallMetadata, internalCallMetadata } from '../server/instrumentation'; +import { BrowserContext } from '../server/browserContext'; +import { Selectors } from '../server/selectors'; export class BrowserDispatcher extends Dispatcher implements channels.BrowserChannel { _type_Browser = true; @@ -72,3 +74,63 @@ export class BrowserDispatcher extends Dispatcher implements channels.BrowserChannel { + _type_Browser = true; + private _contexts = new Set(); + readonly selectors: Selectors; + + constructor(scope: DispatcherScope, browser: Browser) { + super(scope, browser, 'Browser', { version: browser.version(), name: browser.options.name }, true); + // When we have a remotely-connected browser, each client gets a fresh Selector instance, + // so that two clients do not interfere between each other. + this.selectors = new Selectors(); + } + + async newContext(params: channels.BrowserNewContextParams, metadata: CallMetadata): Promise { + if (params.recordVideo) + params.recordVideo.dir = this._object.options.artifactsDir; + const context = await this._object.newContext(params); + this._contexts.add(context); + context._setSelectors(this.selectors); + context.on(BrowserContext.Events.Close, () => this._contexts.delete(context)); + if (params.storageState) + await context.setStorageState(metadata, params.storageState); + return { context: new BrowserContextDispatcher(this._scope, context) }; + } + + async close(): Promise { + // Client should not send us Browser.close. + } + + async killForTests(): Promise { + // Client should not send us Browser.killForTests. + } + + async newBrowserCDPSession(): Promise { + if (!this._object.options.isChromium) + throw new Error(`CDP session is only available in Chromium`); + const crBrowser = this._object as CRBrowser; + return { session: new CDPSessionDispatcher(this._scope, await crBrowser.newBrowserCDPSession()) }; + } + + async startTracing(params: channels.BrowserStartTracingParams): Promise { + if (!this._object.options.isChromium) + throw new Error(`Tracing is only available in Chromium`); + const crBrowser = this._object as CRBrowser; + await crBrowser.startTracing(params.page ? (params.page as PageDispatcher)._object : undefined, params); + } + + async stopTracing(): Promise { + if (!this._object.options.isChromium) + throw new Error(`Tracing is only available in Chromium`); + const crBrowser = this._object as CRBrowser; + const buffer = await crBrowser.stopTracing(); + return { binary: buffer.toString('base64') }; + } + + async cleanupContexts() { + await Promise.all(Array.from(this._contexts).map(context => context.close(internalCallMetadata()))); + } +} diff --git a/packages/playwright-core/src/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/dispatchers/playwrightDispatcher.ts index c9335873b7..775b09795f 100644 --- a/packages/playwright-core/src/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/dispatchers/playwrightDispatcher.ts @@ -15,11 +15,11 @@ */ import * as channels from '../protocol/channels'; +import { Browser } from '../server/browser'; import { GlobalAPIRequestContext } from '../server/fetch'; import { Playwright } from '../server/playwright'; import { SocksProxy } from '../server/socksProxy'; import * as types from '../server/types'; -import { debugLogger } from '../utils/debugLogger'; import { AndroidDispatcher } from './androidDispatcher'; import { BrowserTypeDispatcher } from './browserTypeDispatcher'; import { Dispatcher, DispatcherScope } from './dispatcher'; @@ -27,15 +27,18 @@ import { ElectronDispatcher } from './electronDispatcher'; import { LocalUtilsDispatcher } from './localUtilsDispatcher'; import { APIRequestContextDispatcher } from './networkDispatchers'; import { SelectorsDispatcher } from './selectorsDispatcher'; +import { ConnectedBrowserDispatcher } from './browserDispatcher'; export class PlaywrightDispatcher extends Dispatcher implements channels.PlaywrightChannel { _type_Playwright; + private _browserDispatcher: ConnectedBrowserDispatcher | undefined; private _socksProxy: SocksProxy | undefined; - constructor(scope: DispatcherScope, playwright: Playwright, customSelectors?: channels.SelectorsChannel, preLaunchedBrowser?: channels.BrowserChannel) { + constructor(scope: DispatcherScope, playwright: Playwright, socksProxy?: SocksProxy, preLaunchedBrowser?: Browser) { const descriptors = require('../server/deviceDescriptors') as types.Devices; const deviceDescriptors = Object.entries(descriptors) .map(([name, descriptor]) => ({ name, descriptor })); + const browserDispatcher = preLaunchedBrowser ? new ConnectedBrowserDispatcher(scope, preLaunchedBrowser) : undefined; super(scope, playwright, 'Playwright', { chromium: new BrowserTypeDispatcher(scope, playwright.chromium), firefox: new BrowserTypeDispatcher(scope, playwright.firefox), @@ -44,19 +47,17 @@ export class PlaywrightDispatcher extends Dispatcher this._dispatchEvent('socksRequested', data)); - this._socksProxy.on(SocksProxy.Events.SocksData, data => this._dispatchEvent('socksData', data)); - this._socksProxy.on(SocksProxy.Events.SocksClosed, data => this._dispatchEvent('socksClosed', data)); - debugLogger.log('proxy', `Starting socks proxy server on port ${this._object.options.socksProxyPort}`); + this._browserDispatcher = browserDispatcher; + if (socksProxy) { + this._socksProxy = socksProxy; + socksProxy.on(SocksProxy.Events.SocksRequested, data => this._dispatchEvent('socksRequested', data)); + socksProxy.on(SocksProxy.Events.SocksData, data => this._dispatchEvent('socksData', data)); + socksProxy.on(SocksProxy.Events.SocksClosed, data => this._dispatchEvent('socksClosed', data)); + } } async socksConnected(params: channels.PlaywrightSocksConnectedParams): Promise { @@ -87,4 +88,9 @@ export class PlaywrightDispatcher extends Dispatcher { await this._object.hideHighlight(); } + + async cleanup() { + // Cleanup contexts upon disconnect. + await this._browserDispatcher?.cleanupContexts(); + } } diff --git a/packages/playwright-core/src/grid/gridWorker.ts b/packages/playwright-core/src/grid/gridWorker.ts index 5ddf6eb6fc..1c8e9d356f 100644 --- a/packages/playwright-core/src/grid/gridWorker.ts +++ b/packages/playwright-core/src/grid/gridWorker.ts @@ -20,6 +20,7 @@ import { DispatcherConnection, Root } from '../dispatchers/dispatcher'; import { PlaywrightDispatcher } from '../dispatchers/playwrightDispatcher'; import { createPlaywright } from '../server/playwright'; import { gracefullyCloseAll } from '../utils/processLauncher'; +import { SocksProxy } from '../server/socksProxy'; function launchGridWorker(gridURL: string, agentId: string, workerId: string) { const log = debug(`pw:grid:worker${workerId}`); @@ -30,9 +31,9 @@ function launchGridWorker(gridURL: string, agentId: string, workerId: string) { ws.once('open', () => { new Root(dispatcherConnection, async rootScope => { const playwright = createPlaywright('javascript'); - const dispatcher = new PlaywrightDispatcher(rootScope, playwright); - dispatcher.enableSocksProxy(); - return dispatcher; + const socksProxy = new SocksProxy(); + playwright.options.socksProxyPort = await socksProxy.listen(0); + return new PlaywrightDispatcher(rootScope, playwright, socksProxy); }); }); ws.on('message', message => dispatcherConnection.dispatch(JSON.parse(message.toString()))); diff --git a/packages/playwright-core/src/remote/playwrightClient.ts b/packages/playwright-core/src/remote/playwrightClient.ts index 59c55c0c86..65666c4fd3 100644 --- a/packages/playwright-core/src/remote/playwrightClient.ts +++ b/packages/playwright-core/src/remote/playwrightClient.ts @@ -19,6 +19,8 @@ import { Connection } from '../client/connection'; import { Playwright } from '../client/playwright'; import { makeWaitForNextTask } from '../utils/utils'; +// TODO: this file should be removed because it uses the old protocol. + export type PlaywrightClientConnectOptions = { wsEndpoint: string; timeout?: number; diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 7931e624f6..836b91b7d0 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -16,54 +16,34 @@ import debug from 'debug'; import * as http from 'http'; -import * as ws from 'ws'; -import { DispatcherConnection, Root } from '../dispatchers/dispatcher'; -import { PlaywrightDispatcher } from '../dispatchers/playwrightDispatcher'; +import WebSocket from 'ws'; +import { DispatcherConnection, DispatcherScope, Root } from '../dispatchers/dispatcher'; +import { internalCallMetadata } from '../server/instrumentation'; import { createPlaywright, Playwright } from '../server/playwright'; +import { Browser } from '../server/browser'; import { gracefullyCloseAll } from '../utils/processLauncher'; +import { registry } from '../utils/registry'; +import { PlaywrightDispatcher } from '../dispatchers/playwrightDispatcher'; +import { SocksProxy } from '../server/socksProxy'; const debugLog = debug('pw:server'); -export interface PlaywrightServerDelegate { - path: string; - allowMultipleClients: boolean; - onConnect(connection: DispatcherConnection, forceDisconnect: () => void): Promise<() => any>; - onClose: () => any; -} - export class PlaywrightServer { - private _wsServer: ws.Server | undefined; + private _path: string; + private _maxClients: number; + private _browser: Browser | undefined; + private _wsServer: WebSocket.Server | undefined; private _clientsCount = 0; - private _delegate: PlaywrightServerDelegate; - static async startDefault(): Promise { - const cleanup = async () => { - await gracefullyCloseAll().catch(e => {}); - }; - const delegate: PlaywrightServerDelegate = { - path: '/ws', - allowMultipleClients: false, - onClose: cleanup, - onConnect: async (connection: DispatcherConnection) => { - let playwright: Playwright | undefined; - new Root(connection, async (rootScope): Promise => { - playwright = createPlaywright('javascript'); - const dispatcher = new PlaywrightDispatcher(rootScope, playwright); - if (process.env.PW_SOCKS_PROXY_PORT) - await dispatcher.enableSocksProxy(); - return dispatcher; - }); - return () => { - cleanup(); - playwright?.selectors.unregisterAll(); - }; - }, - }; - return new PlaywrightServer(delegate); + static async startDefault(options: { path?: string, maxClients?: number } = {}): Promise { + const { path = '/ws', maxClients = 1 } = options; + return new PlaywrightServer(path, maxClients); } - constructor(delegate: PlaywrightServerDelegate) { - this._delegate = delegate; + constructor(path: string, maxClients: number, browser?: Browser) { + this._path = path; + this._maxClients = maxClients; + this._browser = browser; } async listen(port: number = 0): Promise { @@ -72,7 +52,6 @@ export class PlaywrightServer { }); server.on('error', error => debugLog(error)); - const path = this._delegate.path; const wsEndpoint = await new Promise((resolve, reject) => { server.listen(port, () => { const address = server.address(); @@ -80,64 +59,170 @@ export class PlaywrightServer { reject(new Error('Could not bind server socket')); return; } - const wsEndpoint = typeof address === 'string' ? `${address}${path}` : `ws://127.0.0.1:${address.port}${path}`; + const wsEndpoint = typeof address === 'string' ? `${address}${this._path}` : `ws://127.0.0.1:${address.port}${this._path}`; resolve(wsEndpoint); }).on('error', reject); }); debugLog('Listening at ' + wsEndpoint); - this._wsServer = new ws.Server({ server, path }); - this._wsServer.on('connection', async socket => { - if (this._clientsCount && !this._delegate.allowMultipleClients) { - socket.close(); + this._wsServer = new WebSocket.Server({ server, path: this._path }); + const originalShouldHandle = this._wsServer.shouldHandle.bind(this._wsServer); + this._wsServer.shouldHandle = request => originalShouldHandle(request) && this._clientsCount < this._maxClients; + this._wsServer.on('connection', async (ws, request) => { + if (this._clientsCount >= this._maxClients) { + ws.close(1013, 'Playwright Server is busy'); return; } this._clientsCount++; - debugLog('Incoming connection'); - - const connection = new DispatcherConnection(); - connection.onmessage = message => { - if (socket.readyState !== ws.CLOSING) - socket.send(JSON.stringify(message)); - }; - socket.on('message', (message: string) => { - connection.dispatch(JSON.parse(Buffer.from(message).toString())); - }); - - const forceDisconnect = () => socket.close(); - let onDisconnect = () => {}; - const disconnected = () => { - this._clientsCount--; - // Avoid sending any more messages over closed socket. - connection.onmessage = () => {}; - onDisconnect(); - }; - socket.on('close', () => { - debugLog('Client closed'); - disconnected(); - }); - socket.on('error', error => { - debugLog('Client error ' + error); - disconnected(); - }); - onDisconnect = await this._delegate.onConnect(connection, forceDisconnect); + const connection = new Connection(ws, request, this._browser, () => this._clientsCount--); + (ws as any)[kConnectionSymbol] = connection; }); return wsEndpoint; } async close() { - if (!this._wsServer) + const server = this._wsServer; + if (!server) return; - debugLog('Closing server'); - const waitForClose = new Promise(f => this._wsServer!.close(f)); + debugLog('closing websocket server'); + const waitForClose = new Promise(f => server.close(f)); // First disconnect all remaining clients. - for (const ws of this._wsServer!.clients) - ws.terminate(); + await Promise.all(Array.from(server.clients).map(async ws => { + const connection = (ws as any)[kConnectionSymbol] as Connection | undefined; + if (connection) + await connection.close(); + try { + ws.terminate(); + } catch (e) { + } + })); await waitForClose; - await new Promise(f => this._wsServer!.options.server!.close(f)); + debugLog('closing http server'); + await new Promise(f => server.options.server!.close(f)); this._wsServer = undefined; - await this._delegate.onClose(); + debugLog('closed server'); + } +} + +let lastConnectionId = 0; +const kConnectionSymbol = Symbol('kConnection'); + +class Connection { + private _ws: WebSocket; + private _onClose: () => void; + private _dispatcherConnection: DispatcherConnection; + private _cleanups: (() => Promise)[] = []; + private _id: number; + private _disconnected = false; + + constructor(ws: WebSocket, request: http.IncomingMessage, browser: Browser | undefined, onClose: () => void) { + this._ws = ws; + this._onClose = onClose; + this._id = ++lastConnectionId; + debugLog(`[id=${this._id}] serving connection: ${request.url}`); + + this._dispatcherConnection = new DispatcherConnection(); + this._dispatcherConnection.onmessage = message => { + if (ws.readyState !== ws.CLOSING) + ws.send(JSON.stringify(message)); + }; + ws.on('message', (message: string) => { + this._dispatcherConnection.dispatch(JSON.parse(Buffer.from(message).toString())); + }); + + ws.on('close', () => this._onDisconnect()); + ws.on('error', error => this._onDisconnect(error)); + + new Root(this._dispatcherConnection, async scope => { + if (browser) + return await this._initPreLaunchedBrowserMode(scope, browser); + const url = new URL('http://localhost' + (request.url || '')); + const header = request.headers['X-Playwright-Browser']; + const browserAlias = url.searchParams.get('browser') || (Array.isArray(header) ? header[0] : header); + if (!browserAlias) + return await this._initPlaywrightConnectMode(scope); + return await this._initLaunchBrowserMode(scope, browserAlias); + }); + } + + private async _initPlaywrightConnectMode(scope: DispatcherScope) { + debugLog(`[id=${this._id}] engaged playwright.connect mode`); + const playwright = createPlaywright('javascript'); + // Close all launched browsers on disconnect. + this._cleanups.push(() => gracefullyCloseAll()); + + const socksProxy = await this._enableSocksProxyIfNeeded(playwright); + return new PlaywrightDispatcher(scope, playwright, socksProxy); + } + + private async _initLaunchBrowserMode(scope: DispatcherScope, browserAlias: string) { + debugLog(`[id=${this._id}] engaged launch mode for "${browserAlias}"`); + const executable = registry.findExecutable(browserAlias); + if (!executable || !executable.browserName) + throw new Error(`Unsupported browser "${browserAlias}`); + + const playwright = createPlaywright('javascript'); + const socksProxy = await this._enableSocksProxyIfNeeded(playwright); + const browser = await playwright[executable.browserName].launch(internalCallMetadata(), { + channel: executable.type === 'channel' ? executable.name : undefined, + }); + + // Close the browser on disconnect. + // TODO: it is technically possible to launch more browsers over protocol. + this._cleanups.push(() => browser.close()); + browser.on(Browser.Events.Disconnected, () => { + // Underlying browser did close for some reason - force disconnect the client. + this.close({ code: 1001, reason: 'Browser closed' }); + }); + + return new PlaywrightDispatcher(scope, playwright, socksProxy, browser); + } + + private async _initPreLaunchedBrowserMode(scope: DispatcherScope, browser: Browser) { + debugLog(`[id=${this._id}] engaged pre-launched mode`); + browser.on(Browser.Events.Disconnected, () => { + // Underlying browser did close for some reason - force disconnect the client. + this.close({ code: 1001, reason: 'Browser closed' }); + }); + const playwright = browser.options.rootSdkObject as Playwright; + const playwrightDispatcher = new PlaywrightDispatcher(scope, playwright, undefined, browser); + // In pre-launched mode, keep the browser and just cleanup new contexts. + // TODO: it is technically possible to launch more browsers over protocol. + this._cleanups.push(() => playwrightDispatcher.cleanup()); + return playwrightDispatcher; + } + + private async _enableSocksProxyIfNeeded(playwright: Playwright) { + if (!process.env.PW_SOCKS_PROXY_PORT) + return; + const socksProxy = new SocksProxy(); + playwright.options.socksProxyPort = await socksProxy.listen(0); + debugLog(`[id=${this._id}] started socks proxy on port ${playwright.options.socksProxyPort}`); + this._cleanups.push(() => socksProxy.close()); + return socksProxy; + } + + private async _onDisconnect(error?: Error) { + this._disconnected = true; + debugLog(`[id=${this._id}] disconnected. error: ${error}`); + // Avoid sending any more messages over closed socket. + this._dispatcherConnection.onmessage = () => {}; + debugLog(`[id=${this._id}] starting cleanup`); + for (const cleanup of this._cleanups) + await cleanup().catch(() => {}); + this._onClose(); + debugLog(`[id=${this._id}] finished cleanup`); + } + + async close(reason?: { code: number, reason: string }) { + if (this._disconnected) + return; + debugLog(`[id=${this._id}] force closing connection: ${reason?.reason || ''} (${reason?.code || 0})`); + try { + this._ws.close(reason?.code, reason?.reason); + } catch (e) { + } } } diff --git a/tests/config/default.playwright.config.ts b/tests/config/default.playwright.config.ts index 6d10a11326..0c3fc3d4fe 100644 --- a/tests/config/default.playwright.config.ts +++ b/tests/config/default.playwright.config.ts @@ -32,7 +32,7 @@ const getExecutablePath = (browserName: BrowserName) => { const mode = process.env.PW_OUT_OF_PROCESS_DRIVER ? 'driver' : - (process.env.PWTEST_MODE || 'default') as ('default' | 'driver' | 'service'); + (process.env.PWTEST_MODE || 'default') as ('default' | 'driver' | 'service' | 'service2'); const headed = !!process.env.HEADFUL; const channel = process.env.PWTEST_CHANNEL as any; const video = !!process.env.PWTEST_VIDEO; @@ -59,12 +59,23 @@ const config: Config; diff --git a/tests/config/testModeFixtures.ts b/tests/config/testModeFixtures.ts index f196320ab6..13dba65585 100644 --- a/tests/config/testModeFixtures.ts +++ b/tests/config/testModeFixtures.ts @@ -33,6 +33,7 @@ export const testModeTest = test.extend<{}, TestModeWorkerOptions & TestModeWork default: new DefaultTestMode(), service: new ServiceTestMode(), driver: new DriverTestMode(), + service2: new DefaultTestMode(), }[mode]; require('playwright-core/lib/utils/utils').setUnderTest(); const playwright = await testMode.setup(); diff --git a/tests/port-forwarding-server.spec.ts b/tests/port-forwarding-server.spec.ts index 698376b86a..27eedb817f 100644 --- a/tests/port-forwarding-server.spec.ts +++ b/tests/port-forwarding-server.spec.ts @@ -20,8 +20,7 @@ import path from 'path'; import net from 'net'; import { contextTest, expect } from './config/browserTest'; -import { PlaywrightClient } from '../packages/playwright-core/lib/remote/playwrightClient'; -import type { Page } from 'playwright-core'; +import type { Page, Browser } from 'playwright-core'; class OutOfProcessPlaywrightServer { private _driverProcess: childProcess.ChildProcess; @@ -61,21 +60,25 @@ class OutOfProcessPlaywrightServer { } const it = contextTest.extend<{ pageFactory: (redirectPortForTest?: number) => Promise }>({ - pageFactory: async ({ browserName, browserType }, run, testInfo) => { + pageFactory: async ({ browserType, browserName, channel }, run, testInfo) => { const playwrightServers: OutOfProcessPlaywrightServer[] = []; + const browsers: Browser[] = []; await run(async (redirectPortForTest?: number): Promise => { const server = new OutOfProcessPlaywrightServer(0, 3200 + testInfo.workerIndex); playwrightServers.push(server); - const service = await PlaywrightClient.connect({ - wsEndpoint: await server.wsEndpoint(), - }); - const playwright = service.playwright(); - playwright._enablePortForwarding(redirectPortForTest); - const browser = await playwright[browserName].launch((browserType as any)._defaultLaunchOptions); + const browser = await browserType.connect({ + wsEndpoint: await server.wsEndpoint() + '?browser=' + (channel || browserName), + __testHookPortForwarding: { redirectPortForTest }, + } as any); + browsers.push(browser); return await browser.newPage(); }); for (const playwrightServer of playwrightServers) await playwrightServer.kill(); + await Promise.all(browsers.map(async browser => { + if (browser.isConnected()) + await new Promise(f => browser.once('disconnected', f)); + })); }, }); diff --git a/utils/testserver/index.js b/utils/testserver/index.js index 408857062e..ea29bcb980 100644 --- a/utils/testserver/index.js +++ b/utils/testserver/index.js @@ -87,7 +87,7 @@ class TestServer { }); this._server.listen(port); this._dirPath = dirPath; - this.debugServer = require('debug')('pw:server'); + this.debugServer = require('debug')('pw:testserver'); this._startTime = new Date(); this._cachedPathPrefix = null;