From a9fe19cbede4b3d9d790367d7a7b37a95df96911 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Thu, 21 Jul 2022 20:50:46 +0200 Subject: [PATCH] feat: use CDP's auto-attach mechanism (#8520) * feat: use CDP's auto-attach mechanism In this PR, we refactor Puppeteer to make use of the CDP's auto-attach mechanism. This allows the backend to pause new targets and give Puppeteer a chance to configure them properly. This fixes the flakiness related to dealing with OOPIFs and should fix some other issues related to the network interception and navigations. If those are not fixed completely by this PR, the PR serves a solid base for fixing them. Closes https://github.com/puppeteer/puppeteer/issues/8507, https://github.com/puppeteer/puppeteer/issues/7990 Unlocks https://github.com/puppeteer/puppeteer/issues/3667 BREAKING CHANGE: With Chromium, Puppeteer will now attach to page/iframe targets immediately. BREAKING CHANGE: Browser.connect requires an explicit product name when connecting to Firefox since Firefox does not support CDP's auto-attach. --- package.json | 2 +- src/common/Browser.ts | 179 ++++++++----- src/common/BrowserConnector.ts | 9 +- src/common/ChromeTargetManager.ts | 394 +++++++++++++++++++++++++++++ src/common/Connection.ts | 9 + src/common/DOMWorld.ts | 2 +- src/common/FirefoxTargetManager.ts | 255 +++++++++++++++++++ src/common/FrameManager.ts | 41 +-- src/common/Page.ts | 102 ++++---- src/common/Target.ts | 30 ++- src/common/TargetManager.ts | 71 ++++++ src/node/ChromeLauncher.ts | 1 + src/node/FirefoxLauncher.ts | 1 + src/types.ts | 3 + test/src/TargetManager.spec.ts | 111 ++++++++ test/src/browser.spec.ts | 14 +- test/src/fixtures.spec.ts | 4 +- test/src/launcher.spec.ts | 140 +++++----- test/src/mocha-utils.ts | 7 + test/src/target.spec.ts | 10 +- 20 files changed, 1171 insertions(+), 214 deletions(-) create mode 100644 src/common/ChromeTargetManager.ts create mode 100644 src/common/FirefoxTargetManager.ts create mode 100644 src/common/TargetManager.ts create mode 100644 test/src/TargetManager.spec.ts 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); }