Implement page.waitForFunction method

The page.waitForFunction method allows to wait for a general predicate.
The predicate will be continiously polled for in page, until
it either returns true or the timeout happens.

The polling parameter could be one of the following:
- 'raf' - to poll on every animation frame
- 'mutation' - to poll on every dom mutation
- <number> - to poll every X milliseconds

References #91
This commit is contained in:
Andrey Lushnikov 2017-07-27 15:17:43 -07:00
parent 47a0366b16
commit ff5ed1c738
5 changed files with 197 additions and 31 deletions

View File

@ -59,6 +59,7 @@
+ [page.url()](#pageurl) + [page.url()](#pageurl)
+ [page.viewport()](#pageviewport) + [page.viewport()](#pageviewport)
+ [page.waitFor(selectorOrTimeout[, options])](#pagewaitforselectorortimeout-options) + [page.waitFor(selectorOrTimeout[, options])](#pagewaitforselectorortimeout-options)
+ [page.waitForFunction(pageFunction[, options], ...args)](#pagewaitforfunctionpagefunction-options-args)
+ [page.waitForNavigation(options)](#pagewaitfornavigationoptions) + [page.waitForNavigation(options)](#pagewaitfornavigationoptions)
+ [page.waitForSelector(selector[, options])](#pagewaitforselectorselector-options) + [page.waitForSelector(selector[, options])](#pagewaitforselectorselector-options)
* [class: Keyboard](#class-keyboard) * [class: Keyboard](#class-keyboard)
@ -91,6 +92,7 @@
+ [frame.title()](#frametitle) + [frame.title()](#frametitle)
+ [frame.url()](#frameurl) + [frame.url()](#frameurl)
+ [frame.waitFor(selectorOrTimeout[, options])](#framewaitforselectorortimeout-options) + [frame.waitFor(selectorOrTimeout[, options])](#framewaitforselectorortimeout-options)
+ [frame.waitForFunction(pageFunction[, options], ...args)](#framewaitforfunctionpagefunction-options-args)
+ [frame.waitForSelector(selector[, options])](#framewaitforselectorselector-options) + [frame.waitForSelector(selector[, options])](#framewaitforselectorselector-options)
* [class: Request](#class-request) * [class: Request](#class-request)
+ [request.headers](#requestheaders) + [request.headers](#requestheaders)
@ -653,6 +655,18 @@ This method behaves differently with respect to the type of the first parameter:
The method is a shortcut for [page.mainFrame().waitFor()](#framewaitforselectorortimeout-options). The method is a shortcut for [page.mainFrame().waitFor()](#framewaitforselectorortimeout-options).
#### page.waitForFunction(pageFunction[, options], ...args)
- `pageFunction` <[function]|[string]> Function to be evaluated in browser context
- `options` <[Object]> Optional waiting parameters
- `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it could be one of the following values:
- `raf` - to constantly execute `pageFunction` in `requestAnimationFrame` callback. This is the tightest polling mode which is suitable to observe styling changes.
- `mutation` - to execute `pageFunction` on every DOM mutation.
- `timeout` <[number]> maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds).
- `...args` <...[Object]> Arguments to pass to `pageFunction`
- returns: <[Promise]> Promise which resolves when element specified by selector string is added to DOM.
Shortcut for [page.mainFrame().waitForFunction()](#framewaitforfunctionpagefunction-options-args).
#### page.waitForNavigation(options) #### page.waitForNavigation(options)
- `options` <[Object]> Navigation parameters which might have the following properties: - `options` <[Object]> Navigation parameters which might have the following properties:
- `timeout` <[number]> Maximum navigation time in milliseconds, defaults to 30 seconds. - `timeout` <[number]> Maximum navigation time in milliseconds, defaults to 30 seconds.
@ -902,6 +916,29 @@ This method behaves differently with respect to the type of the first parameter:
- if `selectorOrTimeout` is a `number`, than the first argument is treated as a timeout in milliseconds and the method returns a promise which resolves after the timeout - if `selectorOrTimeout` is a `number`, than the first argument is treated as a timeout in milliseconds and the method returns a promise which resolves after the timeout
- otherwise, an exception is thrown - otherwise, an exception is thrown
#### frame.waitForFunction(pageFunction[, options], ...args)
- `pageFunction` <[function]|[string]> Function to be evaluated in browser context
- `options` <[Object]> Optional waiting parameters
- `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it could be one of the following values:
- `raf` - to constantly execute `pageFunction` in `requestAnimationFrame` callback. This is the tightest polling mode which is suitable to observe styling changes.
- `mutation` - to execute `pageFunction` on every DOM mutation.
- `timeout` <[number]> maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds).
- `...args` <...[Object]> Arguments to pass to `pageFunction`
- returns: <[Promise]> Promise which resolves when element specified by selector string is added to DOM.
The `waitForFunction` could be used to observe viewport size change:
```js
const {Browser} = require('.');
const browser = new Browser();
browser.newPage().then(async page => {
const watchDog = page.waitForFunction('window.innerWidth < 100);
page.setViewport({width: 50, height: 50});
await watchDog;
browser.close();
});
```
#### frame.waitForSelector(selector[, options]) #### frame.waitForSelector(selector[, options])
- `selector` <[string]> CSS selector of awaited element, - `selector` <[string]> CSS selector of awaited element,
- `options` <[Object]> Optional waiting parameters - `options` <[Object]> Optional waiting parameters

View File

@ -275,9 +275,9 @@ class Frame {
* @return {!Promise} * @return {!Promise}
*/ */
waitFor(selectorOrTimeout, options = {}) { waitFor(selectorOrTimeout, options = {}) {
if (typeof selectorOrTimeout === 'string' || selectorOrTimeout instanceof String) if (helper.isString(selectorOrTimeout))
return this.waitForSelector(selectorOrTimeout, options); return this.waitForSelector(selectorOrTimeout, options);
if (typeof selectorOrTimeout === 'number' || selectorOrTimeout instanceof Number) if (helper.isNumber(selectorOrTimeout))
return new Promise(fulfill => setTimeout(fulfill, selectorOrTimeout)); return new Promise(fulfill => setTimeout(fulfill, selectorOrTimeout));
return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrTimeout))); return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrTimeout)));
} }
@ -290,8 +290,35 @@ class Frame {
waitForSelector(selector, options = {}) { waitForSelector(selector, options = {}) {
const timeout = options.timeout || 30000; const timeout = options.timeout || 30000;
const waitForVisible = !!options.visible; const waitForVisible = !!options.visible;
const pageScript = helper.evaluationString(waitForSelectorPageFunction, selector, waitForVisible, timeout); const polling = waitForVisible ? 'raf' : 'mutation';
return new WaitTask(this, pageScript, timeout).promise; return this.waitForFunction(predicate, {timeout, polling}, selector, waitForVisible);
/**
* @param {string} selector
* @param {boolean} waitForVisible
* @return {boolean}
*/
function predicate(selector, waitForVisible) {
const node = document.querySelector(selector);
if (!node)
return false;
if (!waitForVisible)
return true;
const style = window.getComputedStyle(node);
return style && style.display !== 'none' && style.visibility !== 'hidden';
}
}
/**
* @param {function()} pageFunction
* @param {!Object=} options
* @return {!Promise}
*/
waitForFunction(pageFunction, options = {}, ...args) {
const timeout = options.timeout || 30000;
const polling = options.polling || 'raf';
const predicateCode = 'return ' + helper.evaluationString(pageFunction, ...args);
return new WaitTask(this, predicateCode, polling, timeout).promise;
} }
/** /**
@ -415,12 +442,20 @@ helper.tracePublicAPI(Frame);
class WaitTask { class WaitTask {
/** /**
* @param {!Frame} frame * @param {!Frame} frame
* @param {string} pageScript * @param {string} predicateBody
* @param {string} polling
* @param {number} timeout * @param {number} timeout
*/ */
constructor(frame, pageScript, timeout) { constructor(frame, predicateBody, polling, timeout) {
if (helper.isString(polling))
console.assert(polling === 'raf' || polling === 'mutation', 'Unknown polling option: ' + polling);
else if (helper.isNumber(polling))
console.assert(polling > 0, 'Cannot poll with non-positive interval: ' + polling);
else
throw new Error('Unknown polling options: ' + polling);
this._frame = frame; this._frame = frame;
this._pageScript = pageScript; this._pageScript = helper.evaluationString(waitForPredicatePageFunction, predicateBody, polling, timeout);
this._runCount = 0; this._runCount = 0;
frame._waitTasks.add(this); frame._waitTasks.add(this);
this.promise = new Promise((resolve, reject) => { this.promise = new Promise((resolve, reject) => {
@ -475,31 +510,34 @@ class WaitTask {
} }
/** /**
* @param {string} selector * @param {string} predicateBody
* @param {boolean} waitForVisible * @param {string} polling
* @param {number} timeout * @param {number} timeout
* @return {!Promise<boolean>} * @return {!Promise<boolean>}
*/ */
async function waitForSelectorPageFunction(selector, visible, timeout) { async function waitForPredicatePageFunction(predicateBody, polling, timeout) {
const predicate = new Function(predicateBody);
let timedOut = false; let timedOut = false;
setTimeout(() => timedOut = true, timeout); setTimeout(() => timedOut = true, timeout);
await waitForDOM(); if (polling === 'raf')
await waitForVisible(); await pollRaf();
else if (polling === 'mutation')
await pollMutation();
else if (typeof polling === 'number')
await pollInterval(polling);
return !timedOut; return !timedOut;
/** /**
* @return {!Promise<!Element>} * @return {!Promise<!Element>}
*/ */
function waitForDOM() { function pollMutation() {
let node = document.querySelector(selector); if (predicate())
if (node)
return Promise.resolve(); return Promise.resolve();
let fulfill; let fulfill;
const result = new Promise(x => fulfill = x); const result = new Promise(x => fulfill = x);
const observer = new MutationObserver(mutations => { const observer = new MutationObserver(mutations => {
const node = document.querySelector(selector); if (timedOut || predicate()) {
if (node || timedOut) {
observer.disconnect(); observer.disconnect();
fulfill(); fulfill();
} }
@ -512,26 +550,37 @@ async function waitForSelectorPageFunction(selector, visible, timeout) {
} }
/** /**
* @return {!Promise<!Element>} * @return {!Promise}
*/ */
function waitForVisible() { function pollRaf() {
let fulfill; let fulfill;
const result = new Promise(x => fulfill = x); const result = new Promise(x => fulfill = x);
onRaf(); onRaf();
return result; return result;
function onRaf() { function onRaf() {
if (timedOut) { if (timedOut || predicate())
fulfill(); fulfill();
return; else
}
const node = document.querySelector(selector);
const style = node ? window.getComputedStyle(node) : null;
if (!style || style.display === 'none' || style.visibility === 'hidden') {
requestAnimationFrame(onRaf); requestAnimationFrame(onRaf);
return; }
} }
fulfill();
/**
* @param {number} pollInterval
* @return {!Promise}
*/
function pollInterval(pollInterval) {
let fulfill;
const result = new Promise(x => fulfill = x);
onTimeout();
return result;
function onTimeout() {
if (timedOut || predicate())
fulfill();
else
setTimeout(onTimeout, pollInterval);
} }
} }
} }

View File

@ -571,6 +571,16 @@ class Page extends EventEmitter {
return this.mainFrame().waitForSelector(selector, options); return this.mainFrame().waitForSelector(selector, options);
} }
/**
* @param {function()} pageFunction
* @param {!Object=} options
* @param {!Array<*>} args
* @return {!Promise}
*/
waitForFunction(pageFunction, options = {}, ...args) {
return this.mainFrame().waitForFunction(pageFunction, options, ...args);
}
/** /**
* @param {string} selector * @param {string} selector
* @param {!Array<string>} filePaths * @param {!Array<string>} filePaths
@ -637,10 +647,10 @@ function convertPrintParameterToInches(parameter) {
if (typeof parameter === 'undefined') if (typeof parameter === 'undefined')
return undefined; return undefined;
let pixels; let pixels;
if (typeof parameter === 'number') { if (helper.isNumber(parameter)) {
// Treat numbers as pixel values to be aligned with phantom's paperSize. // Treat numbers as pixel values to be aligned with phantom's paperSize.
pixels = /** @type {number} */ (parameter); pixels = /** @type {number} */ (parameter);
} else if (typeof parameter === 'string') { } else if (helper.isString(parameter)) {
let text = parameter; let text = parameter;
let unit = text.substring(text.length - 2).toLowerCase(); let unit = text.substring(text.length - 2).toLowerCase();
let valueText = ''; let valueText = '';

View File

@ -23,7 +23,7 @@ class Helper {
* @return {string} * @return {string}
*/ */
static evaluationString(fun, ...args) { static evaluationString(fun, ...args) {
if (typeof fun === 'string') { if (Helper.isString(fun)) {
console.assert(args.length === 0, 'Cannot evaluate a string with arguments'); console.assert(args.length === 0, 'Cannot evaluate a string with arguments');
return fun; return fun;
} }
@ -181,6 +181,22 @@ class Helper {
static recordPublicAPICoverage() { static recordPublicAPICoverage() {
apiCoverage = new Map(); apiCoverage = new Map();
} }
/**
* @param {!Object} obj
* @return {boolean}
*/
static isString(obj) {
return typeof obj === 'string' || obj instanceof String;
}
/**
* @param {!Object} obj
* @return {boolean}
*/
static isNumber(obj) {
return typeof obj === 'number' || obj instanceof Number;
}
} }
module.exports = Helper; module.exports = Helper;

View File

@ -198,6 +198,60 @@ describe('Puppeteer', function() {
})); }));
}); });
describe('Frame.waitForFunction', function() {
it('should accept a string', SX(async function() {
const watchdog = page.waitForFunction('window.__FOO === 1');
await page.evaluate(() => window.__FOO = 1);
await watchdog;
}));
it('should poll on interval', SX(async function() {
let success = false;
const startTime = Date.now();
const polling = 100;
const watchdog = page.waitForFunction(() => window.__FOO === 'hit', {polling})
.then(() => success = true);
await page.evaluate(() => window.__FOO = 'hit');
expect(success).toBe(false);
await page.evaluate(() => document.body.appendChild(document.createElement('div')));
await watchdog;
expect(Date.now() - startTime).not.toBeLessThan(polling / 2);
}));
it('should poll on mutation', SX(async function() {
let success = false;
const watchdog = page.waitForFunction(() => window.__FOO === 'hit', {polling: 'mutation'})
.then(() => success = true);
await page.evaluate(() => window.__FOO = 'hit');
expect(success).toBe(false);
await page.evaluate(() => document.body.appendChild(document.createElement('div')));
await watchdog;
}));
it('should poll on raf', SX(async function() {
const watchdog = page.waitForFunction(() => window.__FOO === 'hit', {polling: 'raf'});
await page.evaluate(() => window.__FOO = 'hit');
await watchdog;
}));
it('should throw on bad polling value', SX(async function() {
let error = null;
try {
await page.waitForFunction(() => !!document.body, {polling: 'unknown'});
} catch (e) {
error = e;
}
expect(error).toBeTruthy();
expect(error.message).toContain('polling');
}));
it('should throw negative polling interval', SX(async function() {
let error = null;
try {
await page.waitForFunction(() => !!document.body, {polling: -10});
} catch (e) {
error = e;
}
expect(error).toBeTruthy();
expect(error.message).toContain('Cannot poll with non-positive interval');
}));
});
describe('Frame.waitForSelector', function() { describe('Frame.waitForSelector', function() {
let FrameUtils = require('./frame-utils'); let FrameUtils = require('./frame-utils');
let addElement = tag => document.body.appendChild(document.createElement(tag)); let addElement = tag => document.body.appendChild(document.createElement(tag));