UNPKG

chimpy

Version:

Develop acceptance tests & end-to-end tests with realtime feedback.

710 lines (589 loc) 19.3 kB
"use strict"; /** * Externals */ var async = require('async'), path = require('path'), chokidar = require('chokidar'), _ = require('underscore'), log = require('./log'), freeport = require('freeport'), DDPClient = require('xolvio-ddp'), fs = require('fs'), Hapi = require('hapi'), AutoupdateWatcher = require('./ddp-watcher'), colors = require('colors'), booleanHelper = require('./boolean-helper'), Versions = require('../lib/versions'); colors.enabled = true; var DEFAULT_COLOR = 'yellow'; /** * Internals */ var Mocha = require('./mocha/mocha.js'); var Jasmine = require('./jasmine/jasmine.js'); var Cucumber = require('./cucumberjs/cucumber.js'); var Phantom = require('./phantom.js'); var Chromedriver = require('./chromedriver.js'); var Consoler = require('./consoler.js'); var Selenium = require('./selenium.js'); var SimianReporter = require('./simian-reporter.js'); /** * Exposes the binary path * * @api public */ Chimp.bin = path.resolve(__dirname, path.join('..', 'bin', 'chimp')); Chimp.install = function (callback) { log.debug('[chimp]', 'Installing dependencies'); new Selenium({ port: '1' }).install(callback); }; /** * Chimp Constructor * * Options: * - `browser` browser to run tests in * * @param {Object} options * @api public */ function Chimp(options) { this.chokidar = chokidar; this.options = options || {}; this.processes = []; this.isInterrupting = false; this.exec = require('child_process').exec; this.fs = fs; this.testRunnerRunOrder = []; this.watcher = undefined; // store all cli parameters in env hash // Note: Environment variables are always strings. for (var option in options) { if (option === 'ddp') { handleDdpOption(options); } else { process.env["chimp.".concat(option)] = _.isObject(options[option]) ? JSON.stringify(options[option]) : String(options[option]); } } this._handleChimpInterrupt(); } function handleDdpOption(options) { if (typeof options.ddp === 'string') { process.env['chimp.ddp0'] = String(options.ddp); return; } if (Array.isArray(options.ddp)) { options.ddp.forEach(function (val, index) { process.env["chimp.ddp".concat(index)] = String(val); }); } } /** * Runs an npm install then calls selectMode * * @param {Function} callback * @api public */ Chimp.prototype.init = function (callback) { var self = this; this.informUser(); try { this._initSimianResultBranch(); this._initSimianBuildNumber(); } catch (error) { callback(error); return; } if (this.options.versions || this.options.debug) { var versions = new Versions(this.options); if (this.options.debug) { versions.show(function () { self.selectMode(callback); }); } else { versions.show(); } } else { self.selectMode(callback); } }; Chimp.prototype.informUser = function () { if (this.options.showXolvioMessages) { log.info('\nMaster Chimp and become a testing Ninja! Check out our course: '.green + 'http://bit.ly/2btQaFu\n'.blue.underline); } if (booleanHelper.isTruthy(this.options.criticalSteps)) { this.options.e2eSteps = this.options.criticalSteps; log.warn('[chimp] Please use e2eSteps instead of criticalSteps. criticalSteps is now deprecated.'.red); } if (booleanHelper.isTruthy(this.options.criticalTag)) { this.options.e2eTags = this.options.criticalTag; log.warn('[chimp] Please use e2eTags instead of criticalTag. criticalTag is now deprecated.'.red); } if (booleanHelper.isTruthy(this.options.mochaTags) || booleanHelper.isTruthy(this.options.mochaGrep) || booleanHelper.isTruthy(this.options.mochaTimeout) || booleanHelper.isTruthy(this.options.mochaReporter) || booleanHelper.isTruthy(this.options.mochaSlow)) { log.warn('[chimp] mochaXYZ style configs are now deprecated. Please use a mochaConfig object.'.red); } }; Chimp.prototype._initSimianResultBranch = function () { // Automatically set the result branch for the common CI tools if (this.options.simianAccessToken && this.options.simianResultBranch === null) { if (booleanHelper.isTruthy(process.env.CI_BRANCH)) { // Codeship or custom this.options.simianResultBranch = process.env.CI_BRANCH; } else if (booleanHelper.isTruthy(process.env.CIRCLE_BRANCH)) { // CircleCI this.options.simianResultBranch = process.env.CIRCLE_BRANCH; } else if (booleanHelper.isTruthy(process.env.TRAVIS_BRANCH)) { // TravisCI if (booleanHelper.isFalsey(process.env.TRAVIS_PULL_REQUEST)) { this.options.simianResultBranch = process.env.TRAVIS_BRANCH; } else { // Ignore the builds that simulate the pull request merge, // because the branch will be the target branch. this.options.simianResultBranch = false; } } else { throw new Error('You have not specified the branch that should be reported to Simian!' + ' Do this with the --simianResultBranch argument' + ' or the CI_BRANCH environment variable.'); } } }; Chimp.prototype._initSimianBuildNumber = function _initSimianBuildNumber() { // Automatically set the result branch for the common CI tools if (this.options.simianAccessToken) { if (process.env.CI_BUILD_NUMBER) { // Codeship or custom this.options.simianBuildNumber = process.env.CI_BUILD_NUMBER; } else if (process.env.CIRCLE_BUILD_NUM) { // CircleCI this.options.simianBuildNumber = process.env.CIRCLE_BUILD_NUM; } else if (process.env.TRAVIS_BUILD_NUMBER) { // TravisCI this.options.simianBuildNumber = process.env.TRAVIS_BUILD_NUMBER; } } }; /** * Decides which mode to run and kicks it off * * @param {Function} callback * @api public */ Chimp.prototype.selectMode = function (callback) { if (booleanHelper.isTruthy(this.options.watch)) { this.watch(); } else if (booleanHelper.isTruthy(this.options.server)) { this.server(); } else { this.run(callback); } }; /** * Watches the file system for changes and reruns when it detects them * * @api public */ Chimp.prototype.watch = function () { var self = this; var watchDirectories = []; if (self.options.watchSource) { watchDirectories = self.options.watchSource.split(','); } if (self.options.e2eSteps) { watchDirectories.push(self.options.e2eSteps); } if (self.options.domainSteps) { watchDirectories.push(self.options.domainSteps); } watchDirectories.push(self.options.path); this.watcher = chokidar.watch(watchDirectories, { ignored: /[\/\\](\.|node_modules)/, ignoreInitial: true, persistent: true, usePolling: this.options.watchWithPolling }); // set cucumber tags to be watch based if (booleanHelper.isTruthy(self.options.watchTags)) { self.options.tags = self.options.watchTags; } if (booleanHelper.isTruthy(self.options.ddp)) { var autoUpdateWatcher = new AutoupdateWatcher(self.options); autoUpdateWatcher.watch(function () { log.debug('[chimp] Meteor autoupdate detected'); self.rerun(); }); } // wait for initial file scan to complete this.watcher.once('ready', function () { var watched = []; if (_.isArray(self.options.watchTags)) { _.each(self.options.watchTags, function (watchTag) { watched.push(watchTag.split(',')); }); } else if (_.isString(self.options.watchTags)) { watched.push(self.options.watchTags.split(',')); } log.info("[chimp] Watching features with tagged with ".concat(watched.join()).white); // start watching self.watcher.on('all', self._getDebouncedFunction(function (event, path) { // removing feature files should not rerun if (event === 'unlink' && path.match(/\.feature$/)) { return; } log.debug('[chimp] file changed'); self.rerun(); }, 500)); log.debug('[chimp] watcher ready, running for the first time'); self.rerun(); }); }; Chimp.prototype._getDebouncedFunction = function (func, timeout) { return _.debounce(func, timeout); }; /** * Starts a chimp server on a freeport or on options.serverPort if provided * * @api public */ Chimp.prototype.server = function () { var self = this; if (!this.options.serverPort) { freeport(function (error, port) { if (error) { throw error; } self._startServer(port); }); } else { self._startServer(this.options.serverPort); } }; Chimp.prototype._startServer = function (port) { var server = new Hapi.Server(); server.connection({ host: this.options.serverHost, port: port, routes: { timeout: { server: false, socket: false } } }); this._setupRoutes(server); server.start(); log.info('[chimp] Chimp server is running on port', port, process.env['chimp.ddp']); if (booleanHelper.isTruthy(this.options.ddp)) { this._handshakeOverDDP(); } }; Chimp.prototype._handshakeOverDDP = function () { var ddp = new DDPClient({ host: process.env['chimp.ddp'].match(/http:\/\/(.*):/)[1], port: process.env['chimp.ddp'].match(/:([0-9]+)/)[1], ssl: false, autoReconnect: true, autoReconnectTimer: 500, maintainCollections: true, ddpVersion: '1', useSockJs: true }); ddp.connect(function (error) { if (error) { log.error('[chimp] Error handshaking via DDP'); throw error; } }).then(function () { log.debug('[chimp] Handshaking with DDP server'); ddp.call('handshake').then(function () { log.debug('[chimp] Handshake complete, closing DDP connection'); ddp.close(); }); }); }; Chimp.prototype._parseResult = function (res) { // FIXME this is shitty, there's got to be a nicer way to deal with variable async chains var cucumberResults = res[1][1] ? res[1][1] : res[1][0]; if (!cucumberResults) { log.error('[chimp] Could not get Cucumber Results from run result:'); log.error(res); } log.debug('[chimp] Responding to /run request with:'); log.debug(cucumberResults); return cucumberResults; }; Chimp.prototype._setupRoutes = function (server) { var self = this; server.route({ method: 'GET', path: '/run', handler: function handler(request, reply) { self.rerun(function (err, res) { var cucumberResults = self._parseResult(res); reply(cucumberResults).header('Content-Type', 'application/json'); }); } }); server.route({ method: 'GET', path: '/run/{absolutePath*}', handler: function handler(request, reply) { // / XXX is there a more elegant way we can do this? self.options._[2] = request.params.absolutePath; self.rerun(function (err, res) { var cucumberResults = self._parseResult(res); reply(cucumberResults).header('Content-Type', 'application/json'); }); } }); server.route({ method: 'GET', path: '/interrupt', handler: function handler(request, reply) { self.interrupt(function (err, res) { reply('done').header('Content-Type', 'application/json'); }); } }); server.route({ method: 'GET', path: '/runAll', handler: function handler(request, reply) { self.options._tags = self.options.tags; self.options.tags = '~@ignore'; self.rerun(function (err, res) { self.options.tags = self.options._tags; var cucumberResults = self._parseResult(res); reply(cucumberResults).header('Content-Type', 'application/json'); }); } }); }; /** * Starts servers and runs specs * * @api public */ Chimp.prototype.run = function (callback) { log.info('\n[chimp] Running...'[DEFAULT_COLOR]); var self = this; function getJsonCucumberResults(result) { var startProcessesIndex = 1; if (!result || !result[startProcessesIndex]) { return []; } var jsonResult = '[]'; _.any(['domain', 'e2e', 'generic'], function (type) { var _testRunner = _.findWhere(self.testRunnerRunOrder, { name: 'cucumber', type: type }); if (_testRunner) { jsonResult = result[startProcessesIndex][_testRunner.index]; return true; } }); return JSON.parse(jsonResult); } async.series([self.interrupt.bind(self), self._startProcesses.bind(self), self.interrupt.bind(self)], function (error, result) { if (error) { log.debug('[chimp] run complete with errors', error); if (booleanHelper.isFalsey(self.options.watch)) { self.interrupt(function () {}); } } else { log.debug('[chimp] run complete'); } if (self.options.simianAccessToken && self.options.simianResultBranch !== false) { var jsonCucumberResult = getJsonCucumberResults(result); var simianReporter = new SimianReporter(self.options); simianReporter.report(jsonCucumberResult, function () { callback(error, result); }); } else { callback(error, result); } }); }; /** * Interrupts any running specs in the reverse order. This allows cucumber to shut down first * before webdriver servers, otherwise we can get test errors in the console * * @api public */ Chimp.prototype.interrupt = function (callback) { log.debug('[chimp] interrupting'); var self = this; self.isInterrupting = true; if (!self.processes || self.processes.length === 0) { self.isInterrupting = false; log.debug('[chimp] no processes to interrupt'); if (callback) { callback(); } return; } log.debug('[chimp]', self.processes.length, 'processes to interrupt'); var reverseProcesses = []; while (self.processes.length !== 0) { reverseProcesses.push(self.processes.pop()); } var processes = _.collect(reverseProcesses, function (process) { return process.interrupt.bind(process); }); async.series(processes, function (error, r) { self.isInterrupting = false; log.debug('[chimp] Finished interrupting processes'); if (error) { log.error('[chimp] with errors', error); } if (callback) { callback.apply(this, arguments); } }); }; /** * Combines the interrupt and run methods and latches calls * * @api public */ Chimp.prototype.rerun = function (callback) { log.debug('[chimp] rerunning'); var self = this; if (self.isInterrupting) { log.debug('[chimp] interrupt in progress, ignoring rerun'); return; } self.run(function (err, res) { if (callback) { callback(err, res); } log.debug('[chimp] finished rerun'); }); }; /** * Starts processes in series * * @api private */ Chimp.prototype._startProcesses = function (callback) { var self = this; self.processes = self._createProcesses(); var processes = self.processes.map(function (process) { return process.start.bind(process); }); // pushing at least one processes guarantees the series below runs processes.push(function (callback) { log.debug('[chimp] Finished running async processes'); callback(); }); async.series(processes, function (err, res) { if (err) { self.isInterrupting = false; log.debug('[chimp] Finished running async processes with errors'); } callback(err, res); }); }; /** * Creates the correct sequence of servers needed prior to running cucumber * * @api private */ Chimp.prototype._createProcesses = function () { var processes = []; var self = this; var addTestRunnerToRunOrder = function addTestRunnerToRunOrder(name, type) { self.testRunnerRunOrder.push({ name: name, type: type, index: processes.length - 1 }); }; var userHasNotProvidedSeleniumHost = function userHasNotProvidedSeleniumHost() { return booleanHelper.isFalsey(self.options.host); }; var userHasProvidedBrowser = function userHasProvidedBrowser() { return booleanHelper.isTruthy(self.options.browser); }; if (!this.options.domainOnly) { if (this.options.browser === 'phantomjs') { process.env['chimp.host'] = this.options.host = 'localhost'; var phantom = new Phantom(this.options); processes.push(phantom); } else if (userHasProvidedBrowser() && userHasNotProvidedSeleniumHost()) { process.env['chimp.host'] = this.options.host = 'localhost'; var selenium = new Selenium(this.options); processes.push(selenium); } else if (userHasNotProvidedSeleniumHost()) { // rewrite the browser to be chrome since "chromedriver" is not a valid browser process.env['chimp.browser'] = this.options.browser = 'chrome'; process.env['chimp.host'] = this.options.host = 'localhost'; var chromedriver = new Chromedriver(this.options); processes.push(chromedriver); } } if (booleanHelper.isTruthy(this.options.mocha)) { var mocha = new Mocha(this.options); processes.push(mocha); } else if (booleanHelper.isTruthy(this.options.jasmine)) { var jasmine = new Jasmine(this.options); processes.push(jasmine); } else if (booleanHelper.isTruthy(this.options.e2eSteps) || booleanHelper.isTruthy(this.options.domainSteps)) { // domain scenarios if (booleanHelper.isTruthy(this.options.domainSteps)) { var options = JSON.parse(JSON.stringify(this.options)); if (options.r) { options.r = _.isArray(options.r) ? options.r : [options.r]; } else { options.r = []; } var message = '\n[chimp] domain scenarios...'; options.r.push(options.domainSteps); if (booleanHelper.isTruthy(options.fullDomain)) { delete options.tags; } if (!this.options.domainOnly) { processes.push(new Consoler(message[DEFAULT_COLOR])); } processes.push(new Cucumber(options)); addTestRunnerToRunOrder('cucumber', 'domain'); processes.push(new Consoler('')); } if (booleanHelper.isTruthy(this.options.e2eSteps)) { // e2e scenarios var _options = JSON.parse(JSON.stringify(this.options)); if (_options.r) { _options.r = _.isArray(_options.r) ? _options.r : [_options.r]; } else { _options.r = []; } _options.tags = _options.tags.split(','); _options.tags.push(_options.e2eTags); _options.tags = _options.tags.join(); var _message = "\n[chimp] ".concat(_options.e2eTags, " scenarios ..."); _options.r.push(_options.e2eSteps); processes.push(new Consoler(_message[DEFAULT_COLOR])); processes.push(new Cucumber(_options)); addTestRunnerToRunOrder('cucumber', 'e2e'); processes.push(new Consoler('')); } } else { var cucumber = new Cucumber(this.options); processes.push(cucumber); addTestRunnerToRunOrder('cucumber', 'generic'); } return processes; }; /** * Uses process.kill wen interrupted by Meteor so that Selenium shuts down correctly for node 0.10.x * * @api private */ Chimp.prototype._handleChimpInterrupt = function () { var self = this; process.on('SIGINT', function () { log.debug('[chimp] SIGINT detected, killing process'); process.stdin.end(); self.interrupt(); if (booleanHelper.isTruthy(self.options.watch)) { self.watcher.close(); } }); }; module.exports = Chimp;