UNPKG

ghostjs

Version:

Modern web integration test runner

588 lines (528 loc) 15 kB
/* eslint-disable no-new-func */ import Element from './element' // For testing purposes import { getChromeFlags } from './utils' var debug = require('debug')('ghost') var driver = require('node-phantom-simple') var argv = require('yargs').argv var spawn = require('child_process').spawn var ChromeGhostDriver = require('./chrome/') class Ghost { constructor () { // Default timeout per wait. this.waitTimeout = 30000 if (argv['browser'] === 'phantom') { this.testRunner = 'phantomjs-prebuilt' } else if (argv['browser'] === 'firefox') { this.testRunner = 'slimerjs-core' } else { this.testRunner = 'chrome' } this.driverOpts = null this.setDriverOpts({}) this.browser = null this.currentContext = null this.page = null this.childPages = [] this.clientScripts = [] // Supported network types for network throttling in Chrome this.networkTypes = { 'offline': { offline: true }, 'gprs': { downloadThroughput: 50, uploadThroughput: 20, latency: 500 }, 'regular2g': { downloadThroughput: 250, uploadThroughput: 50, latency: 300 }, 'good2g': { downloadThroughput: 450, uploadThroughput: 150, latency: 150 }, 'regular3g': { downloadThroughput: 750, uploadThroughput: 250, latency: 100 }, 'good3g': { downloadThroughput: 1500, uploadThroughput: 750, latency: 40 }, 'regular4g': { downloadThroughput: 4000, uploadThroughput: 3000, latency: 20 }, 'dsl': { downloadThroughput: 2000, uploadThroughput: 1000, latency: 5 }, 'wifi': { downloadThroughput: 30000, uploadThroughput: 15000, latency: 2 } } // Open the console if we're running slimer, and the GHOST_CONSOLE env var is set. if (this.testRunner.match(/slimerjs/) && process.env.GHOST_CONSOLE) { this.setDriverOpts({parameters: ['-jsconsole']}) } else if (this.testRunner.match(/chrome/)) { const program = spawn(ChromeGhostDriver.path, [], { cwd: process.cwd(), env: process.env }) program.stdout.pipe(process.stdout) program.stderr.pipe(process.stderr) process.stdin.pipe(program.stdin) } } /** * Sets options object that is used in driver creation. */ setDriverOpts (opts) { debug('set driver opts', opts) this.driverOpts = this.testRunner.match(/phantom/) ? opts : {} // Don't do anything for chrome here. if (this.testRunner.match(/chrome/)) { return } if (opts.parameters) { this.driverOpts.parameters = opts.parameters } this.driverOpts.path = require(this.testRunner).path // The dnode `weak` dependency is failing to install on travis. // Disable this for now until someone needs it. this.driverOpts.dnodeOpts = { weak: false } } /** * Adds scripts to be injected to for each page load. * Should be called before ghost#open. */ injectScripts () { debug('inject scripts', arguments) Array.slice(arguments).forEach(script => { this.clientScripts.push(script) }) } /** * Callback when a page loads. * Injects javascript and other things we need. */ onOpen () { // Inject any client scripts this.clientScripts.forEach(script => { this.page.injectJs(script) }) } /** * Opens a page. * @param {String} url Url of the page to open. * @param {Object} options Keys supported: * settings - Key: Value map of all settings to set. * headers - Key: Value map of custom headers. * viewportSize - E.g., {height: 600, width: 800} */ async open (url, options = {}) { debug('open url', url, 'options', options) // If we already have a page object, just navigate it. if (this.page) { return new Promise(resolve => { this.page.open(url, (err, status) => { if (err) { console.error(err) } this.onOpen() resolve(status) }) }) } return new Promise(resolve => { let driverEngine = driver if (this.testRunner.match(/chrome/)) { driverEngine = ChromeGhostDriver } driverEngine.create(this.driverOpts, (err, browser) => { if (err) { console.error(err) } this.browser = browser browser.createPage((err, page) => { if (err) { console.error(err) } this.page = page options.settings = options.settings || {} for (var i in options.settings) { page.set('settings.' + i, options.settings[i]) } if (options.headers) { page.set('customHeaders', options.headers) } if (options.viewportSize) { page.set('viewportSize', options.viewportSize) } if (this.testRunner.match(/chrome/) && options.networkOption) { page.set('networkOption', options.networkOption) } /** * Allow content to pass a custom function into onResourceRequested. */ if (options.onResourceRequested) { page.setFn('onResourceRequested', options.onResourceRequested) } page.onResourceTimeout = (url) => { console.log('page timeout when trying to load ', url) } page.onPageCreated = (page) => { var pageObj = { page: page, url: null } this.childPages.push(pageObj) page.onUrlChanged = (url) => { pageObj.url = url } page.onClosing = (closingPage) => { this.childPages = this.childPages.filter(eachPage => eachPage === closingPage) } } page.onConsoleMessage = (msg) => { if (argv['verbose']) { console.log('[Console]', msg) } } page.open(url, (err, status) => { if (err) { console.error(err) } this.onOpen() resolve(status) }) }) }) }) } close () { debug('close') if (this.page) { this.page.close() } this.page = null this.currentContext = null } async exit () { this.close() if (this.browser) { await this.browser.exit() this.browser = null } } /** * Sets the current page context to run test methods on. * This is useful for running tests in popups for example. * To use the root page, pass an empty value. */ async usePage (pagePattern) { debug('use page', pagePattern) if (!pagePattern) { this.currentContext = null } else { this.currentContext = await this.waitForPage(pagePattern) } } /** * Gets the current page context that we're using. */ get pageContext () { return (this.currentContext && this.currentContext.page) || this.page } goBack () { debug('goBack') this.pageContext.goBack() } goForward () { debug('goForward') this.pageContext.goForward() } /** * Saves a screenshot to disk. * @param {String} filename Filename of the screenshot to save. * @param {String} folder Folder name to save the screenshot into. * @return {String} The full filepath of the saved screenshot. */ async screenshot (filename, folder = 'screenshots') { filename = filename || 'screenshot-' + Date.now() const saveToPath = `${folder}/${filename}.png` this.pageContext.render(saveToPath) return new Promise(resolve => { resolve(`${process.cwd()}/${saveToPath}`) }) } /** * Returns the title of the current page. */ async pageTitle () { debug('getting pageTitle') return new Promise(resolve => { this.pageContext.evaluate(() => { return document.title }, (err, result) => { if (err) { console.error(err) } resolve(result) }) }) } /** * Waits for the page title to match a given state. */ async waitForPageTitle (expected) { debug('waitForPageTitle') var waitFor = this.wait.bind(this) var pageTitle = this.pageTitle.bind(this) return new Promise(async resolve => { var result = await waitFor(async () => { var title = await pageTitle() if (expected instanceof RegExp) { return expected.test(title) } else { return title === expected } }) resolve(result) }) } /** * Returns an element if it finds it in the page, otherwise returns null. * @param {string} selector */ async findElement (selector) { debug('findElement called with selector', selector) return new Promise(resolve => { this.pageContext.evaluate((selector) => { return !!document.querySelector(selector) }, selector, (err, result) => { if (err) { console.warn('findElement error', err) } if (!result) { return resolve(null) } resolve(new Element(this.pageContext, selector)) }) }) } /** * Returns an array of {Element} instances that match a selector. * @param {string} selector */ async findElements (selector) { debug('findElements called with selector', selector) return new Promise(resolve => { this.pageContext.evaluate((selector) => { return document.querySelectorAll(selector).length }, selector, (err, numElements) => { if (err) { console.warn('findElements error', err) } if (!numElements) { return resolve(null) } var elementCollection = [] for (var i = 0; i < numElements; i++) { elementCollection.push(new Element(this.pageContext, selector, i)) } resolve(elementCollection) }) }) } /** * Returns all elements that match the current selector in the page. * @Deprecated */ async countElements (selector) { console.log('countElements is deprecated, use findElements().length instead.') var collection = await this.findElements(selector) return collection.length } /** * Resizes the page to a desired width and height. */ async resize (width, height) { debug('resizing to', width, height) this.pageContext.set('viewportSize', {width, height}) } /** * Executes a script within the page. */ async script (func, args) { debug('scripting page', func) if (!Array.isArray(args)) { args = [args] } return new Promise(resolve => { this.pageContext.evaluate((stringyFunc, args) => { var invoke = new Function( 'return ' + stringyFunc )() return invoke.apply(null, args) }, func.toString(), args, (err, result) => { if (err) { console.error(err) } resolve(result) }) }) } /** * Waits for an arbitrary amount of time, or an async function to resolve. * @param (Number|Function) */ async wait (waitFor = 1000, pollMs = 100) { debug('waiting for', waitFor) debug('waiting (pollMs)', pollMs) if (!(waitFor instanceof Function)) { return new Promise((resolve) => { setTimeout(resolve, waitFor) }) } else { let timeWaited = 0 return new Promise((resolve) => { var poll = async () => { var result = await waitFor() if (result) { resolve(result) } else if (timeWaited > this.waitTimeout) { this.onTimeout('Timeout while waiting.') } else { timeWaited += pollMs setTimeout(poll, pollMs) } } poll() }) } } /** * Called when wait or waitForElement times out. * Can be used as a hook to take screenshots. */ onTimeout (errMessage) { console.log('ghostjs timeout', errMessage) this.screenshot('timeout-' + Date.now()) throw new Error(errMessage) } /** * Waits for an element to exist in the page. */ async waitForElement (selector) { debug('waitForElement', selector) // Scoping gets broken within async promises, so bind these locally. var waitFor = this.wait.bind(this) var findElement = this.findElement.bind(this) return new Promise(async resolve => { var element = await waitFor(async () => { var el = await findElement(selector) if (el) { return el } return false }) resolve(element) }) } /** * Waits for an element to be hidden, or removed from the dom. */ async waitForElementNotVisible (selector) { debug('waitForElementNotVisible', selector) var waitFor = this.wait.bind(this) var findElement = this.findElement.bind(this) return new Promise(async resolve => { var isHidden = await waitFor(async () => { var el = await findElement(selector) return !el || !await el.isVisible() }) resolve(isHidden) }) } /** * Waits for an element to exist, and be visible. */ async waitForElementVisible (selector) { debug('waitForElementVisible', selector) var waitFor = this.wait.bind(this) var findElement = this.findElement.bind(this) return new Promise(async resolve => { var visibleEl = await waitFor(async () => { var el = await findElement(selector) if (el && await el.isVisible()) { return el } else { return false } }) resolve(visibleEl) }) } /** * Waits for a child page to be loaded. */ waitForPage (url) { debug('waitForPage', url) var waitFor = this.wait.bind(this) var childPages = this.childPages return new Promise(async resolve => { var page = await waitFor(async () => { return childPages.filter((val) => { return val.url.includes(url) }) }) resolve(page[0]) }) } /** * Waits for a condition to be met * @deprecated. */ async waitFor (func, pollMs = 100) { console.log('waitFor is deprecated, use wait(fn) instead.') return this.wait(func, pollMs) } /** * Clicks on an element specified by the selector * @param {String} selector */ async click (selector) { const el = await this.waitForElement(selector) await el.click() } /** * Fills an input with a specified value * @param {String} selector */ async fill (selector, fillValue) { const el = await this.waitForElement(selector) await el.fill(fillValue) } } var ghost = new Ghost() export default ghost export { getChromeFlags }