Skip to content

Commit

Permalink
fix(Navigation): correctly wait for navigation in Page.goto
Browse files Browse the repository at this point in the history
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 puppeteer#1218.
  • Loading branch information
aslushnikov committed Nov 10, 2017
1 parent 9c1935b commit 40bd03c
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 67 deletions.
38 changes: 29 additions & 9 deletions lib/FrameManager.js
Expand Up @@ -41,10 +41,22 @@ class FrameManager extends EventEmitter {
this._client.on('Page.frameNavigated', event => this._onFrameNavigated(event.frame));
this._client.on('Page.frameDetached', event => this._onFrameDetached(event.frameId));
this._client.on('Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context));
this._client.on('Page.lifecycleEvent', event => this._onLifecycleEvent(event));

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
*/
Expand Down Expand Up @@ -171,20 +183,14 @@ class FrameManager extends EventEmitter {
this._frames.delete(frame._id);
this.emit(FrameManager.Events.FrameDetached, frame);
}

/**
* @return {?string}
*/
unreachableMainFrameURL() {
return this._mainFrame._unreachableURL || null;
}
}

/** @enum {string} */
FrameManager.Events = {
FrameAttached: 'frameattached',
FrameNavigated: 'framenavigated',
FrameDetached: 'framedetached'
FrameDetached: 'framedetached',
LifecycleEvent: 'lifecycleevent'
};

/**
Expand All @@ -205,6 +211,9 @@ class Frame {
this._context = null;
/** @type {!Set<!WaitTask>} */
this._waitTasks = new Set();
this._loaderId = '';
/** @type {!Set<string>} */
this._lifecycleEvents = new Set();

/** @type {!Set<!Frame>} */
this._childFrames = new Set();
Expand Down Expand Up @@ -523,7 +532,18 @@ class Frame {
_navigated(framePayload) {
this._name = framePayload.name;
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() {
Expand Down
98 changes: 60 additions & 38 deletions lib/NavigatorWatcher.js
Expand Up @@ -15,66 +15,88 @@
*/

const {helper} = require('./helper');
const {FrameManager} = require('./FrameManager');

class NavigatorWatcher {
/**
* @param {!Puppeteer.Session} client
* @param {string} frameId
* @param {!FrameManager} frameManager
* @param {!Frame} frame
* @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.networkIdleInflight === undefined, 'ERROR: networkIdleInflight option is no longer supported.');
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'];
if (Array.isArray(options.waitUntil))
waitUntil = options.waitUntil.slice();
else if (typeof options.waitUntil === 'string')
waitUntil = [options.waitUntil];
for (const value of waitUntil) {
const isAllowedValue = value === 'networkidle0' || value === 'networkidle2' || value === 'load' || value === 'domcontentloaded';
console.assert(isAllowedValue, 'Unknown value for options.waitUntil: ' + value);
}
this._pendingEvents = new Set(waitUntil);
waitUntil = waitUntil.map(value => {
const protocolEvent = puppeteerToProtocolLifecycle[value];
console.assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value);
return protocolEvent;
});

this._eventListeners = [];
this._frameManager = frameManager;
this._frame = frame;
this._initialLoaderId = frame._loaderId;
this._timeout = typeof options.timeout === 'number' ? options.timeout : 30000;
this._waitUntil = waitUntil;
}

/**
* @return {!Promise<?Error>}
*/
async waitForNavigation() {
this._eventListeners = [];
_timeoutPromise() {
if (!this._timeout)
return new Promise(() => {});
const errorMessage = 'Navigation Timeout Exceeded: ' + this._timeout + 'ms exceeded';
return new Promise(fulfill => this._maximumTimer = setTimeout(fulfill, this._timeout))
.then(() => new Error(errorMessage))
}

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);
}
/**
* @return {!Promise<?Error>}
*/
async waitForNavigation() {
this._eventListeners = [
helper.addEventListener(this._frameManager, FrameManager.Events.LifecycleEvent, this._checkLifecycleComplete.bind(this))
];

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 lifecycleCompletePromise = new Promise(fulfill => this._lifecycleCompleteCallback = fulfill);

const error = await Promise.race(navigationPromises);
this._checkLifecycleComplete();
const error = await Promise.race([
this._timeoutPromise(),
lifecycleCompletePromise
]);
this._cleanup();
return error ? new Error(error) : null;
return error;
}

/**
* @param {!{frameId: string, name: string}} event
*/
_onLifecycleEvent(event) {
if (event.frameId !== this._frameId)
_checkLifecycleComplete() {
// We expect navigation to commit.
if (this._frame._loaderId === this._initialLoaderId)
return;
const pptrName = protocolLifecycleToPuppeteer[event.name];
if (!pptrName)
if (!checkLifecycle(this._frame, this._waitUntil))
return;
this._pendingEvents.delete(pptrName);
if (this._pendingEvents.size === 0)
this._pendingEventsCallback();
this._lifecycleCompleteCallback();

/**
* @param {!Frame} frame
* @param {!Array<string>} waitUntil
*/
function checkLifecycle(frame, waitUntil) {
let result = true;
for (let i = 0; result && i < waitUntil.length; ++i)
result = result && frame._lifecycleEvents.has(waitUntil[i]);
const childFrames = frame.childFrames();
for (let i = 0; result && i < childFrames.length; ++i)
result = result && checkLifecycle(childFrames[i], waitUntil);
return result;
}
}

cancel() {
Expand All @@ -87,11 +109,11 @@ class NavigatorWatcher {
}
}

const protocolLifecycleToPuppeteer = {
const puppeteerToProtocolLifecycle = {
'load': 'load',
'DOMContentLoaded': 'domcontentloaded',
'networkIdle': 'networkidle0',
'networkAlmostIdle': 'networkidle2',
'domcontentloaded': 'DOMContentLoaded',
'networkidle0': 'networkIdle',
'networkidle2': 'networkAlmostIdle',
};

module.exports = NavigatorWatcher;
48 changes: 31 additions & 17 deletions lib/Page.js
Expand Up @@ -453,30 +453,44 @@ class Page extends EventEmitter {
* @return {!Promise<?Response>}
*/
async goto(url, options) {
const mainFrame = this._frameManager.mainFrame();
const watcher = new NavigatorWatcher(this._client, mainFrame._id, options);
const referrer = this._networkManager.extraHTTPHeaders()['referer'];

const requests = new Map();
const listener = helper.addEventListener(this._networkManager, NetworkManager.Events.Request, request => requests.set(request.url, request));
const eventListeners = [
helper.addEventListener(this._networkManager, NetworkManager.Events.Request, request => requests.set(request.url, request))
];

const mainFrame = this._frameManager.mainFrame();
const watcher = new NavigatorWatcher(this._frameManager, mainFrame, options);
const navigationPromise = watcher.waitForNavigation();
const referrer = this._networkManager.extraHTTPHeaders()['referer'];
const error = await Promise.race([
this._client.send('Page.navigate', {url, referrer})
.then(() => navigationPromise)
.catch(error => error),
navigationPromise
let error = await Promise.race([
navigate(this._client, url, referrer),
navigationPromise,
]);
if (!error)
error = await navigationPromise;
watcher.cancel();
helper.removeEventListeners([listener]);
helper.removeEventListeners(eventListeners);
if (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());
return request ? request.response() : null;

/**
*
* @param {!Puppeteer.Session} client
* @param {string} url
* @param {string} referrer
* @return {?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;
}
}
}

/**
Expand All @@ -497,7 +511,7 @@ class Page extends EventEmitter {
*/
async waitForNavigation(options) {
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 listener = helper.addEventListener(this._networkManager, NetworkManager.Events.Response, response => responses.set(response.url, response));
Expand Down
6 changes: 3 additions & 3 deletions test/test.js
Expand Up @@ -972,7 +972,7 @@ describe('Page', function() {
it('should fail when main resources failed to load', SX(async function() {
let error = null;
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() {
let hasUnhandledRejection = false;
Expand Down Expand Up @@ -1293,7 +1293,7 @@ describe('Page', function() {
let error = null;
await page.goto(EMPTY_PAGE).catch(e => error = e);
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() {
await page.setRequestInterception(true);
Expand Down Expand Up @@ -1371,7 +1371,7 @@ describe('Page', function() {
});
let error = null;
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() {
await page.setRequestInterception(true);
Expand Down

0 comments on commit 40bd03c

Please sign in to comment.