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 to allow reliable configuration of targets.
This commit is contained in:
Alex Rudenko 2022-07-21 20:50:46 +02:00 committed by Randolf J
parent a654607c74
commit 2cbfdeb0ca
20 changed files with 1165 additions and 208 deletions

View File

@ -27,7 +27,7 @@
"node": ">=14.1.0" "node": ">=14.1.0"
}, },
"scripts": { "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:types": "tsd",
"test:install": "scripts/test-install.sh", "test:install": "scripts/test-install.sh",
"test:firefox": "cross-env PUPPETEER_PRODUCT=firefox MOZ_WEBRENDER=0 mocha", "test:firefox": "cross-env PUPPETEER_PRODUCT=firefox MOZ_WEBRENDER=0 mocha",

View File

@ -17,13 +17,16 @@
import {ChildProcess} from 'child_process'; import {ChildProcess} from 'child_process';
import {Protocol} from 'devtools-protocol'; import {Protocol} from 'devtools-protocol';
import {assert} from './assert.js'; import {assert} from './assert.js';
import {Connection, ConnectionEmittedEvents} from './Connection.js'; import {CDPSession, Connection, ConnectionEmittedEvents} from './Connection.js';
import {EventEmitter} from './EventEmitter.js'; import {EventEmitter} from './EventEmitter.js';
import {waitWithTimeout} from './util.js'; import {waitWithTimeout} from './util.js';
import {Page} from './Page.js'; import {Page} from './Page.js';
import {Viewport} from './PuppeteerViewport.js'; import {Viewport} from './PuppeteerViewport.js';
import {Target} from './Target.js'; import {Target} from './Target.js';
import {TaskQueue} from './TaskQueue.js'; import {TaskQueue} from './TaskQueue.js';
import {TargetManager, TargetManagerEmittedEvents} from './TargetManager.js';
import {ChromeTargetManager} from './ChromeTargetManager.js';
import {FirefoxTargetManager} from './FirefoxTargetManager.js';
/** /**
* BrowserContext options. * BrowserContext options.
@ -218,6 +221,7 @@ export class Browser extends EventEmitter {
* @internal * @internal
*/ */
static async _create( static async _create(
product: 'firefox' | 'chrome' | undefined,
connection: Connection, connection: Connection,
contextIds: string[], contextIds: string[],
ignoreHTTPSErrors: boolean, ignoreHTTPSErrors: boolean,
@ -228,6 +232,7 @@ export class Browser extends EventEmitter {
isPageTargetCallback?: IsPageTargetCallback isPageTargetCallback?: IsPageTargetCallback
): Promise<Browser> { ): Promise<Browser> {
const browser = new Browser( const browser = new Browser(
product,
connection, connection,
contextIds, contextIds,
ignoreHTTPSErrors, ignoreHTTPSErrors,
@ -237,7 +242,7 @@ export class Browser extends EventEmitter {
targetFilterCallback, targetFilterCallback,
isPageTargetCallback isPageTargetCallback
); );
await connection.send('Target.setDiscoverTargets', {discover: true}); await browser._attach();
return browser; return browser;
} }
#ignoreHTTPSErrors: boolean; #ignoreHTTPSErrors: boolean;
@ -250,20 +255,20 @@ export class Browser extends EventEmitter {
#defaultContext: BrowserContext; #defaultContext: BrowserContext;
#contexts: Map<string, BrowserContext>; #contexts: Map<string, BrowserContext>;
#screenshotTaskQueue: TaskQueue; #screenshotTaskQueue: TaskQueue;
#targets: Map<string, Target>; #targetManager: TargetManager;
#ignoredTargets = new Set<string>();
/** /**
* @internal * @internal
*/ */
get _targets(): Map<string, Target> { get _targets(): Map<string, Target> {
return this.#targets; return this.#targetManager.getAvailableTargets();
} }
/** /**
* @internal * @internal
*/ */
constructor( constructor(
product: 'chrome' | 'firefox' | undefined,
connection: Connection, connection: Connection,
contextIds: string[], contextIds: string[],
ignoreHTTPSErrors: boolean, ignoreHTTPSErrors: boolean,
@ -274,6 +279,7 @@ export class Browser extends EventEmitter {
isPageTargetCallback?: IsPageTargetCallback isPageTargetCallback?: IsPageTargetCallback
) { ) {
super(); super();
product = product || 'chrome';
this.#ignoreHTTPSErrors = ignoreHTTPSErrors; this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
this.#defaultViewport = defaultViewport; this.#defaultViewport = defaultViewport;
this.#process = process; this.#process = process;
@ -286,7 +292,19 @@ export class Browser extends EventEmitter {
return true; return true;
}); });
this.#setIsPageTargetCallback(isPageTargetCallback); 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.#defaultContext = new BrowserContext(this.#connection, this);
this.#contexts = new Map(); this.#contexts = new Map();
for (const contextId of contextIds) { for (const contextId of contextIds) {
@ -295,19 +313,62 @@ export class Browser extends EventEmitter {
new BrowserContext(this.#connection, this, contextId) new BrowserContext(this.#connection, this, contextId)
); );
} }
}
this.#targets = new Map(); #emitDisconnected = () => {
this.#connection.on(ConnectionEmittedEvents.Disconnected, () => { this.emit(BrowserEmittedEvents.Disconnected);
return this.emit(BrowserEmittedEvents.Disconnected); };
});
this.#connection.on('Target.targetCreated', this.#targetCreated.bind(this)); /**
* @internal
*/
async _attach(): Promise<void> {
this.#connection.on( this.#connection.on(
'Target.targetDestroyed', ConnectionEmittedEvents.Disconnected,
this.#targetDestroyed.bind(this) this.#emitDisconnected
); );
this.#connection.on( this.#targetManager.on(
'Target.targetInfoChanged', TargetManagerEmittedEvents.TargetAvailable,
this.#targetInfoChanged.bind(this) 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; return this.#process ?? null;
} }
/**
* @internal
*/
_targetManager(): TargetManager {
return this.#targetManager;
}
#setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void { #setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void {
this.#isPageTargetCallback = this.#isPageTargetCallback =
isPageTargetCallback || isPageTargetCallback ||
@ -404,10 +472,10 @@ export class Browser extends EventEmitter {
this.#contexts.delete(contextId); this.#contexts.delete(contextId);
} }
async #targetCreated( #createTarget = (
event: Protocol.Target.TargetCreatedEvent targetInfo: Protocol.Target.TargetInfo,
): Promise<void> { session?: CDPSession
const targetInfo = event.targetInfo; ) => {
const {browserContextId} = targetInfo; const {browserContextId} = targetInfo;
const context = const context =
browserContextId && this.#contexts.has(browserContextId) browserContextId && this.#contexts.has(browserContextId)
@ -418,15 +486,11 @@ export class Browser extends EventEmitter {
throw new Error('Missing browser context'); throw new Error('Missing browser context');
} }
const shouldAttachToTarget = this.#targetFilterCallback(targetInfo); return new Target(
if (!shouldAttachToTarget) {
this.#ignoredTargets.add(targetInfo.targetId);
return;
}
const target = new Target(
targetInfo, targetInfo,
session,
context, context,
this.#targetManager,
() => { () => {
return this.#connection.createSession(targetInfo); return this.#connection.createSession(targetInfo);
}, },
@ -435,30 +499,19 @@ export class Browser extends EventEmitter {
this.#screenshotTaskQueue, this.#screenshotTaskQueue,
this.#isPageTargetCallback 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) { if (await target._initializedPromise) {
this.emit(BrowserEmittedEvents.TargetCreated, target); this.emit(BrowserEmittedEvents.TargetCreated, target);
context.emit(BrowserContextEmittedEvents.TargetCreated, target); target
} .browserContext()
.emit(BrowserContextEmittedEvents.TargetCreated, target);
} }
};
async #targetDestroyed(event: {targetId: string}): Promise<void> { #onDetachedFromTarget = async (target: Target): Promise<void> => {
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})`
);
}
target._initializedCallback(false); target._initializedCallback(false);
this.#targets.delete(event.targetId);
target._closedCallback(); target._closedCallback();
if (await target._initializedPromise) { if (await target._initializedPromise) {
this.emit(BrowserEmittedEvents.TargetDestroyed, target); this.emit(BrowserEmittedEvents.TargetDestroyed, target);
@ -466,28 +519,29 @@ export class Browser extends EventEmitter {
.browserContext() .browserContext()
.emit(BrowserContextEmittedEvents.TargetDestroyed, target); .emit(BrowserContextEmittedEvents.TargetDestroyed, target);
} }
} };
#targetInfoChanged(event: Protocol.Target.TargetInfoChangedEvent): void { #onTargetChanged = ({
if (this.#ignoredTargets.has(event.targetInfo.targetId)) { target,
return; targetInfo,
} }: {
const target = this.#targets.get(event.targetInfo.targetId); target: Target;
if (!target) { targetInfo: Protocol.Target.TargetInfo;
throw new Error( }): void => {
`Missing target in targetInfoChanged (id = ${event.targetInfo.targetId})`
);
}
const previousURL = target.url(); const previousURL = target.url();
const wasInitialized = target._isInitialized; const wasInitialized = target._isInitialized;
target._targetInfoChanged(event.targetInfo); target._targetInfoChanged(targetInfo);
if (wasInitialized && previousURL !== target.url()) { if (wasInitialized && previousURL !== target.url()) {
this.emit(BrowserEmittedEvents.TargetChanged, target); this.emit(BrowserEmittedEvents.TargetChanged, target);
target target
.browserContext() .browserContext()
.emit(BrowserContextEmittedEvents.TargetChanged, target); .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 * The browser websocket endpoint which can be used as an argument to
@ -526,7 +580,7 @@ export class Browser extends EventEmitter {
url: 'about:blank', url: 'about:blank',
browserContextId: contextId || undefined, browserContextId: contextId || undefined,
}); });
const target = this.#targets.get(targetId); const target = this.#targetManager.getAvailableTargets().get(targetId);
if (!target) { if (!target) {
throw new Error(`Missing target for page (id = ${targetId})`); 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. * an array with all the targets in all browser contexts.
*/ */
targets(): Target[] { targets(): Target[] {
return Array.from(this.#targets.values()).filter(target => { return Array.from(
this.#targetManager.getAvailableTargets().values()
).filter(target => {
return target._isInitialized; return target._isInitialized;
}); });
} }
@ -671,6 +727,7 @@ export class Browser extends EventEmitter {
* cannot be used anymore. * cannot be used anymore.
*/ */
disconnect(): void { disconnect(): void {
this.#targetManager.dispose();
this.#connection.dispose(); this.#connection.dispose();
} }

View File

@ -26,7 +26,7 @@ import {Connection} from './Connection.js';
import {ConnectionTransport} from './ConnectionTransport.js'; import {ConnectionTransport} from './ConnectionTransport.js';
import {getFetch} from './fetch.js'; import {getFetch} from './fetch.js';
import {Viewport} from './PuppeteerViewport.js'; import {Viewport} from './PuppeteerViewport.js';
import {Product} from './Product.js';
/** /**
* Generic browser options that can be passed when launching any browser or when * Generic browser options that can be passed when launching any browser or when
* connecting to an existing browser instance. * connecting to an existing browser instance.
@ -75,6 +75,7 @@ export async function _connectToBrowser(
browserWSEndpoint?: string; browserWSEndpoint?: string;
browserURL?: string; browserURL?: string;
transport?: ConnectionTransport; transport?: ConnectionTransport;
product?: Product;
} }
): Promise<Browser> { ): Promise<Browser> {
const { const {
@ -86,6 +87,7 @@ export async function _connectToBrowser(
slowMo = 0, slowMo = 0,
targetFilter, targetFilter,
_isPageTarget: isPageTarget, _isPageTarget: isPageTarget,
product,
} = options; } = options;
assert( assert(
@ -113,7 +115,8 @@ export async function _connectToBrowser(
const {browserContextIds} = await connection.send( const {browserContextIds} = await connection.send(
'Target.getBrowserContexts' 'Target.getBrowserContexts'
); );
return Browser._create( const browser = await Browser._create(
product || 'chrome',
connection, connection,
browserContextIds, browserContextIds,
ignoreHTTPSErrors, ignoreHTTPSErrors,
@ -125,6 +128,8 @@ export async function _connectToBrowser(
targetFilter, targetFilter,
isPageTarget isPageTarget
); );
await browser.pages();
return browser;
} }
async function getWSEndpoint(browserURL: string): Promise<string> { async function getWSEndpoint(browserURL: string): Promise<string> {

View File

@ -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<string, Protocol.Target.TargetInfo> =
new Map();
/**
* A target is added to this map once ChromeTargetManager has created
* a Target and attached at least once to it.
*/
#attachedTargetsByTargetId: Map<string, Target> = new Map();
/**
*
* Tracks which sessions attach to which target.
*/
#attachedTargetsBySessionId: Map<string, Target> = 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<string>();
#targetFilterCallback: TargetFilterCallback | undefined;
#targetFactory: TargetFactory;
#targetInterceptors: WeakMap<CDPSession | Connection, TargetInterceptor[]> =
new WeakMap();
#attachedToTargetListenersBySession: WeakMap<
CDPSession | Connection,
(event: Protocol.Target.AttachedToTargetEvent) => Promise<void>
> = new WeakMap();
#detachedFromTargetListenersBySession: WeakMap<
CDPSession | Connection,
(event: Protocol.Target.DetachedFromTargetEvent) => void
> = new WeakMap();
#initializeCallback = () => {};
#initializePromise: Promise<void> = new Promise(resolve => {
this.#initializeCallback = resolve;
});
#targetsIdsForInit: Set<string> = 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<void> {
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<string, Target> {
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);
};
}

View File

@ -59,6 +59,7 @@ export class Connection extends EventEmitter {
#sessions: Map<string, CDPSession> = new Map(); #sessions: Map<string, CDPSession> = new Map();
#closed = false; #closed = false;
#callbacks: Map<number, ConnectionCallback> = new Map(); #callbacks: Map<number, ConnectionCallback> = new Map();
#manuallyAttached = new Set<string>();
constructor(url: string, transport: ConnectionTransport, delay = 0) { constructor(url: string, transport: ConnectionTransport, delay = 0) {
super(); super();
@ -220,6 +221,13 @@ export class Connection extends EventEmitter {
this.#transport.close(); this.#transport.close();
} }
/**
* @internal
*/
isAutoAttached(targetId: string): boolean {
return !this.#manuallyAttached.has(targetId);
}
/** /**
* @param targetInfo - The target info * @param targetInfo - The target info
* @returns The CDP session that is created * @returns The CDP session that is created
@ -227,6 +235,7 @@ export class Connection extends EventEmitter {
async createSession( async createSession(
targetInfo: Protocol.Target.TargetInfo targetInfo: Protocol.Target.TargetInfo
): Promise<CDPSession> { ): Promise<CDPSession> {
this.#manuallyAttached.add(targetInfo.targetId);
const {sessionId} = await this.send('Target.attachToTarget', { const {sessionId} = await this.send('Target.attachToTarget', {
targetId: targetInfo.targetId, targetId: targetInfo.targetId,
flatten: true, flatten: true,

View File

@ -122,7 +122,7 @@ export class DOMWorld {
if (context) { if (context) {
assert( assert(
this.#contextResolveCallback, this.#contextResolveCallback,
'Execution Context has already been set.' `ExecutionContext ${context._contextId} has already been set.`
); );
this.#ctxBindings.clear(); this.#ctxBindings.clear();
this.#contextResolveCallback?.call(null, context); this.#contextResolveCallback?.call(null, context);

View File

@ -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<string, Protocol.Target.TargetInfo> =
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<string, Target> = new Map();
/**
* Tracks which sessions attach to which target.
*/
#availableTargetsBySessionId: Map<string, Target> = 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<string>();
#targetFilterCallback: TargetFilterCallback | undefined;
#targetFactory: TargetFactory;
#targetInterceptors: WeakMap<CDPSession | Connection, TargetInterceptor[]> =
new WeakMap();
#attachedToTargetListenersBySession: WeakMap<
CDPSession | Connection,
(event: Protocol.Target.AttachedToTargetEvent) => Promise<void>
> = new WeakMap();
#initializeCallback = () => {};
#initializePromise: Promise<void> = new Promise(resolve => {
this.#initializeCallback = resolve;
});
#targetsIdsForInit: Set<string> = 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<string, Target> {
return this.#availableTargetsByTargetId;
}
dispose(): void {
this.#connection.off('Target.targetCreated', this.#onTargetCreated);
this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed);
}
async initialize(): Promise<void> {
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<void> => {
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();
}
}
}

View File

@ -16,7 +16,7 @@
import {Protocol} from 'devtools-protocol'; import {Protocol} from 'devtools-protocol';
import {assert} from './assert.js'; import {assert} from './assert.js';
import {CDPSession, Connection} from './Connection.js'; import {CDPSession} from './Connection.js';
import {DOMWorld, WaitForSelectorOptions} from './DOMWorld.js'; import {DOMWorld, WaitForSelectorOptions} from './DOMWorld.js';
import {ElementHandle} from './ElementHandle.js'; import {ElementHandle} from './ElementHandle.js';
import {EventEmitter} from './EventEmitter.js'; import {EventEmitter} from './EventEmitter.js';
@ -26,6 +26,7 @@ import {MouseButton} from './Input.js';
import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js'; import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js';
import {NetworkManager} from './NetworkManager.js'; import {NetworkManager} from './NetworkManager.js';
import {Page} from './Page.js'; import {Page} from './Page.js';
import {Target} from './Target.js';
import {TimeoutSettings} from './TimeoutSettings.js'; import {TimeoutSettings} from './TimeoutSettings.js';
import {EvaluateFunc, HandleFor, NodeFor} from './types.js'; import {EvaluateFunc, HandleFor, NodeFor} from './types.js';
import {debugError, isErrorLike} from './util.js'; import {debugError, isErrorLike} from './util.js';
@ -129,12 +130,6 @@ export class FrameManager extends EventEmitter {
session.on('Page.lifecycleEvent', event => { session.on('Page.lifecycleEvent', event => {
this.#onLifecycleEvent(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<void> { async initialize(client: CDPSession = this.#client): Promise<void> {
@ -142,13 +137,6 @@ export class FrameManager extends EventEmitter {
const result = await Promise.all([ const result = await Promise.all([
client.send('Page.enable'), client.send('Page.enable'),
client.send('Page.getFrameTree'), client.send('Page.getFrameTree'),
client !== this.#client
? client.send('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
})
: Promise.resolve(),
]); ]);
const {frameTree} = result[1]; const {frameTree} = result[1];
@ -264,28 +252,21 @@ export class FrameManager extends EventEmitter {
return await watcher.navigationResponse(); return await watcher.navigationResponse();
} }
async #onAttachedToTarget(event: Protocol.Target.AttachedToTargetEvent) { async onAttachedToTarget(target: Target): Promise<void> {
if (event.targetInfo.type !== 'iframe') { if (target._getTargetInfo().type !== 'iframe') {
return; return;
} }
const frame = this.#frames.get(event.targetInfo.targetId); const frame = this.#frames.get(target._getTargetInfo().targetId);
const connection = Connection.fromSession(this.#client);
assert(connection);
const session = connection.session(event.sessionId);
assert(session);
if (frame) { if (frame) {
frame._updateClient(session); frame._updateClient(target._session()!);
} }
this.setupEventListeners(session); this.setupEventListeners(target._session()!);
await this.initialize(session); this.initialize(target._session());
} }
async #onDetachedFromTarget(event: Protocol.Target.DetachedFromTargetEvent) { async onDetachedFromTarget(target: Target): Promise<void> {
if (!event.targetId) { const frame = this.#frames.get(target._targetId);
return;
}
const frame = this.#frames.get(event.targetId);
if (frame && frame.isOOPFrame()) { if (frame && frame.isOOPFrame()) {
// When an OOP iframe is removed from the page, it // When an OOP iframe is removed from the page, it
// will only get a Target.detachedFromTarget event. // will only get a Target.detachedFromTarget event.
@ -374,7 +355,7 @@ export class FrameManager extends EventEmitter {
} }
assert(parentFrameId); assert(parentFrameId);
const parentFrame = this.#frames.get(parentFrameId); const parentFrame = this.#frames.get(parentFrameId);
assert(parentFrame); assert(parentFrame, `Parent frame ${parentFrameId} not found`);
const frame = new Frame(this, parentFrame, frameId, session); const frame = new Frame(this, parentFrame, frameId, session);
this.#frames.set(frame._id, frame); this.#frames.set(frame._id, frame);
this.emit(FrameManagerEmittedEvents.FrameAttached, frame); this.emit(FrameManagerEmittedEvents.FrameAttached, frame);

View File

@ -19,7 +19,7 @@ import type {Readable} from 'stream';
import {Accessibility} from './Accessibility.js'; import {Accessibility} from './Accessibility.js';
import {assert} from './assert.js'; import {assert} from './assert.js';
import {Browser, BrowserContext} from './Browser.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 {ConsoleMessage, ConsoleMessageType} from './ConsoleMessage.js';
import {Coverage} from './Coverage.js'; import {Coverage} from './Coverage.js';
import {Dialog} from './Dialog.js'; import {Dialog} from './Dialog.js';
@ -46,6 +46,7 @@ import {
import {LowerCasePaperFormat, PDFOptions, _paperFormats} from './PDFOptions.js'; import {LowerCasePaperFormat, PDFOptions, _paperFormats} from './PDFOptions.js';
import {Viewport} from './PuppeteerViewport.js'; import {Viewport} from './PuppeteerViewport.js';
import {Target} from './Target.js'; import {Target} from './Target.js';
import {TargetManagerEmittedEvents} from './TargetManager.js';
import {TaskQueue} from './TaskQueue.js'; import {TaskQueue} from './TaskQueue.js';
import {TimeoutSettings} from './TimeoutSettings.js'; import {TimeoutSettings} from './TimeoutSettings.js';
import {Tracing} from './Tracing.js'; import {Tracing} from './Tracing.js';
@ -508,49 +509,13 @@ export class Page extends EventEmitter {
this.#screenshotTaskQueue = screenshotTaskQueue; this.#screenshotTaskQueue = screenshotTaskQueue;
this.#viewport = null; this.#viewport = null;
client.on( this.#target
'Target.attachedToTarget', ._targetManager()
(event: Protocol.Target.AttachedToTargetEvent) => { .addTargetInterceptor(this.#client, this.#onAttachedToTarget);
switch (event.targetInfo.type) {
case 'worker': this.#target
const connection = Connection.fromSession(client); ._targetManager()
assert(connection); .on(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget);
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.#frameManager.on(FrameManagerEmittedEvents.FrameAttached, event => { this.#frameManager.on(FrameManagerEmittedEvents.FrameAttached, event => {
return this.emit(PageEmittedEvents.FrameAttached, event); return this.emit(PageEmittedEvents.FrameAttached, event);
@ -614,19 +579,58 @@ export class Page extends EventEmitter {
return this.#onFileChooser(event); return this.#onFileChooser(event);
}); });
this.#target._isClosedPromise.then(() => { 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.emit(PageEmittedEvents.Close);
this.#closed = true; 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<void> { async #initialize(): Promise<void> {
await Promise.all([ await Promise.all([
this.#frameManager.initialize(), this.#frameManager.initialize(),
this.#client.send('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
}),
this.#client.send('Performance.enable'), this.#client.send('Performance.enable'),
this.#client.send('Log.enable'), this.#client.send('Log.enable'),
]); ]);

View File

@ -21,12 +21,14 @@ import {Browser, BrowserContext, IsPageTargetCallback} from './Browser.js';
import {Viewport} from './PuppeteerViewport.js'; import {Viewport} from './PuppeteerViewport.js';
import {Protocol} from 'devtools-protocol'; import {Protocol} from 'devtools-protocol';
import {TaskQueue} from './TaskQueue.js'; import {TaskQueue} from './TaskQueue.js';
import {TargetManager} from './TargetManager.js';
/** /**
* @public * @public
*/ */
export class Target { export class Target {
#browserContext: BrowserContext; #browserContext: BrowserContext;
#session?: CDPSession;
#targetInfo: Protocol.Target.TargetInfo; #targetInfo: Protocol.Target.TargetInfo;
#sessionFactory: () => Promise<CDPSession>; #sessionFactory: () => Promise<CDPSession>;
#ignoreHTTPSErrors: boolean; #ignoreHTTPSErrors: boolean;
@ -64,18 +66,24 @@ export class Target {
*/ */
_isPageTargetCallback: IsPageTargetCallback; _isPageTargetCallback: IsPageTargetCallback;
#targetManager: TargetManager;
/** /**
* @internal * @internal
*/ */
constructor( constructor(
targetInfo: Protocol.Target.TargetInfo, targetInfo: Protocol.Target.TargetInfo,
session: CDPSession | undefined,
browserContext: BrowserContext, browserContext: BrowserContext,
targetManager: TargetManager,
sessionFactory: () => Promise<CDPSession>, sessionFactory: () => Promise<CDPSession>,
ignoreHTTPSErrors: boolean, ignoreHTTPSErrors: boolean,
defaultViewport: Viewport | null, defaultViewport: Viewport | null,
screenshotTaskQueue: TaskQueue, screenshotTaskQueue: TaskQueue,
isPageTargetCallback: IsPageTargetCallback isPageTargetCallback: IsPageTargetCallback
) { ) {
this.#session = session;
this.#targetManager = targetManager;
this.#targetInfo = targetInfo; this.#targetInfo = targetInfo;
this.#browserContext = browserContext; this.#browserContext = browserContext;
this._targetId = targetInfo.targetId; 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. * Creates a Chrome Devtools Protocol session attached to the target.
*/ */
@ -120,6 +135,13 @@ export class Target {
return this.#sessionFactory(); return this.#sessionFactory();
} }
/**
* @internal
*/
_targetManager(): TargetManager {
return this.#targetManager;
}
/** /**
* @internal * @internal
*/ */
@ -132,7 +154,9 @@ export class Target {
*/ */
async page(): Promise<Page | null> { async page(): Promise<Page | null> {
if (this._isPageTargetCallback(this.#targetInfo) && !this.#pagePromise) { 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( return Page._create(
client, client,
this, this,
@ -157,7 +181,9 @@ export class Target {
} }
if (!this.#workerPromise) { if (!this.#workerPromise) {
// TODO(einbinder): Make workers send their console logs. // 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( return new WebWorker(
client, client,
this.#targetInfo.url, this.#targetInfo.url,

View File

@ -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<void>;
/**
* 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<string, Target>;
initialize(): Promise<void>;
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',
}

View File

@ -155,6 +155,7 @@ export class ChromeLauncher implements ProductLauncher {
preferredRevision: this._preferredRevision, preferredRevision: this._preferredRevision,
}); });
browser = await Browser._create( browser = await Browser._create(
this.product,
connection, connection,
[], [],
ignoreHTTPSErrors, ignoreHTTPSErrors,

View File

@ -152,6 +152,7 @@ export class FirefoxLauncher implements ProductLauncher {
preferredRevision: this._preferredRevision, preferredRevision: this._preferredRevision,
}); });
browser = await Browser._create( browser = await Browser._create(
this.product,
connection, connection,
[], [],
ignoreHTTPSErrors, ignoreHTTPSErrors,

View File

@ -13,6 +13,7 @@ export * from './common/AriaQueryHandler.js';
export * from './common/Browser.js'; export * from './common/Browser.js';
export * from './common/BrowserConnector.js'; export * from './common/BrowserConnector.js';
export * from './common/BrowserWebSocketTransport.js'; export * from './common/BrowserWebSocketTransport.js';
export * from './common/ChromeTargetManager.js';
export * from './common/Connection.js'; export * from './common/Connection.js';
export * from './common/ConnectionTransport.js'; export * from './common/ConnectionTransport.js';
export * from './common/ConsoleMessage.js'; export * from './common/ConsoleMessage.js';
@ -27,6 +28,7 @@ export * from './common/Errors.js';
export * from './common/EventEmitter.js'; export * from './common/EventEmitter.js';
export * from './common/ExecutionContext.js'; export * from './common/ExecutionContext.js';
export * from './common/FileChooser.js'; export * from './common/FileChooser.js';
export * from './common/FirefoxTargetManager.js';
export * from './common/FrameManager.js'; export * from './common/FrameManager.js';
export * from './common/HTTPRequest.js'; export * from './common/HTTPRequest.js';
export * from './common/HTTPResponse.js'; export * from './common/HTTPResponse.js';
@ -44,6 +46,7 @@ export * from './common/PuppeteerViewport.js';
export * from './common/QueryHandler.js'; export * from './common/QueryHandler.js';
export * from './common/SecurityDetails.js'; export * from './common/SecurityDetails.js';
export * from './common/Target.js'; export * from './common/Target.js';
export * from './common/TargetManager.js';
export * from './common/TaskQueue.js'; export * from './common/TaskQueue.js';
export * from './common/TimeoutSettings.js'; export * from './common/TimeoutSettings.js';
export * from './common/Tracing.js'; export * from './common/Tracing.js';

View File

@ -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);
});
});

View File

@ -63,10 +63,13 @@ describe('Browser specs', function () {
expect(process!.pid).toBeGreaterThan(0); expect(process!.pid).toBeGreaterThan(0);
}); });
it('should not return child_process for remote browser', async () => { it('should not return child_process for remote browser', async () => {
const {browser, puppeteer} = getTestState(); const {browser, puppeteer, isFirefox} = getTestState();
const browserWSEndpoint = browser.wsEndpoint(); 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); expect(remoteBrowser.process()).toBe(null);
remoteBrowser.disconnect(); remoteBrowser.disconnect();
}); });
@ -74,10 +77,13 @@ describe('Browser specs', function () {
describe('Browser.isConnected', () => { describe('Browser.isConnected', () => {
it('should set the browser connected state', async () => { it('should set the browser connected state', async () => {
const {browser, puppeteer} = getTestState(); const {browser, puppeteer, isFirefox} = getTestState();
const browserWSEndpoint = browser.wsEndpoint(); 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); expect(newBrowser.isConnected()).toBe(true);
newBrowser.disconnect(); newBrowser.disconnect();
expect(newBrowser.isConnected()).toBe(false); expect(newBrowser.isConnected()).toBe(false);

View File

@ -68,7 +68,8 @@ describe('Fixtures', function () {
expect(dumpioData).toContain('DevTools listening on ws://'); expect(dumpioData).toContain('DevTools listening on ws://');
}); });
it('should close the browser when the node process closes', async () => { 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 {spawn, execSync} = await import('child_process');
const options = Object.assign({}, defaultBrowserOptions, { const options = Object.assign({}, defaultBrowserOptions, {
@ -93,6 +94,7 @@ describe('Fixtures', function () {
}); });
const browser = await puppeteer.connect({ const browser = await puppeteer.connect({
browserWSEndpoint: await wsEndPointPromise, browserWSEndpoint: await wsEndPointPromise,
product: isFirefox ? 'firefox' : 'chrome',
}); });
const promises = [ const promises = [
new Promise(resolve => { new Promise(resolve => {

View File

@ -139,11 +139,13 @@ describe('Launcher specs', function () {
describe('Browser.disconnect', function () { describe('Browser.disconnect', function () {
it('should reject navigation when browser closes', async () => { it('should reject navigation when browser closes', async () => {
const {server, puppeteer, defaultBrowserOptions} = getTestState(); const {server, puppeteer, defaultBrowserOptions, isFirefox} =
getTestState();
server.setRoute('/one-style.css', () => {}); server.setRoute('/one-style.css', () => {});
const browser = await puppeteer.launch(defaultBrowserOptions); const browser = await puppeteer.launch(defaultBrowserOptions);
const remote = await puppeteer.connect({ const remote = await puppeteer.connect({
browserWSEndpoint: browser.wsEndpoint(), browserWSEndpoint: browser.wsEndpoint(),
product: isFirefox ? 'firefox' : 'chrome',
}); });
const page = await remote.newPage(); const page = await remote.newPage();
const navigationPromise = page const navigationPromise = page
@ -163,12 +165,14 @@ describe('Launcher specs', function () {
await browser.close(); await browser.close();
}); });
it('should reject waitForSelector when browser closes', async () => { it('should reject waitForSelector when browser closes', async () => {
const {server, puppeteer, defaultBrowserOptions} = getTestState(); const {server, puppeteer, defaultBrowserOptions, isFirefox} =
getTestState();
server.setRoute('/empty.html', () => {}); server.setRoute('/empty.html', () => {});
const browser = await puppeteer.launch(defaultBrowserOptions); const browser = await puppeteer.launch(defaultBrowserOptions);
const remote = await puppeteer.connect({ const remote = await puppeteer.connect({
browserWSEndpoint: browser.wsEndpoint(), browserWSEndpoint: browser.wsEndpoint(),
product: isFirefox ? 'firefox' : 'chrome',
}); });
const page = await remote.newPage(); const page = await remote.newPage();
const watchdog = page const watchdog = page
@ -184,11 +188,13 @@ describe('Launcher specs', function () {
}); });
describe('Browser.close', function () { describe('Browser.close', function () {
it('should terminate network waiters', async () => { it('should terminate network waiters', async () => {
const {server, puppeteer, defaultBrowserOptions} = getTestState(); const {server, puppeteer, defaultBrowserOptions, isFirefox} =
getTestState();
const browser = await puppeteer.launch(defaultBrowserOptions); const browser = await puppeteer.launch(defaultBrowserOptions);
const remote = await puppeteer.connect({ const remote = await puppeteer.connect({
browserWSEndpoint: browser.wsEndpoint(), browserWSEndpoint: browser.wsEndpoint(),
product: isFirefox ? 'firefox' : 'chrome',
}); });
const newPage = await remote.newPage(); const newPage = await remote.newPage();
const results = await Promise.all([ const results = await Promise.all([
@ -653,26 +659,24 @@ describe('Launcher specs', function () {
expect(userAgent).toContain('Chrome'); expect(userAgent).toContain('Chrome');
}); });
itOnlyRegularInstall( it('should be able to launch Firefox', async function () {
'should be able to launch Firefox',
async function () {
this.timeout(FIREFOX_TIMEOUT); this.timeout(FIREFOX_TIMEOUT);
const {puppeteer} = getTestState(); const {puppeteer} = getTestState();
const browser = await puppeteer.launch({product: 'firefox'}); const browser = await puppeteer.launch({product: 'firefox'});
const userAgent = await browser.userAgent(); const userAgent = await browser.userAgent();
await browser.close(); await browser.close();
expect(userAgent).toContain('Firefox'); expect(userAgent).toContain('Firefox');
} });
);
}); });
describe('Puppeteer.connect', function () { describe('Puppeteer.connect', function () {
it('should be able to connect multiple times to the same browser', async () => { 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 originalBrowser = await puppeteer.launch(defaultBrowserOptions);
const otherBrowser = await puppeteer.connect({ const otherBrowser = await puppeteer.connect({
browserWSEndpoint: originalBrowser.wsEndpoint(), browserWSEndpoint: originalBrowser.wsEndpoint(),
product: isFirefox ? 'firefox' : 'chrome',
}); });
const page = await otherBrowser.newPage(); const page = await otherBrowser.newPage();
expect( expect(
@ -691,11 +695,12 @@ describe('Launcher specs', function () {
await originalBrowser.close(); await originalBrowser.close();
}); });
it('should be able to close remote browser', async () => { 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 originalBrowser = await puppeteer.launch(defaultBrowserOptions);
const remoteBrowser = await puppeteer.connect({ const remoteBrowser = await puppeteer.connect({
browserWSEndpoint: originalBrowser.wsEndpoint(), browserWSEndpoint: originalBrowser.wsEndpoint(),
product: isFirefox ? 'firefox' : 'chrome',
}); });
await Promise.all([ await Promise.all([
utils.waitEvent(originalBrowser, 'disconnected'), utils.waitEvent(originalBrowser, 'disconnected'),
@ -703,7 +708,8 @@ describe('Launcher specs', function () {
]); ]);
}); });
it('should support ignoreHTTPSErrors option', async () => { it('should support ignoreHTTPSErrors option', async () => {
const {httpsServer, puppeteer, defaultBrowserOptions} = getTestState(); const {httpsServer, puppeteer, defaultBrowserOptions, isFirefox} =
getTestState();
const originalBrowser = await puppeteer.launch(defaultBrowserOptions); const originalBrowser = await puppeteer.launch(defaultBrowserOptions);
const browserWSEndpoint = originalBrowser.wsEndpoint(); const browserWSEndpoint = originalBrowser.wsEndpoint();
@ -711,6 +717,7 @@ describe('Launcher specs', function () {
const browser = await puppeteer.connect({ const browser = await puppeteer.connect({
browserWSEndpoint, browserWSEndpoint,
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
product: isFirefox ? 'firefox' : 'chrome',
}); });
const page = await browser.newPage(); const page = await browser.newPage();
let error!: Error; let error!: Error;
@ -732,7 +739,8 @@ describe('Launcher specs', function () {
}); });
// @see https://github.com/puppeteer/puppeteer/issues/4197 // @see https://github.com/puppeteer/puppeteer/issues/4197
itFailsFirefox('should support targetFilter option', async () => { itFailsFirefox('should support targetFilter option', async () => {
const {server, puppeteer, defaultBrowserOptions} = getTestState(); const {server, puppeteer, defaultBrowserOptions, isFirefox} =
getTestState();
const originalBrowser = await puppeteer.launch(defaultBrowserOptions); const originalBrowser = await puppeteer.launch(defaultBrowserOptions);
const browserWSEndpoint = originalBrowser.wsEndpoint(); const browserWSEndpoint = originalBrowser.wsEndpoint();
@ -745,6 +753,7 @@ describe('Launcher specs', function () {
const browser = await puppeteer.connect({ const browser = await puppeteer.connect({
browserWSEndpoint, browserWSEndpoint,
product: isFirefox ? 'firefox' : 'chrome',
targetFilter: (targetInfo: Protocol.Target.TargetInfo) => { targetFilter: (targetInfo: Protocol.Target.TargetInfo) => {
return !targetInfo.url?.includes('should-be-ignored'); return !targetInfo.url?.includes('should-be-ignored');
}, },
@ -828,14 +837,18 @@ describe('Launcher specs', function () {
} }
); );
it('should be able to reconnect', async () => { 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 browserOne = await puppeteer.launch(defaultBrowserOptions);
const browserWSEndpoint = browserOne.wsEndpoint(); const browserWSEndpoint = browserOne.wsEndpoint();
const pageOne = await browserOne.newPage(); const pageOne = await browserOne.newPage();
await pageOne.goto(server.EMPTY_PAGE); await pageOne.goto(server.EMPTY_PAGE);
browserOne.disconnect(); browserOne.disconnect();
const browserTwo = await puppeteer.connect({browserWSEndpoint}); const browserTwo = await puppeteer.connect({
browserWSEndpoint,
product: isFirefox ? 'firefox' : 'chrome',
});
const pages = await browserTwo.pages(); const pages = await browserTwo.pages();
const pageTwo = pages.find(page => { const pageTwo = pages.find(page => {
return page.url() === server.EMPTY_PAGE; return page.url() === server.EMPTY_PAGE;
@ -975,12 +988,20 @@ describe('Launcher specs', function () {
}); });
describe('Browser.Events.disconnected', function () { describe('Browser.Events.disconnected', function () {
it('should be emitted when: browser gets closed, disconnected or underlying websocket gets closed', async () => { itFailsFirefox(
const {puppeteer, defaultBrowserOptions} = getTestState(); '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 originalBrowser = await puppeteer.launch(defaultBrowserOptions);
const browserWSEndpoint = originalBrowser.wsEndpoint(); const browserWSEndpoint = originalBrowser.wsEndpoint();
const remoteBrowser1 = await puppeteer.connect({browserWSEndpoint}); const remoteBrowser1 = await puppeteer.connect({
const remoteBrowser2 = await puppeteer.connect({browserWSEndpoint}); browserWSEndpoint,
product: isFirefox ? 'firefox' : 'chrome',
});
const remoteBrowser2 = await puppeteer.connect({
browserWSEndpoint,
product: isFirefox ? 'firefox' : 'chrome',
});
let disconnectedOriginal = 0; let disconnectedOriginal = 0;
let disconnectedRemote1 = 0; let disconnectedRemote1 = 0;
@ -1013,6 +1034,7 @@ describe('Launcher specs', function () {
expect(disconnectedOriginal).toBe(1); expect(disconnectedOriginal).toBe(1);
expect(disconnectedRemote1).toBe(1); expect(disconnectedRemote1).toBe(1);
expect(disconnectedRemote2).toBe(1); expect(disconnectedRemote2).toBe(1);
}); }
);
}); });
}); });

View File

@ -268,6 +268,13 @@ if (process.env['MOCHA_WORKER_ID'] === '0') {
-> binary: ${ -> binary: ${
defaultBrowserOptions.executablePath || defaultBrowserOptions.executablePath ||
path.relative(process.cwd(), puppeteer.executablePath()) path.relative(process.cwd(), puppeteer.executablePath())
}
-> mode: ${
isHeadless
? headless === 'chrome'
? '--headless=chrome'
: '--headless'
: 'headful'
}` }`
); );
} }

View File

@ -172,11 +172,13 @@ describe('Target', function () {
}); });
}); });
await page.evaluate(() => { await page.evaluate(() => {
return (globalThis as any).registrationPromise.then( return (
(registration: {unregister: () => any}) => { globalThis as unknown as {
return registration.unregister(); registrationPromise: Promise<{unregister: () => void}>;
} }
); ).registrationPromise.then((registration: any) => {
return registration.unregister();
});
}); });
expect(await destroyedTarget).toBe(await createdTarget); expect(await destroyedTarget).toBe(await createdTarget);
} }