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"
},
"scripts": {
"test": "c8 --check-coverage --lines 94 run-s test:chrome test:chrome:* test:firefox",
"test": "c8 --check-coverage --lines 93 run-s test:chrome test:chrome:* test:firefox",
"test:types": "tsd",
"test:install": "scripts/test-install.sh",
"test:firefox": "cross-env PUPPETEER_PRODUCT=firefox MOZ_WEBRENDER=0 mocha",

View File

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

View File

@ -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<Browser> {
const {
@ -86,6 +87,7 @@ export async function _connectToBrowser(
slowMo = 0,
targetFilter,
_isPageTarget: isPageTarget,
product,
} = options;
assert(
@ -113,7 +115,8 @@ export async function _connectToBrowser(
const {browserContextIds} = await connection.send(
'Target.getBrowserContexts'
);
return Browser._create(
const browser = await Browser._create(
product || 'chrome',
connection,
browserContextIds,
ignoreHTTPSErrors,
@ -125,6 +128,8 @@ export async function _connectToBrowser(
targetFilter,
isPageTarget
);
await browser.pages();
return browser;
}
async function getWSEndpoint(browserURL: string): Promise<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();
#closed = false;
#callbacks: Map<number, ConnectionCallback> = new Map();
#manuallyAttached = new Set<string>();
constructor(url: string, transport: ConnectionTransport, delay = 0) {
super();
@ -220,6 +221,13 @@ export class Connection extends EventEmitter {
this.#transport.close();
}
/**
* @internal
*/
isAutoAttached(targetId: string): boolean {
return !this.#manuallyAttached.has(targetId);
}
/**
* @param targetInfo - The target info
* @returns The CDP session that is created
@ -227,6 +235,7 @@ export class Connection extends EventEmitter {
async createSession(
targetInfo: Protocol.Target.TargetInfo
): Promise<CDPSession> {
this.#manuallyAttached.add(targetInfo.targetId);
const {sessionId} = await this.send('Target.attachToTarget', {
targetId: targetInfo.targetId,
flatten: true,

View File

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

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 {assert} from './assert.js';
import {CDPSession, Connection} from './Connection.js';
import {CDPSession} from './Connection.js';
import {DOMWorld, WaitForSelectorOptions} from './DOMWorld.js';
import {ElementHandle} from './ElementHandle.js';
import {EventEmitter} from './EventEmitter.js';
@ -26,6 +26,7 @@ import {MouseButton} from './Input.js';
import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js';
import {NetworkManager} from './NetworkManager.js';
import {Page} from './Page.js';
import {Target} from './Target.js';
import {TimeoutSettings} from './TimeoutSettings.js';
import {EvaluateFunc, HandleFor, NodeFor} from './types.js';
import {debugError, isErrorLike} from './util.js';
@ -129,12 +130,6 @@ export class FrameManager extends EventEmitter {
session.on('Page.lifecycleEvent', event => {
this.#onLifecycleEvent(event);
});
session.on('Target.attachedToTarget', async event => {
this.#onAttachedToTarget(event);
});
session.on('Target.detachedFromTarget', async event => {
this.#onDetachedFromTarget(event);
});
}
async initialize(client: CDPSession = this.#client): Promise<void> {
@ -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<void> {
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<void> {
const frame = this.#frames.get(target._targetId);
if (frame && frame.isOOPFrame()) {
// When an OOP iframe is removed from the page, it
// will only get a Target.detachedFromTarget event.
@ -374,7 +355,7 @@ export class FrameManager extends EventEmitter {
}
assert(parentFrameId);
const parentFrame = this.#frames.get(parentFrameId);
assert(parentFrame);
assert(parentFrame, `Parent frame ${parentFrameId} not found`);
const frame = new Frame(this, parentFrame, frameId, session);
this.#frames.set(frame._id, frame);
this.emit(FrameManagerEmittedEvents.FrameAttached, frame);

View File

@ -19,7 +19,7 @@ import type {Readable} from 'stream';
import {Accessibility} from './Accessibility.js';
import {assert} from './assert.js';
import {Browser, BrowserContext} from './Browser.js';
import {CDPSession, CDPSessionEmittedEvents, Connection} from './Connection.js';
import {CDPSession, CDPSessionEmittedEvents} from './Connection.js';
import {ConsoleMessage, ConsoleMessageType} from './ConsoleMessage.js';
import {Coverage} from './Coverage.js';
import {Dialog} from './Dialog.js';
@ -46,6 +46,7 @@ import {
import {LowerCasePaperFormat, PDFOptions, _paperFormats} from './PDFOptions.js';
import {Viewport} from './PuppeteerViewport.js';
import {Target} from './Target.js';
import {TargetManagerEmittedEvents} from './TargetManager.js';
import {TaskQueue} from './TaskQueue.js';
import {TimeoutSettings} from './TimeoutSettings.js';
import {Tracing} from './Tracing.js';
@ -508,49 +509,13 @@ export class Page extends EventEmitter {
this.#screenshotTaskQueue = screenshotTaskQueue;
this.#viewport = null;
client.on(
'Target.attachedToTarget',
(event: Protocol.Target.AttachedToTargetEvent) => {
switch (event.targetInfo.type) {
case 'worker':
const connection = Connection.fromSession(client);
assert(connection);
const session = connection.session(event.sessionId);
assert(session);
const worker = new WebWorker(
session,
event.targetInfo.url,
this.#addConsoleMessage.bind(this),
this.#handleException.bind(this)
);
this.#workers.set(event.sessionId, worker);
this.emit(PageEmittedEvents.WorkerCreated, worker);
break;
case 'iframe':
break;
default:
// If we don't detach from service workers, they will never die. We
// still want to attach to workers for emitting events. We still
// want to attach to iframes so sessions may interact with them. We
// detach from all other types out of an abundance of caution. See
// https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc?ss=chromium&q=f:devtools%20-f:out%20%22::kTypePage%5B%5D%22
// for the complete list of available types.
client
.send('Target.detachFromTarget', {
sessionId: event.sessionId,
})
.catch(debugError);
}
}
);
client.on('Target.detachedFromTarget', event => {
const worker = this.#workers.get(event.sessionId);
if (!worker) {
return;
}
this.#workers.delete(event.sessionId);
this.emit(PageEmittedEvents.WorkerDestroyed, worker);
});
this.#target
._targetManager()
.addTargetInterceptor(this.#client, this.#onAttachedToTarget);
this.#target
._targetManager()
.on(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget);
this.#frameManager.on(FrameManagerEmittedEvents.FrameAttached, event => {
return this.emit(PageEmittedEvents.FrameAttached, event);
@ -614,19 +579,58 @@ export class Page extends EventEmitter {
return this.#onFileChooser(event);
});
this.#target._isClosedPromise.then(() => {
this.#target
._targetManager()
.removeTargetInterceptor(this.#client, this.#onAttachedToTarget);
this.#target
._targetManager()
.off(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget);
this.emit(PageEmittedEvents.Close);
this.#closed = true;
});
}
#onDetachedFromTarget = (target: Target) => {
const sessionId = target._session()?.id();
this.#frameManager.onDetachedFromTarget(target);
const worker = this.#workers.get(sessionId!);
if (!worker) {
return;
}
this.#workers.delete(sessionId!);
this.emit(PageEmittedEvents.WorkerDestroyed, worker);
};
#onAttachedToTarget = async (createdTarget: Target) => {
await this.#frameManager.onAttachedToTarget(createdTarget);
if (createdTarget._getTargetInfo().type === 'worker') {
const session = createdTarget._session();
assert(session);
const worker = new WebWorker(
session,
createdTarget.url(),
this.#addConsoleMessage.bind(this),
this.#handleException.bind(this)
);
this.#workers.set(session.id(), worker);
this.emit(PageEmittedEvents.WorkerCreated, worker);
}
if (createdTarget._session()) {
this.#target
._targetManager()
.addTargetInterceptor(
createdTarget._session()!,
this.#onAttachedToTarget
);
}
};
async #initialize(): Promise<void> {
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'),
]);

View File

@ -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<CDPSession>;
#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<CDPSession>,
ignoreHTTPSErrors: boolean,
defaultViewport: Viewport | null,
screenshotTaskQueue: TaskQueue,
isPageTargetCallback: IsPageTargetCallback
) {
this.#session = session;
this.#targetManager = targetManager;
this.#targetInfo = targetInfo;
this.#browserContext = browserContext;
this._targetId = targetInfo.targetId;
@ -113,6 +121,13 @@ export class Target {
}
}
/**
* @internal
*/
_session(): CDPSession | undefined {
return this.#session;
}
/**
* Creates a Chrome Devtools Protocol session attached to the target.
*/
@ -120,6 +135,13 @@ export class Target {
return this.#sessionFactory();
}
/**
* @internal
*/
_targetManager(): TargetManager {
return this.#targetManager;
}
/**
* @internal
*/
@ -132,7 +154,9 @@ export class Target {
*/
async page(): Promise<Page | null> {
if (this._isPageTargetCallback(this.#targetInfo) && !this.#pagePromise) {
this.#pagePromise = this.#sessionFactory().then(client => {
this.#pagePromise = (
this.#session ? Promise.resolve(this.#session) : this.#sessionFactory()
).then(client => {
return Page._create(
client,
this,
@ -157,7 +181,9 @@ export class Target {
}
if (!this.#workerPromise) {
// TODO(einbinder): Make workers send their console logs.
this.#workerPromise = this.#sessionFactory().then(client => {
this.#workerPromise = (
this.#session ? Promise.resolve(this.#session) : this.#sessionFactory()
).then(client => {
return new WebWorker(
client,
this.#targetInfo.url,

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,
});
browser = await Browser._create(
this.product,
connection,
[],
ignoreHTTPSErrors,

View File

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

View File

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

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

View File

@ -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 => {

View File

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

View File

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

View File

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