diff --git a/docs/api.md b/docs/api.md index e8599fc2b7f..22418cbb56d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,6 +59,7 @@ + [page.url()](#pageurl) + [page.viewport()](#pageviewport) + [page.waitFor(selectorOrTimeout[, options])](#pagewaitforselectorortimeout-options) + + [page.waitForFunction(pageFunction[, options], ...args)](#pagewaitforfunctionpagefunction-options-args) + [page.waitForNavigation(options)](#pagewaitfornavigationoptions) + [page.waitForSelector(selector[, options])](#pagewaitforselectorselector-options) * [class: Keyboard](#class-keyboard) @@ -91,6 +92,7 @@ + [frame.title()](#frametitle) + [frame.url()](#frameurl) + [frame.waitFor(selectorOrTimeout[, options])](#framewaitforselectorortimeout-options) + + [frame.waitForFunction(pageFunction[, options], ...args)](#framewaitforfunctionpagefunction-options-args) + [frame.waitForSelector(selector[, options])](#framewaitforselectorselector-options) * [class: Request](#class-request) + [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). +#### 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) - `options` <[Object]> Navigation parameters which might have the following properties: - `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 - 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]) - `selector` <[string]> CSS selector of awaited element, - `options` <[Object]> Optional waiting parameters diff --git a/lib/FrameManager.js b/lib/FrameManager.js index 13f7b017ed2..ccfd4c70f8f 100644 --- a/lib/FrameManager.js +++ b/lib/FrameManager.js @@ -275,9 +275,9 @@ class Frame { * @return {!Promise} */ waitFor(selectorOrTimeout, options = {}) { - if (typeof selectorOrTimeout === 'string' || selectorOrTimeout instanceof String) + if (helper.isString(selectorOrTimeout)) return this.waitForSelector(selectorOrTimeout, options); - if (typeof selectorOrTimeout === 'number' || selectorOrTimeout instanceof Number) + if (helper.isNumber(selectorOrTimeout)) return new Promise(fulfill => setTimeout(fulfill, selectorOrTimeout)); return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrTimeout))); } @@ -290,8 +290,35 @@ class Frame { waitForSelector(selector, options = {}) { const timeout = options.timeout || 30000; const waitForVisible = !!options.visible; - const pageScript = helper.evaluationString(waitForSelectorPageFunction, selector, waitForVisible, timeout); - return new WaitTask(this, pageScript, timeout).promise; + const polling = waitForVisible ? 'raf' : 'mutation'; + 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 { /** * @param {!Frame} frame - * @param {string} pageScript + * @param {string} predicateBody + * @param {string} polling * @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._pageScript = pageScript; + this._pageScript = helper.evaluationString(waitForPredicatePageFunction, predicateBody, polling, timeout); this._runCount = 0; frame._waitTasks.add(this); this.promise = new Promise((resolve, reject) => { @@ -475,31 +510,34 @@ class WaitTask { } /** - * @param {string} selector - * @param {boolean} waitForVisible + * @param {string} predicateBody + * @param {string} polling * @param {number} timeout * @return {!Promise} */ -async function waitForSelectorPageFunction(selector, visible, timeout) { +async function waitForPredicatePageFunction(predicateBody, polling, timeout) { + const predicate = new Function(predicateBody); let timedOut = false; setTimeout(() => timedOut = true, timeout); - await waitForDOM(); - await waitForVisible(); + if (polling === 'raf') + await pollRaf(); + else if (polling === 'mutation') + await pollMutation(); + else if (typeof polling === 'number') + await pollInterval(polling); return !timedOut; /** * @return {!Promise} */ - function waitForDOM() { - let node = document.querySelector(selector); - if (node) + function pollMutation() { + if (predicate()) return Promise.resolve(); let fulfill; const result = new Promise(x => fulfill = x); const observer = new MutationObserver(mutations => { - const node = document.querySelector(selector); - if (node || timedOut) { + if (timedOut || predicate()) { observer.disconnect(); fulfill(); } @@ -512,26 +550,37 @@ async function waitForSelectorPageFunction(selector, visible, timeout) { } /** - * @return {!Promise} + * @return {!Promise} */ - function waitForVisible() { + function pollRaf() { let fulfill; const result = new Promise(x => fulfill = x); onRaf(); return result; function onRaf() { - if (timedOut) { + if (timedOut || predicate()) fulfill(); - return; - } - const node = document.querySelector(selector); - const style = node ? window.getComputedStyle(node) : null; - if (!style || style.display === 'none' || style.visibility === 'hidden') { + else 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); } } } diff --git a/lib/Page.js b/lib/Page.js index d886b3ba814..65c833a57f5 100644 --- a/lib/Page.js +++ b/lib/Page.js @@ -571,6 +571,16 @@ class Page extends EventEmitter { 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 {!Array} filePaths @@ -637,10 +647,10 @@ function convertPrintParameterToInches(parameter) { if (typeof parameter === 'undefined') return undefined; let pixels; - if (typeof parameter === 'number') { + if (helper.isNumber(parameter)) { // Treat numbers as pixel values to be aligned with phantom's paperSize. pixels = /** @type {number} */ (parameter); - } else if (typeof parameter === 'string') { + } else if (helper.isString(parameter)) { let text = parameter; let unit = text.substring(text.length - 2).toLowerCase(); let valueText = ''; diff --git a/lib/helper.js b/lib/helper.js index c018f87952b..844e4e8a7a0 100644 --- a/lib/helper.js +++ b/lib/helper.js @@ -23,7 +23,7 @@ class Helper { * @return {string} */ static evaluationString(fun, ...args) { - if (typeof fun === 'string') { + if (Helper.isString(fun)) { console.assert(args.length === 0, 'Cannot evaluate a string with arguments'); return fun; } @@ -181,6 +181,22 @@ class Helper { static recordPublicAPICoverage() { 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; diff --git a/test/test.js b/test/test.js index aca52d08cf5..f00c3c8e118 100644 --- a/test/test.js +++ b/test/test.js @@ -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() { let FrameUtils = require('./frame-utils'); let addElement = tag => document.body.appendChild(document.createElement(tag));