feat(page): support waitUntil option for page.setContent (#3557)

This patch teaches `page.setContent` to await resources in
the new document.

**NOTE**: This patch changes behavior: currently, `page.setContent`
awaits the `"domcontentloaded"` event; with this patch, we can now await
other lifecycle events, and switched default to the `"load"` event.

The change is justified since current behavior made `page.setContent`
unusable for its main designated usecases, pushing our client
to use [dataURL workaround](https://github.com/GoogleChrome/puppeteer/issues/728#issuecomment-334301491).

Fixes #728
This commit is contained in:
Andrey Lushnikov 2018-11-20 15:32:46 -08:00 committed by GitHub
parent e2e43bc23d
commit 927d0f443b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 82 additions and 28 deletions

View File

@ -126,7 +126,7 @@
* [page.select(selector, ...values)](#pageselectselector-values) * [page.select(selector, ...values)](#pageselectselector-values)
* [page.setBypassCSP(enabled)](#pagesetbypasscspenabled) * [page.setBypassCSP(enabled)](#pagesetbypasscspenabled)
* [page.setCacheEnabled([enabled])](#pagesetcacheenabledenabled) * [page.setCacheEnabled([enabled])](#pagesetcacheenabledenabled)
* [page.setContent(html)](#pagesetcontenthtml) * [page.setContent(html[, options])](#pagesetcontenthtml-options)
* [page.setCookie(...cookies)](#pagesetcookiecookies) * [page.setCookie(...cookies)](#pagesetcookiecookies)
* [page.setDefaultNavigationTimeout(timeout)](#pagesetdefaultnavigationtimeouttimeout) * [page.setDefaultNavigationTimeout(timeout)](#pagesetdefaultnavigationtimeouttimeout)
* [page.setExtraHTTPHeaders(headers)](#pagesetextrahttpheadersheaders) * [page.setExtraHTTPHeaders(headers)](#pagesetextrahttpheadersheaders)
@ -206,7 +206,7 @@
* [frame.name()](#framename) * [frame.name()](#framename)
* [frame.parentFrame()](#frameparentframe) * [frame.parentFrame()](#frameparentframe)
* [frame.select(selector, ...values)](#frameselectselector-values) * [frame.select(selector, ...values)](#frameselectselector-values)
* [frame.setContent(html)](#framesetcontenthtml) * [frame.setContent(html[, options])](#framesetcontenthtml-options)
* [frame.tap(selector)](#frametapselector) * [frame.tap(selector)](#frametapselector)
* [frame.title()](#frametitle) * [frame.title()](#frametitle)
* [frame.type(selector, text[, options])](#frametypeselector-text-options) * [frame.type(selector, text[, options])](#frametypeselector-text-options)
@ -1606,8 +1606,15 @@ that `page.setBypassCSP` should be called before navigating to the domain.
Toggles ignoring cache for each request based on the enabled state. By default, caching is enabled. Toggles ignoring cache for each request based on the enabled state. By default, caching is enabled.
#### page.setContent(html) #### page.setContent(html[, options])
- `html` <[string]> HTML markup to assign to the page. - `html` <[string]> HTML markup to assign to the page.
- `options` <[Object]> Parameters which might have the following properties:
- `timeout` <[number]> Maximum time in milliseconds for resources to load, defaults to 30 seconds, pass `0` to disable timeout.
- `waitUntil` <[string]|[Array]<[string]>> When to consider setting markup succeeded, defaults to `load`. Given an array of event strings, setting content is considered to be successful after all events have been fired. Events can be either:
- `load` - consider setting content to be finished when the `load` event is fired.
- `domcontentloaded` - consider setting content to be finished when the `DOMContentLoaded` event is fired.
- `networkidle0` - consider setting content to be finished when there are no more than 0 network connections for at least `500` ms.
- `networkidle2` - consider setting content to be finished when there are no more than 2 network connections for at least `500` ms.
- returns: <[Promise]> - returns: <[Promise]>
#### page.setCookie(...cookies) #### page.setCookie(...cookies)
@ -2552,8 +2559,15 @@ frame.select('select#colors', 'blue'); // single selection
frame.select('select#colors', 'red', 'green', 'blue'); // multiple selections frame.select('select#colors', 'red', 'green', 'blue'); // multiple selections
``` ```
#### frame.setContent(html) #### frame.setContent(html[, options])
- `html` <[string]> HTML markup to assign to the page. - `html` <[string]> HTML markup to assign to the page.
- `options` <[Object]> Parameters which might have the following properties:
- `timeout` <[number]> Maximum time in milliseconds for resources to load, defaults to 30 seconds, pass `0` to disable timeout.
- `waitUntil` <[string]|[Array]<[string]>> When to consider setting markup succeeded, defaults to `load`. Given an array of event strings, setting content is considered to be successful after all events have been fired. Events can be either:
- `load` - consider setting content to be finished when the `load` event is fired.
- `domcontentloaded` - consider setting content to be finished when the `DOMContentLoaded` event is fired.
- `networkidle0` - consider setting content to be finished when there are no more than 0 network connections for at least `500` ms.
- `networkidle2` - consider setting content to be finished when there are no more than 2 network connections for at least `500` ms.
- returns: <[Promise]> - returns: <[Promise]>
#### frame.tap(selector) #### frame.tap(selector)
@ -3533,4 +3547,4 @@ TimeoutError is emitted whenever certain operations are terminated due to timeou
[Worker]: #class-worker "Worker" [Worker]: #class-worker "Worker"
[Accessibility]: #class-accessibility "Accessibility" [Accessibility]: #class-accessibility "Accessibility"
[AXNode]: #accessibilitysnapshotoptions "AXNode" [AXNode]: #accessibilitysnapshotoptions "AXNode"
[ConnectionTransport]: ../lib/WebSocketTransport.js "ConnectionTransport" [ConnectionTransport]: ../lib/WebSocketTransport.js "ConnectionTransport"

View File

@ -69,12 +69,14 @@ class FrameManager extends EventEmitter {
* @return {!Promise<?Puppeteer.Response>} * @return {!Promise<?Puppeteer.Response>}
*/ */
async navigateFrame(frame, url, options = {}) { async navigateFrame(frame, url, options = {}) {
assertNoLegacyNavigationOptions(options);
const { const {
referer = this._networkManager.extraHTTPHeaders()['referer'], referer = this._networkManager.extraHTTPHeaders()['referer'],
waitUntil = ['load'],
timeout = this._defaultNavigationTimeout, timeout = this._defaultNavigationTimeout,
} = options; } = options;
const watcher = new NavigatorWatcher(this._client, this, this._networkManager, frame, timeout, options); const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout);
let ensureNewDocumentNavigation = false; let ensureNewDocumentNavigation = false;
let error = await Promise.race([ let error = await Promise.race([
navigate(this._client, url, referer, frame._id), navigate(this._client, url, referer, frame._id),
@ -115,10 +117,12 @@ class FrameManager extends EventEmitter {
* @return {!Promise<?Puppeteer.Response>} * @return {!Promise<?Puppeteer.Response>}
*/ */
async waitForFrameNavigation(frame, options = {}) { async waitForFrameNavigation(frame, options = {}) {
assertNoLegacyNavigationOptions(options);
const { const {
timeout = this._defaultNavigationTimeout waitUntil = ['load'],
timeout = this._defaultNavigationTimeout,
} = options; } = options;
const watcher = new NavigatorWatcher(this._client, this, this._networkManager, frame, timeout, options); const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout);
const error = await Promise.race([ const error = await Promise.race([
watcher.timeoutOrTerminationPromise(), watcher.timeoutOrTerminationPromise(),
watcher.sameDocumentNavigationPromise(), watcher.sameDocumentNavigationPromise(),
@ -525,13 +529,28 @@ class Frame {
/** /**
* @param {string} html * @param {string} html
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options
*/ */
async setContent(html) { async setContent(html, options = {}) {
const {
waitUntil = ['load'],
timeout = 30000,
} = options;
// We rely upon the fact that document.open() will reset frame lifecycle with "init"
// lifecycle event. @see https://crrev.com/608658
await this.evaluate(html => { await this.evaluate(html => {
document.open(); document.open();
document.write(html); document.write(html);
document.close(); document.close();
}, html); }, html);
const watcher = new LifecycleWatcher(this._frameManager, this, waitUntil, timeout);
const error = await Promise.race([
watcher.timeoutOrTerminationPromise(),
watcher.lifecyclePromise(),
]);
watcher.dispose();
if (error)
throw error;
} }
/** /**
@ -1127,22 +1146,20 @@ async function waitForPredicatePageFunction(predicateBody, polling, timeout, ...
} }
} }
class NavigatorWatcher { function assertNoLegacyNavigationOptions(options) {
assert(options['networkIdleTimeout'] === undefined, 'ERROR: networkIdleTimeout option is no longer supported.');
assert(options['networkIdleInflight'] === undefined, 'ERROR: networkIdleInflight option is no longer supported.');
assert(options.waitUntil !== 'networkidle', 'ERROR: "networkidle" option is no longer supported. Use "networkidle2" instead');
}
class LifecycleWatcher {
/** /**
* @param {!Puppeteer.CDPSession} client
* @param {!FrameManager} frameManager * @param {!FrameManager} frameManager
* @param {!NetworkManager} networkManager
* @param {!Puppeteer.Frame} frame * @param {!Puppeteer.Frame} frame
* @param {string|!Array<string>} waitUntil
* @param {number} timeout * @param {number} timeout
* @param {!{waitUntil?: string|!Array<string>}} options
*/ */
constructor(client, frameManager, networkManager, frame, timeout, options = {}) { constructor(frameManager, frame, waitUntil, timeout) {
assert(options['networkIdleTimeout'] === undefined, 'ERROR: networkIdleTimeout option is no longer supported.');
assert(options['networkIdleInflight'] === undefined, 'ERROR: networkIdleInflight option is no longer supported.');
assert(options.waitUntil !== 'networkidle', 'ERROR: "networkidle" option is no longer supported. Use "networkidle2" instead');
let {
waitUntil = ['load']
} = options;
if (Array.isArray(waitUntil)) if (Array.isArray(waitUntil))
waitUntil = waitUntil.slice(); waitUntil = waitUntil.slice();
else if (typeof waitUntil === 'string') else if (typeof waitUntil === 'string')
@ -1154,15 +1171,14 @@ class NavigatorWatcher {
}); });
this._frameManager = frameManager; this._frameManager = frameManager;
this._networkManager = networkManager; this._networkManager = frameManager._networkManager;
this._frame = frame; this._frame = frame;
this._initialLoaderId = frame._loaderId; this._initialLoaderId = frame._loaderId;
this._timeout = timeout; this._timeout = timeout;
/** @type {?Puppeteer.Request} */ /** @type {?Puppeteer.Request} */
this._navigationRequest = null; this._navigationRequest = null;
this._hasSameDocumentNavigation = false;
this._eventListeners = [ this._eventListeners = [
helper.addEventListener(client, CDPSession.Events.Disconnected, () => this._terminate(new Error('Navigation failed because browser has disconnected!'))), helper.addEventListener(frameManager._client, CDPSession.Events.Disconnected, () => this._terminate(new Error('Navigation failed because browser has disconnected!'))),
helper.addEventListener(this._frameManager, FrameManager.Events.LifecycleEvent, this._checkLifecycleComplete.bind(this)), helper.addEventListener(this._frameManager, FrameManager.Events.LifecycleEvent, this._checkLifecycleComplete.bind(this)),
helper.addEventListener(this._frameManager, FrameManager.Events.FrameNavigatedWithinDocument, this._navigatedWithinDocument.bind(this)), helper.addEventListener(this._frameManager, FrameManager.Events.FrameNavigatedWithinDocument, this._navigatedWithinDocument.bind(this)),
helper.addEventListener(this._frameManager, FrameManager.Events.FrameDetached, this._onFrameDetached.bind(this)), helper.addEventListener(this._frameManager, FrameManager.Events.FrameDetached, this._onFrameDetached.bind(this)),
@ -1173,6 +1189,10 @@ class NavigatorWatcher {
this._sameDocumentNavigationCompleteCallback = fulfill; this._sameDocumentNavigationCompleteCallback = fulfill;
}); });
this._lifecyclePromise = new Promise(fulfill => {
this._lifecycleCallback = fulfill;
});
this._newDocumentNavigationPromise = new Promise(fulfill => { this._newDocumentNavigationPromise = new Promise(fulfill => {
this._newDocumentNavigationCompleteCallback = fulfill; this._newDocumentNavigationCompleteCallback = fulfill;
}); });
@ -1231,6 +1251,13 @@ class NavigatorWatcher {
return this._newDocumentNavigationPromise; return this._newDocumentNavigationPromise;
} }
/**
* @return {!Promise}
*/
lifecyclePromise() {
return this._lifecyclePromise;
}
/** /**
* @return {!Promise<?Error>} * @return {!Promise<?Error>}
*/ */
@ -1261,10 +1288,11 @@ class NavigatorWatcher {
_checkLifecycleComplete() { _checkLifecycleComplete() {
// We expect navigation to commit. // We expect navigation to commit.
if (this._frame._loaderId === this._initialLoaderId && !this._hasSameDocumentNavigation)
return;
if (!checkLifecycle(this._frame, this._expectedLifecycle)) if (!checkLifecycle(this._frame, this._expectedLifecycle))
return; return;
this._lifecycleCallback();
if (this._frame._loaderId === this._initialLoaderId && !this._hasSameDocumentNavigation)
return;
if (this._hasSameDocumentNavigation) if (this._hasSameDocumentNavigation)
this._sameDocumentNavigationCompleteCallback(); this._sameDocumentNavigationCompleteCallback();
if (this._frame._loaderId !== this._initialLoaderId) if (this._frame._loaderId !== this._initialLoaderId)

View File

@ -609,9 +609,10 @@ class Page extends EventEmitter {
/** /**
* @param {string} html * @param {string} html
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options
*/ */
async setContent(html) { async setContent(html, options) {
await this._frameManager.mainFrame().setContent(html); await this._frameManager.mainFrame().setContent(html, options);
} }
/** /**

View File

@ -1284,6 +1284,17 @@ module.exports.addTests = function({testRunner, expect, headless}) {
const result = await page.content(); const result = await page.content();
expect(result).toBe(`${doctype}${expectedOutput}`); expect(result).toBe(`${doctype}${expectedOutput}`);
}); });
it('should await resources to load', async({page, server}) => {
const imgPath = '/img.png';
let imgResponse = null;
server.setRoute(imgPath, (req, res) => imgResponse = res);
let loaded = false;
const contentPromise = page.setContent(`<img src="${server.PREFIX + imgPath}"></img>`).then(() => loaded = true);
await server.waitForRequest(imgPath);
expect(loaded).toBe(false);
imgResponse.end();
await contentPromise;
});
}); });
describe('Page.setBypassCSP', function() { describe('Page.setBypassCSP', function() {

View File

@ -30,7 +30,7 @@ const EXCLUDE_CLASSES = new Set([
'Helper', 'Helper',
'Launcher', 'Launcher',
'Multimap', 'Multimap',
'NavigatorWatcher', 'LifecycleWatcher',
'NetworkManager', 'NetworkManager',
'PipeTransport', 'PipeTransport',
'TaskQueue', 'TaskQueue',