diff --git a/package.json b/package.json index b2c9f226c7e34..4d44b776146ba 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "node": ">=14.1.0" }, "scripts": { - "test": "c8 --check-coverage --lines 94 run-s test:chrome test:chrome:* test:firefox", + "test": "c8 --check-coverage --lines 93 run-s test:chrome test:chrome:* test:firefox", "test:types": "tsd", "test:install": "scripts/test-install.sh", "test:firefox": "cross-env PUPPETEER_PRODUCT=firefox MOZ_WEBRENDER=0 mocha", diff --git a/src/common/Browser.ts b/src/common/Browser.ts index b3667a569259b..783dee7340006 100644 --- a/src/common/Browser.ts +++ b/src/common/Browser.ts @@ -17,13 +17,16 @@ import {ChildProcess} from 'child_process'; import {Protocol} from 'devtools-protocol'; import {assert} from './assert.js'; -import {Connection, ConnectionEmittedEvents} from './Connection.js'; +import {CDPSession, Connection, ConnectionEmittedEvents} from './Connection.js'; import {EventEmitter} from './EventEmitter.js'; import {waitWithTimeout} from './util.js'; import {Page} from './Page.js'; import {Viewport} from './PuppeteerViewport.js'; import {Target} from './Target.js'; import {TaskQueue} from './TaskQueue.js'; +import {TargetManager, TargetManagerEmittedEvents} from './TargetManager.js'; +import {ChromeTargetManager} from './ChromeTargetManager.js'; +import {FirefoxTargetManager} from './FirefoxTargetManager.js'; /** * BrowserContext options. @@ -218,6 +221,7 @@ export class Browser extends EventEmitter { * @internal */ static async _create( + product: 'firefox' | 'chrome' | undefined, connection: Connection, contextIds: string[], ignoreHTTPSErrors: boolean, @@ -228,6 +232,7 @@ export class Browser extends EventEmitter { isPageTargetCallback?: IsPageTargetCallback ): Promise { const browser = new Browser( + product, connection, contextIds, ignoreHTTPSErrors, @@ -237,7 +242,7 @@ export class Browser extends EventEmitter { targetFilterCallback, isPageTargetCallback ); - await connection.send('Target.setDiscoverTargets', {discover: true}); + await browser._attach(); return browser; } #ignoreHTTPSErrors: boolean; @@ -250,20 +255,20 @@ export class Browser extends EventEmitter { #defaultContext: BrowserContext; #contexts: Map; #screenshotTaskQueue: TaskQueue; - #targets: Map; - #ignoredTargets = new Set(); + #targetManager: TargetManager; /** * @internal */ get _targets(): Map { - return this.#targets; + return this.#targetManager.getAvailableTargets(); } /** * @internal */ constructor( + product: 'chrome' | 'firefox' | undefined, connection: Connection, contextIds: string[], ignoreHTTPSErrors: boolean, @@ -274,6 +279,7 @@ export class Browser extends EventEmitter { isPageTargetCallback?: IsPageTargetCallback ) { super(); + product = product || 'chrome'; this.#ignoreHTTPSErrors = ignoreHTTPSErrors; this.#defaultViewport = defaultViewport; this.#process = process; @@ -286,7 +292,19 @@ export class Browser extends EventEmitter { return true; }); this.#setIsPageTargetCallback(isPageTargetCallback); - + if (product === 'firefox') { + this.#targetManager = new FirefoxTargetManager( + connection, + this.#createTarget, + this.#targetFilterCallback + ); + } else { + this.#targetManager = new ChromeTargetManager( + connection, + this.#createTarget, + this.#targetFilterCallback + ); + } this.#defaultContext = new BrowserContext(this.#connection, this); this.#contexts = new Map(); for (const contextId of contextIds) { @@ -295,19 +313,62 @@ export class Browser extends EventEmitter { new BrowserContext(this.#connection, this, contextId) ); } + } - this.#targets = new Map(); - this.#connection.on(ConnectionEmittedEvents.Disconnected, () => { - return this.emit(BrowserEmittedEvents.Disconnected); - }); - this.#connection.on('Target.targetCreated', this.#targetCreated.bind(this)); + #emitDisconnected = () => { + this.emit(BrowserEmittedEvents.Disconnected); + }; + + /** + * @internal + */ + async _attach(): Promise { this.#connection.on( - 'Target.targetDestroyed', - this.#targetDestroyed.bind(this) + ConnectionEmittedEvents.Disconnected, + this.#emitDisconnected ); - this.#connection.on( - 'Target.targetInfoChanged', - this.#targetInfoChanged.bind(this) + this.#targetManager.on( + TargetManagerEmittedEvents.TargetAvailable, + this.#onAttachedToTarget + ); + this.#targetManager.on( + TargetManagerEmittedEvents.TargetGone, + this.#onDetachedFromTarget + ); + this.#targetManager.on( + TargetManagerEmittedEvents.TargetChanged, + this.#onTargetChanged + ); + this.#targetManager.on( + TargetManagerEmittedEvents.TargetDiscovered, + this.#onTargetDiscovered + ); + await this.#targetManager.initialize(); + } + + /** + * @internal + */ + _detach(): void { + this.#connection.off( + ConnectionEmittedEvents.Disconnected, + this.#emitDisconnected + ); + this.#targetManager.off( + TargetManagerEmittedEvents.TargetAvailable, + this.#onAttachedToTarget + ); + this.#targetManager.off( + TargetManagerEmittedEvents.TargetGone, + this.#onDetachedFromTarget + ); + this.#targetManager.off( + TargetManagerEmittedEvents.TargetChanged, + this.#onTargetChanged + ); + this.#targetManager.off( + TargetManagerEmittedEvents.TargetDiscovered, + this.#onTargetDiscovered ); } @@ -319,6 +380,13 @@ export class Browser extends EventEmitter { return this.#process ?? null; } + /** + * @internal + */ + _targetManager(): TargetManager { + return this.#targetManager; + } + #setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void { this.#isPageTargetCallback = isPageTargetCallback || @@ -404,10 +472,10 @@ export class Browser extends EventEmitter { this.#contexts.delete(contextId); } - async #targetCreated( - event: Protocol.Target.TargetCreatedEvent - ): Promise { - const targetInfo = event.targetInfo; + #createTarget = ( + targetInfo: Protocol.Target.TargetInfo, + session?: CDPSession + ) => { const {browserContextId} = targetInfo; const context = browserContextId && this.#contexts.has(browserContextId) @@ -418,15 +486,11 @@ export class Browser extends EventEmitter { throw new Error('Missing browser context'); } - const shouldAttachToTarget = this.#targetFilterCallback(targetInfo); - if (!shouldAttachToTarget) { - this.#ignoredTargets.add(targetInfo.targetId); - return; - } - - const target = new Target( + return new Target( targetInfo, + session, context, + this.#targetManager, () => { return this.#connection.createSession(targetInfo); }, @@ -435,30 +499,19 @@ export class Browser extends EventEmitter { this.#screenshotTaskQueue, this.#isPageTargetCallback ); - assert( - !this.#targets.has(event.targetInfo.targetId), - 'Target should not exist before targetCreated' - ); - this.#targets.set(event.targetInfo.targetId, target); + }; + #onAttachedToTarget = async (target: Target) => { if (await target._initializedPromise) { this.emit(BrowserEmittedEvents.TargetCreated, target); - context.emit(BrowserContextEmittedEvents.TargetCreated, target); + target + .browserContext() + .emit(BrowserContextEmittedEvents.TargetCreated, target); } - } + }; - async #targetDestroyed(event: {targetId: string}): Promise { - if (this.#ignoredTargets.has(event.targetId)) { - return; - } - const target = this.#targets.get(event.targetId); - if (!target) { - throw new Error( - `Missing target in _targetDestroyed (id = ${event.targetId})` - ); - } + #onDetachedFromTarget = async (target: Target): Promise => { target._initializedCallback(false); - this.#targets.delete(event.targetId); target._closedCallback(); if (await target._initializedPromise) { this.emit(BrowserEmittedEvents.TargetDestroyed, target); @@ -466,28 +519,29 @@ export class Browser extends EventEmitter { .browserContext() .emit(BrowserContextEmittedEvents.TargetDestroyed, target); } - } - - #targetInfoChanged(event: Protocol.Target.TargetInfoChangedEvent): void { - if (this.#ignoredTargets.has(event.targetInfo.targetId)) { - return; - } - const target = this.#targets.get(event.targetInfo.targetId); - if (!target) { - throw new Error( - `Missing target in targetInfoChanged (id = ${event.targetInfo.targetId})` - ); - } + }; + + #onTargetChanged = ({ + target, + targetInfo, + }: { + target: Target; + targetInfo: Protocol.Target.TargetInfo; + }): void => { const previousURL = target.url(); const wasInitialized = target._isInitialized; - target._targetInfoChanged(event.targetInfo); + target._targetInfoChanged(targetInfo); if (wasInitialized && previousURL !== target.url()) { this.emit(BrowserEmittedEvents.TargetChanged, target); target .browserContext() .emit(BrowserContextEmittedEvents.TargetChanged, target); } - } + }; + + #onTargetDiscovered = (targetInfo: Protocol.Target.TargetInfo): void => { + this.emit('targetdiscovered', targetInfo); + }; /** * The browser websocket endpoint which can be used as an argument to @@ -526,7 +580,7 @@ export class Browser extends EventEmitter { url: 'about:blank', browserContextId: contextId || undefined, }); - const target = this.#targets.get(targetId); + const target = this.#targetManager.getAvailableTargets().get(targetId); if (!target) { throw new Error(`Missing target for page (id = ${targetId})`); } @@ -548,7 +602,9 @@ export class Browser extends EventEmitter { * an array with all the targets in all browser contexts. */ targets(): Target[] { - return Array.from(this.#targets.values()).filter(target => { + return Array.from( + this.#targetManager.getAvailableTargets().values() + ).filter(target => { return target._isInitialized; }); } @@ -671,6 +727,7 @@ export class Browser extends EventEmitter { * cannot be used anymore. */ disconnect(): void { + this.#targetManager.dispose(); this.#connection.dispose(); } diff --git a/src/common/BrowserConnector.ts b/src/common/BrowserConnector.ts index 9cd67c5ceb93b..be425b8877cad 100644 --- a/src/common/BrowserConnector.ts +++ b/src/common/BrowserConnector.ts @@ -26,7 +26,7 @@ import {Connection} from './Connection.js'; import {ConnectionTransport} from './ConnectionTransport.js'; import {getFetch} from './fetch.js'; import {Viewport} from './PuppeteerViewport.js'; - +import {Product} from './Product.js'; /** * Generic browser options that can be passed when launching any browser or when * connecting to an existing browser instance. @@ -75,6 +75,7 @@ export async function _connectToBrowser( browserWSEndpoint?: string; browserURL?: string; transport?: ConnectionTransport; + product?: Product; } ): Promise { const { @@ -86,6 +87,7 @@ export async function _connectToBrowser( slowMo = 0, targetFilter, _isPageTarget: isPageTarget, + product, } = options; assert( @@ -113,7 +115,8 @@ export async function _connectToBrowser( const {browserContextIds} = await connection.send( 'Target.getBrowserContexts' ); - return Browser._create( + const browser = await Browser._create( + product || 'chrome', connection, browserContextIds, ignoreHTTPSErrors, @@ -125,6 +128,8 @@ export async function _connectToBrowser( targetFilter, isPageTarget ); + await browser.pages(); + return browser; } async function getWSEndpoint(browserURL: string): Promise { diff --git a/src/common/ChromeTargetManager.ts b/src/common/ChromeTargetManager.ts new file mode 100644 index 0000000000000..3de8022cb02bb --- /dev/null +++ b/src/common/ChromeTargetManager.ts @@ -0,0 +1,394 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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 Protocol from 'devtools-protocol'; +import {assert} from './assert.js'; +import {CDPSession, Connection} from './Connection.js'; +import {EventEmitter} from './EventEmitter.js'; +import {Target} from './Target.js'; +import {debugError} from './util.js'; +import {TargetFilterCallback} from './Browser.js'; +import { + TargetInterceptor, + TargetFactory, + TargetManager, + TargetManagerEmittedEvents, +} from './TargetManager.js'; + +/** + * ChromeTargetManager uses the CDP's auto-attach mechanism to intercept + * new targets and allow the rest of Puppeteer to configure listeners while + * the target is paused. + * + * @internal + */ +export class ChromeTargetManager extends EventEmitter implements TargetManager { + #connection: Connection; + /** + * Keeps track of the following events: 'Target.targetCreated', + * 'Target.targetDestroyed', 'Target.targetInfoChanged'. + * + * A target becomes discovered when 'Target.targetCreated' is received. + * A target is removed from this map once 'Target.targetDestroyed' is + * received. + * + * `targetFilterCallback` has no effect on this map. + */ + #discoveredTargetsByTargetId: Map = + new Map(); + /** + * A target is added to this map once ChromeTargetManager has created + * a Target and attached at least once to it. + */ + #attachedTargetsByTargetId: Map = new Map(); + /** + * + * Tracks which sessions attach to which target. + */ + #attachedTargetsBySessionId: Map = new Map(); + /** + * If a target was filtered out by `targetFilterCallback`, we still receive + * events about it from CDP, but we don't forward them to the rest of Puppeteer. + */ + #ignoredTargets = new Set(); + #targetFilterCallback: TargetFilterCallback | undefined; + #targetFactory: TargetFactory; + + #targetInterceptors: WeakMap = + new WeakMap(); + + #attachedToTargetListenersBySession: WeakMap< + CDPSession | Connection, + (event: Protocol.Target.AttachedToTargetEvent) => Promise + > = new WeakMap(); + #detachedFromTargetListenersBySession: WeakMap< + CDPSession | Connection, + (event: Protocol.Target.DetachedFromTargetEvent) => void + > = new WeakMap(); + + #initializeCallback = () => {}; + #initializePromise: Promise = new Promise(resolve => { + this.#initializeCallback = resolve; + }); + #targetsIdsForInit: Set = new Set(); + + constructor( + connection: Connection, + targetFactory: TargetFactory, + targetFilterCallback?: TargetFilterCallback + ) { + super(); + this.#connection = connection; + this.#targetFilterCallback = targetFilterCallback; + this.#targetFactory = targetFactory; + + this.#connection.on('Target.targetCreated', this.#onTargetCreated); + this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed); + this.#connection.on('Target.targetInfoChanged', this.#onTargetInfoChanged); + this.#connection.on('sessiondetached', this.#onSessionDetached); + this.#setupAttachmentListeners(this.#connection); + + this.#connection.send('Target.setDiscoverTargets', {discover: true}); + } + + async initialize(): Promise { + this.#targetsIdsForInit = new Set(); + for (const [ + targetId, + targetInfo, + ] of this.#discoveredTargetsByTargetId.entries()) { + if ( + !this.#targetFilterCallback || + this.#targetFilterCallback(targetInfo) + ) { + this.#targetsIdsForInit.add(targetId); + } + } + await this.#connection.send('Target.setAutoAttach', { + waitForDebuggerOnStart: true, + flatten: true, + autoAttach: true, + }); + await this.#initializePromise; + } + + dispose(): void { + this.#connection.off('Target.targetCreated', this.#onTargetCreated); + this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed); + this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged); + this.#connection.off('sessiondetached', this.#onSessionDetached); + + this.#removeAttachmentListeners(this.#connection); + } + + getAvailableTargets(): Map { + return this.#attachedTargetsByTargetId; + } + + addTargetInterceptor( + session: CDPSession | Connection, + interceptor: TargetInterceptor + ): void { + const interceptors = this.#targetInterceptors.get(session) || []; + interceptors.push(interceptor); + this.#targetInterceptors.set(session, interceptors); + } + + removeTargetInterceptor( + client: CDPSession | Connection, + interceptor: TargetInterceptor + ): void { + const interceptors = this.#targetInterceptors.get(client) || []; + this.#targetInterceptors.set( + client, + interceptors.filter(currentInterceptor => { + return currentInterceptor !== interceptor; + }) + ); + } + + #setupAttachmentListeners(session: CDPSession | Connection): void { + const listener = (event: Protocol.Target.AttachedToTargetEvent) => { + return this.#onAttachedToTarget(session, event); + }; + assert(!this.#attachedToTargetListenersBySession.has(session)); + this.#attachedToTargetListenersBySession.set(session, listener); + session.on('Target.attachedToTarget', listener); + + const detachedListener = ( + event: Protocol.Target.DetachedFromTargetEvent + ) => { + return this.#onDetachedFromTarget(session, event); + }; + assert(!this.#detachedFromTargetListenersBySession.has(session)); + this.#detachedFromTargetListenersBySession.set(session, detachedListener); + session.on('Target.detachedFromTarget', detachedListener); + } + + #removeAttachmentListeners(session: CDPSession | Connection): void { + if (this.#attachedToTargetListenersBySession.has(session)) { + session.off( + 'Target.attachedToTarget', + this.#attachedToTargetListenersBySession.get(session)! + ); + this.#attachedToTargetListenersBySession.delete(session); + } + + if (this.#detachedFromTargetListenersBySession.has(session)) { + session.off( + 'Target.detachedFromTarget', + this.#detachedFromTargetListenersBySession.get(session)! + ); + this.#detachedFromTargetListenersBySession.delete(session); + } + } + + #onSessionDetached = (session: CDPSession) => { + this.#removeAttachmentListeners(session); + this.#targetInterceptors.delete(session); + }; + + #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => { + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + this.emit(TargetManagerEmittedEvents.TargetDiscovered, event.targetInfo); + + // The connection is already attached to the browser target implicitly, + // therefore, no new CDPSession is created and we have special handling + // here. + if (event.targetInfo.type === 'browser' && event.targetInfo.attached) { + if (this.#attachedTargetsByTargetId.has(event.targetInfo.targetId)) { + return; + } + const target = this.#targetFactory(event.targetInfo, undefined); + this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target); + } + + if (event.targetInfo.type === 'shared_worker') { + // Special case (https://crbug.com/1338156): currently, shared_workers + // don't get auto-attached. This should be removed once the auto-attach + // works. + await this.#connection.createSession(event.targetInfo); + } + }; + + #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent) => { + const targetInfo = this.#discoveredTargetsByTargetId.get(event.targetId); + this.#discoveredTargetsByTargetId.delete(event.targetId); + this.#finishInitializationIfReady(event.targetId); + if ( + targetInfo?.type === 'service_worker' && + this.#attachedTargetsByTargetId.has(event.targetId) + ) { + // Special case for service workers: report TargetGone event when + // the worker is destroyed. + const target = this.#attachedTargetsByTargetId.get(event.targetId); + this.emit(TargetManagerEmittedEvents.TargetGone, target); + this.#attachedTargetsByTargetId.delete(event.targetId); + } + }; + + #onTargetInfoChanged = (event: Protocol.Target.TargetInfoChangedEvent) => { + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + if ( + this.#ignoredTargets.has(event.targetInfo.targetId) || + !this.#attachedTargetsByTargetId.has(event.targetInfo.targetId) || + !event.targetInfo.attached + ) { + return; + } + + const target = this.#attachedTargetsByTargetId.get( + event.targetInfo.targetId + ); + this.emit(TargetManagerEmittedEvents.TargetChanged, { + target: target!, + targetInfo: event.targetInfo, + }); + }; + + #onAttachedToTarget = async ( + parentSession: Connection | CDPSession, + event: Protocol.Target.AttachedToTargetEvent + ) => { + const targetInfo = event.targetInfo; + const session = this.#connection.session(event.sessionId); + if (!session) { + throw new Error(`Session ${event.sessionId} was not created.`); + } + + const silentDetach = async () => { + await session.send('Runtime.runIfWaitingForDebugger').catch(debugError); + // We don't use `session.detach()` because that dispatches all commands on + // the connection instead of the parent session. + await parentSession + .send('Target.detachFromTarget', { + sessionId: session.id(), + }) + .catch(debugError); + }; + + // Special case for service workers: being attached to service workers will + // prevent them from ever being destroyed. Therefore, we silently detach + // from service workers unless the connection was manually created via + // `page.worker()`. To determine this, we use + // `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we + // should determine if a target is auto-attached or not with the help of + // CDP. + if ( + targetInfo.type === 'service_worker' && + this.#connection.isAutoAttached(targetInfo.targetId) + ) { + this.#finishInitializationIfReady(targetInfo.targetId); + await silentDetach(); + if (parentSession instanceof CDPSession) { + const target = this.#targetFactory(targetInfo); + this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); + this.emit(TargetManagerEmittedEvents.TargetAvailable, target); + } + return; + } + + if (this.#targetFilterCallback && !this.#targetFilterCallback(targetInfo)) { + this.#ignoredTargets.add(targetInfo.targetId); + this.#finishInitializationIfReady(targetInfo.targetId); + await silentDetach(); + return; + } + + const existingTarget = this.#attachedTargetsByTargetId.has( + targetInfo.targetId + ); + + const target = existingTarget + ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)! + : this.#targetFactory(targetInfo, session); + + this.#setupAttachmentListeners(session); + + if (existingTarget) { + this.#attachedTargetsBySessionId.set( + session.id(), + this.#attachedTargetsByTargetId.get(targetInfo.targetId)! + ); + } else { + this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); + this.#attachedTargetsBySessionId.set(session.id(), target); + } + + for (const interceptor of this.#targetInterceptors.get(parentSession) || + []) { + if (!(parentSession instanceof Connection)) { + // Sanity check: if parent session is not a connection, it should be + // present in #attachedTargetsBySessionId. + assert(this.#attachedTargetsBySessionId.has(parentSession.id())); + } + await interceptor( + target, + parentSession instanceof Connection + ? null + : this.#attachedTargetsBySessionId.get(parentSession.id())! + ); + } + + this.#targetsIdsForInit.delete(target._targetId); + this.emit(TargetManagerEmittedEvents.TargetAvailable, target); + if (this.#targetsIdsForInit.size === 0) { + this.#initializeCallback(); + } + + // TODO: the browser might be shutting down here. What do we do with the + // error? + await Promise.all([ + session.send('Target.setAutoAttach', { + waitForDebuggerOnStart: true, + flatten: true, + autoAttach: true, + }), + session.send('Runtime.runIfWaitingForDebugger'), + ]).catch(debugError); + }; + + #finishInitializationIfReady(targetId: string): void { + this.#targetsIdsForInit.delete(targetId); + if (this.#targetsIdsForInit.size === 0) { + this.#initializeCallback(); + } + } + + #onDetachedFromTarget = ( + _parentSession: Connection | CDPSession, + event: Protocol.Target.DetachedFromTargetEvent + ) => { + const target = this.#attachedTargetsBySessionId.get(event.sessionId); + + this.#attachedTargetsBySessionId.delete(event.sessionId); + + if (!target) { + return; + } + + this.#attachedTargetsByTargetId.delete(target._targetId); + this.emit(TargetManagerEmittedEvents.TargetGone, target); + }; +} diff --git a/src/common/Connection.ts b/src/common/Connection.ts index e637ad8d13e99..0f6db4d6923e5 100644 --- a/src/common/Connection.ts +++ b/src/common/Connection.ts @@ -59,6 +59,7 @@ export class Connection extends EventEmitter { #sessions: Map = new Map(); #closed = false; #callbacks: Map = new Map(); + #manuallyAttached = new Set(); constructor(url: string, transport: ConnectionTransport, delay = 0) { super(); @@ -220,6 +221,13 @@ export class Connection extends EventEmitter { this.#transport.close(); } + /** + * @internal + */ + isAutoAttached(targetId: string): boolean { + return !this.#manuallyAttached.has(targetId); + } + /** * @param targetInfo - The target info * @returns The CDP session that is created @@ -227,6 +235,7 @@ export class Connection extends EventEmitter { async createSession( targetInfo: Protocol.Target.TargetInfo ): Promise { + this.#manuallyAttached.add(targetInfo.targetId); const {sessionId} = await this.send('Target.attachToTarget', { targetId: targetInfo.targetId, flatten: true, diff --git a/src/common/DOMWorld.ts b/src/common/DOMWorld.ts index 52b7ca9989893..a6d7d05141082 100644 --- a/src/common/DOMWorld.ts +++ b/src/common/DOMWorld.ts @@ -122,7 +122,7 @@ export class DOMWorld { if (context) { assert( this.#contextResolveCallback, - 'Execution Context has already been set.' + `ExecutionContext ${context._contextId} has already been set.` ); this.#ctxBindings.clear(); this.#contextResolveCallback?.call(null, context); diff --git a/src/common/FirefoxTargetManager.ts b/src/common/FirefoxTargetManager.ts new file mode 100644 index 0000000000000..cc1ebdf722fb6 --- /dev/null +++ b/src/common/FirefoxTargetManager.ts @@ -0,0 +1,255 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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 Protocol from 'devtools-protocol'; +import {assert} from './assert.js'; +import {CDPSession, Connection} from './Connection.js'; +import {Target} from './Target.js'; +import {TargetFilterCallback} from './Browser.js'; +import { + TargetFactory, + TargetInterceptor, + TargetManagerEmittedEvents, + TargetManager, +} from './TargetManager.js'; +import {EventEmitter} from './EventEmitter.js'; + +/** + * FirefoxTargetManager implements target management using + * `Target.setDiscoverTargets` without using auto-attach. It, therefore, creates + * targets that lazily establish their CDP sessions. + * + * Although the approach is potentially flaky, there is no other way for Firefox + * because Firefox's CDP implementation does not support auto-attach. + * + * Firefox does not support targetInfoChanged and detachedFromTarget events: + * - https://bugzilla.mozilla.org/show_bug.cgi?id=1610855 + * - https://bugzilla.mozilla.org/show_bug.cgi?id=1636979 + * @internal + */ +export class FirefoxTargetManager + extends EventEmitter + implements TargetManager +{ + #connection: Connection; + /** + * Keeps track of the following events: 'Target.targetCreated', + * 'Target.targetDestroyed'. + * + * A target becomes discovered when 'Target.targetCreated' is received. + * A target is removed from this map once 'Target.targetDestroyed' is + * received. + * + * `targetFilterCallback` has no effect on this map. + */ + #discoveredTargetsByTargetId: Map = + new Map(); + /** + * Keeps track of targets that were created via 'Target.targetCreated' + * and which one are not filtered out by `targetFilterCallback`. + * + * The target is removed from here once it's been destroyed. + */ + #availableTargetsByTargetId: Map = new Map(); + /** + * Tracks which sessions attach to which target. + */ + #availableTargetsBySessionId: Map = new Map(); + /** + * If a target was filtered out by `targetFilterCallback`, we still receive + * events about it from CDP, but we don't forward them to the rest of Puppeteer. + */ + #ignoredTargets = new Set(); + #targetFilterCallback: TargetFilterCallback | undefined; + #targetFactory: TargetFactory; + + #targetInterceptors: WeakMap = + new WeakMap(); + + #attachedToTargetListenersBySession: WeakMap< + CDPSession | Connection, + (event: Protocol.Target.AttachedToTargetEvent) => Promise + > = new WeakMap(); + + #initializeCallback = () => {}; + #initializePromise: Promise = new Promise(resolve => { + this.#initializeCallback = resolve; + }); + #targetsIdsForInit: Set = new Set(); + + constructor( + connection: Connection, + targetFactory: TargetFactory, + targetFilterCallback?: TargetFilterCallback + ) { + super(); + this.#connection = connection; + this.#targetFilterCallback = targetFilterCallback; + this.#targetFactory = targetFactory; + + this.#connection.on('Target.targetCreated', this.#onTargetCreated); + this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed); + this.#connection.on('sessiondetached', this.#onSessionDetached); + this.setupAttachmentListeners(this.#connection); + } + + addTargetInterceptor( + client: CDPSession | Connection, + interceptor: TargetInterceptor + ): void { + const interceptors = this.#targetInterceptors.get(client) || []; + interceptors.push(interceptor); + this.#targetInterceptors.set(client, interceptors); + } + + removeTargetInterceptor( + client: CDPSession | Connection, + interceptor: TargetInterceptor + ): void { + const interceptors = this.#targetInterceptors.get(client) || []; + this.#targetInterceptors.set( + client, + interceptors.filter(currentInterceptor => { + return currentInterceptor !== interceptor; + }) + ); + } + + setupAttachmentListeners(session: CDPSession | Connection): void { + const listener = (event: Protocol.Target.AttachedToTargetEvent) => { + return this.#onAttachedToTarget(session, event); + }; + assert(!this.#attachedToTargetListenersBySession.has(session)); + this.#attachedToTargetListenersBySession.set(session, listener); + session.on('Target.attachedToTarget', listener); + } + + #onSessionDetached = (session: CDPSession) => { + this.removeSessionListeners(session); + this.#targetInterceptors.delete(session); + this.#availableTargetsBySessionId.delete(session.id()); + }; + + removeSessionListeners(session: CDPSession): void { + if (this.#attachedToTargetListenersBySession.has(session)) { + session.off( + 'Target.attachedToTarget', + this.#attachedToTargetListenersBySession.get(session)! + ); + this.#attachedToTargetListenersBySession.delete(session); + } + } + + getAvailableTargets(): Map { + return this.#availableTargetsByTargetId; + } + + dispose(): void { + this.#connection.off('Target.targetCreated', this.#onTargetCreated); + this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed); + } + + async initialize(): Promise { + await this.#connection.send('Target.setDiscoverTargets', {discover: true}); + this.#targetsIdsForInit = new Set(this.#discoveredTargetsByTargetId.keys()); + await this.#initializePromise; + } + + #onTargetCreated = async ( + event: Protocol.Target.TargetCreatedEvent + ): Promise => { + if (this.#discoveredTargetsByTargetId.has(event.targetInfo.targetId)) { + return; + } + + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + if (event.targetInfo.type === 'browser' && event.targetInfo.attached) { + const target = this.#targetFactory(event.targetInfo, undefined); + this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target); + this.#finishInitializationIfReady(target._targetId); + return; + } + + if ( + this.#targetFilterCallback && + !this.#targetFilterCallback(event.targetInfo) + ) { + this.#ignoredTargets.add(event.targetInfo.targetId); + this.#finishInitializationIfReady(event.targetInfo.targetId); + return; + } + + const target = this.#targetFactory(event.targetInfo, undefined); + this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target); + this.emit(TargetManagerEmittedEvents.TargetAvailable, target); + this.#finishInitializationIfReady(target._targetId); + }; + + #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent): void => { + this.#discoveredTargetsByTargetId.delete(event.targetId); + this.#finishInitializationIfReady(event.targetId); + const target = this.#availableTargetsByTargetId.get(event.targetId); + if (target) { + this.emit(TargetManagerEmittedEvents.TargetGone, target); + this.#availableTargetsByTargetId.delete(event.targetId); + } + }; + + #onAttachedToTarget = async ( + parentSession: Connection | CDPSession, + event: Protocol.Target.AttachedToTargetEvent + ) => { + const targetInfo = event.targetInfo; + const session = this.#connection.session(event.sessionId); + if (!session) { + throw new Error(`Session ${event.sessionId} was not created.`); + } + + const target = this.#availableTargetsByTargetId.get(targetInfo.targetId); + + assert(target, `Target ${targetInfo.targetId} is missing`); + + this.setupAttachmentListeners(session); + + this.#availableTargetsBySessionId.set( + session.id(), + this.#availableTargetsByTargetId.get(targetInfo.targetId)! + ); + + for (const hook of this.#targetInterceptors.get(parentSession) || []) { + if (!(parentSession instanceof Connection)) { + assert(this.#availableTargetsBySessionId.has(parentSession.id())); + } + await hook( + target, + parentSession instanceof Connection + ? null + : this.#availableTargetsBySessionId.get(parentSession.id())! + ); + } + }; + + #finishInitializationIfReady(targetId: string): void { + this.#targetsIdsForInit.delete(targetId); + if (this.#targetsIdsForInit.size === 0) { + this.#initializeCallback(); + } + } +} diff --git a/src/common/FrameManager.ts b/src/common/FrameManager.ts index 0312ac294f04c..b2ddde456f613 100644 --- a/src/common/FrameManager.ts +++ b/src/common/FrameManager.ts @@ -16,7 +16,7 @@ import {Protocol} from 'devtools-protocol'; import {assert} from './assert.js'; -import {CDPSession, Connection} from './Connection.js'; +import {CDPSession} from './Connection.js'; import {DOMWorld, WaitForSelectorOptions} from './DOMWorld.js'; import {ElementHandle} from './ElementHandle.js'; import {EventEmitter} from './EventEmitter.js'; @@ -26,6 +26,7 @@ import {MouseButton} from './Input.js'; import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js'; import {NetworkManager} from './NetworkManager.js'; import {Page} from './Page.js'; +import {Target} from './Target.js'; import {TimeoutSettings} from './TimeoutSettings.js'; import {EvaluateFunc, HandleFor, NodeFor} from './types.js'; import {debugError, isErrorLike} from './util.js'; @@ -129,12 +130,6 @@ export class FrameManager extends EventEmitter { session.on('Page.lifecycleEvent', event => { this.#onLifecycleEvent(event); }); - session.on('Target.attachedToTarget', async event => { - this.#onAttachedToTarget(event); - }); - session.on('Target.detachedFromTarget', async event => { - this.#onDetachedFromTarget(event); - }); } async initialize(client: CDPSession = this.#client): Promise { @@ -142,13 +137,6 @@ export class FrameManager extends EventEmitter { const result = await Promise.all([ client.send('Page.enable'), client.send('Page.getFrameTree'), - client !== this.#client - ? client.send('Target.setAutoAttach', { - autoAttach: true, - waitForDebuggerOnStart: false, - flatten: true, - }) - : Promise.resolve(), ]); const {frameTree} = result[1]; @@ -264,28 +252,21 @@ export class FrameManager extends EventEmitter { return await watcher.navigationResponse(); } - async #onAttachedToTarget(event: Protocol.Target.AttachedToTargetEvent) { - if (event.targetInfo.type !== 'iframe') { + async onAttachedToTarget(target: Target): Promise { + if (target._getTargetInfo().type !== 'iframe') { return; } - const frame = this.#frames.get(event.targetInfo.targetId); - const connection = Connection.fromSession(this.#client); - assert(connection); - const session = connection.session(event.sessionId); - assert(session); + const frame = this.#frames.get(target._getTargetInfo().targetId); if (frame) { - frame._updateClient(session); + frame._updateClient(target._session()!); } - this.setupEventListeners(session); - await this.initialize(session); + this.setupEventListeners(target._session()!); + this.initialize(target._session()); } - async #onDetachedFromTarget(event: Protocol.Target.DetachedFromTargetEvent) { - if (!event.targetId) { - return; - } - const frame = this.#frames.get(event.targetId); + async onDetachedFromTarget(target: Target): Promise { + const frame = this.#frames.get(target._targetId); if (frame && frame.isOOPFrame()) { // When an OOP iframe is removed from the page, it // will only get a Target.detachedFromTarget event. @@ -374,7 +355,7 @@ export class FrameManager extends EventEmitter { } assert(parentFrameId); const parentFrame = this.#frames.get(parentFrameId); - assert(parentFrame); + assert(parentFrame, `Parent frame ${parentFrameId} not found`); const frame = new Frame(this, parentFrame, frameId, session); this.#frames.set(frame._id, frame); this.emit(FrameManagerEmittedEvents.FrameAttached, frame); diff --git a/src/common/Page.ts b/src/common/Page.ts index 1ba4d4119e0a5..496e2216f3286 100644 --- a/src/common/Page.ts +++ b/src/common/Page.ts @@ -19,7 +19,7 @@ import type {Readable} from 'stream'; import {Accessibility} from './Accessibility.js'; import {assert} from './assert.js'; import {Browser, BrowserContext} from './Browser.js'; -import {CDPSession, CDPSessionEmittedEvents, Connection} from './Connection.js'; +import {CDPSession, CDPSessionEmittedEvents} from './Connection.js'; import {ConsoleMessage, ConsoleMessageType} from './ConsoleMessage.js'; import {Coverage} from './Coverage.js'; import {Dialog} from './Dialog.js'; @@ -46,6 +46,7 @@ import { import {LowerCasePaperFormat, PDFOptions, _paperFormats} from './PDFOptions.js'; import {Viewport} from './PuppeteerViewport.js'; import {Target} from './Target.js'; +import {TargetManagerEmittedEvents} from './TargetManager.js'; import {TaskQueue} from './TaskQueue.js'; import {TimeoutSettings} from './TimeoutSettings.js'; import {Tracing} from './Tracing.js'; @@ -508,49 +509,13 @@ export class Page extends EventEmitter { this.#screenshotTaskQueue = screenshotTaskQueue; this.#viewport = null; - client.on( - 'Target.attachedToTarget', - (event: Protocol.Target.AttachedToTargetEvent) => { - switch (event.targetInfo.type) { - case 'worker': - const connection = Connection.fromSession(client); - assert(connection); - const session = connection.session(event.sessionId); - assert(session); - const worker = new WebWorker( - session, - event.targetInfo.url, - this.#addConsoleMessage.bind(this), - this.#handleException.bind(this) - ); - this.#workers.set(event.sessionId, worker); - this.emit(PageEmittedEvents.WorkerCreated, worker); - break; - case 'iframe': - break; - default: - // If we don't detach from service workers, they will never die. We - // still want to attach to workers for emitting events. We still - // want to attach to iframes so sessions may interact with them. We - // detach from all other types out of an abundance of caution. See - // https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc?ss=chromium&q=f:devtools%20-f:out%20%22::kTypePage%5B%5D%22 - // for the complete list of available types. - client - .send('Target.detachFromTarget', { - sessionId: event.sessionId, - }) - .catch(debugError); - } - } - ); - client.on('Target.detachedFromTarget', event => { - const worker = this.#workers.get(event.sessionId); - if (!worker) { - return; - } - this.#workers.delete(event.sessionId); - this.emit(PageEmittedEvents.WorkerDestroyed, worker); - }); + this.#target + ._targetManager() + .addTargetInterceptor(this.#client, this.#onAttachedToTarget); + + this.#target + ._targetManager() + .on(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget); this.#frameManager.on(FrameManagerEmittedEvents.FrameAttached, event => { return this.emit(PageEmittedEvents.FrameAttached, event); @@ -614,19 +579,58 @@ export class Page extends EventEmitter { return this.#onFileChooser(event); }); this.#target._isClosedPromise.then(() => { + this.#target + ._targetManager() + .removeTargetInterceptor(this.#client, this.#onAttachedToTarget); + + this.#target + ._targetManager() + .off(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget); this.emit(PageEmittedEvents.Close); this.#closed = true; }); } + #onDetachedFromTarget = (target: Target) => { + const sessionId = target._session()?.id(); + + this.#frameManager.onDetachedFromTarget(target); + + const worker = this.#workers.get(sessionId!); + if (!worker) { + return; + } + this.#workers.delete(sessionId!); + this.emit(PageEmittedEvents.WorkerDestroyed, worker); + }; + + #onAttachedToTarget = async (createdTarget: Target) => { + await this.#frameManager.onAttachedToTarget(createdTarget); + if (createdTarget._getTargetInfo().type === 'worker') { + const session = createdTarget._session(); + assert(session); + const worker = new WebWorker( + session, + createdTarget.url(), + this.#addConsoleMessage.bind(this), + this.#handleException.bind(this) + ); + this.#workers.set(session.id(), worker); + this.emit(PageEmittedEvents.WorkerCreated, worker); + } + if (createdTarget._session()) { + this.#target + ._targetManager() + .addTargetInterceptor( + createdTarget._session()!, + this.#onAttachedToTarget + ); + } + }; + async #initialize(): Promise { await Promise.all([ this.#frameManager.initialize(), - this.#client.send('Target.setAutoAttach', { - autoAttach: true, - waitForDebuggerOnStart: false, - flatten: true, - }), this.#client.send('Performance.enable'), this.#client.send('Log.enable'), ]); diff --git a/src/common/Target.ts b/src/common/Target.ts index a0c69a293cc1b..90c326ccfb9f6 100644 --- a/src/common/Target.ts +++ b/src/common/Target.ts @@ -21,12 +21,14 @@ import {Browser, BrowserContext, IsPageTargetCallback} from './Browser.js'; import {Viewport} from './PuppeteerViewport.js'; import {Protocol} from 'devtools-protocol'; import {TaskQueue} from './TaskQueue.js'; +import {TargetManager} from './TargetManager.js'; /** * @public */ export class Target { #browserContext: BrowserContext; + #session?: CDPSession; #targetInfo: Protocol.Target.TargetInfo; #sessionFactory: () => Promise; #ignoreHTTPSErrors: boolean; @@ -64,18 +66,24 @@ export class Target { */ _isPageTargetCallback: IsPageTargetCallback; + #targetManager: TargetManager; + /** * @internal */ constructor( targetInfo: Protocol.Target.TargetInfo, + session: CDPSession | undefined, browserContext: BrowserContext, + targetManager: TargetManager, sessionFactory: () => Promise, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotTaskQueue: TaskQueue, isPageTargetCallback: IsPageTargetCallback ) { + this.#session = session; + this.#targetManager = targetManager; this.#targetInfo = targetInfo; this.#browserContext = browserContext; this._targetId = targetInfo.targetId; @@ -113,6 +121,13 @@ export class Target { } } + /** + * @internal + */ + _session(): CDPSession | undefined { + return this.#session; + } + /** * Creates a Chrome Devtools Protocol session attached to the target. */ @@ -120,6 +135,13 @@ export class Target { return this.#sessionFactory(); } + /** + * @internal + */ + _targetManager(): TargetManager { + return this.#targetManager; + } + /** * @internal */ @@ -132,7 +154,9 @@ export class Target { */ async page(): Promise { if (this._isPageTargetCallback(this.#targetInfo) && !this.#pagePromise) { - this.#pagePromise = this.#sessionFactory().then(client => { + this.#pagePromise = ( + this.#session ? Promise.resolve(this.#session) : this.#sessionFactory() + ).then(client => { return Page._create( client, this, @@ -157,7 +181,9 @@ export class Target { } if (!this.#workerPromise) { // TODO(einbinder): Make workers send their console logs. - this.#workerPromise = this.#sessionFactory().then(client => { + this.#workerPromise = ( + this.#session ? Promise.resolve(this.#session) : this.#sessionFactory() + ).then(client => { return new WebWorker( client, this.#targetInfo.url, diff --git a/src/common/TargetManager.ts b/src/common/TargetManager.ts new file mode 100644 index 0000000000000..1b82599adadb2 --- /dev/null +++ b/src/common/TargetManager.ts @@ -0,0 +1,71 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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 Protocol from 'devtools-protocol'; +import {CDPSession} from './Connection.js'; +import {EventEmitter} from './EventEmitter.js'; +import {Target} from './Target.js'; + +/** + * @internal + */ +export type TargetFactory = ( + targetInfo: Protocol.Target.TargetInfo, + session?: CDPSession +) => Target; + +/** + * @internal + */ +export type TargetInterceptor = ( + createdTarget: Target, + parentTarget: Target | null +) => Promise; + +/** + * TargetManager encapsulates all interactions with CDP targets and is + * responsible for coordinating the configuration of targets with the rest of + * Puppeteer. Code outside of this class should not subscribe `Target.*` events + * and only use the TargetManager events. + * + * There are two implementations: one for Chrome that uses CDP's auto-attach + * mechanism and one for Firefox because Firefox does not support auto-attach. + * + * @internal + */ +export interface TargetManager extends EventEmitter { + getAvailableTargets(): Map; + initialize(): Promise; + dispose(): void; + addTargetInterceptor( + session: CDPSession, + interceptor: TargetInterceptor + ): void; + removeTargetInterceptor( + session: CDPSession, + interceptor: TargetInterceptor + ): void; +} + +/** + * @internal + */ +export const enum TargetManagerEmittedEvents { + TargetDiscovered = 'targetDiscovered', + TargetAvailable = 'targetAvailable', + TargetGone = 'targetGone', + TargetChanged = 'targetChanged', +} diff --git a/src/node/ChromeLauncher.ts b/src/node/ChromeLauncher.ts index 2db109a75280e..9d595dd506fa0 100644 --- a/src/node/ChromeLauncher.ts +++ b/src/node/ChromeLauncher.ts @@ -155,6 +155,7 @@ export class ChromeLauncher implements ProductLauncher { preferredRevision: this._preferredRevision, }); browser = await Browser._create( + this.product, connection, [], ignoreHTTPSErrors, diff --git a/src/node/FirefoxLauncher.ts b/src/node/FirefoxLauncher.ts index 6a4316a39b951..9a888e4d70dbb 100644 --- a/src/node/FirefoxLauncher.ts +++ b/src/node/FirefoxLauncher.ts @@ -152,6 +152,7 @@ export class FirefoxLauncher implements ProductLauncher { preferredRevision: this._preferredRevision, }); browser = await Browser._create( + this.product, connection, [], ignoreHTTPSErrors, diff --git a/src/types.ts b/src/types.ts index fa4ddbec0480a..df7eead4de671 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export * from './common/AriaQueryHandler.js'; export * from './common/Browser.js'; export * from './common/BrowserConnector.js'; export * from './common/BrowserWebSocketTransport.js'; +export * from './common/ChromeTargetManager.js'; export * from './common/Connection.js'; export * from './common/ConnectionTransport.js'; export * from './common/ConsoleMessage.js'; @@ -27,6 +28,7 @@ export * from './common/Errors.js'; export * from './common/EventEmitter.js'; export * from './common/ExecutionContext.js'; export * from './common/FileChooser.js'; +export * from './common/FirefoxTargetManager.js'; export * from './common/FrameManager.js'; export * from './common/HTTPRequest.js'; export * from './common/HTTPResponse.js'; @@ -44,6 +46,7 @@ export * from './common/PuppeteerViewport.js'; export * from './common/QueryHandler.js'; export * from './common/SecurityDetails.js'; export * from './common/Target.js'; +export * from './common/TargetManager.js'; export * from './common/TaskQueue.js'; export * from './common/TimeoutSettings.js'; export * from './common/Tracing.js'; diff --git a/test/src/TargetManager.spec.ts b/test/src/TargetManager.spec.ts new file mode 100644 index 0000000000000..175cde72f3272 --- /dev/null +++ b/test/src/TargetManager.spec.ts @@ -0,0 +1,111 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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 {describeChromeOnly, getTestState} from './mocha-utils'; // eslint-disable-line import/extensions +import utils from './utils.js'; + +import expect from 'expect'; + +import { + Browser, + BrowserContext, +} from '../../lib/cjs/puppeteer/common/Browser.js'; + +describeChromeOnly('TargetManager', () => { + /* We use a special browser for this test as we need the --site-per-process flag */ + let browser: Browser; + let context: BrowserContext; + + before(async () => { + const {puppeteer, defaultBrowserOptions} = getTestState(); + browser = await puppeteer.launch( + Object.assign({}, defaultBrowserOptions, { + args: (defaultBrowserOptions.args || []).concat([ + '--site-per-process', + '--remote-debugging-port=21222', + '--host-rules=MAP * 127.0.0.1', + ]), + }) + ); + }); + + beforeEach(async () => { + context = await browser.createIncognitoBrowserContext(); + }); + + afterEach(async () => { + await context.close(); + }); + + after(async () => { + await browser.close(); + }); + + it('should handle targets', async () => { + const {server} = getTestState(); + + const targetManager = browser._targetManager(); + expect(targetManager.getAvailableTargets().size).toBe(2); + + expect(await context.pages()).toHaveLength(0); + expect(targetManager.getAvailableTargets().size).toBe(2); + + const page = await context.newPage(); + expect(await context.pages()).toHaveLength(1); + expect(targetManager.getAvailableTargets().size).toBe(3); + + await page.goto(server.EMPTY_PAGE); + expect(await context.pages()).toHaveLength(1); + expect(targetManager.getAvailableTargets().size).toBe(3); + + // attach a local iframe. + let framePromise = page.waitForFrame(frame => { + return frame.url().endsWith('/empty.html'); + }); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await framePromise; + expect(await context.pages()).toHaveLength(1); + expect(targetManager.getAvailableTargets().size).toBe(3); + expect(page.frames()).toHaveLength(2); + + // // attach a remote frame iframe. + framePromise = page.waitForFrame(frame => { + return frame.url() === server.CROSS_PROCESS_PREFIX + '/empty.html'; + }); + await utils.attachFrame( + page, + 'frame2', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + await framePromise; + expect(await context.pages()).toHaveLength(1); + expect(targetManager.getAvailableTargets().size).toBe(4); + expect(page.frames()).toHaveLength(3); + + framePromise = page.waitForFrame(frame => { + return frame.url() === server.CROSS_PROCESS_PREFIX + '/empty.html'; + }); + await utils.attachFrame( + page, + 'frame3', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + await framePromise; + expect(await context.pages()).toHaveLength(1); + expect(targetManager.getAvailableTargets().size).toBe(5); + expect(page.frames()).toHaveLength(4); + }); +}); diff --git a/test/src/browser.spec.ts b/test/src/browser.spec.ts index e63fe9a2448b7..0054a65120e56 100644 --- a/test/src/browser.spec.ts +++ b/test/src/browser.spec.ts @@ -63,10 +63,13 @@ describe('Browser specs', function () { expect(process!.pid).toBeGreaterThan(0); }); it('should not return child_process for remote browser', async () => { - const {browser, puppeteer} = getTestState(); + const {browser, puppeteer, isFirefox} = getTestState(); const browserWSEndpoint = browser.wsEndpoint(); - const remoteBrowser = await puppeteer.connect({browserWSEndpoint}); + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint, + product: isFirefox ? 'firefox' : 'chrome', + }); expect(remoteBrowser.process()).toBe(null); remoteBrowser.disconnect(); }); @@ -74,10 +77,13 @@ describe('Browser specs', function () { describe('Browser.isConnected', () => { it('should set the browser connected state', async () => { - const {browser, puppeteer} = getTestState(); + const {browser, puppeteer, isFirefox} = getTestState(); const browserWSEndpoint = browser.wsEndpoint(); - const newBrowser = await puppeteer.connect({browserWSEndpoint}); + const newBrowser = await puppeteer.connect({ + browserWSEndpoint, + product: isFirefox ? 'firefox' : 'chrome', + }); expect(newBrowser.isConnected()).toBe(true); newBrowser.disconnect(); expect(newBrowser.isConnected()).toBe(false); diff --git a/test/src/fixtures.spec.ts b/test/src/fixtures.spec.ts index e20054dc6dcdc..bc606f23fd2ba 100644 --- a/test/src/fixtures.spec.ts +++ b/test/src/fixtures.spec.ts @@ -68,7 +68,8 @@ describe('Fixtures', function () { expect(dumpioData).toContain('DevTools listening on ws://'); }); it('should close the browser when the node process closes', async () => { - const {defaultBrowserOptions, puppeteerPath, puppeteer} = getTestState(); + const {defaultBrowserOptions, puppeteerPath, puppeteer, isFirefox} = + getTestState(); const {spawn, execSync} = await import('child_process'); const options = Object.assign({}, defaultBrowserOptions, { @@ -93,6 +94,7 @@ describe('Fixtures', function () { }); const browser = await puppeteer.connect({ browserWSEndpoint: await wsEndPointPromise, + product: isFirefox ? 'firefox' : 'chrome', }); const promises = [ new Promise(resolve => { diff --git a/test/src/launcher.spec.ts b/test/src/launcher.spec.ts index 20e8331bffe39..b1ef5ee3640ff 100644 --- a/test/src/launcher.spec.ts +++ b/test/src/launcher.spec.ts @@ -139,11 +139,13 @@ describe('Launcher specs', function () { describe('Browser.disconnect', function () { it('should reject navigation when browser closes', async () => { - const {server, puppeteer, defaultBrowserOptions} = getTestState(); + const {server, puppeteer, defaultBrowserOptions, isFirefox} = + getTestState(); server.setRoute('/one-style.css', () => {}); const browser = await puppeteer.launch(defaultBrowserOptions); const remote = await puppeteer.connect({ browserWSEndpoint: browser.wsEndpoint(), + product: isFirefox ? 'firefox' : 'chrome', }); const page = await remote.newPage(); const navigationPromise = page @@ -163,12 +165,14 @@ describe('Launcher specs', function () { await browser.close(); }); it('should reject waitForSelector when browser closes', async () => { - const {server, puppeteer, defaultBrowserOptions} = getTestState(); + const {server, puppeteer, defaultBrowserOptions, isFirefox} = + getTestState(); server.setRoute('/empty.html', () => {}); const browser = await puppeteer.launch(defaultBrowserOptions); const remote = await puppeteer.connect({ browserWSEndpoint: browser.wsEndpoint(), + product: isFirefox ? 'firefox' : 'chrome', }); const page = await remote.newPage(); const watchdog = page @@ -184,11 +188,13 @@ describe('Launcher specs', function () { }); describe('Browser.close', function () { it('should terminate network waiters', async () => { - const {server, puppeteer, defaultBrowserOptions} = getTestState(); + const {server, puppeteer, defaultBrowserOptions, isFirefox} = + getTestState(); const browser = await puppeteer.launch(defaultBrowserOptions); const remote = await puppeteer.connect({ browserWSEndpoint: browser.wsEndpoint(), + product: isFirefox ? 'firefox' : 'chrome', }); const newPage = await remote.newPage(); const results = await Promise.all([ @@ -653,26 +659,24 @@ describe('Launcher specs', function () { expect(userAgent).toContain('Chrome'); }); - itOnlyRegularInstall( - 'should be able to launch Firefox', - async function () { - this.timeout(FIREFOX_TIMEOUT); - const {puppeteer} = getTestState(); - const browser = await puppeteer.launch({product: 'firefox'}); - const userAgent = await browser.userAgent(); - await browser.close(); - expect(userAgent).toContain('Firefox'); - } - ); + it('should be able to launch Firefox', async function () { + this.timeout(FIREFOX_TIMEOUT); + const {puppeteer} = getTestState(); + const browser = await puppeteer.launch({product: 'firefox'}); + const userAgent = await browser.userAgent(); + await browser.close(); + expect(userAgent).toContain('Firefox'); + }); }); describe('Puppeteer.connect', function () { it('should be able to connect multiple times to the same browser', async () => { - const {puppeteer, defaultBrowserOptions} = getTestState(); + const {puppeteer, defaultBrowserOptions, isFirefox} = getTestState(); const originalBrowser = await puppeteer.launch(defaultBrowserOptions); const otherBrowser = await puppeteer.connect({ browserWSEndpoint: originalBrowser.wsEndpoint(), + product: isFirefox ? 'firefox' : 'chrome', }); const page = await otherBrowser.newPage(); expect( @@ -691,11 +695,12 @@ describe('Launcher specs', function () { await originalBrowser.close(); }); it('should be able to close remote browser', async () => { - const {defaultBrowserOptions, puppeteer} = getTestState(); + const {defaultBrowserOptions, puppeteer, isFirefox} = getTestState(); const originalBrowser = await puppeteer.launch(defaultBrowserOptions); const remoteBrowser = await puppeteer.connect({ browserWSEndpoint: originalBrowser.wsEndpoint(), + product: isFirefox ? 'firefox' : 'chrome', }); await Promise.all([ utils.waitEvent(originalBrowser, 'disconnected'), @@ -703,7 +708,8 @@ describe('Launcher specs', function () { ]); }); it('should support ignoreHTTPSErrors option', async () => { - const {httpsServer, puppeteer, defaultBrowserOptions} = getTestState(); + const {httpsServer, puppeteer, defaultBrowserOptions, isFirefox} = + getTestState(); const originalBrowser = await puppeteer.launch(defaultBrowserOptions); const browserWSEndpoint = originalBrowser.wsEndpoint(); @@ -711,6 +717,7 @@ describe('Launcher specs', function () { const browser = await puppeteer.connect({ browserWSEndpoint, ignoreHTTPSErrors: true, + product: isFirefox ? 'firefox' : 'chrome', }); const page = await browser.newPage(); let error!: Error; @@ -732,7 +739,8 @@ describe('Launcher specs', function () { }); // @see https://github.com/puppeteer/puppeteer/issues/4197 itFailsFirefox('should support targetFilter option', async () => { - const {server, puppeteer, defaultBrowserOptions} = getTestState(); + const {server, puppeteer, defaultBrowserOptions, isFirefox} = + getTestState(); const originalBrowser = await puppeteer.launch(defaultBrowserOptions); const browserWSEndpoint = originalBrowser.wsEndpoint(); @@ -745,6 +753,7 @@ describe('Launcher specs', function () { const browser = await puppeteer.connect({ browserWSEndpoint, + product: isFirefox ? 'firefox' : 'chrome', targetFilter: (targetInfo: Protocol.Target.TargetInfo) => { return !targetInfo.url?.includes('should-be-ignored'); }, @@ -828,14 +837,18 @@ describe('Launcher specs', function () { } ); it('should be able to reconnect', async () => { - const {puppeteer, server, defaultBrowserOptions} = getTestState(); + const {puppeteer, server, defaultBrowserOptions, isFirefox} = + getTestState(); const browserOne = await puppeteer.launch(defaultBrowserOptions); const browserWSEndpoint = browserOne.wsEndpoint(); const pageOne = await browserOne.newPage(); await pageOne.goto(server.EMPTY_PAGE); browserOne.disconnect(); - const browserTwo = await puppeteer.connect({browserWSEndpoint}); + const browserTwo = await puppeteer.connect({ + browserWSEndpoint, + product: isFirefox ? 'firefox' : 'chrome', + }); const pages = await browserTwo.pages(); const pageTwo = pages.find(page => { return page.url() === server.EMPTY_PAGE; @@ -975,44 +988,53 @@ describe('Launcher specs', function () { }); describe('Browser.Events.disconnected', function () { - it('should be emitted when: browser gets closed, disconnected or underlying websocket gets closed', async () => { - const {puppeteer, defaultBrowserOptions} = getTestState(); - const originalBrowser = await puppeteer.launch(defaultBrowserOptions); - const browserWSEndpoint = originalBrowser.wsEndpoint(); - const remoteBrowser1 = await puppeteer.connect({browserWSEndpoint}); - const remoteBrowser2 = await puppeteer.connect({browserWSEndpoint}); - - let disconnectedOriginal = 0; - let disconnectedRemote1 = 0; - let disconnectedRemote2 = 0; - originalBrowser.on('disconnected', () => { - return ++disconnectedOriginal; - }); - remoteBrowser1.on('disconnected', () => { - return ++disconnectedRemote1; - }); - remoteBrowser2.on('disconnected', () => { - return ++disconnectedRemote2; - }); - - await Promise.all([ - utils.waitEvent(remoteBrowser2, 'disconnected'), - remoteBrowser2.disconnect(), - ]); - - expect(disconnectedOriginal).toBe(0); - expect(disconnectedRemote1).toBe(0); - expect(disconnectedRemote2).toBe(1); - - await Promise.all([ - utils.waitEvent(remoteBrowser1, 'disconnected'), - utils.waitEvent(originalBrowser, 'disconnected'), - originalBrowser.close(), - ]); - - expect(disconnectedOriginal).toBe(1); - expect(disconnectedRemote1).toBe(1); - expect(disconnectedRemote2).toBe(1); - }); + itFailsFirefox( + 'should be emitted when: browser gets closed, disconnected or underlying websocket gets closed', + async () => { + const {puppeteer, defaultBrowserOptions, isFirefox} = getTestState(); + const originalBrowser = await puppeteer.launch(defaultBrowserOptions); + const browserWSEndpoint = originalBrowser.wsEndpoint(); + const remoteBrowser1 = await puppeteer.connect({ + browserWSEndpoint, + product: isFirefox ? 'firefox' : 'chrome', + }); + const remoteBrowser2 = await puppeteer.connect({ + browserWSEndpoint, + product: isFirefox ? 'firefox' : 'chrome', + }); + + let disconnectedOriginal = 0; + let disconnectedRemote1 = 0; + let disconnectedRemote2 = 0; + originalBrowser.on('disconnected', () => { + return ++disconnectedOriginal; + }); + remoteBrowser1.on('disconnected', () => { + return ++disconnectedRemote1; + }); + remoteBrowser2.on('disconnected', () => { + return ++disconnectedRemote2; + }); + + await Promise.all([ + utils.waitEvent(remoteBrowser2, 'disconnected'), + remoteBrowser2.disconnect(), + ]); + + expect(disconnectedOriginal).toBe(0); + expect(disconnectedRemote1).toBe(0); + expect(disconnectedRemote2).toBe(1); + + await Promise.all([ + utils.waitEvent(remoteBrowser1, 'disconnected'), + utils.waitEvent(originalBrowser, 'disconnected'), + originalBrowser.close(), + ]); + + expect(disconnectedOriginal).toBe(1); + expect(disconnectedRemote1).toBe(1); + expect(disconnectedRemote2).toBe(1); + } + ); }); }); diff --git a/test/src/mocha-utils.ts b/test/src/mocha-utils.ts index 473cd989e0d8a..b60428fea90c6 100644 --- a/test/src/mocha-utils.ts +++ b/test/src/mocha-utils.ts @@ -268,6 +268,13 @@ if (process.env['MOCHA_WORKER_ID'] === '0') { -> binary: ${ defaultBrowserOptions.executablePath || path.relative(process.cwd(), puppeteer.executablePath()) + } + -> mode: ${ + isHeadless + ? headless === 'chrome' + ? '--headless=chrome' + : '--headless' + : 'headful' }` ); } diff --git a/test/src/target.spec.ts b/test/src/target.spec.ts index d8fa9f16be8fb..a67b31f0ee866 100644 --- a/test/src/target.spec.ts +++ b/test/src/target.spec.ts @@ -172,11 +172,13 @@ describe('Target', function () { }); }); await page.evaluate(() => { - return (globalThis as any).registrationPromise.then( - (registration: {unregister: () => any}) => { - return registration.unregister(); + return ( + globalThis as unknown as { + registrationPromise: Promise<{unregister: () => void}>; } - ); + ).registrationPromise.then((registration: any) => { + return registration.unregister(); + }); }); expect(await destroyedTarget).toBe(await createdTarget); }