lib/LifecycleWatcher.js
const { helper, assert } = require('./helper')
const Events = require('./Events')
const { TimeoutError } = require('./Errors')
/**
* An utility class that watches the supplied frame and its children (if any)
* to determine if they reach the specified lifecycle(s)
*
* Lifecycle mapping in the form of supplied to CDP value:
* - load: load
* - domcontentloaded: DOMContentLoaded
* - networkIdle: networkIdle
* - networkAlmostIdle: networkAlmostIdle
* - networkidle0: networkIdle
* - networkidle2: networkAlmostIdle
*/
class LifecycleWatcher {
/**
* @param {!FrameManager} frameManager - The frame manager for the page containing the frame being navigated
* @param {!Frame} frame - The frame being navigated
* @param {string|Array<string>} waitUntil - The lifecycle(s) desired to be obtained by the frame and its children
* @param {number} [timeout] - An optional timeout value
*/
constructor (frameManager, frame, waitUntil, timeout) {
let waitUntilArray
if (Array.isArray(waitUntil)) {
waitUntilArray = waitUntil.slice()
} else if (typeof waitUntil === 'string') {
waitUntilArray = [waitUntil]
}
/**
* @type {Array<string>}
* @private
*/
this._expectedLifecycle = waitUntilArray.map(value => {
const protocolEvent = protocolLifecycle[value]
assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value)
return protocolEvent
})
/**
* @type {!FrameManager}
* @private
*/
this._frameManager = frameManager
/**
* @type {?NetworkManager}
* @private
*/
this._networkManager = frameManager._networkManager
/**
* @type {!Frame}
* @private
*/
this._frame = frame
/**
* @type {string}
* @private
*/
this._initialLoaderId = frame._loaderId
/**
* @type {number}
* @private
*/
this._timeout = timeout
/** @type {?Request} */
this._navigationRequest = null
/**
* @type {{emitter: !EventEmitter, eventName: (string|symbol), handler: (function(*))}[]}
* @private
*/
this._eventListeners = [
helper.addEventListener(
frameManager._client,
frameManager._client.$$disconnectEvent || Events.CRIClient.Disconnected,
this._onConnectionDisconnected.bind(this)
),
helper.addEventListener(
this._frameManager,
Events.FrameManager.LifecycleEvent,
this._checkLifecycleComplete.bind(this)
),
helper.addEventListener(
this._frameManager,
Events.FrameManager.FrameNavigatedWithinDocument,
this._navigatedWithinDocument.bind(this)
),
helper.addEventListener(
this._frameManager,
Events.FrameManager.FrameDetached,
this._onFrameDetached.bind(this)
)
]
if (this._networkManager) {
this._eventListeners.push(
helper.addEventListener(
this._networkManager,
Events.NetworkManager.Request,
this._onRequest.bind(this)
)
)
}
/**
* A Promise that resolves if the frame navigated within the same document (History.pushState etc)
* @type {Promise<*>}
*/
this._sameDocumentNavigationPromise = new Promise(resolve => {
this._sameDocumentNavigationCompleteCallback = resolve
})
/**
* A Promise that resolves if the frame being navigated reached the expected lifecycle
* @type {Promise<*>}
*/
this._lifecyclePromise = new Promise(resolve => {
this._lifecycleCallback = resolve
})
/**
* A Promise that resolves if the frame being navigated navigated to a new page
* @type {Promise<*>}
*/
this._newDocumentNavigationPromise = new Promise(resolve => {
this._newDocumentNavigationCompleteCallback = resolve
})
/**
* A Promise that resolves if the frame being navigated did not navigate within the
* supplied timeout if any
* @type {Promise<*>}
*/
this._timeoutPromise = this._createTimeoutPromise()
/**
* A Promise that resolves if the the watcher is terminated
* @type {Promise<*>}
*/
this._terminationPromise = new Promise(resolve => {
this._terminationCallback = resolve
})
this._checkLifecycleComplete()
}
_onConnectionDisconnected () {
this._terminate(
new Error('Navigation failed because browser has disconnected!')
)
}
/**
* @param {!Request} request
*/
_onRequest (request) {
if (request.frame() !== this._frame || !request.isNavigationRequest()) {
return
}
this._navigationRequest = request
}
/**
* @param {!Frame} frame
*/
_onFrameDetached (frame) {
if (this._frame === frame) {
this._terminationCallback.call(
null,
new Error('Navigating frame was detached')
)
return
}
this._checkLifecycleComplete()
}
/**
* @return {?Response}
*/
navigationResponse () {
return this._navigationRequest ? this._navigationRequest.response() : null
}
/**
* @param {!Error} error
*/
_terminate (error) {
this._terminationCallback.call(null, error)
}
/**
* @return {Promise<Error|undefined>}
*/
sameDocumentNavigationPromise () {
return this._sameDocumentNavigationPromise
}
/**
* @return {Promise<Error|undefined>}
*/
newDocumentNavigationPromise () {
return this._newDocumentNavigationPromise
}
/**
* @return {Promise<*>}
*/
lifecyclePromise () {
return this._lifecyclePromise
}
/**
* @return {Promise<Error|undefined>}
*/
timeoutOrTerminationPromise () {
return Promise.race([this._timeoutPromise, this._terminationPromise])
}
/**
* @return {Promise<Error|undefined>}
*/
_createTimeoutPromise () {
if (!this._timeout) return new Promise(() => {})
const errorMessage =
'Navigation Timeout Exceeded: ' + this._timeout + 'ms exceeded'
return new Promise(resolve => {
this._maximumTimer = setTimeout(resolve, this._timeout)
}).then(() => new TimeoutError(errorMessage))
}
/**
* @param {!Frame} frame
*/
_navigatedWithinDocument (frame) {
if (frame !== this._frame) return
this._hasSameDocumentNavigation = true
this._checkLifecycleComplete()
}
/**
* Checks the frame being navigated and all its child frames for the expected lifecycle(s)
* @private
*/
_checkLifecycleComplete () {
// We expect navigation to commit.
if (!checkLifecycle(this._frame, this._expectedLifecycle)) return
this._lifecycleCallback()
if (
this._frame._loaderId === this._initialLoaderId &&
!this._hasSameDocumentNavigation
) {
return
}
if (this._hasSameDocumentNavigation) {
this._sameDocumentNavigationCompleteCallback()
}
if (this._frame._loaderId !== this._initialLoaderId) {
this._newDocumentNavigationCompleteCallback()
}
}
/**
* Dispose of the LifecycleWatcher (i.e. clean up)
*/
dispose () {
helper.removeEventListeners(this._eventListeners)
clearTimeout(this._maximumTimer)
}
}
/**
* @param {!Frame} frame
* @param {Array<string>} expectedLifecycle
* @return {boolean}
*/
function checkLifecycle (frame, expectedLifecycle) {
let i = 0
for (; i < expectedLifecycle.length; i++) {
if (!frame._lifecycleEvents.has(expectedLifecycle[i])) return false
}
const childFrames = frame.childFrames()
for (i = 0; i < childFrames.length; i++) {
if (!checkLifecycle(childFrames[i], expectedLifecycle)) return false
}
return true
}
const protocolLifecycle = {
load: 'load',
domcontentloaded: 'DOMContentLoaded',
networkIdle: 'networkIdle',
networkAlmostIdle: 'networkAlmostIdle',
networkidle0: 'networkIdle',
networkidle2: 'networkAlmostIdle'
}
/**
* @type {LifecycleWatcher}
*/
module.exports = LifecycleWatcher