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:
Andrey Lushnikov 2017-11-10 15:33:14 -08:00 committed by GitHub
parent 9b907b9ada
commit 44d1e834a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 128 additions and 73 deletions

View File

@ -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() {

View File

@ -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._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;
});
} }
this._pendingEvents = new Set(waitUntil);
/**
* @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))); _checkLifecycleComplete() {
const pendingEventsFired = new Promise(fulfill => this._pendingEventsCallback = fulfill); // We expect navigation to commit.
navigationPromises.push(pendingEventsFired); if (this._frame._loaderId === this._initialLoaderId)
return;
const error = await Promise.race(navigationPromises); if (!checkLifecycle(this._frame, this._expectedLifecycle))
this._cleanup(); return;
return error ? new Error(error) : null; this._lifecycleCompleteCallback();
}
/** /**
* @param {!{frameId: string, name: string}} event * @param {!Puppeteer.Frame} frame
* @param {!Array<string>} expectedLifecycle
* @return {boolean}
*/ */
_onLifecycleEvent(event) { function checkLifecycle(frame, expectedLifecycle) {
if (event.frameId !== this._frameId) for (const event of expectedLifecycle) {
return; if (!frame._lifecycleEvents.has(event))
const pptrName = protocolLifecycleToPuppeteer[event.name]; return false;
if (!pptrName) }
return; return true;
this._pendingEvents.delete(pptrName); }
if (this._pendingEvents.size === 0)
this._pendingEventsCallback();
} }
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;

View File

@ -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;

View File

@ -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);