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' });
```
This commit is contained in:
Dmitry Gozman 2022-02-10 16:36:23 -08:00 committed by GitHub
parent 2bc19ae076
commit 66b5cf5ae1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 292 additions and 209 deletions

View file

@ -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<PlaywrightDispatcher> => {
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<Browser, channels.BrowserChannel> implements channels.BrowserChannel {
_type_Browser = true;
private _contexts = new Set<BrowserContext>();
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<channels.BrowserNewContextResult> {
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<void> {
// Client should not send us Browser.close.
}
async killForTests(): Promise<void> {
// Client should not send us Browser.killForTests.
}
async newBrowserCDPSession(): Promise<channels.BrowserNewBrowserCDPSessionResult> {
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<void> {
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<channels.BrowserStopTracingResult> {
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 {

View file

@ -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

View file

@ -168,6 +168,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> 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;

View file

@ -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<Browser, channels.BrowserChannel> implements channels.BrowserChannel {
_type_Browser = true;
@ -72,3 +74,63 @@ export class BrowserDispatcher extends Dispatcher<Browser, channels.BrowserChann
return { binary: buffer.toString('base64') };
}
}
// This class implements multiplexing browser dispatchers over a single Browser instance.
export class ConnectedBrowserDispatcher extends Dispatcher<Browser, channels.BrowserChannel> implements channels.BrowserChannel {
_type_Browser = true;
private _contexts = new Set<BrowserContext>();
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<channels.BrowserNewContextResult> {
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<void> {
// Client should not send us Browser.close.
}
async killForTests(): Promise<void> {
// Client should not send us Browser.killForTests.
}
async newBrowserCDPSession(): Promise<channels.BrowserNewBrowserCDPSessionResult> {
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<void> {
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<channels.BrowserStopTracingResult> {
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())));
}
}

View file

@ -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<Playwright, channels.PlaywrightChannel> 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<Playwright, channels.Playwr
electron: new ElectronDispatcher(scope, playwright.electron),
utils: new LocalUtilsDispatcher(scope),
deviceDescriptors,
selectors: customSelectors || new SelectorsDispatcher(scope, playwright.selectors),
preLaunchedBrowser,
selectors: new SelectorsDispatcher(scope, browserDispatcher?.selectors || playwright.selectors),
preLaunchedBrowser: browserDispatcher,
}, false);
this._type_Playwright = true;
}
async enableSocksProxy() {
this._socksProxy = new SocksProxy();
this._object.options.socksProxyPort = await this._socksProxy.listen(0);
this._socksProxy.on(SocksProxy.Events.SocksRequested, data => 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<void> {
@ -87,4 +88,9 @@ export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.Playwr
async hideHighlight(params: channels.PlaywrightHideHighlightParams, metadata?: channels.Metadata): Promise<channels.PlaywrightHideHighlightResult> {
await this._object.hideHighlight();
}
async cleanup() {
// Cleanup contexts upon disconnect.
await this._browserDispatcher?.cleanupContexts();
}
}

View file

@ -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())));

View file

@ -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;

View file

@ -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<PlaywrightServer> {
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<PlaywrightDispatcher> => {
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<PlaywrightServer> {
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<string> {
@ -72,7 +52,6 @@ export class PlaywrightServer {
});
server.on('error', error => debugLog(error));
const path = this._delegate.path;
const wsEndpoint = await new Promise<string>((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<void>)[] = [];
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) {
}
}
}

View file

@ -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<CoverageWorkerOptions & PlaywrightWorkerOptions & Playwrigh
['html', { open: 'on-failure' }]
],
projects: [],
webServer: mode === 'service' ? {
};
if (mode === 'service') {
config.webServer = {
command: 'npx playwright experimental-grid-server',
port: 3333,
reuseExistingServer: true,
} : undefined,
};
};
}
if (mode === 'service2') {
config.webServer = {
command: 'npx playwright run-server 3333',
port: 3333,
reuseExistingServer: true,
};
}
const browserNames = ['chromium', 'webkit', 'firefox'] as BrowserName[];
for (const browserName of browserNames) {
@ -90,6 +101,9 @@ for (const browserName of browserNames) {
},
trace: trace ? 'on' : undefined,
coverageName: browserName,
connectOptions: mode === 'service2' ? {
wsEndpoint: 'ws://localhost:3333/?browser=' + (channel || browserName),
} : undefined,
},
metadata: {
platform: process.platform,

View file

@ -18,7 +18,7 @@ import { GridClient } from '../../packages/playwright-core/lib/grid/gridClient';
import { start } from '../../packages/playwright-core/lib/outofprocess';
import { Playwright } from '../../packages/playwright-core/lib/client/playwright';
export type TestModeName = 'default' | 'driver' | 'service';
export type TestModeName = 'default' | 'driver' | 'service' | 'service2';
interface TestMode {
setup(): Promise<Playwright>;

View file

@ -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();

View file

@ -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<Page> }>({
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<Page> => {
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));
}));
},
});

View file

@ -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;