fix(Navigation): correctly wait for navigation in Page.goto (#1355)
This patch: - starts persisting lifecycle state for every frame - migrates NavigationWatcher to rely on these lifecycle events - refactors Page.goto to properly return navigation errors Fixes #1218.
This commit is contained in:
parent
9b907b9ada
commit
44d1e834a4
@ -41,10 +41,22 @@ class FrameManager extends EventEmitter {
|
|||||||
this._client.on('Page.frameNavigated', event => this._onFrameNavigated(event.frame));
|
this._client.on('Page.frameNavigated', event => this._onFrameNavigated(event.frame));
|
||||||
this._client.on('Page.frameDetached', event => this._onFrameDetached(event.frameId));
|
this._client.on('Page.frameDetached', event => this._onFrameDetached(event.frameId));
|
||||||
this._client.on('Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context));
|
this._client.on('Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context));
|
||||||
|
this._client.on('Page.lifecycleEvent', event => this._onLifecycleEvent(event));
|
||||||
|
|
||||||
this._handleFrameTree(frameTree);
|
this._handleFrameTree(frameTree);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {!Object} event
|
||||||
|
*/
|
||||||
|
_onLifecycleEvent(event) {
|
||||||
|
const frame = this._frames.get(event.frameId);
|
||||||
|
if (!frame)
|
||||||
|
return;
|
||||||
|
frame._onLifecycleEvent(event.loaderId, event.name);
|
||||||
|
this.emit(FrameManager.Events.LifecycleEvent, frame);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{frame: Object, childFrames: ?Array}} frameTree
|
* @param {{frame: Object, childFrames: ?Array}} frameTree
|
||||||
*/
|
*/
|
||||||
@ -171,20 +183,14 @@ class FrameManager extends EventEmitter {
|
|||||||
this._frames.delete(frame._id);
|
this._frames.delete(frame._id);
|
||||||
this.emit(FrameManager.Events.FrameDetached, frame);
|
this.emit(FrameManager.Events.FrameDetached, frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return {?string}
|
|
||||||
*/
|
|
||||||
unreachableMainFrameURL() {
|
|
||||||
return this._mainFrame._unreachableURL || null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
FrameManager.Events = {
|
FrameManager.Events = {
|
||||||
FrameAttached: 'frameattached',
|
FrameAttached: 'frameattached',
|
||||||
FrameNavigated: 'framenavigated',
|
FrameNavigated: 'framenavigated',
|
||||||
FrameDetached: 'framedetached'
|
FrameDetached: 'framedetached',
|
||||||
|
LifecycleEvent: 'lifecycleevent'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -205,6 +211,9 @@ class Frame {
|
|||||||
this._context = null;
|
this._context = null;
|
||||||
/** @type {!Set<!WaitTask>} */
|
/** @type {!Set<!WaitTask>} */
|
||||||
this._waitTasks = new Set();
|
this._waitTasks = new Set();
|
||||||
|
this._loaderId = '';
|
||||||
|
/** @type {!Set<string>} */
|
||||||
|
this._lifecycleEvents = new Set();
|
||||||
|
|
||||||
/** @type {!Set<!Frame>} */
|
/** @type {!Set<!Frame>} */
|
||||||
this._childFrames = new Set();
|
this._childFrames = new Set();
|
||||||
@ -523,7 +532,18 @@ class Frame {
|
|||||||
_navigated(framePayload) {
|
_navigated(framePayload) {
|
||||||
this._name = framePayload.name;
|
this._name = framePayload.name;
|
||||||
this._url = framePayload.url;
|
this._url = framePayload.url;
|
||||||
this._unreachableURL = framePayload.unreachableUrl;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} loaderId
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
_onLifecycleEvent(loaderId, name) {
|
||||||
|
if (name === 'init') {
|
||||||
|
this._loaderId = loaderId;
|
||||||
|
this._lifecycleEvents.clear();
|
||||||
|
}
|
||||||
|
this._lifecycleEvents.add(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
_detach() {
|
_detach() {
|
||||||
|
@ -15,66 +15,87 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const {helper} = require('./helper');
|
const {helper} = require('./helper');
|
||||||
|
const {FrameManager} = require('./FrameManager');
|
||||||
|
|
||||||
class NavigatorWatcher {
|
class NavigatorWatcher {
|
||||||
/**
|
/**
|
||||||
* @param {!Puppeteer.Session} client
|
* @param {!FrameManager} frameManager
|
||||||
* @param {string} frameId
|
* @param {!Puppeteer.Frame} frame
|
||||||
* @param {!Object=} options
|
* @param {!Object=} options
|
||||||
*/
|
*/
|
||||||
constructor(client, frameId, options = {}) {
|
constructor(frameManager, frame, options = {}) {
|
||||||
console.assert(options.networkIdleTimeout === undefined, 'ERROR: networkIdleTimeout option is no longer supported.');
|
console.assert(options.networkIdleTimeout === undefined, 'ERROR: networkIdleTimeout option is no longer supported.');
|
||||||
console.assert(options.networkIdleInflight === undefined, 'ERROR: networkIdleInflight option is no longer supported.');
|
console.assert(options.networkIdleInflight === undefined, 'ERROR: networkIdleInflight option is no longer supported.');
|
||||||
console.assert(options.waitUntil !== 'networkidle', 'ERROR: "networkidle" option is no longer supported. Use "networkidle2" instead');
|
console.assert(options.waitUntil !== 'networkidle', 'ERROR: "networkidle" option is no longer supported. Use "networkidle2" instead');
|
||||||
this._client = client;
|
|
||||||
this._frameId = frameId;
|
|
||||||
this._timeout = typeof options.timeout === 'number' ? options.timeout : 30000;
|
|
||||||
let waitUntil = ['load'];
|
let waitUntil = ['load'];
|
||||||
if (Array.isArray(options.waitUntil))
|
if (Array.isArray(options.waitUntil))
|
||||||
waitUntil = options.waitUntil.slice();
|
waitUntil = options.waitUntil.slice();
|
||||||
else if (typeof options.waitUntil === 'string')
|
else if (typeof options.waitUntil === 'string')
|
||||||
waitUntil = [options.waitUntil];
|
waitUntil = [options.waitUntil];
|
||||||
for (const value of waitUntil) {
|
this._expectedLifecycle = waitUntil.map(value => {
|
||||||
const isAllowedValue = value === 'networkidle0' || value === 'networkidle2' || value === 'load' || value === 'domcontentloaded';
|
const protocolEvent = puppeteerToProtocolLifecycle[value];
|
||||||
console.assert(isAllowedValue, 'Unknown value for options.waitUntil: ' + value);
|
console.assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value);
|
||||||
}
|
return protocolEvent;
|
||||||
this._pendingEvents = new Set(waitUntil);
|
});
|
||||||
|
|
||||||
|
this._frameManager = frameManager;
|
||||||
|
this._frame = frame;
|
||||||
|
this._initialLoaderId = frame._loaderId;
|
||||||
|
this._timeout = typeof options.timeout === 'number' ? options.timeout : 30000;
|
||||||
|
this._eventListeners = [
|
||||||
|
helper.addEventListener(this._frameManager, FrameManager.Events.LifecycleEvent, this._checkLifecycleComplete.bind(this))
|
||||||
|
];
|
||||||
|
|
||||||
|
const lifecycleCompletePromise = new Promise(fulfill => {
|
||||||
|
this._lifecycleCompleteCallback = fulfill;
|
||||||
|
});
|
||||||
|
this._navigationPromise = Promise.race([
|
||||||
|
this._createTimeoutPromise(),
|
||||||
|
lifecycleCompletePromise
|
||||||
|
]).then(error => {
|
||||||
|
this._cleanup();
|
||||||
|
return error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {?Promise<?Error>}
|
||||||
|
*/
|
||||||
|
_createTimeoutPromise() {
|
||||||
|
if (!this._timeout)
|
||||||
|
return null;
|
||||||
|
const errorMessage = 'Navigation Timeout Exceeded: ' + this._timeout + 'ms exceeded';
|
||||||
|
return new Promise(fulfill => this._maximumTimer = setTimeout(fulfill, this._timeout))
|
||||||
|
.then(() => new Error(errorMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return {!Promise<?Error>}
|
* @return {!Promise<?Error>}
|
||||||
*/
|
*/
|
||||||
async waitForNavigation() {
|
async navigationPromise() {
|
||||||
this._eventListeners = [];
|
return this._navigationPromise;
|
||||||
|
|
||||||
const navigationPromises = [];
|
|
||||||
if (this._timeout) {
|
|
||||||
const watchdog = new Promise(fulfill => this._maximumTimer = setTimeout(fulfill, this._timeout))
|
|
||||||
.then(() => 'Navigation Timeout Exceeded: ' + this._timeout + 'ms exceeded');
|
|
||||||
navigationPromises.push(watchdog);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._eventListeners.push(helper.addEventListener(this._client, 'Page.lifecycleEvent', this._onLifecycleEvent.bind(this)));
|
|
||||||
const pendingEventsFired = new Promise(fulfill => this._pendingEventsCallback = fulfill);
|
|
||||||
navigationPromises.push(pendingEventsFired);
|
|
||||||
|
|
||||||
const error = await Promise.race(navigationPromises);
|
|
||||||
this._cleanup();
|
|
||||||
return error ? new Error(error) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
_checkLifecycleComplete() {
|
||||||
* @param {!{frameId: string, name: string}} event
|
// We expect navigation to commit.
|
||||||
*/
|
if (this._frame._loaderId === this._initialLoaderId)
|
||||||
_onLifecycleEvent(event) {
|
|
||||||
if (event.frameId !== this._frameId)
|
|
||||||
return;
|
return;
|
||||||
const pptrName = protocolLifecycleToPuppeteer[event.name];
|
if (!checkLifecycle(this._frame, this._expectedLifecycle))
|
||||||
if (!pptrName)
|
|
||||||
return;
|
return;
|
||||||
this._pendingEvents.delete(pptrName);
|
this._lifecycleCompleteCallback();
|
||||||
if (this._pendingEvents.size === 0)
|
|
||||||
this._pendingEventsCallback();
|
/**
|
||||||
|
* @param {!Puppeteer.Frame} frame
|
||||||
|
* @param {!Array<string>} expectedLifecycle
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
function checkLifecycle(frame, expectedLifecycle) {
|
||||||
|
for (const event of expectedLifecycle) {
|
||||||
|
if (!frame._lifecycleEvents.has(event))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
@ -83,15 +104,16 @@ class NavigatorWatcher {
|
|||||||
|
|
||||||
_cleanup() {
|
_cleanup() {
|
||||||
helper.removeEventListeners(this._eventListeners);
|
helper.removeEventListeners(this._eventListeners);
|
||||||
|
this._lifecycleCompleteCallback(new Error('Navigation failed'));
|
||||||
clearTimeout(this._maximumTimer);
|
clearTimeout(this._maximumTimer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocolLifecycleToPuppeteer = {
|
const puppeteerToProtocolLifecycle = {
|
||||||
'load': 'load',
|
'load': 'load',
|
||||||
'DOMContentLoaded': 'domcontentloaded',
|
'domcontentloaded': 'DOMContentLoaded',
|
||||||
'networkIdle': 'networkidle0',
|
'networkidle0': 'networkIdle',
|
||||||
'networkAlmostIdle': 'networkidle2',
|
'networkidle2': 'networkAlmostIdle',
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = NavigatorWatcher;
|
module.exports = NavigatorWatcher;
|
||||||
|
51
lib/Page.js
51
lib/Page.js
@ -453,30 +453,43 @@ class Page extends EventEmitter {
|
|||||||
* @return {!Promise<?Response>}
|
* @return {!Promise<?Response>}
|
||||||
*/
|
*/
|
||||||
async goto(url, options) {
|
async goto(url, options) {
|
||||||
const mainFrame = this._frameManager.mainFrame();
|
|
||||||
const watcher = new NavigatorWatcher(this._client, mainFrame._id, options);
|
|
||||||
const requests = new Map();
|
|
||||||
const listener = helper.addEventListener(this._networkManager, NetworkManager.Events.Request, request => requests.set(request.url, request));
|
|
||||||
const navigationPromise = watcher.waitForNavigation();
|
|
||||||
const referrer = this._networkManager.extraHTTPHeaders()['referer'];
|
const referrer = this._networkManager.extraHTTPHeaders()['referer'];
|
||||||
const error = await Promise.race([
|
|
||||||
this._client.send('Page.navigate', {url, referrer})
|
const requests = new Map();
|
||||||
.then(() => navigationPromise)
|
const eventListeners = [
|
||||||
.catch(error => error),
|
helper.addEventListener(this._networkManager, NetworkManager.Events.Request, request => requests.set(request.url, request))
|
||||||
navigationPromise
|
];
|
||||||
|
|
||||||
|
const mainFrame = this._frameManager.mainFrame();
|
||||||
|
const watcher = new NavigatorWatcher(this._frameManager, mainFrame, options);
|
||||||
|
const navigationPromise = watcher.navigationPromise();
|
||||||
|
let error = await Promise.race([
|
||||||
|
navigate(this._client, url, referrer),
|
||||||
|
navigationPromise,
|
||||||
]);
|
]);
|
||||||
|
if (!error)
|
||||||
|
error = await navigationPromise;
|
||||||
watcher.cancel();
|
watcher.cancel();
|
||||||
helper.removeEventListeners([listener]);
|
helper.removeEventListeners(eventListeners);
|
||||||
if (error)
|
if (error)
|
||||||
throw error;
|
throw error;
|
||||||
const unreachableURL = this._frameManager.unreachableMainFrameURL();
|
|
||||||
if (unreachableURL) {
|
|
||||||
const request = requests.get(unreachableURL) || null;
|
|
||||||
const failure = request ? request.failure() : null;
|
|
||||||
throw new Error('Failed to navigate: ' + url + (failure ? ' ' + failure.errorText : ''));
|
|
||||||
}
|
|
||||||
const request = requests.get(this.mainFrame().url());
|
const request = requests.get(this.mainFrame().url());
|
||||||
return request ? request.response() : null;
|
return request ? request.response() : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {!Puppeteer.Session} client
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string} referrer
|
||||||
|
* @return {!Promise<?Error>}
|
||||||
|
*/
|
||||||
|
async function navigate(client, url, referrer) {
|
||||||
|
try {
|
||||||
|
const response = await client.send('Page.navigate', {url, referrer});
|
||||||
|
return response.errorText ? new Error(response.errorText) : null;
|
||||||
|
} catch (error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -497,11 +510,11 @@ class Page extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async waitForNavigation(options) {
|
async waitForNavigation(options) {
|
||||||
const mainFrame = this._frameManager.mainFrame();
|
const mainFrame = this._frameManager.mainFrame();
|
||||||
const watcher = new NavigatorWatcher(this._client, mainFrame._id, options);
|
const watcher = new NavigatorWatcher(this._frameManager, mainFrame, options);
|
||||||
|
|
||||||
const responses = new Map();
|
const responses = new Map();
|
||||||
const listener = helper.addEventListener(this._networkManager, NetworkManager.Events.Response, response => responses.set(response.url, response));
|
const listener = helper.addEventListener(this._networkManager, NetworkManager.Events.Response, response => responses.set(response.url, response));
|
||||||
const error = await watcher.waitForNavigation();
|
const error = await watcher.navigationPromise();
|
||||||
helper.removeEventListeners([listener]);
|
helper.removeEventListeners([listener]);
|
||||||
if (error)
|
if (error)
|
||||||
throw error;
|
throw error;
|
||||||
|
@ -972,7 +972,7 @@ describe('Page', function() {
|
|||||||
it('should fail when main resources failed to load', SX(async function() {
|
it('should fail when main resources failed to load', SX(async function() {
|
||||||
let error = null;
|
let error = null;
|
||||||
await page.goto('http://localhost:44123/non-existing-url').catch(e => error = e);
|
await page.goto('http://localhost:44123/non-existing-url').catch(e => error = e);
|
||||||
expect(error.message).toContain('Failed to navigate');
|
expect(error.message).toContain('net::ERR_CONNECTION_REFUSED');
|
||||||
}));
|
}));
|
||||||
it('should fail when exceeding maximum navigation timeout', SX(async function() {
|
it('should fail when exceeding maximum navigation timeout', SX(async function() {
|
||||||
let hasUnhandledRejection = false;
|
let hasUnhandledRejection = false;
|
||||||
@ -1293,7 +1293,7 @@ describe('Page', function() {
|
|||||||
let error = null;
|
let error = null;
|
||||||
await page.goto(EMPTY_PAGE).catch(e => error = e);
|
await page.goto(EMPTY_PAGE).catch(e => error = e);
|
||||||
expect(error).toBeTruthy();
|
expect(error).toBeTruthy();
|
||||||
expect(error.message).toContain('Failed to navigate');
|
expect(error.message).toContain('net::ERR_FAILED');
|
||||||
}));
|
}));
|
||||||
it('should work with redirects', SX(async function() {
|
it('should work with redirects', SX(async function() {
|
||||||
await page.setRequestInterception(true);
|
await page.setRequestInterception(true);
|
||||||
@ -1371,7 +1371,7 @@ describe('Page', function() {
|
|||||||
});
|
});
|
||||||
let error = null;
|
let error = null;
|
||||||
await page.goto('data:text/html,No way!').catch(err => error = err);
|
await page.goto('data:text/html,No way!').catch(err => error = err);
|
||||||
expect(error.message).toContain('Failed to navigate');
|
expect(error.message).toContain('net::ERR_FAILED');
|
||||||
}));
|
}));
|
||||||
it('should navigate to URL with hash and and fire requests without hash', SX(async function() {
|
it('should navigate to URL with hash and and fire requests without hash', SX(async function() {
|
||||||
await page.setRequestInterception(true);
|
await page.setRequestInterception(true);
|
||||||
|
Loading…
Reference in New Issue
Block a user