chimpy
Version:
Develop acceptance tests & end-to-end tests with realtime feedback.
710 lines (589 loc) • 19.3 kB
JavaScript
"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;