diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..637d92c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +node_modules/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a15935 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Gideon Pyzer / Huddle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab0c5e1 --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# puppeteer-extensions +This library exposes a number of convenience functions to extend Puppeteer's API, in order to make writing tests easier. +The idea is that many of these functions (or similar ones) will eventually make their way into Puppeteer's own API, but +this allows us to experiment with new ways of improving UI testing. + +## Usage +- `page` Puppeteer page instance +- `timeout` [Optional] Timeout for waits in milliseconds (default: 5000 ms) + +```javascript +const extensions = require('puppeteer-extensions')(page); +``` + +```javascript +(async() { + const listItem = '.todo-list li'; + ... + await page.extensions.waitForNthSelectorAttributeValue(listItem, 1, 'class', 'completed'); +})(); + +``` + + +## API +The API is split into categories to better organise the extension functions. This currently includes: + +- [Waits](#waits) +- [Retrieval](#retrieval) +- [Miscellaneous](#miscellaneous) + + +**resetRequests()** + +Resets the requests cache used by the `waits` API. This should be called when you are going to navigate to another page, +in order to track the new requests correctly. + +## Waits +**waitForResource(resource, timeout=defaultTimeout)** +- `resource` \ The URL of the resource (or a substring of it) +- `timeout` \ Timeout for the check + +Wait for a resource request to be responded to + + +**waitForLoadedWebFontCountToBe(count, timeout=defaultTimeout)** +- `count` \ The number of web fonts to expect +- `timeout` \ Timeout for the check + +Wait for a specific number of web fonts to be loaded and ready on the page + + +**waitForFunction(fn, options, ...args)** +- `fn` \ The function to execute on the page +- `options` \ Optional waiting parameters +- `args` \<...args> Arguments to be passed into the function + +Wait for function to execute on the page (see [waitForFunction](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagewaitforfunctionpagefunction-options-args)) + + +**waitForSelector(selector, timeout=defaultTimeout)** +- `selector` \ The selector for the element on the page +- `timeout` \ Timeout for the check + +Wait for element with a given selector to exist on the page (see [waitForSelector](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagewaitforselectorselector-options)) + + +**waitUntilExistsAndVisible(selector)** +- `selector` \ The selector for the element on the page + +Wait until an element exists on the page and is visible (i.e. not transparent) (see [waitForSelector](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagewaitforselectorselector-options)) + + +**waitWhileExistsAndVisible(selector)** +- `selector` \ The selector for the element on the page + +Wait while an element still exists on the page and is visible (i.e. not transparent) (see [waitForSelector](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagewaitforselectorselector-options)) + + +**waitUntilSelectorHasVisibleContent(selector)** +- `selector` \ The selector for the element on the page + +Wait until the selector has visible content (i.e. the element takes up some width and height on the page) (i.e. not transparent) + + +**waitWhileSelectorHasVisibleContent(selector)** +- `selector` \ The selector for the element on the page + +Wait while the selector has visible content (i.e. the element takes up some width and height on the page) (i.e. not transparent) + + +**waitForNthSelectorAttribute(selector, nth, attributeName)** +- `selector` \ The selector for the element on the page +- `nth` \ The nth element found by the selector +- `attributeName` \ The attribute name to look for + +Wait for the nth element found from the selector has a particular attribute + + +**waitForSelectorAttribute(selector, attributeName)** +- `selector` \ The selector for the element on the page +- `attributeName` \ The attribute name to look for + +Wait for the element found from the selector has a particular attribute + + +**waitForNthSelectorAttributeValue(selector, nth, attributeName, attributeValue)** +- `selector` \ The selector for the element on the page +- `nth` \ The nth element found by the selector +- `attributeName` \ The attribute name to look for +- `attributeValue` \ The attribute value to match the attributeName + +Wait for the nth element found from the selector has a particular attribute value pair + + +**waitForSelectorAttributeValue(selector, attributeName, attributeValue)** +- `selector` \ The selector for the element on the page +- `attributeName` \ The attribute name to look for +- `attributeValue` \ The attribute value to match the attributeName + +Wait for the element found from the selector has a particular attribute value pair + + +**waitForElementCount(selector, expectedCount)** +- `selector` \ The selector for the element on the page +- `expectedCount` \ The number of elements to expect + +Wait for the element count to be a particular value + + +**waitForUrl(regex)** +- `regex` \ The regular expression to match the URL on + +Wait for the current window location to match a particular regular expression + + +**waitFor(milliseconds)** +- `milliseconds` \ The number of milliseconds to wait for + +Wait for a given number of milliseconds + + +## Retrieval + +**getValue(selector)** +- `selector` \ The selector for the element to get the value for +- **returns** \ The value property value for the element + +Get the value property value for a particular element + + +**getText(selector)** +- `selector` \ The selector for the element to get the text for +- **returns** \ The text property value for the element + +Get the text property value for a particular element + + +**getPropertyValue(selector, property)** +- `selector` \ The selector for the element to get the property value for +- `property` \ The property to look for +- **returns** \ The property value for the element + +Get the value of a particular property for a particular element + + +**isElementFocused(selector)** +- `selector` \ The selector of the element to check for focus state +- **returns** \ Whether the element is focused or not + +Check if element is focused + +## Miscellaneous + +**turnOffAnimations()** + +Turn off CSS animations on the page to help avoid flaky visual comparisons + + +**evaluate(fn, ...args)** +- `fn` \ The function to execute on the page +- `args` \<...args> Arguments to be passed into the function + +Runs a function on the page \ No newline at end of file diff --git a/api/miscellaneous.js b/api/miscellaneous.js new file mode 100644 index 0000000..952108b --- /dev/null +++ b/api/miscellaneous.js @@ -0,0 +1,43 @@ +/** + * + * This file represents the miscellaneous API. It exposes functions that don't fit under a particular category. + * + **/ + +const serializeFunctionWithArgs = require('../external/serialization-utils'); + +module.exports = puppeteerPage => ({ + /** + * Turn off CSS animations on the page to help avoid flaky visual comparisons + */ + async turnOffAnimations () { + return puppeteerPage.evaluate(() => { + function disableAnimations() { + const {jQuery} = window; + if (jQuery) { + jQuery.fx.off = true; + } + + const css = document.createElement('style'); + css.type = 'text/css'; + css.innerHTML = '* { -webkit-transition: none !important; transition: none !important; -webkit-animation: none !important; animation: none !important; }'; + document.body.appendChild( css ); + } + + if (document.readyState !== 'loading') { + disableAnimations(); + } else { + window.addEventListener('load', disableAnimations, false); + } + }) + }, + /** + * Run a function on the page + * @param {function} fn - The function to execute on the page + * @param {...args} args - Arguments to be passed into the function + */ + async evaluate(fn, ...args) { + const fnStr = serializeFunctionWithArgs(fn, ...args); + return puppeteerPage.evaluate(fnStr); + }, +}); diff --git a/api/retrieval.js b/api/retrieval.js new file mode 100644 index 0000000..9e6ed27 --- /dev/null +++ b/api/retrieval.js @@ -0,0 +1,55 @@ +/** + * + * This file represents the retrieval API. It exposes convenience functions for getting properties/values/state from the UI. + * + **/ + +module.exports = puppeteerPage => ({ + /** + * Get the value property value for a particular element + * @param {string} selector - The selector for the element to get the value for + * @returns {string} value - The value property value for the element + */ + async getValue(selector) { + return puppeteerPage.evaluate(selector => { + return document.querySelector(selector).value; + }, selector); + }, + /** + * Get the text property value for a particular element + * @param {string} selector - The selector for the element to get the text for + * @returns {string} value - The text property value for the element + */ + async getText(selector) { + return puppeteerPage.evaluate(selector => { + return document.querySelector(selector).textContent; + }, selector); + }, + /** + * Get the value of a particular property for a particular element + * @param {string} selector - The selector for the element to get the property value for + * @param {string} property - The property to look for + * @returns {string} value - The property value for the element + */ + async getPropertyValue(selector, property) { + try { + return puppeteerPage.evaluate((selector, property) => { + const element = document.querySelector(selector); + return element[property]; + }, selector, property); + } catch(e) { + throw Error(`Unable able to get ${property} from ${selector}.`, e); + } + }, + /** + * Check if element is focused + * @param {string} selector - The selector of the element to check for focus state + * @returns {boolean} Whether the element is focused or not + */ + async isElementFocused (selector) { + return puppeteerPage.evaluate(selector => { + const element = document.querySelector(selector); + return element === document.activeElement; + }, selector); + }, +}); diff --git a/api/waits.js b/api/waits.js new file mode 100644 index 0000000..047b6f9 --- /dev/null +++ b/api/waits.js @@ -0,0 +1,232 @@ +/** + * + * This file represents the waits API. It exposes useful polling functions for particular resources or selectors + * + **/ + +const serializeFunctionWithArgs = require('../external/serialization-utils'); + +const pollFor = ({checkFn, interval, timeout, timeoutMsg}) => { + return new Promise((resolve, reject) => { + const startTime = new Date().getTime(); + const timer = setInterval(async () => { + if ((new Date().getTime() - startTime) < timeout) { + if (await checkFn()) { + clearInterval(timer); + resolve(); + } + } else { + clearInterval(timer); + reject(timeoutMsg); + } + }, interval); + }); +}; + +const isSuccessfulResponse = request => { + const response = request.response && request.response(); + return response && (response.status() === 200 || response.status() === 304); +}; + +module.exports = (puppeteerPage, requests, defaultTimeout) => ({ + /** + * Wait for a resource request to be responded to + * @param {string} resource - The URL of the resource (or a substring of it) + * @param {number] [timeout=defaultTimeout] - Timeout for the check + */ + waitForResource (resource, timeout = defaultTimeout) { + return new Promise((resolve, reject) => { + + const resourceRequestHasResponded = () => { + const resourceRequest = requests && requests.find(r => r.url && r.url().indexOf(resource) !== -1); + return isSuccessfulResponse(resourceRequest); + }; + + if (resourceRequestHasResponded()) { + resolve(); + } else { + pollFor({ + checkFn: () => { + return resourceRequestHasResponded(); + }, + internal: 100, + timeout: timeout, + timeoutMsg: 'Timeout waiting for resource match.' + }).then(resolve).catch(reject) + } + }); + }, + /** + * Wait for a specific number of web fonts to be loaded and ready on the page + * @param {number} count - The number of web fonts to expect + * @param {number] [timeout=defaultTimeout] - Timeout for the check + */ + async waitForLoadedWebFontCountToBe(count, timeout = defaultTimeout) { + let hasInjectedWebFontsAllLoadedFunction = false; + + async function checkWebFontIsLoaded() { + const fontResponses = requests.filter(r => { + if (r.resourceType() === 'font') { + return isSuccessfulResponse(r); + } + + return false; + }); + + if (fontResponses.length === count) { + if (hasInjectedWebFontsAllLoadedFunction) { + return puppeteerPage.evaluate(() => { + return !!window.__webFontsAllLoaded; + }); + } else { + await puppeteerPage.evaluate(() => { + (async function() { + window.__webFontsAllLoaded = await document.fonts.ready; + })(); + }); + + hasInjectedWebFontsAllLoadedFunction = true; + return false; + } + } + return false; + } + + return pollFor({ + checkFn: checkWebFontIsLoaded, + internal: 100, + timeout: timeout, + timeoutMsg: `Timeout waiting for ${count} web font responses` + }); + }, + /** + * Wait for function to execute on the page + * @param {function} fn - The function to execute on the page + * @param {object} options - Optional waiting parameters + * @param {...args} args - Arguments to be passed into the function + * @see https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagewaitforfunctionpagefunction-options-args + */ + async waitForFunction(fn, options, ...args) { + const fnStr = serializeFunctionWithArgs(fn, ...args); + return puppeteerPage.waitForFunction(fnStr, options); + }, + /** + * Wait for element with a given selector to exist on the page + * @param {string} selector - The selector for the element on the page + * @param {number] [timeout=defaultTimeout] - Timeout for the check + * @see https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagewaitforselectorselector-options + */ + async waitForSelector(selector, timeout = defaultTimeout) { + return puppeteerPage.waitForSelector(selector, { timeout: timeout }); + }, + /** + * Wait until an element exists on the page and is visible (i.e. not transparent) + * @param {string} selector - The selector for the element on the page + * @see https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagewaitforselectorselector-options + */ + async waitUntilExistsAndVisible(selector) { + return puppeteerPage.waitForSelector(selector, { visible: true }); + }, + /** + * Wait while an element still exists on the page and is visible (i.e. not transparent) + * @param {string} selector - The selector for the element on the page + * @see https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagewaitforselectorselector-options + */ + async waitWhileExistsAndVisible(selector) { + return puppeteerPage.waitForSelector(selector, { hidden: true }); + }, + /** + * Wait until the selector has visible content (i.e. the element takes up some width and height on the page) + * @param {string} selector - The selector for the element on the page + */ + async waitUntilSelectorHasVisibleContent(selector) { + return puppeteerPage.waitForFunction(selector => { + const elem = document.querySelector(selector); + const isVisible = elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length; + return !!isVisible; + }, {timeout: defaultTimeout}, selector); + }, + /** + * Wait while the selector has visible content (i.e. the element takes up some width and height on the page) + * @param {string} selector - The selector for the element on the page + */ + async waitWhileSelectorHasVisibleContent(selector) { + return puppeteerPage.waitForFunction(selector => { + const elem = document.querySelector(selector); + const isVisible = elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length; + return !isVisible; + }, {timeout: defaultTimeout}, selector); + }, + /** + * Wait for the nth element found from the selector has a particular attribute + * @param {string} selector - The selector for the element on the page + * @param {number} nth - The nth element found by the selector + * @param {string} attributeName - The attribute name to look for + */ + async waitForNthSelectorAttribute(selector, nth, attributeName) { + return puppeteerPage.waitForFunction((selector, nth, attributeName) => { + const element = document.querySelectorAll(selector)[nth - 1]; + return typeof element.attributes[attributeName] !== 'undefined'; + }, {timeout: defaultTimeout}, selector, nth, attributeName); + }, + /** + * Wait for the element found from the selector has a particular attribute + * @param {string} selector - The selector for the element on the page + * @param {string} attributeName - The attribute name to look for + */ + async waitForSelectorAttribute (selector, attributeName) { + return this.waitForNthSelectorAttribute(selector, 1, attributeName); + }, + /** + * Wait for the nth element found from the selector has a particular attribute value pair + * @param {string} selector - The selector for the element on the page + * @param {number} nth - The nth element found by the selector + * @param {string} attributeName - The attribute name to look for + * @param {string} attributeValue - The attribute value to match the attributeName + */ + async waitForNthSelectorAttributeValue (selector, nth, attributeName, attributeValue) { + return puppeteerPage.waitForFunction((selector, nth, attributeName, attributeValue) => { + const element = document.querySelectorAll(selector)[nth - 1]; + return element.attributes[attributeName] && element.attributes[attributeName].value === attributeValue; + }, {timeout: defaultTimeout}, selector, nth, attributeName, attributeValue); + }, + /** + * Wait for the element found from the selector has a particular attribute value pair + * @param {string} selector - The selector for the element on the page + * @param {string} attributeName - The attribute name to look for + * @param {string} attributeValue - The attribute value to match the attributeName + */ + async waitForSelectorAttributeValue (selector, attributeName, attributeValue) { + return this.waitForNthSelectorAttributeValue(selector, 1, attributeName, attributeValue); + }, + /** + * Wait for the element count to be a particular value + * @param {string} selector - The selector for the element on the page + * @param {number} expectedCount - The number of elements to expect + */ + async waitForElementCount(selector, expectedCount) { + return puppeteerPage.waitForFunction((selector, expectedCount) => { + return document.querySelectorAll(selector).length === expectedCount; + }, { timeout: defaultTimeout}, selector, expectedCount); + }, + /** + * Wait for the current window location to match a particular regular expression + * @param {RegExp} regex - The regular expression to match the URL on + */ + async waitForUrl(regex) { + return this.waitForFunction(regex => { + return regex.test(window.location.href); + }, { timeout: defaultTimeout}, regex); + }, + /** + * Wait for a given number of milliseconds + * @param {number} milliseconds - The number of milliseconds to wait for + */ + async waitFor(milliseconds) { + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, milliseconds); + }); + }, +}); \ No newline at end of file diff --git a/external/serialization-utils.js b/external/serialization-utils.js new file mode 100644 index 0000000..ec10057 --- /dev/null +++ b/external/serialization-utils.js @@ -0,0 +1,103 @@ +/** + * + * This file contains functions taken, and sometimes modified, from the PhantomJS repository, under BSD-3-Clause licence + * https://github.com/ariya/phantomjs/blob/master/LICENSE.BSD + * + * The following copyright notice(s) apply: + * + * Copyright (C) 2011 Ariya Hidayat + * Copyright (C) 2011 Ivan De Marino + * Copyright (C) 2011 James Roe + * Copyright (C) 2011 execjosh, http://execjosh.blogspot.com + * Copyright (C) 2012 James M. Greene +**/ + +// Source: https://github.com/ariya/phantomjs/blob/master/src/modules/webpage.js#L205 +const detectType = value => { + let s = typeof value; + if (s === 'object') { + if (value) { + if (value instanceof Array) { + s = 'array'; + } else if (value instanceof RegExp) { + s = 'regexp'; + } else if (value instanceof Date) { + s = 'date'; + } + } else { + s = 'null'; + } + } + return s; +}; + +// Source: https://github.com/ariya/phantomjs/blob/master/src/modules/webpage.js#L167 +const quoteString = str => { + let c, i, l = str.length, o = '"'; + for (i = 0; i < l; i += 1) { + c = str.charAt(i); + if (c >= ' ') { + if (c === '\\' || c === '"') { + o += '\\'; + } + o += c; + } else { + switch (c) { + case '\b': + o += '\\b'; + break; + case '\f': + o += '\\f'; + break; + case '\n': + o += '\\n'; + break; + case '\r': + o += '\\r'; + break; + case '\t': + o += '\\t'; + break; + default: + c = c.charCodeAt(); + o += '\\u00' + Math.floor(c / 16).toString(16) + + (c % 16).toString(16); + } + } + } + return o + '"'; +}; + +// Source: from https://github.com/ariya/phantomjs/blob/master/src/modules/webpage.js#L354-L388 +module.exports = function serializeFunctionWithArgs(fn, ...args) { + if (!(fn instanceof Function || typeof fn === 'string' || fn instanceof String)) { + throw Error('Wrong use of evaluate'); + } + + let str = '(function() { return (' + fn.toString() + ')('; + + args.forEach(arg => { + let argType = detectType(arg); + + switch (argType) { + case 'object': //< for type "object" + case 'array': //< for type "array" + str += JSON.stringify(arg) + ','; + break; + case 'date': //< for type "date" + str += 'new Date(' + JSON.stringify(arg) + '),'; + break; + case 'string': //< for type "string" + str += quoteString(arg) + ','; + break; + default: // for types: "null", "number", "function", "regexp", "undefined" + str += arg + ','; + break; + } + }); + + return str.replace(/,$/, '') + '); })()'; +}; + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..8091930 --- /dev/null +++ b/index.js @@ -0,0 +1,21 @@ +const waits = require('./api/waits'); +const retrieval = require('./api/retrieval'); +const miscellaneous = require('./api/miscellaneous'); +const DEFAULT_TIMEOUT_MS = 5000; +let resourceRequests = []; + +module.exports = (puppeteerInstance, timeout = DEFAULT_TIMEOUT_MS) => { + puppeteerInstance.on('request', request => { + resourceRequests.push(request); + }); + + const resetRequests = () => { + resourceRequests = []; + }; + + return Object.assign({resetRequests}, + waits(puppeteerInstance, resourceRequests, timeout), + retrieval(puppeteerInstance), + miscellaneous(puppeteerInstance), + ); +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..27a4a51 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "puppeteer-extensions", + "version": "1.0.0", + "description": "Convenience functions for the Puppeteer", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/HuddleEng/puppeteer-extensions.git" + }, + "author": "Gideon Pyzer / Huddle", + "license": "MIT", + "bugs": { + "url": "https://github.com/HuddleEng/puppeteer-extensions/issues" + }, + "homepage": "https://github.com/HuddleEng/puppeteer-extensions#readme" +}