diff options
Diffstat (limited to 'remote/test/puppeteer/src/common/LifecycleWatcher.ts')
-rw-r--r-- | remote/test/puppeteer/src/common/LifecycleWatcher.ts | 309 |
1 files changed, 309 insertions, 0 deletions
diff --git a/remote/test/puppeteer/src/common/LifecycleWatcher.ts b/remote/test/puppeteer/src/common/LifecycleWatcher.ts new file mode 100644 index 0000000000..b68e22dcd4 --- /dev/null +++ b/remote/test/puppeteer/src/common/LifecycleWatcher.ts @@ -0,0 +1,309 @@ +/** + * Copyright 2019 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 {assert} from '../util/assert.js'; +import { + addEventListener, + PuppeteerEventListener, + removeEventListeners, +} from './util.js'; +import { + DeferredPromise, + createDeferredPromise, +} from '../util/DeferredPromise.js'; +import {TimeoutError} from './Errors.js'; +import {FrameManager, FrameManagerEmittedEvents} from './FrameManager.js'; +import {Frame} from './Frame.js'; +import {HTTPRequest} from './HTTPRequest.js'; +import {HTTPResponse} from './HTTPResponse.js'; +import {NetworkManagerEmittedEvents} from './NetworkManager.js'; +import {CDPSessionEmittedEvents} from './Connection.js'; +/** + * @public + */ +export type PuppeteerLifeCycleEvent = + | 'load' + | 'domcontentloaded' + | 'networkidle0' + | 'networkidle2'; + +/** + * @public + */ +export type ProtocolLifeCycleEvent = + | 'load' + | 'DOMContentLoaded' + | 'networkIdle' + | 'networkAlmostIdle'; + +const puppeteerToProtocolLifecycle = new Map< + PuppeteerLifeCycleEvent, + ProtocolLifeCycleEvent +>([ + ['load', 'load'], + ['domcontentloaded', 'DOMContentLoaded'], + ['networkidle0', 'networkIdle'], + ['networkidle2', 'networkAlmostIdle'], +]); + +const noop = (): void => {}; + +/** + * @internal + */ +export class LifecycleWatcher { + #expectedLifecycle: ProtocolLifeCycleEvent[]; + #frameManager: FrameManager; + #frame: Frame; + #timeout: number; + #navigationRequest: HTTPRequest | null = null; + #eventListeners: PuppeteerEventListener[]; + #initialLoaderId: string; + + #sameDocumentNavigationCompleteCallback: (x?: Error) => void = noop; + #sameDocumentNavigationPromise = new Promise<Error | undefined>(fulfill => { + this.#sameDocumentNavigationCompleteCallback = fulfill; + }); + + #lifecycleCallback: () => void = noop; + #lifecyclePromise: Promise<void> = new Promise(fulfill => { + this.#lifecycleCallback = fulfill; + }); + + #newDocumentNavigationCompleteCallback: (x?: Error) => void = noop; + #newDocumentNavigationPromise: Promise<Error | undefined> = new Promise( + fulfill => { + this.#newDocumentNavigationCompleteCallback = fulfill; + } + ); + + #terminationCallback: (x?: Error) => void = noop; + #terminationPromise: Promise<Error | undefined> = new Promise(fulfill => { + this.#terminationCallback = fulfill; + }); + + #timeoutPromise: Promise<TimeoutError | undefined>; + + #maximumTimer?: NodeJS.Timeout; + #hasSameDocumentNavigation?: boolean; + #swapped?: boolean; + + #navigationResponseReceived?: DeferredPromise<void>; + + constructor( + frameManager: FrameManager, + frame: Frame, + waitUntil: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[], + timeout: number + ) { + 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 as ProtocolLifeCycleEvent; + }); + + this.#frameManager = frameManager; + this.#frame = frame; + this.#timeout = timeout; + this.#eventListeners = [ + addEventListener( + frameManager.client, + CDPSessionEmittedEvents.Disconnected, + this.#terminate.bind( + this, + new Error('Navigation failed because browser has disconnected!') + ) + ), + addEventListener( + this.#frameManager, + FrameManagerEmittedEvents.LifecycleEvent, + this.#checkLifecycleComplete.bind(this) + ), + addEventListener( + this.#frameManager, + FrameManagerEmittedEvents.FrameNavigatedWithinDocument, + this.#navigatedWithinDocument.bind(this) + ), + addEventListener( + this.#frameManager, + FrameManagerEmittedEvents.FrameNavigated, + this.#navigated.bind(this) + ), + addEventListener( + this.#frameManager, + FrameManagerEmittedEvents.FrameSwapped, + this.#frameSwapped.bind(this) + ), + addEventListener( + this.#frameManager, + FrameManagerEmittedEvents.FrameDetached, + this.#onFrameDetached.bind(this) + ), + addEventListener( + this.#frameManager.networkManager, + NetworkManagerEmittedEvents.Request, + this.#onRequest.bind(this) + ), + addEventListener( + this.#frameManager.networkManager, + NetworkManagerEmittedEvents.Response, + this.#onResponse.bind(this) + ), + ]; + + this.#timeoutPromise = this.#createTimeoutPromise(); + 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 = createDeferredPromise(); + if (request.response() !== null) { + this.#navigationResponseReceived?.resolve(); + } + } + + #onResponse(response: HTTPResponse): void { + if (this.#navigationRequest?._requestId !== response.request()._requestId) { + return; + } + this.#navigationResponseReceived?.resolve(); + } + + #onFrameDetached(frame: Frame): void { + if (this.#frame === frame) { + this.#terminationCallback.call( + null, + new Error('Navigating frame was detached') + ); + return; + } + this.#checkLifecycleComplete(); + } + + async navigationResponse(): Promise<HTTPResponse | null> { + // Continue with a possibly null response. + await this.#navigationResponseReceived?.catch(() => {}); + return this.#navigationRequest ? this.#navigationRequest.response() : null; + } + + #terminate(error: Error): void { + this.#terminationCallback.call(null, error); + } + + sameDocumentNavigationPromise(): Promise<Error | undefined> { + return this.#sameDocumentNavigationPromise; + } + + newDocumentNavigationPromise(): Promise<Error | undefined> { + return this.#newDocumentNavigationPromise; + } + + lifecyclePromise(): Promise<void> { + return this.#lifecyclePromise; + } + + timeoutOrTerminationPromise(): Promise<Error | TimeoutError | undefined> { + return Promise.race([this.#timeoutPromise, this.#terminationPromise]); + } + + async #createTimeoutPromise(): Promise<TimeoutError | undefined> { + if (!this.#timeout) { + return new Promise(noop); + } + const errorMessage = + 'Navigation timeout of ' + this.#timeout + ' ms exceeded'; + await new Promise(fulfill => { + return (this.#maximumTimer = setTimeout(fulfill, this.#timeout)); + }); + return new TimeoutError(errorMessage); + } + + #navigatedWithinDocument(frame: Frame): void { + if (frame !== this.#frame) { + return; + } + this.#hasSameDocumentNavigation = true; + this.#checkLifecycleComplete(); + } + + #navigated(frame: Frame): void { + if (frame !== this.#frame) { + return; + } + this.#checkLifecycleComplete(); + } + + #frameSwapped(frame: Frame): void { + if (frame !== this.#frame) { + return; + } + this.#swapped = true; + this.#checkLifecycleComplete(); + } + + #checkLifecycleComplete(): void { + // We expect navigation to commit. + if (!checkLifecycle(this.#frame, this.#expectedLifecycle)) { + return; + } + this.#lifecycleCallback(); + if (this.#hasSameDocumentNavigation) { + this.#sameDocumentNavigationCompleteCallback(); + } + if (this.#swapped || this.#frame._loaderId !== this.#initialLoaderId) { + this.#newDocumentNavigationCompleteCallback(); + } + + function checkLifecycle( + frame: Frame, + 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 { + removeEventListeners(this.#eventListeners); + this.#maximumTimer !== undefined && clearTimeout(this.#maximumTimer); + } +} |