chore: expose driver protocol on the pw object (#17363)

This commit is contained in:
Pavel Feldman 2022-09-15 15:53:18 -07:00 committed by GitHub
parent b09ea69024
commit 872bcc9734
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 563 additions and 131 deletions

View file

@ -21,15 +21,12 @@ import * as playwright from '../..';
import type { BrowserType } from '../client/browserType';
import type { LaunchServerOptions } from '../client/types';
import { createPlaywright, DispatcherConnection, RootDispatcher, PlaywrightDispatcher } from '../server';
import type { Playwright } from '../server';
import type { Playwright } from '../server/playwright';
import { IpcTransport, PipeTransport } from '../protocol/transport';
import { PlaywrightServer } from '../remote/playwrightServer';
import { gracefullyCloseAll } from '../utils/processLauncher';
import { Recorder } from '../server/recorder';
import { EmptyRecorderApp } from '../server/recorder/recorderApp';
import type { BrowserContext } from '../server/browserContext';
import { serverSideCallMetadata } from '../server/instrumentation';
import type { Mode } from '../server/recorder/recorderTypes';
import { ReuseController } from '../server/reuseController';
export function printApiJson() {
// Note: this file is generated by build-playwright-driver.sh
@ -60,8 +57,8 @@ export async function runServer(port: number | undefined, path = '/', maxClients
process.on('exit', () => server.close().catch(console.error));
console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console
process.stdin.on('close', () => selfDestruct());
if (process.send && server.preLaunchedPlaywright())
wireController(server.preLaunchedPlaywright()!, wsEndpoint);
if (reuseBrowser && process.send)
wireController(server.preLaunchedPlaywright(), wsEndpoint);
}
export async function launchBrowserServer(browserName: string, configFile?: string) {
@ -82,118 +79,48 @@ function selfDestruct() {
});
}
const internalMetadata = serverSideCallMetadata();
class ProtocolHandler {
private _playwright: Playwright;
private _autoCloseTimer: NodeJS.Timeout | undefined;
private _controller: ReuseController;
constructor(playwright: Playwright) {
this._playwright = playwright;
playwright.instrumentation.addListener({
onPageOpen: () => this._sendSnapshot(),
onPageNavigated: () => this._sendSnapshot(),
onPageClose: () => this._sendSnapshot(),
}, null);
}
private _sendSnapshot() {
const browsers = [];
for (const browser of this._playwright.allBrowsers()) {
const b = {
name: browser.options.name,
guid: browser.guid,
contexts: [] as any[]
};
browsers.push(b);
for (const context of browser.contexts()) {
const c = {
guid: context.guid,
pages: [] as any[]
};
b.contexts.push(c);
for (const page of context.pages()) {
const p = {
guid: page.guid,
url: page.mainFrame().url()
};
c.pages.push(p);
}
}
}
process.send!({ method: 'browsersChanged', params: { browsers } });
this._controller = playwright.reuseController;
this._controller.setAutoCloseAllowed(true);
this._controller.setTrackHierarcy(true);
this._controller.setReuseBrowser(true);
this._controller.on(ReuseController.Events.BrowsersChanged, browsers => {
process.send!({ method: 'browsersChanged', params: { browsers } });
});
this._controller.on(ReuseController.Events.InspectRequested, selector => {
process.send!({ method: 'inspectRequested', params: { selector } });
});
}
async resetForReuse() {
const contexts = new Set<BrowserContext>();
for (const page of this._playwright.allPages())
contexts.add(page.context());
for (const context of contexts)
await context.resetForReuse(internalMetadata, null);
await this._controller.resetForReuse();
}
async navigate(params: { url: string }) {
for (const p of this._playwright.allPages())
await p.mainFrame().goto(internalMetadata, params.url);
await this._controller.navigateAll(params.url);
}
async setMode(params: { mode: Mode, language?: string, file?: string }) {
await gc(this._playwright);
if (params.mode === 'none') {
for (const recorder of await allRecorders(this._playwright)) {
recorder.setHighlightedSelector('');
recorder.setMode('none');
}
this.setAutoClose({ enabled: true });
return;
}
const browsers = this._playwright.allBrowsers();
if (!browsers.length)
await this._playwright.chromium.launch(internalMetadata, { headless: false });
// Create page if none.
const pages = this._playwright.allPages();
if (!pages.length) {
const [browser] = this._playwright.allBrowsers();
const { context } = await browser.newContextForReuse({}, internalMetadata);
await context.newPage(internalMetadata);
}
// Toggle the mode.
for (const recorder of await allRecorders(this._playwright)) {
recorder.setHighlightedSelector('');
if (params.mode === 'recording')
recorder.setOutput(params.language!, params.file);
recorder.setMode(params.mode);
}
this.setAutoClose({ enabled: true });
await this._controller.setRecorderMode(params);
}
async setAutoClose(params: { enabled: boolean }) {
if (this._autoCloseTimer)
clearTimeout(this._autoCloseTimer);
if (!params.enabled)
return;
const heartBeat = () => {
if (!this._playwright.allPages().length)
selfDestruct();
else
this._autoCloseTimer = setTimeout(heartBeat, 5000);
};
this._autoCloseTimer = setTimeout(heartBeat, 30000);
await this._controller.setAutoCloseEnabled(params.enabled);
}
async highlight(params: { selector: string }) {
for (const recorder of await allRecorders(this._playwright))
recorder.setHighlightedSelector(params.selector);
await this._controller.highlightAll(params.selector);
}
async hideHighlight() {
await this._playwright.hideHighlight();
await this._controller.hideHighlightAll();
}
async kill() {
selfDestruct();
await this._controller.kill();
}
}
@ -209,28 +136,3 @@ function wireController(playwright: Playwright, wsEndpoint: string) {
}
});
}
async function gc(playwright: Playwright) {
for (const browser of playwright.allBrowsers()) {
for (const context of browser.contexts()) {
if (!context.pages().length)
await context.close(serverSideCallMetadata());
}
if (!browser.contexts())
await browser.close();
}
}
async function allRecorders(playwright: Playwright): Promise<Recorder[]> {
const contexts = new Set<BrowserContext>();
for (const page of playwright.allPages())
contexts.add(page.context());
const result = await Promise.all([...contexts].map(c => Recorder.show(c, { omitCallTracking: true }, () => Promise.resolve(new InspectingRecorderApp()))));
return result.filter(Boolean) as Recorder[];
}
class InspectingRecorderApp extends EmptyRecorderApp {
override async setSelector(selector: string): Promise<void> {
process.send!({ method: 'inspectRequested', params: { selector } });
}
}

View file

@ -23,7 +23,7 @@ function launchGridBrowserWorker(gridURL: string, agentId: string, workerId: str
const log = debug(`pw:grid:worker:${workerId}`);
log('created');
const ws = new WebSocket(gridURL.replace('http://', 'ws://') + `/registerWorker?agentId=${agentId}&workerId=${workerId}`);
new PlaywrightConnection(Promise.resolve(), 'auto', ws, { enableSocksProxy: true, browserName, launchOptions: {} }, { playwright: null, browser: null }, log, async () => {
new PlaywrightConnection(Promise.resolve(), 'auto', ws, false, { enableSocksProxy: true, browserName, launchOptions: {} }, { playwright: null, browser: null }, log, async () => {
log('exiting process');
setTimeout(() => process.exit(0), 30000);
// Meanwhile, try to gracefully close all browsers.

View file

@ -52,6 +52,7 @@ export type InitializerTraits<T> =
T extends BrowserTypeChannel ? BrowserTypeInitializer :
T extends SelectorsChannel ? SelectorsInitializer :
T extends SocksSupportChannel ? SocksSupportInitializer :
T extends ReuseControllerChannel ? ReuseControllerInitializer :
T extends PlaywrightChannel ? PlaywrightInitializer :
T extends RootChannel ? RootInitializer :
T extends LocalUtilsChannel ? LocalUtilsInitializer :
@ -89,6 +90,7 @@ export type EventsTraits<T> =
T extends BrowserTypeChannel ? BrowserTypeEvents :
T extends SelectorsChannel ? SelectorsEvents :
T extends SocksSupportChannel ? SocksSupportEvents :
T extends ReuseControllerChannel ? ReuseControllerEvents :
T extends PlaywrightChannel ? PlaywrightEvents :
T extends RootChannel ? RootEvents :
T extends LocalUtilsChannel ? LocalUtilsEvents :
@ -126,6 +128,7 @@ export type EventTargetTraits<T> =
T extends BrowserTypeChannel ? BrowserTypeEventTarget :
T extends SelectorsChannel ? SelectorsEventTarget :
T extends SocksSupportChannel ? SocksSupportEventTarget :
T extends ReuseControllerChannel ? ReuseControllerEventTarget :
T extends PlaywrightChannel ? PlaywrightEventTarget :
T extends RootChannel ? RootEventTarget :
T extends LocalUtilsChannel ? LocalUtilsEventTarget :
@ -555,6 +558,94 @@ export type PlaywrightHideHighlightResult = void;
export interface PlaywrightEvents {
}
// ----------- ReuseController -----------
export type ReuseControllerInitializer = {};
export interface ReuseControllerEventTarget {
on(event: 'inspectRequested', callback: (params: ReuseControllerInspectRequestedEvent) => void): this;
on(event: 'browsersChanged', callback: (params: ReuseControllerBrowsersChangedEvent) => void): this;
}
export interface ReuseControllerChannel extends ReuseControllerEventTarget, Channel {
_type_ReuseController: boolean;
setTrackHierarchy(params: ReuseControllerSetTrackHierarchyParams, metadata?: Metadata): Promise<ReuseControllerSetTrackHierarchyResult>;
setReuseBrowser(params: ReuseControllerSetReuseBrowserParams, metadata?: Metadata): Promise<ReuseControllerSetReuseBrowserResult>;
resetForReuse(params?: ReuseControllerResetForReuseParams, metadata?: Metadata): Promise<ReuseControllerResetForReuseResult>;
navigateAll(params: ReuseControllerNavigateAllParams, metadata?: Metadata): Promise<ReuseControllerNavigateAllResult>;
setRecorderMode(params: ReuseControllerSetRecorderModeParams, metadata?: Metadata): Promise<ReuseControllerSetRecorderModeResult>;
setAutoClose(params: ReuseControllerSetAutoCloseParams, metadata?: Metadata): Promise<ReuseControllerSetAutoCloseResult>;
highlightAll(params: ReuseControllerHighlightAllParams, metadata?: Metadata): Promise<ReuseControllerHighlightAllResult>;
hideHighlightAll(params?: ReuseControllerHideHighlightAllParams, metadata?: Metadata): Promise<ReuseControllerHideHighlightAllResult>;
kill(params?: ReuseControllerKillParams, metadata?: Metadata): Promise<ReuseControllerKillResult>;
}
export type ReuseControllerInspectRequestedEvent = {
selector: string,
};
export type ReuseControllerBrowsersChangedEvent = {
browsers: {
contexts: {
pages: string[],
}[],
}[],
};
export type ReuseControllerSetTrackHierarchyParams = {
enabled: boolean,
};
export type ReuseControllerSetTrackHierarchyOptions = {
};
export type ReuseControllerSetTrackHierarchyResult = void;
export type ReuseControllerSetReuseBrowserParams = {
enabled: boolean,
};
export type ReuseControllerSetReuseBrowserOptions = {
};
export type ReuseControllerSetReuseBrowserResult = void;
export type ReuseControllerResetForReuseParams = {};
export type ReuseControllerResetForReuseOptions = {};
export type ReuseControllerResetForReuseResult = void;
export type ReuseControllerNavigateAllParams = {
url: string,
};
export type ReuseControllerNavigateAllOptions = {
};
export type ReuseControllerNavigateAllResult = void;
export type ReuseControllerSetRecorderModeParams = {
mode: 'inspecting' | 'recording' | 'none',
language?: string,
file?: string,
};
export type ReuseControllerSetRecorderModeOptions = {
language?: string,
file?: string,
};
export type ReuseControllerSetRecorderModeResult = void;
export type ReuseControllerSetAutoCloseParams = {
enabled: boolean,
};
export type ReuseControllerSetAutoCloseOptions = {
};
export type ReuseControllerSetAutoCloseResult = void;
export type ReuseControllerHighlightAllParams = {
selector: string,
};
export type ReuseControllerHighlightAllOptions = {
};
export type ReuseControllerHighlightAllResult = void;
export type ReuseControllerHideHighlightAllParams = {};
export type ReuseControllerHideHighlightAllOptions = {};
export type ReuseControllerHideHighlightAllResult = void;
export type ReuseControllerKillParams = {};
export type ReuseControllerKillOptions = {};
export type ReuseControllerKillResult = void;
export interface ReuseControllerEvents {
'inspectRequested': ReuseControllerInspectRequestedEvent;
'browsersChanged': ReuseControllerBrowsersChangedEvent;
}
// ----------- SocksSupport -----------
export type SocksSupportInitializer = {};
export interface SocksSupportEventTarget {

View file

@ -622,6 +622,68 @@ Playwright:
hideHighlight:
ReuseController:
type: interface
commands:
setTrackHierarchy:
parameters:
enabled: boolean
setReuseBrowser:
parameters:
enabled: boolean
resetForReuse:
navigateAll:
parameters:
url: string
setRecorderMode:
parameters:
mode:
type: enum
literals:
- inspecting
- recording
- none
language: string?
file: string?
setAutoClose:
parameters:
enabled: boolean
highlightAll:
parameters:
selector: string
hideHighlightAll:
kill:
events:
inspectRequested:
parameters:
selector: string
browsersChanged:
parameters:
browsers:
type: array
items:
type: object
properties:
contexts:
type: array
items:
type: object
properties:
pages:
type: array
items: string
SocksSupport:
type: interface

View file

@ -308,6 +308,49 @@ scheme.PlaywrightNewRequestResult = tObject({
});
scheme.PlaywrightHideHighlightParams = tOptional(tObject({}));
scheme.PlaywrightHideHighlightResult = tOptional(tObject({}));
scheme.ReuseControllerInitializer = tOptional(tObject({}));
scheme.ReuseControllerInspectRequestedEvent = tObject({
selector: tString,
});
scheme.ReuseControllerBrowsersChangedEvent = tObject({
browsers: tArray(tObject({
contexts: tArray(tObject({
pages: tArray(tString),
})),
})),
});
scheme.ReuseControllerSetTrackHierarchyParams = tObject({
enabled: tBoolean,
});
scheme.ReuseControllerSetTrackHierarchyResult = tOptional(tObject({}));
scheme.ReuseControllerSetReuseBrowserParams = tObject({
enabled: tBoolean,
});
scheme.ReuseControllerSetReuseBrowserResult = tOptional(tObject({}));
scheme.ReuseControllerResetForReuseParams = tOptional(tObject({}));
scheme.ReuseControllerResetForReuseResult = tOptional(tObject({}));
scheme.ReuseControllerNavigateAllParams = tObject({
url: tString,
});
scheme.ReuseControllerNavigateAllResult = tOptional(tObject({}));
scheme.ReuseControllerSetRecorderModeParams = tObject({
mode: tEnum(['inspecting', 'recording', 'none']),
language: tOptional(tString),
file: tOptional(tString),
});
scheme.ReuseControllerSetRecorderModeResult = tOptional(tObject({}));
scheme.ReuseControllerSetAutoCloseParams = tObject({
enabled: tBoolean,
});
scheme.ReuseControllerSetAutoCloseResult = tOptional(tObject({}));
scheme.ReuseControllerHighlightAllParams = tObject({
selector: tString,
});
scheme.ReuseControllerHighlightAllResult = tOptional(tObject({}));
scheme.ReuseControllerHideHighlightAllParams = tOptional(tObject({}));
scheme.ReuseControllerHideHighlightAllResult = tOptional(tObject({}));
scheme.ReuseControllerKillParams = tOptional(tObject({}));
scheme.ReuseControllerKillResult = tOptional(tObject({}));
scheme.SocksSupportInitializer = tOptional(tObject({}));
scheme.SocksSupportSocksRequestedEvent = tObject({
uid: tString,

View file

@ -15,7 +15,7 @@
*/
import type { WebSocket } from '../utilsBundle';
import type { Playwright } from '../server';
import type { DispatcherScope, Playwright } from '../server';
import { createPlaywright, DispatcherConnection, RootDispatcher, PlaywrightDispatcher } from '../server';
import { Browser } from '../server/browser';
import { serverSideCallMetadata } from '../server/instrumentation';
@ -24,6 +24,7 @@ import { SocksProxy } from '../common/socksProxy';
import type { Mode } from './playwrightServer';
import { assert } from '../utils';
import type { LaunchOptions } from '../server/types';
import { ReuseControllerDispatcher } from '../server/dispatchers/reuseControllerDispatcher';
type Options = {
enableSocksProxy: boolean,
@ -45,9 +46,9 @@ export class PlaywrightConnection {
private _disconnected = false;
private _preLaunched: PreLaunched;
private _options: Options;
private _root: RootDispatcher;
private _root: DispatcherScope;
constructor(lock: Promise<void>, mode: Mode, ws: WebSocket, options: Options, preLaunched: PreLaunched, log: (m: string) => void, onClose: () => void) {
constructor(lock: Promise<void>, mode: Mode, ws: WebSocket, isReuseControllerClient: boolean, options: Options, preLaunched: PreLaunched, log: (m: string) => void, onClose: () => void) {
this._ws = ws;
this._preLaunched = preLaunched;
this._options = options;
@ -72,6 +73,11 @@ export class PlaywrightConnection {
ws.on('close', () => this._onDisconnect());
ws.on('error', error => this._onDisconnect(error));
if (isReuseControllerClient) {
this._root = this._initReuseControllerMode();
return;
}
this._root = new RootDispatcher(this._dispatcherConnection, async scope => {
if (mode === 'reuse-browser')
return await this._initReuseBrowsersMode(scope);
@ -130,6 +136,14 @@ export class PlaywrightConnection {
return playwrightDispatcher;
}
private _initReuseControllerMode(): ReuseControllerDispatcher {
this._debugLog(`engaged reuse controller mode`);
const playwright = this._preLaunched.playwright!;
this._cleanups.push(() => gracefullyCloseAll());
// Always create new instance based on the reused Playwright instance.
return new ReuseControllerDispatcher(this._dispatcherConnection, playwright.reuseController);
}
private async _initReuseBrowsersMode(scope: RootDispatcher) {
this._debugLog(`engaged reuse browsers mode for ${this._options.browserName}`);
const playwright = this._preLaunched.playwright!;
@ -150,7 +164,7 @@ export class PlaywrightConnection {
}
if (!browser) {
browser = await playwright[this._options.browserName as 'chromium'].launch(serverSideCallMetadata(), {
browser = await playwright[(this._options.browserName || 'chromium') as 'chromium'].launch(serverSideCallMetadata(), {
...this._options.launchOptions,
headless: false,
});

View file

@ -35,6 +35,7 @@ function newLogger() {
return (message: string) => debugLog(`[id=${id}] ${message}`);
}
// TODO: replace 'reuse-browser' with 'allow-reuse' in 1.27.
export type Mode = 'use-pre-launched-browser' | 'reuse-browser' | 'auto';
type ServerOptions = {
@ -58,11 +59,11 @@ export class PlaywrightServer {
assert(options.preLaunchedBrowser);
this._preLaunchedPlaywright = options.preLaunchedBrowser.options.rootSdkObject as Playwright;
}
if (mode === 'reuse-browser')
this._preLaunchedPlaywright = createPlaywright('javascript');
}
preLaunchedPlaywright(): Playwright | null {
preLaunchedPlaywright(): Playwright {
if (!this._preLaunchedPlaywright)
this._preLaunchedPlaywright = createPlaywright('javascript');
return this._preLaunchedPlaywright;
}
@ -87,9 +88,10 @@ export class PlaywrightServer {
debugLog('Listening at ' + wsEndpoint);
this._wsServer = new wsServer({ server, path: this._options.path });
const semaphore = new Semaphore(this._options.maxConcurrentConnections);
const browserSemaphore = new Semaphore(this._options.maxConcurrentConnections);
const controllerSemaphore = new Semaphore(1);
this._wsServer.on('connection', (ws, request) => {
if (semaphore.requested() >= this._options.maxIncomingConnections) {
if (browserSemaphore.requested() >= this._options.maxIncomingConnections) {
ws.close(1013, 'Playwright Server is busy');
return;
}
@ -109,9 +111,27 @@ export class PlaywrightServer {
const log = newLogger();
log(`serving connection: ${request.url}`);
const isReuseControllerClient = !!request.headers['x-playwright-reuse-controller'];
const semaphore = isReuseControllerClient ? controllerSemaphore : browserSemaphore;
// If we started in the legacy reuse-browser mode, create this._preLaunchedPlaywright.
// If we get a reuse-controller request, create this._preLaunchedPlaywright.
if (isReuseControllerClient || (this._mode === 'reuse-browser') && !this._preLaunchedPlaywright)
this.preLaunchedPlaywright();
// If we have a playwright to reuse, consult controller for reuse mode.
let mode = this._mode;
if (mode === 'auto' && this._preLaunchedPlaywright?.reuseController.reuseBrowser())
mode = 'reuse-browser';
if (mode === 'reuse-browser')
semaphore.setMax(1);
else
semaphore.setMax(this._options.maxConcurrentConnections);
const connection = new PlaywrightConnection(
semaphore.aquire(),
this._mode, ws,
mode, ws, isReuseControllerClient,
{ enableSocksProxy, browserName, launchOptions },
{ playwright: this._preLaunchedPlaywright, browser: this._options.preLaunchedBrowser || null },
log, () => semaphore.release());
@ -149,10 +169,15 @@ export class Semaphore {
private _max: number;
private _aquired = 0;
private _queue: ManualPromise[] = [];
constructor(max: number) {
this._max = max;
}
setMax(max: number) {
this._max = max;
}
aquire(): Promise<void> {
const lock = new ManualPromise();
this._queue.push(lock);

View file

@ -0,0 +1,76 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the 'License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type * as channels from '../../protocol/channels';
import { ReuseController } from '../reuseController';
import type { DispatcherConnection, RootDispatcher } from './dispatcher';
import { Dispatcher } from './dispatcher';
export class ReuseControllerDispatcher extends Dispatcher<ReuseController, channels.ReuseControllerChannel, RootDispatcher> implements channels.ReuseControllerChannel {
_type_ReuseController;
constructor(connection: DispatcherConnection, reuseController: ReuseController) {
super(connection, reuseController, 'ReuseController', {});
this._type_ReuseController = true;
this._object.on(ReuseController.Events.BrowsersChanged, browsers => {
this._dispatchEvent('browsersChanged', { browsers });
});
this._object.on(ReuseController.Events.InspectRequested, selector => {
this._dispatchEvent('inspectRequested', { selector });
});
}
async setTrackHierarchy(params: channels.ReuseControllerSetTrackHierarchyParams) {
this._object.setTrackHierarcy(params.enabled);
}
async setReuseBrowser(params: channels.ReuseControllerSetReuseBrowserParams) {
this._object.setReuseBrowser(params.enabled);
}
async resetForReuse() {
await this._object.resetForReuse();
}
async navigateAll(params: channels.ReuseControllerNavigateAllParams) {
await this._object.navigateAll(params.url);
}
async setRecorderMode(params: channels.ReuseControllerSetRecorderModeParams) {
await this._object.setRecorderMode(params);
}
async setAutoClose(params: channels.ReuseControllerSetAutoCloseParams) {
await this._object.setAutoCloseEnabled(params.enabled);
}
async highlightAll(params: channels.ReuseControllerHighlightAllParams) {
await this._object.highlightAll(params.selector);
}
async hideHighlightAll() {
await this._object.hideHighlightAll();
}
async kill() {
await this._object.kill();
}
override _dispose() {
super._dispose();
this._object.dispose();
}
}

View file

@ -26,6 +26,7 @@ import type { CallMetadata } from './instrumentation';
import { createInstrumentation, SdkObject } from './instrumentation';
import { debugLogger } from '../common/debugLogger';
import type { Page } from './page';
import { ReuseController } from './reuseController';
export class Playwright extends SdkObject {
readonly selectors: Selectors;
@ -35,6 +36,7 @@ export class Playwright extends SdkObject {
readonly firefox: Firefox;
readonly webkit: WebKit;
readonly options: PlaywrightOptions;
readonly reuseController: ReuseController;
private _allPages = new Set<Page>();
private _allBrowsers = new Set<Browser>();
@ -60,6 +62,7 @@ export class Playwright extends SdkObject {
this.electron = new Electron(this.options);
this.android = new Android(new AdbBackend(), this.options);
this.selectors = this.options.selectors;
this.reuseController = new ReuseController(this);
}
async hideHighlight() {

View file

@ -0,0 +1,215 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Mode } from '../server/recorder/recorderTypes';
import { gracefullyCloseAll } from '../utils/processLauncher';
import type { Browser } from './browser';
import type { BrowserContext } from './browserContext';
import { createInstrumentation, SdkObject, serverSideCallMetadata } from './instrumentation';
import type { InstrumentationListener } from './instrumentation';
import type { Playwright } from './playwright';
import { Recorder } from './recorder';
import { EmptyRecorderApp } from './recorder/recorderApp';
const internalMetadata = serverSideCallMetadata();
export class ReuseController extends SdkObject {
static Events = {
BrowsersChanged: 'browsersChanged',
InspectRequested: 'inspectRequested'
};
private _autoCloseTimer: NodeJS.Timeout | undefined;
// TODO: remove in 1.27
private _autoCloseAllowed = false;
private _trackHierarchyListener: InstrumentationListener | undefined;
private _playwright: Playwright;
private _reuseBrowser = false;
constructor(playwright: Playwright) {
super({ attribution: { isInternalPlaywright: true }, instrumentation: createInstrumentation() } as any, undefined, 'ReuseController');
this._playwright = playwright;
}
setAutoCloseAllowed(allowed: boolean) {
this._autoCloseAllowed = allowed;
}
dispose() {
this.setTrackHierarcy(false);
this.setAutoCloseAllowed(false);
this.setReuseBrowser(false);
}
setTrackHierarcy(enabled: boolean) {
if (enabled && !this._trackHierarchyListener) {
this._trackHierarchyListener = {
onPageOpen: () => this._emitSnapshot(),
onPageNavigated: () => this._emitSnapshot(),
onPageClose: () => this._emitSnapshot(),
};
this.instrumentation.addListener(this._trackHierarchyListener, null);
} else if (!enabled && this._trackHierarchyListener) {
this.instrumentation.removeListener(this._trackHierarchyListener);
this._trackHierarchyListener = undefined;
}
}
reuseBrowser(): boolean {
return this._reuseBrowser;
}
setReuseBrowser(enabled: boolean) {
this._reuseBrowser = enabled;
}
async resetForReuse() {
const contexts = new Set<BrowserContext>();
for (const page of this._playwright.allPages())
contexts.add(page.context());
for (const context of contexts)
await context.resetForReuse(internalMetadata, null);
}
async navigateAll(url: string) {
for (const p of this._playwright.allPages())
await p.mainFrame().goto(internalMetadata, url);
}
async setRecorderMode(params: { mode: Mode, language?: string, file?: string }) {
await this._closeBrowsersWithoutPages();
if (params.mode === 'none') {
for (const recorder of await this._allRecorders()) {
recorder.setHighlightedSelector('');
recorder.setMode('none');
}
this.setAutoCloseEnabled(true);
return;
}
if (!this._playwright.allBrowsers().length)
await this._playwright.chromium.launch(internalMetadata, { headless: false });
// Create page if none.
const pages = this._playwright.allPages();
if (!pages.length) {
const [browser] = this._playwright.allBrowsers();
const { context } = await browser.newContextForReuse({}, internalMetadata);
await context.newPage(internalMetadata);
}
// Toggle the mode.
for (const recorder of await this._allRecorders()) {
recorder.setHighlightedSelector('');
if (params.mode === 'recording')
recorder.setOutput(params.language!, params.file);
recorder.setMode(params.mode);
}
this.setAutoCloseEnabled(true);
}
async setAutoCloseEnabled(enabled: boolean) {
if (!this._autoCloseAllowed)
return;
if (this._autoCloseTimer)
clearTimeout(this._autoCloseTimer);
if (!enabled)
return;
const heartBeat = () => {
if (!this._playwright.allPages().length)
selfDestruct();
else
this._autoCloseTimer = setTimeout(heartBeat, 5000);
};
this._autoCloseTimer = setTimeout(heartBeat, 30000);
}
async highlightAll(selector: string) {
for (const recorder of await this._allRecorders())
recorder.setHighlightedSelector(selector);
}
async hideHighlightAll() {
await this._playwright.hideHighlight();
}
allBrowsers(): Browser[] {
return [...this._playwright.allBrowsers()];
}
async kill() {
selfDestruct();
}
private _emitSnapshot() {
const browsers = [];
for (const browser of this._playwright.allBrowsers()) {
const b = {
contexts: [] as any[]
};
browsers.push(b);
for (const context of browser.contexts()) {
const c = {
pages: [] as any[]
};
b.contexts.push(c);
for (const page of context.pages())
c.pages.push(page.mainFrame().url());
}
}
this.emit(ReuseController.Events.BrowsersChanged, browsers);
}
private async _allRecorders(): Promise<Recorder[]> {
const contexts = new Set<BrowserContext>();
for (const page of this._playwright.allPages())
contexts.add(page.context());
const result = await Promise.all([...contexts].map(c => Recorder.show(c, { omitCallTracking: true }, () => Promise.resolve(new InspectingRecorderApp(this)))));
return result.filter(Boolean) as Recorder[];
}
private async _closeBrowsersWithoutPages() {
for (const browser of this._playwright.allBrowsers()) {
for (const context of browser.contexts()) {
if (!context.pages().length)
await context.close(serverSideCallMetadata());
}
if (!browser.contexts())
await browser.close();
}
}
}
function selfDestruct() {
// Force exit after 30 seconds.
setTimeout(() => process.exit(0), 30000);
// Meanwhile, try to gracefully close all browsers.
gracefullyCloseAll().then(() => {
process.exit(0);
});
}
class InspectingRecorderApp extends EmptyRecorderApp {
private _reuseController: ReuseController;
constructor(reuseController: ReuseController) {
super();
this._reuseController = reuseController;
}
override async setSelector(selector: string): Promise<void> {
this._reuseController.emit(ReuseController.Events.InspectRequested, selector);
}
}

View file

@ -218,6 +218,7 @@ export class Runner {
};
for (const [project, files] of filesByProject) {
report.projects.push({
docker: process.env.PLAYWRIGHT_DOCKER,
name: project.name,
testDir: path.resolve(configFile, project.testDir),
files: files