c11297baa5
#8768 fixes flakiness in handling navigations but it didn't account for the fact that subsequent navigation requests could be aborted via the request interception. In that case, the navigationResponseReceived promise would never be resolved. This PR adds a listener for the failed network requests and resolves the promise if the network request has failed. Adding test coverage for this seems tricky, as the reproduction depends on timing of the second navigation request. Closes #9175
322 lines
9.1 KiB
TypeScript
322 lines
9.1 KiB
TypeScript
/**
|
|
* 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)
|
|
),
|
|
addEventListener(
|
|
this.#frameManager.networkManager,
|
|
NetworkManagerEmittedEvents.RequestFailed,
|
|
this.#onRequestFailed.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();
|
|
}
|
|
}
|
|
|
|
#onRequestFailed(request: HTTPRequest): void {
|
|
if (this.#navigationRequest?._requestId !== request._requestId) {
|
|
return;
|
|
}
|
|
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);
|
|
}
|
|
}
|