diff --git a/ghostjs-core/README.md b/ghostjs-core/README.md index 615835e..a0c140a 100644 --- a/ghostjs-core/README.md +++ b/ghostjs-core/README.md @@ -149,6 +149,10 @@ GHOST_CONSOLE=1 ./node_modules/.bin/ghostjs onResourceRequested - Pass a custom function to execute whenever a resource is requested. +### Running on electron. + +GhostJS can also be used to run your integration tests on electron. ```--ghost-protocol=electron``` (defaults to phantom). + ## Contributors Please see: [CONTRIBUTING.md](https://github.com/KevinGrandon/ghostjs/blob/master/CONTRIBUTING.md) diff --git a/ghostjs-core/package.json b/ghostjs-core/package.json index 21d69b0..090883c 100644 --- a/ghostjs-core/package.json +++ b/ghostjs-core/package.json @@ -36,6 +36,7 @@ ], "devDependencies": { "babel-eslint": "^7.1.0", + "electron": "^1.4.6", "standard": "^8.5.0" }, "standard": { diff --git a/ghostjs-core/src/element.js b/ghostjs-core/src/element.js index 34d9bae..753f609 100644 --- a/ghostjs-core/src/element.js +++ b/ghostjs-core/src/element.js @@ -2,12 +2,14 @@ export default class Element { /** * Creates a proxy to an element on the page. - * @param {object} page The current phantom/slimer page. + * @param {function} executeScript A method to script with the page. + * @param {function} uploadFiles A method to upload files to the page. * @param {string} selector The selector to locate the element. * @param {integer} lookupOffset The offset of the element. Used to lookup a single element in the case of a findElements() */ - constructor (page, selector, lookupOffset = 0) { - this.page = page + constructor (executeScript, uploadFile, selector, lookupOffset = 0) { + this.executeScript = executeScript + this.uploadFile = uploadFile this.selector = selector this.lookupOffset = lookupOffset } @@ -18,7 +20,7 @@ export default class Element { async mouse (method, xPos, yPos) { return new Promise(resolve => { - this.page.evaluate((selector, lookupOffset, mouseType, xPos, yPos) => { + resolve(this.executeScript((selector, lookupOffset, mouseType, xPos, yPos) => { try { var el = document.querySelectorAll(selector)[lookupOffset] var evt = document.createEvent('MouseEvents') @@ -41,10 +43,7 @@ export default class Element { return false } }, - this.selector, this.lookupOffset, method, xPos, yPos, - (err, result) => { - resolve(result) - }) + [this.selector, this.lookupOffset, method, xPos, yPos])) }) } @@ -54,7 +53,7 @@ export default class Element { async file (filePath) { return new Promise(resolve => { // TODO: This won't work for element collections (when this instance has an offset) - this.page.uploadFile(this.selector, filePath) + this.uploadFile(this.selector, filePath) resolve() }) } @@ -65,7 +64,7 @@ export default class Element { */ async fill (setFill) { return new Promise(resolve => { - this.page.evaluate((selector, lookupOffset, value) => { + resolve(this.executeScript((selector, lookupOffset, value) => { var el = document.querySelectorAll(selector)[lookupOffset] if (!el) { return null @@ -144,22 +143,16 @@ export default class Element { console.log('Unable to blur element ' + el.outerHTML + ': ' + e) } }, - this.selector, this.lookupOffset, setFill, - (err, result) => { - resolve(result) - }) + [this.selector, this.lookupOffset, setFill])) }) } async getAttribute (attribute) { return new Promise(resolve => { - this.page.evaluate((selector, lookupOffset, attribute) => { + resolve(this.executeScript((selector, lookupOffset, attribute) => { return document.querySelectorAll(selector)[lookupOffset][attribute] }, - this.selector, this.lookupOffset, attribute, - (err, result) => { - resolve(result) - }) + [this.selector, this.lookupOffset, attribute])) }) } @@ -173,7 +166,7 @@ export default class Element { async isVisible () { return new Promise(resolve => { - this.page.evaluate((selector, lookupOffset) => { + resolve(this.executeScript((selector, lookupOffset) => { var el = document.querySelectorAll(selector)[lookupOffset] var style try { @@ -193,16 +186,13 @@ export default class Element { } return el.clientHeight > 0 && el.clientWidth > 0 }, - this.selector, this.lookupOffset, - (err, result) => { - resolve(result) - }) + [this.selector, this.lookupOffset])) }) } async rect (func) { return new Promise(resolve => { - this.page.evaluate((selector, lookupOffset) => { + resolve(this.executeScript((selector, lookupOffset) => { var el = document.querySelectorAll(selector)[lookupOffset] if (!el) { return null @@ -218,10 +208,7 @@ export default class Element { width: rect.width } }, - this.selector, this.lookupOffset, - (err, result) => { - resolve(result) - }) + [this.selector, this.lookupOffset])) }) } @@ -231,7 +218,7 @@ export default class Element { } return new Promise(resolve => { - this.page.evaluate((func, selector, lookupOffset, args) => { + resolve(this.executeScript((func, selector, lookupOffset, args) => { var el = document.querySelectorAll(selector)[lookupOffset] args.unshift(el) var invoke = new Function( @@ -239,10 +226,7 @@ export default class Element { )(); return invoke.apply(null, args) }, - func.toString(), this.selector, this.lookupOffset, args, - (err, result) => { - resolve(result) - }) + [func.toString(), this.selector, this.lookupOffset, args])) }) } } diff --git a/ghostjs-core/src/ghostjs.js b/ghostjs-core/src/ghostjs.js index 46c9b06..c035c3d 100644 --- a/ghostjs-core/src/ghostjs.js +++ b/ghostjs-core/src/ghostjs.js @@ -1,46 +1,21 @@ var debug = require('debug')('ghost') -var driver = require('node-phantom-simple') var argv = require('yargs').argv -import Element from './element' + +import PhantomProtocol from './protocol/phantom' +import ElectronProtocol from './protocol/electron' class Ghost { constructor () { // Default timeout per wait. this.waitTimeout = 30000 - this.testRunner = argv['ghost-runner'] || 'phantomjs-prebuilt' - this.driverOpts = null - this.setDriverOpts({}) - this.browser = null - this.currentContext = null - this.page = null - this.childPages = [] - this.clientScripts = [] - - // 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']}) - } - } - - /** - * Sets options object that is used in driver creation. - */ - setDriverOpts (opts) { - debug('set driver opts', opts) - this.driverOpts = this.testRunner.match(/phantom/) - ? opts - : {} + let protocolType = argv['ghost-protocol'] || 'phantom' - if (opts.parameters) { - this.driverOpts.parameters = opts.parameters + if (protocolType === 'phantom') { + this.protocol = new PhantomProtocol(this); + } else { + this.protocol = new ElectronProtocol(this); } - - 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 } } /** @@ -49,20 +24,8 @@ class Ghost { */ 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) - }) + const args = Array.slice(arguments) + this.protocol.injectScripts.apply(this.protocol, args) } /** @@ -75,140 +38,22 @@ class Ghost { */ 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) => { - this.onOpen() - resolve(status) - }) - }) - } - - return new Promise(resolve => { - driver.create(this.driverOpts, (err, browser) => { - this.browser = browser - browser.createPage((err, page) => { - 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) - } - - /** - * 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) => { - this.onOpen() - resolve(status) - }) - }) - }) - }) + return this.protocol.open(url, options) } close () { debug('close') - if (this.page) { - this.page.close() - } - this.page = null - this.currentContext = null - } - - async exit () { - this.close() - 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; + return this.protocol.close() } goBack () { debug('goBack') - this.pageContext.goBack() + this.protocol.goBack() } goForward () { debug('goForward') - this.pageContext.goForward() - } - - screenshot (filename, folder='screenshots') { - filename = filename || 'screenshot-' + Date.now() - this.pageContext.render(`${folder}/${filename}.png`) - } - - /** - * Returns the title of the current page. - */ - async pageTitle () { - debug('getting pageTitle') - return new Promise(resolve => { - this.pageContext.evaluate(() => { return document.title }, - (err, result) => { - resolve(result) - }) - }) + this.protocol.goForward() } /** @@ -237,22 +82,7 @@ class Ghost { */ 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)) - }) - }) + return this.protocol.findElement(selector) } /** @@ -261,37 +91,7 @@ class Ghost { */ 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; + return this.protocol.findElements(selector) } /** @@ -299,7 +99,7 @@ class Ghost { */ async resize (width, height) { debug('resizing to', width, height) - this.pageContext.set('viewportSize', {width, height}) + return this.protocol.resize(width, height) } /** @@ -307,23 +107,7 @@ class Ghost { */ 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) => { - resolve(result) - }) - }) + return this.protocol.script(func, args) } /** @@ -362,8 +146,12 @@ class Ghost { */ onTimeout (errMessage) { console.log('ghostjs timeout', errMessage) - this.screenshot('timeout-' + Date.now()) - throw new Error(errMessage) + return this.protocol.onTimeout(errMessage) + } + + async exit () { + debug('exit') + return await this.protocol.exit() } /** @@ -425,27 +213,33 @@ class Ghost { /** * Waits for a child page to be loaded. */ - waitForPage (url) { + async 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]) - }) + return await this.protocol.waitForPage(url) } - /** - * 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) + async usePage (pagePattern) { + debug('usePage', pagePattern) + return await this.protocol.usePage(pagePattern) + } + + async pageTitle () { + debug('pageTitle') + return await this.protocol.pageTitle() + } + + async waitForPageTitle (expected) { + debug('waitForPageTitle', expected) + return await this.protocol.waitForPageTitle(expected) + } + + async setDriverOpts (options) { + debug('setDriverOpts', options) + if (!this.protocol.setDriverOpts) { + console.log('The test protocol does not support setDriverOpts.') + return + } + return await this.protocol.setDriverOpts(options) } } diff --git a/ghostjs-core/src/protocol/electron.js b/ghostjs-core/src/protocol/electron.js new file mode 100644 index 0000000..fb9010e --- /dev/null +++ b/ghostjs-core/src/protocol/electron.js @@ -0,0 +1,317 @@ +var debug = require('debug')('ghost:electron') +import {app, BrowserWindow} from 'electron'; +import Element from '../element' + +export default class ElectronProtocol { + constructor (ghost) { + // TEMP: Pull in utility functions from ghost. + this.wait = ghost.wait + + this.currentWin = null + this.domLoaded = false + + this.testRunner = 'ghost-electron' + } + + /** + * 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) + }) + } + + handleFailure = (event, code, details, failedUrl, isMainFrame) => { + if (isMainFrame) { + this.cleanup({ + message: 'navigation error', + code, + details, + url: failedUrl || this.url + }); + } + } + + handleDetails = (event, status, url, oldUrl, statusCode, method, referrer, headers, resourceType) => { + if (resourceType === 'mainFrame') { + this.responseDetails = { + url, + code, + method, + referrer, + headers + } + } + } + + handleDomReady = () => { + this.domLoaded = true + } + + handleFinish = (event) => { + this.cleanup(null, this.responseDetails) + } + + cleanup () { + this.currentWin.webContents.removeListener('did-fail-load', this.handleFailure); + this.currentWin.webContents.removeListener('did-fail-provisional-load', this.handleFailure); + this.currentWin.webContents.removeListener('did-get-response-details', this.handleDetails); + this.currentWin.webContents.removeListener('dom-ready', this.handleDomReady); + this.currentWin.webContents.removeListener('did-finish-load', this.handleFinish); + } + + + /** + * 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) + return new Promise(resolve => { + app.on('ready', async () => { + await this.createWindow(url, options) + resolve() + }) + }) + } + + async createWindow (url, options) { + this.url = url; + + // TODO: We can spawn an electron window here, or ideally communicate with an already opened window. + + this.currentWin = new BrowserWindow(); + this.currentWin.webContents.on('did-fail-load', this.handleFailure); + this.currentWin.webContents.on('did-fail-provisional-load', this.handleFailure); + this.currentWin.webContents.on('did-get-response-details', this.handleDetails); + this.currentWin.webContents.on('dom-ready', this.handleDomReady); + this.currentWin.webContents.on('did-finish-load', this.handleFinish); + this.currentWin.webContents.loadURL(url, loadUrlOptions); + } + + close () { + debug('close') + /* + if (this.page) { + this.page.close() + } + this.page = null + this.currentContext = null + */ + } + + async exit () { + /* + this.close() + 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.currentWin = null; + } else { + this.currentContext = await this.waitForPage(pagePattern) + } + } + + goBack () { + debug('goBack') + this.currentWin.webContents.goBack(); + } + + goForward () { + debug('goForward') + this.currentWin.webContents.goForward(); + } + + screenshot (filename, folder='screenshots') { + var done = (img) => { + img.toPng() + } + this.currentWin.capturePage(done) + } + + /** + * Returns the title of the current page. + */ + async pageTitle () { + debug('getting pageTitle') + return await this.script(() => { + return document.title + }); + } + + /** + * Waits for the page title to match a given state. + */ + async waitForPageTitle (expected) { + debug('waitForPageTitle') + // var waitFor = this.wait + // 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) + // }) + } + + makeElement (selector, offset) { + return new Element( + this.script.bind(this), + (selector, filePath) => console.log('not implemented yet'), + selector, + offset + ) + } + + /** + * 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(this.makeElement(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(async resolve => { + const numElements = await this.script((selector) => { + return document.querySelectorAll(selector).length + }) + + if (!numElements) { + return resolve(null) + } + + var elementCollection = [] + for (var i = 0; i < numElements; i++) { + elementCollection.push(this.makeElement(selector, i)) + } + resolve(elementCollection) + }) + } + + /** + * Resizes the page to a desired width and height. + */ + async resize (width, height) { + debug('resizing to', width, height) + this.currentWin.setSize(width, height) + } + + /** + * Executes a script within the page. + */ + async script (func, args) { + debug('scripting page', func) + return new Promise((resolve, reject) => { + if (!Array.isArray(args)) { + args = [args] + } + + const response = (event, response) => { + renderer.removeListener('error', error) + renderer.removeListener('log', log) + resolve(response) + } + + const error = (event, error) => { + renderer.removeListener('log', log) + renderer.removeListener('response', response) + reject(error) + } + + const log = (event, args) => console.log.bind(console) + + renderer.once('response', response) + renderer.once('error', error) + renderer.on('log', log) + + this.currentWin.webContents.executeJavaScript((stringyFunc, args) => { + var invoke = new Function( + "return " + stringyFunc + )(); + return invoke.apply(null, args) + }) + }) + } + + /** + * 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 a child page to be loaded. + */ + waitForPage (url) { + debug('waitForPage', url) + // var waitFor = this.wait + // 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]) + // }) + } +} diff --git a/ghostjs-core/src/protocol/phantom.js b/ghostjs-core/src/protocol/phantom.js new file mode 100644 index 0000000..8edff47 --- /dev/null +++ b/ghostjs-core/src/protocol/phantom.js @@ -0,0 +1,354 @@ +var debug = require('debug')('ghost:phantom') +var argv = require('yargs').argv +var driver = require('node-phantom-simple') +import Element from '../element' + +export default class PhantomProtocol { + constructor (ghost) { + // TEMP: Pull in utility functions from ghost. + this.wait = ghost.wait + + this.testRunner = argv['ghost-runner'] || 'phantomjs-prebuilt' + this.driverOpts = null + this.setDriverOpts({}) + this.browser = null + this.currentContext = null + this.page = null + this.childPages = [] + this.clientScripts = [] + + // 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']}) + } + } + + /** + * Sets options object that is used in driver creation. + */ + setDriverOpts (opts) { + debug('set driver opts', opts) + this.driverOpts = this.testRunner.match(/phantom/) + ? opts + : {} + + 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) => { + this.onOpen() + resolve(status) + }) + }) + } + + return new Promise(resolve => { + driver.create(this.driverOpts, (err, browser) => { + this.browser = browser + browser.createPage((err, page) => { + 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) + } + + /** + * 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) => { + this.onOpen() + resolve(status) + }) + }) + }) + }) + } + + close () { + debug('close') + if (this.page) { + this.page.close() + } + this.page = null + this.currentContext = null + } + + async exit () { + this.close() + 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() + } + + screenshot (filename, folder='screenshots') { + filename = filename || 'screenshot-' + Date.now() + this.pageContext.render(`${folder}/${filename}.png`) + } + + /** + * Returns the title of the current page. + */ + async pageTitle () { + debug('getting pageTitle') + return new Promise(resolve => { + this.pageContext.evaluate(() => { return document.title }, + (err, result) => { + resolve(result) + }) + }) + } + + /** + * Waits for the page title to match a given state. + */ + async waitForPageTitle (expected) { + debug('waitForPageTitle') + var waitFor = this.wait + 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) + }) + } + + makeElement (selector, offset) { + return new Element( + this.script.bind(this), + (selector, filePath) => this.pageContextuploadFile(selector, filePath), + selector, + offset + ) + } + + /** + * 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(this.makeElement(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(this.makeElement(selector, i)) + } + resolve(elementCollection) + }) + }) + } + + /** + * 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) => { + resolve(result) + }) + }) + } + + /** + * 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 a child page to be loaded. + */ + waitForPage (url) { + debug('waitForPage', url) + var waitFor = this.wait + 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]) + }) + } +} diff --git a/ghostjs-examples/package.json b/ghostjs-examples/package.json index 2acd4b2..f3e7f42 100644 --- a/ghostjs-examples/package.json +++ b/ghostjs-examples/package.json @@ -6,7 +6,8 @@ "scripts": { "test": "npm run test:phantom && npm run test:slimerjs", "test:phantom": "ghostjs test/*.js", - "test:slimerjs": "ghostjs --ghost-runner slimerjs-firefox test/*.js" + "test:slimerjs": "ghostjs --ghost-runner slimerjs-firefox test/*.js", + "test:electron": "ghostjs --ghost-protocol electron test/*.js" }, "author": "Kevin Grandon ", "license": "MPL-2.0", diff --git a/ghostjs-examples/test/find_element_test.js b/ghostjs-examples/test/find_element_test.js index d91e7a5..23d7216 100644 --- a/ghostjs-examples/test/find_element_test.js +++ b/ghostjs-examples/test/find_element_test.js @@ -16,12 +16,12 @@ describe('ghost#findElement', () => { assert.equal(await myElement.html(), 'myElement Content') }) - it('waitFor element state', async () => { + it('wait for element state', async () => { await ghost.open('http://localhost:8888/basic_content.html') let trigger = await ghost.findElement('#moreContentTrigger') await trigger.click() - var isVisible = await ghost.waitFor(async () => { + var isVisible = await ghost.wait(async () => { var findEl = await ghost.findElement('.moreContent') return findEl && await findEl.isVisible() }) diff --git a/ghostjs-examples/test/https_test.js b/ghostjs-examples/test/https_test.js index baa9eac..255dfce 100644 --- a/ghostjs-examples/test/https_test.js +++ b/ghostjs-examples/test/https_test.js @@ -23,7 +23,7 @@ describe('HTTPS server', () => { assert.equal(result, 'fail') ghost.exit() }) - if (ghost.testRunner.match(/phantom/)) { + if (ghost.protocol.testRunner.match(/phantom/)) { it('has a title', async () => { // Only works with PhantomJS, at present ghost.setDriverOpts({parameters: {'ignore-ssl-errors': 'yes'}}) diff --git a/ghostjs-examples/test/navigation_test.js b/ghostjs-examples/test/navigation_test.js index 6371ad5..a51bc28 100644 --- a/ghostjs-examples/test/navigation_test.js +++ b/ghostjs-examples/test/navigation_test.js @@ -15,12 +15,12 @@ describe('ghost#goBack/goForward', () => { assert.equal(await ghost.pageTitle(), 'Form') ghost.goBack() - await ghost.waitFor(async () => { + await ghost.wait(async () => { return await ghost.pageTitle() === 'Basic Content' }) ghost.goForward() - await ghost.waitFor(async () => { + await ghost.wait(async () => { return await ghost.pageTitle() === 'Form' }) }) diff --git a/ghostjs-examples/test/wait_for_test.js b/ghostjs-examples/test/wait_for_test.js index 1a284cc..5460fc2 100644 --- a/ghostjs-examples/test/wait_for_test.js +++ b/ghostjs-examples/test/wait_for_test.js @@ -4,7 +4,7 @@ import assert from 'assert' describe('ghost#waitFor', () => { it('we can wait', async () => { var curr = 0 - await ghost.waitFor(() => { + await ghost.wait(() => { curr++ return curr === 10 }, 10) diff --git a/ghostjs-examples/test/wait_test.js b/ghostjs-examples/test/wait_test.js index eea0b25..046a471 100644 --- a/ghostjs-examples/test/wait_test.js +++ b/ghostjs-examples/test/wait_test.js @@ -15,7 +15,7 @@ describe('ghost#wait', () => { it('waits for a function', async () => { var curr = 0 - await ghost.waitFor(() => { + await ghost.wait(() => { curr++ return curr === 10 }, 10)