LifecycleWatcher.ts (7875B)
1 /** 2 * @license 3 * Copyright 2019 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import type Protocol from 'devtools-protocol'; 8 9 import {type Frame, FrameEvent} from '../api/Frame.js'; 10 import type {HTTPRequest} from '../api/HTTPRequest.js'; 11 import type {HTTPResponse} from '../api/HTTPResponse.js'; 12 import type {TimeoutError} from '../common/Errors.js'; 13 import {EventEmitter} from '../common/EventEmitter.js'; 14 import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; 15 import {assert} from '../util/assert.js'; 16 import {Deferred} from '../util/Deferred.js'; 17 import {DisposableStack} from '../util/disposable.js'; 18 19 import type {CdpFrame} from './Frame.js'; 20 import {FrameManagerEvent} from './FrameManagerEvents.js'; 21 import type {NetworkManager} from './NetworkManager.js'; 22 23 /** 24 * @public 25 */ 26 export type PuppeteerLifeCycleEvent = 27 /** 28 * Waits for the 'load' event. 29 */ 30 | 'load' 31 /** 32 * Waits for the 'DOMContentLoaded' event. 33 */ 34 | 'domcontentloaded' 35 /** 36 * Waits till there are no more than 0 network connections for at least `500` 37 * ms. 38 */ 39 | 'networkidle0' 40 /** 41 * Waits till there are no more than 2 network connections for at least `500` 42 * ms. 43 */ 44 | 'networkidle2'; 45 46 /** 47 * @public 48 */ 49 export type ProtocolLifeCycleEvent = 50 | 'load' 51 | 'DOMContentLoaded' 52 | 'networkIdle' 53 | 'networkAlmostIdle'; 54 55 const puppeteerToProtocolLifecycle = new Map< 56 PuppeteerLifeCycleEvent, 57 ProtocolLifeCycleEvent 58 >([ 59 ['load', 'load'], 60 ['domcontentloaded', 'DOMContentLoaded'], 61 ['networkidle0', 'networkIdle'], 62 ['networkidle2', 'networkAlmostIdle'], 63 ]); 64 65 /** 66 * @internal 67 */ 68 export class LifecycleWatcher { 69 #expectedLifecycle: ProtocolLifeCycleEvent[]; 70 #frame: CdpFrame; 71 #timeout: number; 72 #navigationRequest: HTTPRequest | null = null; 73 #subscriptions = new DisposableStack(); 74 #initialLoaderId: string; 75 76 #terminationDeferred: Deferred<Error>; 77 #sameDocumentNavigationDeferred = Deferred.create<undefined>(); 78 #lifecycleDeferred = Deferred.create<void>(); 79 #newDocumentNavigationDeferred = Deferred.create<undefined>(); 80 81 #hasSameDocumentNavigation?: boolean; 82 #swapped?: boolean; 83 84 #navigationResponseReceived?: Deferred<void>; 85 86 constructor( 87 networkManager: NetworkManager, 88 frame: CdpFrame, 89 waitUntil: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[], 90 timeout: number, 91 signal?: AbortSignal, 92 ) { 93 if (Array.isArray(waitUntil)) { 94 waitUntil = waitUntil.slice(); 95 } else if (typeof waitUntil === 'string') { 96 waitUntil = [waitUntil]; 97 } 98 this.#initialLoaderId = frame._loaderId; 99 this.#expectedLifecycle = waitUntil.map(value => { 100 const protocolEvent = puppeteerToProtocolLifecycle.get(value); 101 assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value); 102 return protocolEvent as ProtocolLifeCycleEvent; 103 }); 104 105 signal?.addEventListener('abort', () => { 106 this.#terminationDeferred.reject(signal.reason); 107 }); 108 109 this.#frame = frame; 110 this.#timeout = timeout; 111 const frameManagerEmitter = this.#subscriptions.use( 112 new EventEmitter(frame._frameManager), 113 ); 114 frameManagerEmitter.on( 115 FrameManagerEvent.LifecycleEvent, 116 this.#checkLifecycleComplete.bind(this), 117 ); 118 119 const frameEmitter = this.#subscriptions.use(new EventEmitter(frame)); 120 frameEmitter.on( 121 FrameEvent.FrameNavigatedWithinDocument, 122 this.#navigatedWithinDocument.bind(this), 123 ); 124 frameEmitter.on(FrameEvent.FrameNavigated, this.#navigated.bind(this)); 125 frameEmitter.on(FrameEvent.FrameSwapped, this.#frameSwapped.bind(this)); 126 frameEmitter.on( 127 FrameEvent.FrameSwappedByActivation, 128 this.#frameSwapped.bind(this), 129 ); 130 frameEmitter.on(FrameEvent.FrameDetached, this.#onFrameDetached.bind(this)); 131 132 const networkManagerEmitter = this.#subscriptions.use( 133 new EventEmitter(networkManager), 134 ); 135 networkManagerEmitter.on( 136 NetworkManagerEvent.Request, 137 this.#onRequest.bind(this), 138 ); 139 networkManagerEmitter.on( 140 NetworkManagerEvent.Response, 141 this.#onResponse.bind(this), 142 ); 143 networkManagerEmitter.on( 144 NetworkManagerEvent.RequestFailed, 145 this.#onRequestFailed.bind(this), 146 ); 147 148 this.#terminationDeferred = Deferred.create<Error>({ 149 timeout: this.#timeout, 150 message: `Navigation timeout of ${this.#timeout} ms exceeded`, 151 }); 152 153 this.#checkLifecycleComplete(); 154 } 155 156 #onRequest(request: HTTPRequest): void { 157 if (request.frame() !== this.#frame || !request.isNavigationRequest()) { 158 return; 159 } 160 this.#navigationRequest = request; 161 // Resolve previous navigation response in case there are multiple 162 // navigation requests reported by the backend. This generally should not 163 // happen by it looks like it's possible. 164 this.#navigationResponseReceived?.resolve(); 165 this.#navigationResponseReceived = Deferred.create(); 166 if (request.response() !== null) { 167 this.#navigationResponseReceived?.resolve(); 168 } 169 } 170 171 #onRequestFailed(request: HTTPRequest): void { 172 if (this.#navigationRequest?.id !== request.id) { 173 return; 174 } 175 this.#navigationResponseReceived?.resolve(); 176 } 177 178 #onResponse(response: HTTPResponse): void { 179 if (this.#navigationRequest?.id !== response.request().id) { 180 return; 181 } 182 this.#navigationResponseReceived?.resolve(); 183 } 184 185 #onFrameDetached(frame: Frame): void { 186 if (this.#frame === frame) { 187 this.#terminationDeferred.resolve( 188 new Error('Navigating frame was detached'), 189 ); 190 return; 191 } 192 this.#checkLifecycleComplete(); 193 } 194 195 async navigationResponse(): Promise<HTTPResponse | null> { 196 // Continue with a possibly null response. 197 await this.#navigationResponseReceived?.valueOrThrow(); 198 return this.#navigationRequest ? this.#navigationRequest.response() : null; 199 } 200 201 sameDocumentNavigationPromise(): Promise<Error | undefined> { 202 return this.#sameDocumentNavigationDeferred.valueOrThrow(); 203 } 204 205 newDocumentNavigationPromise(): Promise<Error | undefined> { 206 return this.#newDocumentNavigationDeferred.valueOrThrow(); 207 } 208 209 lifecyclePromise(): Promise<void> { 210 return this.#lifecycleDeferred.valueOrThrow(); 211 } 212 213 terminationPromise(): Promise<Error | TimeoutError | undefined> { 214 return this.#terminationDeferred.valueOrThrow(); 215 } 216 217 #navigatedWithinDocument(): void { 218 this.#hasSameDocumentNavigation = true; 219 this.#checkLifecycleComplete(); 220 } 221 222 #navigated(navigationType: Protocol.Page.NavigationType): void { 223 if (navigationType === 'BackForwardCacheRestore') { 224 return this.#frameSwapped(); 225 } 226 this.#checkLifecycleComplete(); 227 } 228 229 #frameSwapped(): void { 230 this.#swapped = true; 231 this.#checkLifecycleComplete(); 232 } 233 234 #checkLifecycleComplete(): void { 235 // We expect navigation to commit. 236 if (!checkLifecycle(this.#frame, this.#expectedLifecycle)) { 237 return; 238 } 239 this.#lifecycleDeferred.resolve(); 240 if (this.#hasSameDocumentNavigation) { 241 this.#sameDocumentNavigationDeferred.resolve(undefined); 242 } 243 if (this.#swapped || this.#frame._loaderId !== this.#initialLoaderId) { 244 this.#newDocumentNavigationDeferred.resolve(undefined); 245 } 246 247 function checkLifecycle( 248 frame: CdpFrame, 249 expectedLifecycle: ProtocolLifeCycleEvent[], 250 ): boolean { 251 for (const event of expectedLifecycle) { 252 if (!frame._lifecycleEvents.has(event)) { 253 return false; 254 } 255 } 256 for (const child of frame.childFrames()) { 257 if ( 258 child._hasStartedLoading && 259 !checkLifecycle(child, expectedLifecycle) 260 ) { 261 return false; 262 } 263 } 264 return true; 265 } 266 } 267 268 dispose(): void { 269 this.#subscriptions.dispose(); 270 this.#terminationDeferred.resolve(new Error('LifecycleWatcher disposed')); 271 } 272 }