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:
parent
2bc19ae076
commit
66b5cf5ae1
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue