diff --git a/packages/playwright-core/src/cli/driver.ts b/packages/playwright-core/src/cli/driver.ts index c6fd75249e6fd..9c306ca7a66b1 100644 --- a/packages/playwright-core/src/cli/driver.ts +++ b/packages/playwright-core/src/cli/driver.ts @@ -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(); - 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 { - const contexts = new Set(); - 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 { - process.send!({ method: 'inspectRequested', params: { selector } }); - } -} diff --git a/packages/playwright-core/src/grid/gridBrowserWorker.ts b/packages/playwright-core/src/grid/gridBrowserWorker.ts index 725fdb882276f..4656f1a8dc327 100644 --- a/packages/playwright-core/src/grid/gridBrowserWorker.ts +++ b/packages/playwright-core/src/grid/gridBrowserWorker.ts @@ -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. diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index ca23e24c1e96b..0e74ff3be0e79 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -52,6 +52,7 @@ export type InitializerTraits = 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 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 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; + setReuseBrowser(params: ReuseControllerSetReuseBrowserParams, metadata?: Metadata): Promise; + resetForReuse(params?: ReuseControllerResetForReuseParams, metadata?: Metadata): Promise; + navigateAll(params: ReuseControllerNavigateAllParams, metadata?: Metadata): Promise; + setRecorderMode(params: ReuseControllerSetRecorderModeParams, metadata?: Metadata): Promise; + setAutoClose(params: ReuseControllerSetAutoCloseParams, metadata?: Metadata): Promise; + highlightAll(params: ReuseControllerHighlightAllParams, metadata?: Metadata): Promise; + hideHighlightAll(params?: ReuseControllerHideHighlightAllParams, metadata?: Metadata): Promise; + kill(params?: ReuseControllerKillParams, metadata?: Metadata): Promise; +} +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 { diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index 4f064d76c8b3a..3ed1dbc8898cf 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -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 diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 23de16ab29b08..cf40dedfd8db8 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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, diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index 9f6655792c652..be117ba65374d 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -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, mode: Mode, ws: WebSocket, options: Options, preLaunched: PreLaunched, log: (m: string) => void, onClose: () => void) { + constructor(lock: Promise, 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, }); diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 1957e9276ca90..12eb5627b9766 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -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 { const lock = new ManualPromise(); this._queue.push(lock); diff --git a/packages/playwright-core/src/server/dispatchers/reuseControllerDispatcher.ts b/packages/playwright-core/src/server/dispatchers/reuseControllerDispatcher.ts new file mode 100644 index 0000000000000..6b98475678996 --- /dev/null +++ b/packages/playwright-core/src/server/dispatchers/reuseControllerDispatcher.ts @@ -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 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(); + } +} diff --git a/packages/playwright-core/src/server/playwright.ts b/packages/playwright-core/src/server/playwright.ts index 8ac8d43580d94..121dcf745838d 100644 --- a/packages/playwright-core/src/server/playwright.ts +++ b/packages/playwright-core/src/server/playwright.ts @@ -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(); private _allBrowsers = new Set(); @@ -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() { diff --git a/packages/playwright-core/src/server/reuseController.ts b/packages/playwright-core/src/server/reuseController.ts new file mode 100644 index 0000000000000..1f1f5b30bb8a4 --- /dev/null +++ b/packages/playwright-core/src/server/reuseController.ts @@ -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(); + 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 { + const contexts = new Set(); + 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 { + this._reuseController.emit(ReuseController.Events.InspectRequested, selector); + } +} diff --git a/packages/playwright-test/src/runner.ts b/packages/playwright-test/src/runner.ts index 7df06bdcb90fa..777855d9497c4 100644 --- a/packages/playwright-test/src/runner.ts +++ b/packages/playwright-test/src/runner.ts @@ -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