| /** |
| * @license |
| * Copyright 2019 Google Inc. |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| |
| import type Protocol from 'devtools-protocol'; |
| |
| import {type Frame, FrameEvent} from '../api/Frame.js'; |
| import type {HTTPRequest} from '../api/HTTPRequest.js'; |
| import type {HTTPResponse} from '../api/HTTPResponse.js'; |
| import type {TimeoutError} from '../common/Errors.js'; |
| import {EventEmitter} from '../common/EventEmitter.js'; |
| import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; |
| import {assert} from '../util/assert.js'; |
| import {Deferred} from '../util/Deferred.js'; |
| import {DisposableStack} from '../util/disposable.js'; |
| |
| import type {CdpFrame} from './Frame.js'; |
| import {FrameManagerEvent} from './FrameManagerEvents.js'; |
| import type {NetworkManager} from './NetworkManager.js'; |
| |
| /** |
| * @public |
| */ |
| export type PuppeteerLifeCycleEvent = |
| /** |
| * Waits for the 'load' event. |
| */ |
| | 'load' |
| /** |
| * Waits for the 'DOMContentLoaded' event. |
| */ |
| | 'domcontentloaded' |
| /** |
| * Waits till there are no more than 0 network connections for at least `500` |
| * ms. |
| */ |
| | 'networkidle0' |
| /** |
| * Waits till there are no more than 2 network connections for at least `500` |
| * ms. |
| */ |
| | 'networkidle2'; |
| |
| /** |
| * @public |
| */ |
| export type ProtocolLifeCycleEvent = |
| | 'load' |
| | 'DOMContentLoaded' |
| | 'networkIdle' |
| | 'networkAlmostIdle'; |
| |
| const puppeteerToProtocolLifecycle = new Map< |
| PuppeteerLifeCycleEvent, |
| ProtocolLifeCycleEvent |
| >([ |
| ['load', 'load'], |
| ['domcontentloaded', 'DOMContentLoaded'], |
| ['networkidle0', 'networkIdle'], |
| ['networkidle2', 'networkAlmostIdle'], |
| ]); |
| |
| /** |
| * @internal |
| */ |
| export class LifecycleWatcher { |
| #expectedLifecycle: ProtocolLifeCycleEvent[]; |
| #frame: CdpFrame; |
| #timeout: number; |
| #navigationRequest: HTTPRequest | null = null; |
| #subscriptions = new DisposableStack(); |
| #initialLoaderId: string; |
| |
| #terminationDeferred: Deferred<Error>; |
| #sameDocumentNavigationDeferred = Deferred.create<undefined>(); |
| #lifecycleDeferred = Deferred.create<void>(); |
| #newDocumentNavigationDeferred = Deferred.create<undefined>(); |
| #error = new Error('LifecycleWatcher terminated'); |
| |
| #hasSameDocumentNavigation?: boolean; |
| #swapped?: boolean; |
| |
| #navigationResponseReceived?: Deferred<void>; |
| |
| constructor( |
| networkManager: NetworkManager, |
| frame: CdpFrame, |
| waitUntil: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[], |
| timeout: number, |
| signal?: AbortSignal, |
| ) { |
| if (Array.isArray(waitUntil)) { |
| waitUntil = waitUntil.slice(); |
| } else if (typeof waitUntil === 'string') { |
| waitUntil = [waitUntil]; |
| } |
| this.#initialLoaderId = frame._loaderId; |
| this.#expectedLifecycle = waitUntil.map(value => { |
| const protocolEvent = puppeteerToProtocolLifecycle.get(value); |
| assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value); |
| return protocolEvent; |
| }); |
| |
| signal?.addEventListener('abort', () => { |
| if (signal.reason instanceof Error) { |
| signal.reason.cause = this.#error; |
| } |
| this.#terminationDeferred.reject(signal.reason); |
| }); |
| |
| this.#frame = frame; |
| this.#timeout = timeout; |
| const frameManagerEmitter = this.#subscriptions.use( |
| new EventEmitter(frame._frameManager), |
| ); |
| frameManagerEmitter.on( |
| FrameManagerEvent.LifecycleEvent, |
| this.#checkLifecycleComplete.bind(this), |
| ); |
| |
| const frameEmitter = this.#subscriptions.use(new EventEmitter(frame)); |
| frameEmitter.on( |
| FrameEvent.FrameNavigatedWithinDocument, |
| this.#navigatedWithinDocument.bind(this), |
| ); |
| frameEmitter.on(FrameEvent.FrameNavigated, this.#navigated.bind(this)); |
| frameEmitter.on(FrameEvent.FrameSwapped, this.#frameSwapped.bind(this)); |
| frameEmitter.on( |
| FrameEvent.FrameSwappedByActivation, |
| this.#frameSwapped.bind(this), |
| ); |
| frameEmitter.on(FrameEvent.FrameDetached, this.#onFrameDetached.bind(this)); |
| |
| const networkManagerEmitter = this.#subscriptions.use( |
| new EventEmitter(networkManager), |
| ); |
| networkManagerEmitter.on( |
| NetworkManagerEvent.Request, |
| this.#onRequest.bind(this), |
| ); |
| networkManagerEmitter.on( |
| NetworkManagerEvent.Response, |
| this.#onResponse.bind(this), |
| ); |
| networkManagerEmitter.on( |
| NetworkManagerEvent.RequestFailed, |
| this.#onRequestFailed.bind(this), |
| ); |
| |
| this.#terminationDeferred = Deferred.create<Error>({ |
| timeout: this.#timeout, |
| message: `Navigation timeout of ${this.#timeout} ms exceeded`, |
| }); |
| |
| this.#checkLifecycleComplete(); |
| } |
| |
| #onRequest(request: HTTPRequest): void { |
| if (request.frame() !== this.#frame || !request.isNavigationRequest()) { |
| return; |
| } |
| this.#navigationRequest = request; |
| // Resolve previous navigation response in case there are multiple |
| // navigation requests reported by the backend. This generally should not |
| // happen by it looks like it's possible. |
| this.#navigationResponseReceived?.resolve(); |
| this.#navigationResponseReceived = Deferred.create(); |
| if (request.response() !== null) { |
| this.#navigationResponseReceived?.resolve(); |
| } |
| } |
| |
| #onRequestFailed(request: HTTPRequest): void { |
| if (this.#navigationRequest?.id !== request.id) { |
| return; |
| } |
| this.#navigationResponseReceived?.resolve(); |
| } |
| |
| #onResponse(response: HTTPResponse): void { |
| if (this.#navigationRequest?.id !== response.request().id) { |
| return; |
| } |
| this.#navigationResponseReceived?.resolve(); |
| } |
| |
| #onFrameDetached(frame: Frame): void { |
| if (this.#frame === frame) { |
| this.#error.message = 'Navigating frame was detached'; |
| this.#terminationDeferred.resolve(this.#error); |
| return; |
| } |
| this.#checkLifecycleComplete(); |
| } |
| |
| async navigationResponse(): Promise<HTTPResponse | null> { |
| // Continue with a possibly null response. |
| await this.#navigationResponseReceived?.valueOrThrow(); |
| return this.#navigationRequest ? this.#navigationRequest.response() : null; |
| } |
| |
| sameDocumentNavigationPromise(): Promise<Error | undefined> { |
| return this.#sameDocumentNavigationDeferred.valueOrThrow(); |
| } |
| |
| newDocumentNavigationPromise(): Promise<Error | undefined> { |
| return this.#newDocumentNavigationDeferred.valueOrThrow(); |
| } |
| |
| lifecyclePromise(): Promise<void> { |
| return this.#lifecycleDeferred.valueOrThrow(); |
| } |
| |
| terminationPromise(): Promise<Error | TimeoutError | undefined> { |
| return this.#terminationDeferred.valueOrThrow(); |
| } |
| |
| #navigatedWithinDocument(): void { |
| this.#hasSameDocumentNavigation = true; |
| this.#checkLifecycleComplete(); |
| } |
| |
| #navigated(navigationType: Protocol.Page.NavigationType): void { |
| if (navigationType === 'BackForwardCacheRestore') { |
| return this.#frameSwapped(); |
| } |
| this.#checkLifecycleComplete(); |
| } |
| |
| #frameSwapped(): void { |
| this.#swapped = true; |
| this.#checkLifecycleComplete(); |
| } |
| |
| #checkLifecycleComplete(): void { |
| // We expect navigation to commit. |
| if (!checkLifecycle(this.#frame, this.#expectedLifecycle)) { |
| return; |
| } |
| this.#lifecycleDeferred.resolve(); |
| if (this.#hasSameDocumentNavigation) { |
| this.#sameDocumentNavigationDeferred.resolve(undefined); |
| } |
| if (this.#swapped || this.#frame._loaderId !== this.#initialLoaderId) { |
| this.#newDocumentNavigationDeferred.resolve(undefined); |
| } |
| |
| function checkLifecycle( |
| frame: CdpFrame, |
| expectedLifecycle: ProtocolLifeCycleEvent[], |
| ): boolean { |
| for (const event of expectedLifecycle) { |
| if (!frame._lifecycleEvents.has(event)) { |
| return false; |
| } |
| } |
| for (const child of frame.childFrames()) { |
| if ( |
| child._hasStartedLoading && |
| !checkLifecycle(child, expectedLifecycle) |
| ) { |
| return false; |
| } |
| } |
| return true; |
| } |
| } |
| |
| dispose(): void { |
| this.#subscriptions.dispose(); |
| this.#error.cause = new Error('LifecycleWatcher disposed'); |
| this.#terminationDeferred.resolve(this.#error); |
| } |
| } |