diff --git a/package.json b/package.json index 115781395c4c0..f502ac4b92d71 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test": "npm run lint --silent && npm run test:unit:coverage", "test:unit": "npm run build:test && mocha", "test:unit:firefox": "cross-env PUPPETEER_PRODUCT=firefox npm run test:unit", - "test:unit:coverage": "c8 --check-coverage --lines 94 npm run test:unit", + "test:unit:coverage": "c8 --check-coverage --lines 93 npm run test:unit", "test:unit:chrome-headless": "cross-env HEADLESS=chrome npm run test:unit", "test:protocol-revision": "ts-node -s scripts/ensure-correct-devtools-protocol-package", "test:pinned-deps": "ts-node -s scripts/ensure-pinned-deps", diff --git a/src/common/Browser.ts b/src/common/Browser.ts index 8e9c93d2001f7..d34c459129bd7 100644 --- a/src/common/Browser.ts +++ b/src/common/Browser.ts @@ -17,13 +17,19 @@ 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, + ChromeTargetManager, + TargetManagerEmittedEvents, +} from './TargetManager.js'; +import {FirefoxTargetManager} from './FirefoxTargetManager.js'; /** * BrowserContext options. @@ -218,6 +224,7 @@ export class Browser extends EventEmitter { * @internal */ static async _create( + product: 'firefox' | 'chrome' | undefined, connection: Connection, contextIds: string[], ignoreHTTPSErrors: boolean, @@ -228,6 +235,7 @@ export class Browser extends EventEmitter { isPageTargetCallback?: IsPageTargetCallback ): Promise { const browser = new Browser( + product, connection, contextIds, ignoreHTTPSErrors, @@ -237,7 +245,7 @@ export class Browser extends EventEmitter { targetFilterCallback, isPageTargetCallback ); - await connection.send('Target.setDiscoverTargets', {discover: true}); + await browser._attach(); return browser; } #ignoreHTTPSErrors: boolean; @@ -250,20 +258,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.attachedTargets(); } /** * @internal */ constructor( + product: 'chrome' | 'firefox' | undefined, connection: Connection, contextIds: string[], ignoreHTTPSErrors: boolean, @@ -274,6 +282,7 @@ export class Browser extends EventEmitter { isPageTargetCallback?: IsPageTargetCallback ) { super(); + product = product || 'chrome'; this.#ignoreHTTPSErrors = ignoreHTTPSErrors; this.#defaultViewport = defaultViewport; this.#process = process; @@ -286,6 +295,18 @@ export class Browser extends EventEmitter { return true; }); this.#setIsPageTargetCallback(isPageTargetCallback); + this.#targetManager = + product === 'chrome' + ? new ChromeTargetManager( + connection, + this.#createTarget, + this.#targetFilterCallback + ) + : new FirefoxTargetManager( + connection, + this.#createTarget, + this.#targetFilterCallback + ); this.#defaultContext = new BrowserContext(this.#connection, this); this.#contexts = new Map(); @@ -295,19 +316,54 @@ 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.AttachedToTarget, + this.#onAttachedToTarget + ); + this.#targetManager.on( + TargetManagerEmittedEvents.DetachedFromTarget, + this.#onDetachedFromTarget + ); + this.#targetManager.on( + TargetManagerEmittedEvents.TargetChanged, + this.#onTargetChanged + ); + await this.#targetManager.initialize(); + } + + /** + * @internal + */ + _detach(): void { + this.#connection.off( + ConnectionEmittedEvents.Disconnected, + this.#emitDisconnected + ); + this.#targetManager.off( + TargetManagerEmittedEvents.AttachedToTarget, + this.#onAttachedToTarget + ); + this.#targetManager.off( + TargetManagerEmittedEvents.DetachedFromTarget, + this.#onDetachedFromTarget + ); + this.#targetManager.off( + TargetManagerEmittedEvents.TargetChanged, + this.#onTargetChanged ); } @@ -319,6 +375,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 +467,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 +481,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 +494,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 +514,25 @@ 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); } - } + }; /** * The browser websocket endpoint which can be used as an argument to @@ -526,7 +571,7 @@ export class Browser extends EventEmitter { url: 'about:blank', browserContextId: contextId || undefined, }); - const target = this.#targets.get(targetId); + const target = this.#targetManager.attachedTargets().get(targetId); if (!target) { throw new Error(`Missing target for page (id = ${targetId})`); } @@ -548,9 +593,11 @@ 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 target._isInitialized; - }); + return Array.from(this.#targetManager.attachedTargets().values()).filter( + target => { + return target._isInitialized; + } + ); } /** diff --git a/src/common/BrowserConnector.ts b/src/common/BrowserConnector.ts index 9cd67c5ceb93b..2163792f0556a 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( @@ -114,6 +116,7 @@ export async function _connectToBrowser( 'Target.getBrowserContexts' ); return Browser._create( + product || 'chrome', connection, browserContextIds, ignoreHTTPSErrors, diff --git a/src/common/Connection.ts b/src/common/Connection.ts index 0b5c46f5bec0f..168260f690f4f 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(); @@ -210,6 +211,10 @@ export class Connection extends EventEmitter { this.#transport.close(); } + isAutoAttached(targetId: string): boolean { + return !this.#manuallyAttached.has(targetId); + } + /** * @param targetInfo - The target info * @returns The CDP session that is created @@ -217,6 +222,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/FirefoxTargetManager.ts b/src/common/FirefoxTargetManager.ts new file mode 100644 index 0000000000000..9e41e337b2f42 --- /dev/null +++ b/src/common/FirefoxTargetManager.ts @@ -0,0 +1,304 @@ +/** + * 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, + TargetAttachHook, + TargetManagerEmittedEvents, + debugTargetManager, +} from './TargetManager.js'; +import {EventEmitter} from './EventEmitter.js'; + +export class FirefoxTargetManager extends EventEmitter { + #connection: Connection; + #discoveredTargetsByTargetId: Map = + new Map(); + #attachedTargetsByTargetId: Map = new Map(); + #attachedTargetsBySessionId: Map = new Map(); + #ignoredTargets = new Set(); + #targetFilterCallback: TargetFilterCallback | undefined; + #targetFactory: TargetFactory; + + #targetAttachHooks: 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); + } + + addTargetAttachHook( + client: CDPSession | Connection, + cb: TargetAttachHook + ): void { + const list = this.#targetAttachHooks.get(client) || []; + list.push(cb); + this.#targetAttachHooks.set(client, list); + } + + removeTargetAttachHook( + client: CDPSession | Connection, + cb: TargetAttachHook + ): void { + const list = this.#targetAttachHooks.get(client) || []; + this.#targetAttachHooks.set( + client, + list.filter(item => { + return item !== cb; + }) + ); + } + + 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); + } + + #onSessionDetached = (session: CDPSession) => { + this.removeSessionListeners(session); + this.#targetAttachHooks.delete(session); + this.#attachedTargetsBySessionId.delete(session.id()); + }; + + removeSessionListeners(session: CDPSession): 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); + } + } + + attachedTargets(): Map { + return this.#attachedTargetsByTargetId; + } + + dispose(): void { + this.#connection.off('Target.targetCreated', this.onTargetCreated); + this.#connection.off('Target.targetDestroyed', this.onTargetDestroyed); + this.#connection.off('Target.targetInfoChanged', this.onTargetInfoChanged); + } + + async initialize(): Promise { + await this.#connection.send('Target.setDiscoverTargets', {discover: true}); + this.#targetsIdsForInit = new Set(this.#discoveredTargetsByTargetId.keys()); + await this.#initializePromise; + } + + protected 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.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target); + return; + } + + if ( + this.#targetFilterCallback && + !this.#targetFilterCallback(event.targetInfo) + ) { + this.#ignoredTargets.add(event.targetInfo.targetId); + this.#checkInit(event.targetInfo.targetId); + return; + } + + const target = this.#targetFactory(event.targetInfo, undefined); + this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target); + this.emit(TargetManagerEmittedEvents.AttachedToTarget, target); + if (this.#targetsIdsForInit.size === 0) { + this.#initializeCallback(); + } + }; + + protected onTargetDestroyed = ( + event: Protocol.Target.TargetDestroyedEvent + ): void => { + this.#discoveredTargetsByTargetId.delete(event.targetId); + this.#checkInit(event.targetId); + const target = this.#attachedTargetsByTargetId.get(event.targetId); + if (target) { + this.emit(TargetManagerEmittedEvents.DetachedFromTarget, target); + this.#attachedTargetsByTargetId.delete(event.targetId); + } + }; + + protected onTargetInfoChanged = ( + event: Protocol.Target.TargetInfoChangedEvent + ): void => { + 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 + ) => { + debugTargetManager( + 'Attached to session', + 'parentSession', + parentSession instanceof CDPSession ? parentSession.id() : 'main', + 'event', + event + ); + 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.#attachedTargetsByTargetId.get(targetInfo.targetId); + + if (!target) { + throw new Error('NO TARGET'); + } + + // 4) Set up listeners for the session so that session events are received. + this.setupAttachmentListeners(session); + + // 5) Update the maps + this.#attachedTargetsBySessionId.set( + session.id(), + this.#attachedTargetsByTargetId.get(targetInfo.targetId)! + ); + + // 6) At this point the target is paused so we can allow clients to + // configure themselves using hooks. + for (const hook of this.#targetAttachHooks.get(parentSession) || []) { + if (!(parentSession instanceof Connection)) { + assert(this.#attachedTargetsBySessionId.has(parentSession.id())); + } + await hook( + target, + parentSession instanceof Connection + ? null + : this.#attachedTargetsBySessionId.get(parentSession.id())! + ); + } + }; + + #onDetachedFromTarget = ( + parentSession: Connection | CDPSession, + event: Protocol.Target.DetachedFromTargetEvent + ) => { + debugTargetManager( + 'Detached from session', + 'parentSession', + parentSession instanceof CDPSession ? parentSession.id() : 'main', + 'event', + event + ); + + const target = this.#attachedTargetsBySessionId.get(event.sessionId); + + this.#attachedTargetsBySessionId.delete(event.sessionId); + + if (!target) { + return; + } + + this.#attachedTargetsByTargetId.delete(target._targetId); + this.emit(TargetManagerEmittedEvents.DetachedFromTarget, target); + }; + + #checkInit(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 658500add0b0f..f2a75033fd571 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 {EventEmitter} from './EventEmitter.js'; import {EVALUATION_SCRIPT_URL, ExecutionContext} from './ExecutionContext.js'; @@ -26,6 +26,7 @@ import {ElementHandle} from './ElementHandle.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} 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. diff --git a/src/common/Page.ts b/src/common/Page.ts index e783e1063516f..6dc49af3d4412 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'; @@ -501,49 +502,16 @@ 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() + .addTargetAttachHook(this.#client, this.#targetAttachHook); + + this.#target + ._targetManager() + .on( + TargetManagerEmittedEvents.DetachedFromTarget, + this.#onDetachedFromTarget + ); this.#frameManager.on(FrameManagerEmittedEvents.FrameAttached, event => { return this.emit(PageEmittedEvents.FrameAttached, event); @@ -607,19 +575,58 @@ export class Page extends EventEmitter { return this.#onFileChooser(event); }); this.#target._isClosedPromise.then(() => { + this.#target + ._targetManager() + .removeTargetAttachHook(this.#client, this.#targetAttachHook); + + this.#target + ._targetManager() + .off( + TargetManagerEmittedEvents.DetachedFromTarget, + 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); + }; + + #targetAttachHook = 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() + .addTargetAttachHook(createdTarget._session()!, this.#targetAttachHook); + } + }; + 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..887129ddab16a 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,10 @@ export class Target { } } + _session(): CDPSession | undefined { + return this.#session; + } + /** * Creates a Chrome Devtools Protocol session attached to the target. */ @@ -120,6 +132,10 @@ export class Target { return this.#sessionFactory(); } + _targetManager(): TargetManager { + return this.#targetManager; + } + /** * @internal */ @@ -132,7 +148,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 +175,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..5f335ea46e4bc --- /dev/null +++ b/src/common/TargetManager.ts @@ -0,0 +1,466 @@ +/** + * 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 {debug} from './Debug.js'; +import {debugError} from './util.js'; +import {TargetFilterCallback} from './Browser.js'; + +export type TargetFactory = ( + targetInfo: Protocol.Target.TargetInfo, + session?: CDPSession +) => Target; + +export type TargetAttachHook = ( + createdTarget: Target, + parentTarget: Target | null +) => Promise; + +export const debugTargetManager = debug('puppeteer:targetManager:workflow'); + +class TrackedMap { + #data: Map = new Map(); + #debug: (...args: unknown[]) => void; + + constructor(debug: (...args: unknown[]) => void) { + this.#debug = debug; + } + + set(key: Key, value: Value) { + this.#debug(`Setting ${key} to`, value); + if (this.#data.has(key)) { + this.#debug(`Map already contains: key=${key}`); + } + this.#data.set(key, value); + } + + has(key: Key): boolean { + return this.#data.has(key); + } + + delete(key: Key): void { + if (!this.#data.has(key)) { + this.#debug(`Map does not contain: key=${key}`); + } + this.#data.delete(key); + } + + get(key: Key): Value | undefined { + if (!this.has(key)) { + this.#debug(`Map does not contain: key=${key}`); + } + return this.#data.get(key); + } + + toMap(): Map { + return new Map(this.#data); + } +} + +export interface TargetManager extends EventEmitter { + attachedTargets(): Map; + initialize(): Promise; + addTargetAttachHook( + client: CDPSession | Connection, + cb: TargetAttachHook + ): void; + removeTargetAttachHook( + client: CDPSession | Connection, + cb: TargetAttachHook + ): void; +} + +/** + * TargetManager encapsulates all interactions with CDP targets. + * Code outside of this class should not subscribe `Target.*` events + * and only use the TargetManager events. + */ +export class ChromeTargetManager extends EventEmitter implements TargetManager { + #connection: Connection; + #discoveredTargetsByTargetId: TrackedMap = + new TrackedMap( + debug('puppeteer:targetManager:discoveredTargetsByTargetId') + ); + #attachedTargetsByTargetId: TrackedMap = new TrackedMap( + debug('puppeteer:targetManager:attachedTargetsByTargetId') + ); + #attachedTargetsBySessionId: TrackedMap = new TrackedMap( + debug('puppeteer:targetManager:attachedTargetsBySessionId') + ); + #ignoredTargets = new Set(); + #targetFilterCallback: TargetFilterCallback | undefined; + #targetFactory: TargetFactory; + + #targetAttachHooks: 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}); + } + + addTargetAttachHook( + client: CDPSession | Connection, + cb: TargetAttachHook + ): void { + const list = this.#targetAttachHooks.get(client) || []; + list.push(cb); + this.#targetAttachHooks.set(client, list); + } + + removeTargetAttachHook( + client: CDPSession | Connection, + cb: TargetAttachHook + ): void { + const list = this.#targetAttachHooks.get(client) || []; + this.#targetAttachHooks.set( + client, + list.filter(item => { + return item !== cb; + }) + ); + } + + 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); + } + + #onSessionDetached = (session: CDPSession) => { + this.removeSessionListeners(session); + this.#targetAttachHooks.delete(session); + }; + + removeSessionListeners(session: CDPSession): 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); + } + } + + attachedTargets(): Map { + return this.#attachedTargetsByTargetId.toMap(); + } + + dispose(): void { + this.#connection.off('Target.targetCreated', this.#onTargetCreated); + this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed); + this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged); + } + + async initialize(): Promise { + this.#targetsIdsForInit = new Set( + this.#discoveredTargetsByTargetId.toMap().keys() + ); + await this.#connection.send('Target.setAutoAttach', { + waitForDebuggerOnStart: true, + flatten: true, + autoAttach: true, + }); + await this.#initializePromise; + } + + #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => { + this.#discoveredTargetsByTargetId.set( + event.targetInfo.targetId, + event.targetInfo + ); + + 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.#checkInit(event.targetId); + if ( + targetInfo?.type === 'service_worker' && + this.#attachedTargetsByTargetId.has(event.targetId) + ) { + const target = this.#attachedTargetsByTargetId.get(event.targetId); + this.emit(TargetManagerEmittedEvents.DetachedFromTarget, 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 + ) => { + debugTargetManager( + 'Attached to session', + 'parentSession', + parentSession instanceof CDPSession ? parentSession.id() : 'main', + 'event', + event + ); + const targetInfo = event.targetInfo; + const session = this.#connection.session(event.sessionId); + if (!session) { + throw new Error(`Session ${event.sessionId} was not created.`); + } + + // 1) From here on, we should either detach the session or keep it. + + // Should silently detach? Currently, only service workers attached to + // not-main targets have to be detached. + // 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. + if ( + targetInfo.type === 'service_worker' && + this.#connection.isAutoAttached(targetInfo.targetId) + ) { + this.#checkInit(targetInfo.targetId); + debugTargetManager( + 'Silently detaching a session', + 'parentSession', + parentSession instanceof CDPSession ? parentSession.id() : 'main', + 'targetInfo', + targetInfo + ); + + await session.send('Runtime.runIfWaitingForDebugger').catch(debugError); + await parentSession + .send('Target.detachFromTarget', { + sessionId: session.id(), + }) + .catch(debugError); + + if (parentSession instanceof CDPSession) { + const target = this.#targetFactory(targetInfo); + this.#attachedTargetsByTargetId.set(targetInfo.targetId, target); + this.emit(TargetManagerEmittedEvents.AttachedToTarget, target); + } + // When we detach silently, we don't register the session/target in any maps. + return; + } + + // 2) Here we check the target filter and silently detach from targes that + // don't match. It is similar to the previous branch so perhaps except + // with record the targetId in ignoredTargets. + if (this.#targetFilterCallback && !this.#targetFilterCallback(targetInfo)) { + this.#ignoredTargets.add(targetInfo.targetId); + this.#checkInit(targetInfo.targetId); + await session.send('Runtime.runIfWaitingForDebugger').catch(debugError); + await parentSession + .send('Target.detachFromTarget', { + sessionId: session.id(), + }) + .catch(debugError); + // When we detach silently, we don't register the session/target in any maps. + return; + } + + // 3) At this point, we are sure that the session exists and that the target + // should be attached to. One target might be attached to multiple + // sessions. Therefore, we need to check if we already attached to it. + const existingTarget = this.#attachedTargetsByTargetId.has( + targetInfo.targetId + ); + + const target = existingTarget + ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)! + : this.#targetFactory(targetInfo, session); + + // 4) Set up listeners for the session so that session events are received. + this.setupAttachmentListeners(session); + + // 5) Update the maps + 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); + } + + // 6) At this point the target is paused so we can allow clients to + // configure themselves using hooks. + for (const hook of this.#targetAttachHooks.get(parentSession) || []) { + if (!(parentSession instanceof Connection)) { + assert(this.#attachedTargetsBySessionId.has(parentSession.id())); + } + await hook( + target, + parentSession instanceof Connection + ? null + : this.#attachedTargetsBySessionId.get(parentSession.id())! + ); + } + + // 7) Track if the target has been initialized. + this.#targetsIdsForInit.delete(target._targetId); + this.emit(TargetManagerEmittedEvents.AttachedToTarget, target); + if (this.#targetsIdsForInit.size === 0) { + this.#initializeCallback(); + } + + // 8) Actually resume the target and configure auto-attach. + await Promise.all([ + session.send('Target.setAutoAttach', { + waitForDebuggerOnStart: true, + flatten: true, + autoAttach: true, + }), + session.send('Runtime.runIfWaitingForDebugger'), + ]).catch(debugError); + // TODO: the browser might be shutting down here. What do we do with the + // error? + + // 9) The service worker target needs to be detached. TODO: figure out if + // this is correct. if (targetInfo.type === 'service_worker') { await + // parentSession .send('Target.detachFromTarget', { sessionId: + // session.id(), + // }) + // .catch(debugError); + // } + }; + + #checkInit(targetId: string): void { + this.#targetsIdsForInit.delete(targetId); + if (this.#targetsIdsForInit.size === 0) { + this.#initializeCallback(); + } + } + + #onDetachedFromTarget = ( + parentSession: Connection | CDPSession, + event: Protocol.Target.DetachedFromTargetEvent + ) => { + debugTargetManager( + 'Detached from session', + 'parentSession', + parentSession instanceof CDPSession ? parentSession.id() : 'main', + 'event', + event + ); + + const target = this.#attachedTargetsBySessionId.get(event.sessionId); + + this.#attachedTargetsBySessionId.delete(event.sessionId); + + if (!target) { + return; + } + + this.#attachedTargetsByTargetId.delete(target._targetId); + this.emit(TargetManagerEmittedEvents.DetachedFromTarget, target); + }; +} + +/** + * @internal + */ +export const enum TargetManagerEmittedEvents { + AttachedToTarget = 'attachedToTarget', + DetachedFromTarget = 'detachedFromTarget', + 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..842b068c4998a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,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 +45,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..ecc247d1887ff --- /dev/null +++ b/test/src/TargetManager.spec.ts @@ -0,0 +1,114 @@ +/** + * 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.attachedTargets().size).toBe(2); + + const pages = await browser.pages(); + expect(pages).toHaveLength(1); + let [page] = pages; + await page!.close(); + expect(await browser.pages()).toHaveLength(0); + expect(targetManager.attachedTargets().size).toBe(1); + page = await browser.newPage(); + expect(await browser.pages()).toHaveLength(1); + expect(targetManager.attachedTargets().size).toBe(2); + + await page.goto(server.EMPTY_PAGE); + expect(await browser.pages()).toHaveLength(1); + expect(targetManager.attachedTargets().size).toBe(2); + + // 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 browser.pages()).toHaveLength(1); + expect(targetManager.attachedTargets().size).toBe(2); + 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 browser.pages()).toHaveLength(1); + expect(targetManager.attachedTargets().size).toBe(3); + 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 browser.pages()).toHaveLength(1); + expect(targetManager.attachedTargets().size).toBe(4); + 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..51bef04c7136f 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; @@ -976,11 +989,17 @@ 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 {puppeteer, defaultBrowserOptions, isFirefox} = getTestState(); const originalBrowser = await puppeteer.launch(defaultBrowserOptions); const browserWSEndpoint = originalBrowser.wsEndpoint(); - const remoteBrowser1 = await puppeteer.connect({browserWSEndpoint}); - const remoteBrowser2 = await puppeteer.connect({browserWSEndpoint}); + 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; diff --git a/test/src/target.spec.ts b/test/src/target.spec.ts index d8fa9f16be8fb..80263177f7789 100644 --- a/test/src/target.spec.ts +++ b/test/src/target.spec.ts @@ -173,7 +173,7 @@ describe('Target', function () { }); await page.evaluate(() => { return (globalThis as any).registrationPromise.then( - (registration: {unregister: () => any}) => { + (registration: any) => { return registration.unregister(); } ); @@ -196,6 +196,7 @@ describe('Target', function () { }) ).toBe('[object ServiceWorkerGlobalScope]'); }); + // TODO: fix shared workers. itFailsFirefox('should create a worker from a shared worker', async () => { const {page, server, context} = getTestState();