grunt-mocha-blanket
Version:
Headless Blanket.js code coverage and Mocha testing via PhantomJS
512 lines (417 loc) • 20 kB
JavaScript
// grunt-blanket-mocha 0.3.3
//
// Copyright (C) 2013 Dave Cadwallader, Model N, Inc.
// Distributed under the MIT License
//
// Documentation and full license available at:
// https://github.com/ModelN/grunt-blanket-qunit
//
// Based on grunt-mocha
// https://github.com/kmiyashiro/grunt-mocha
// Copyright (c) 2012 "Cowboy" Ben Alman, contributors
;
// Nodejs libs.
var _ = require('lodash');
var util = require('util');
var path = require('path');
var EventEmitter = require('events').EventEmitter;
var reporters = require('mocha').reporters;
// Helpers
var helpers = require('../support/mocha-helpers');
module.exports = function(grunt) {
var ok, totals, status, coverageThreshold, modulePattern, modulePatternRegex, excludedFiles, customThreshold, customModuleThreshold;
// External lib.
var phantomjs = require('grunt-lib-phantomjs').init(grunt);
var reporter;
// Growl is optional
var growl;
try {
growl = require('growl');
} catch(e) {
growl = function(){};
grunt.verbose.write('Growl not found, \'npm install growl\' for Growl support');
}
// Get an asset file, local to the root of the project.
var asset = path.join.bind(null, __dirname, '..');
var printPassFailMessage = function(name, numCovered, numTotal, threshold, printPassing) {
var percent = (numCovered / numTotal) * 100;
var pass = (percent >= threshold);
// If not passed, check if file is marked for manual exclusion. Else, fail it.
var exclude = _(excludedFiles).some(function (o) {return o.test(name);});
var result = pass ? "PASS" : ( exclude ? "SKIP" : "FAIL");
var percentDisplay = Math.floor(percent);
if (percentDisplay < 10) {
percentDisplay = " " + percentDisplay;
} else if (percentDisplay < 100) {
percentDisplay = " " + percentDisplay;
}
var operator;
if (percentDisplay > threshold) {
operator = ">";
} else if (percentDisplay < threshold) {
operator = "<";
} else {
operator = "=";
}
var msg = result + " [" + percentDisplay + "% " + operator + " " + threshold + "% ] : " +
name + " (" + numCovered + " / " + numTotal + ")";
status.blanketTotal++;
if (pass) {
status.blanketPass++;
if (printPassing || grunt.option('verbose')) {
grunt.log.writeln(msg.green);
}
} else if (result === "SKIP"){ //Visually mark that these have been skipped.
grunt.log.writeln(msg.magenta);
} else {
ok = false;
status.blanketFail++;
grunt.log.writeln(msg.red);
}
};
// Manage runners listening to phantomjs
var phantomjsEventManager = (function() {
var listeners = {};
var suites = [];
phantomjs.on('blanket:done', function() {
phantomjs.halt();
});
phantomjs.on('blanket:fileDone', function(thisTotal, filename) {
if (status.blanketPass === 0 && status.blanketFail === 0 ) {
grunt.log.writeln();
}
var coveredLines = thisTotal[0];
var totalLines = thisTotal[1];
var threshold = coverageThreshold;
// Check for a custom threshold
var custom = _.find(customThreshold, function (o) {
return o[0].test(filename);
});
if (custom !== undefined) {
threshold = custom[1];
}
printPassFailMessage(filename, coveredLines, totalLines, threshold);
totals.totalLines += totalLines;
totals.coveredLines += coveredLines;
if (modulePatternRegex) {
var match = filename.match(modulePatternRegex);
if (!match) return;
var moduleName = match[1];
if(!totals.moduleTotalStatements.hasOwnProperty(moduleName)) {
totals.moduleTotalStatements[moduleName] = 0;
totals.moduleTotalCoveredStatements[moduleName] = 0;
}
totals.moduleTotalStatements[moduleName] += totalLines;
totals.moduleTotalCoveredStatements[moduleName] += coveredLines;
}
});
// Hook on Phantomjs Mocha reporter events.
phantomjs.on('mocha.*', function(test) {
var name, fullTitle, slow, err;
var evt = this.event.replace('mocha.', '');
// Expand test values (and façace the Mocha test object)
if (test) {
fullTitle = test.fullTitle;
test.fullTitle = function() {
return fullTitle;
};
slow = this.slow;
test.slow = function() {
return slow;
};
test.parent = suites[suites.length - 1] || null;
err = test.err;
}
if (evt === 'suite') {
suites.push(test);
} else if (evt === 'suite end') {
suites.pop(test);
}
// Trigger events for each runner listening
for (name in listeners) {
listeners[name].emit.call(listeners[name], evt, test, err);
}
});
return {
add: function(name, runner) {
listeners[name] = runner;
},
remove: function(name) {
delete listeners[name];
}
};
}());
// Built-in error handlers.
phantomjs.on('fail.load', function(url) {
phantomjs.halt();
grunt.verbose.write('Running PhantomJS...').or.write('...');
grunt.log.error();
grunt.warn('PhantomJS unable to load "' + url + '" URI.', 90);
});
phantomjs.on('fail.timeout', function() {
phantomjs.halt();
grunt.log.writeln();
grunt.warn('PhantomJS timed out, possibly due to a missing Mocha run() call.', 90);
});
// Debugging messages.
phantomjs.on('debug', grunt.log.debug.bind(grunt.log, 'phantomjs'));
// ==========================================================================
// TASKS
// ==========================================================================
grunt.registerMultiTask('blanket_mocha', 'Run Mocha unit tests in a headless PhantomJS instance.', function() {
// Merge task-specific and/or target-specific options with these defaults.
var options = this.options({
// Output console.log calls
log: false,
// Mocha reporter
reporter: 'Dot',
// Default PhantomJS timeout.
timeout: 5000,
// Mocha-PhantomJS bridge file to be injected.
inject: asset('phantomjs/bridge.js'),
// Main PhantomJS script file
phantomScript: asset('phantomjs/main.js'),
// Explicit non-file URLs to test.
urls: [],
// Fail with grunt.warn on first test failure
bail: false,
// Log script errors as grunt errors
logErrors: false
});
ok = true;
totals = {
totalLines: 0,
coveredLines: 0,
moduleTotalStatements : {},
moduleTotalCoveredStatements : {}
};
status = {blanketTotal: 0, blanketPass: 0, blanketFail: 0};
coverageThreshold = grunt.option('threshold') || options.threshold;
modulePattern = grunt.option('modulePattern') || options.modulePattern;
if (modulePattern) {
modulePatternRegex = new RegExp(modulePattern);
}
var grep = grunt.option('grep');
options.mocha = options.mocha || {};
// Get the array of excludedFiles, normalize and prepare them
// Users should be able to define it in the command-line as an array or include it in the test file.
excludedFiles = grunt.option('excludedFiles') || options.excludedFiles || [];
excludedFiles = _(excludedFiles).map(function (o) {
return new RegExp(path.normalize(o) + '$');
}).value();
// Get the custom thresholds for files, normalize and prepare the file names
customThreshold = grunt.option('customThreshold') || options.customThreshold || {};
customThreshold = _(customThreshold).pairs().map(function (o) {
return [new RegExp(path.normalize(o[0]) + '$'), o[1]];
}).value();
customModuleThreshold = grunt.option('customModuleThreshold') || options.customModuleThreshold|| {};
if (grep) {
options.mocha.grep = grep;
}
// Output console messages if log == true
if (options.log) {
phantomjs.removeAllListeners(['console']);
phantomjs.on('console', grunt.log.writeln);
} else {
phantomjs.off('console', grunt.log.writeln);
}
// Output errors on script errors
if (options.logErrors) {
phantomjs.on('error.*', function(error, stack) {
var formattedStack = _.map(stack, function(frame) {
return " at " + (frame.function ? frame.function : "undefined") + " (" + frame.file + ":" + frame.line + ")";
}).join("\n");
grunt.fail.warn(error + "\n" + formattedStack, 3);
});
}
var optsStr = JSON.stringify(options, null, ' ');
grunt.verbose.writeln('Options: ' + optsStr);
// Clean Phantomjs options to prevent any conflicts
var PhantomjsOptions = _.omit(options, 'reporter', 'urls', 'log', 'bail');
var phantomOptsStr = JSON.stringify(PhantomjsOptions, null, ' ');
grunt.verbose.writeln('Phantom options: ' + phantomOptsStr);
// Combine any specified URLs with src files.
var urls = options.urls.concat(_.compact(this.filesSrc));
// Remember all stats from all tests
var testStats = [];
// This task is asynchronous.
var done = this.async();
// Hijack console.log to capture reporter output
var dest = options.dest;
var output = [];
var consoleLog = console.log;
// Latest mocha xunit reporter sends to process.stdout instead of console
var processWrite = process.stdout.write;
// Only hijack if we really need to
if (dest) {
grunt.file.delete(dest);
console.log = function() {
consoleLog.apply(console, arguments);
// FIXME: This breaks older versions of mocha
// processWrite.apply(process.stdout, arguments);
output.push(util.format.apply(util, arguments));
};
}
// Process each filepath in-order.
grunt.util.async.forEachSeries(urls, function(url, next) {
grunt.log.writeln('Testing: ' + url);
// create a new mocha runner façade
var runner = new EventEmitter();
phantomjsEventManager.add(url, runner);
// Clear runner event listener when test is over
runner.on('end', function() {
phantomjsEventManager.remove(url);
});
// Set Mocha reporter
var Reporter = null;
if (reporters[options.reporter]) {
Reporter = reporters[options.reporter];
} else {
// Resolve external reporter module
var externalReporter;
try {
externalReporter = require.resolve(options.reporter);
} catch (e) {
// Resolve to local path
externalReporter = path.resolve(options.reporter);
}
if (externalReporter) {
try {
Reporter = require(externalReporter);
}
catch (e) { }
}
}
if (Reporter === null) {
grunt.fatal('Specified reporter is unknown or unresolvable: ' + options.reporter);
}
reporter = new Reporter(runner, options);
// Launch PhantomJS.
phantomjs.spawn(url, {
// Exit code to use if PhantomJS fails in an uncatchable way.
failCode: 90,
// Additional PhantomJS options.
options: PhantomjsOptions,
// Do stuff when done.
done: function(err) {
var stats = runner.stats;
testStats.push(stats);
if (err) {
// Show Growl notice
// @TODO: Get an example of this
// growl('PhantomJS Error!');
// If there was a PhantomJS error, abort the series.
grunt.fatal(err);
done(false);
} else {
// If failures, show growl notice
if (stats.failures > 0) {
var reduced = helpers.reduceStats([stats]);
var failMsg = reduced.failures + '/' + reduced.tests +
' tests failed (' + reduced.duration + 's)';
// Show Growl notice, if avail
growl(failMsg, {
image: asset('growl/error.png'),
title: 'Failure in ' + grunt.task.current.target,
priority: 3
});
// Bail tests if bail option is true
if (options.bail) grunt.warn(failMsg);
}
// Process next file/url
next();
}
}
});
},
// All tests have been run.
function() {
if (dest) {
// Restore console.log to original and write the output
console.log = consoleLog;
if (!grunt.file.exists(dest)) {
// Write only if our reporter ignored our `output` option
grunt.file.write(dest, output.join('\n'));
}
}
grunt.log.writeln();
var customThresholdMsg = "", failMsg = "";
var numCustomThreshold = Object.keys(customThreshold ).length;
if (numCustomThreshold) {
customThresholdMsg = "; " + numCustomThreshold + " files used custom threshold";
}
grunt.log.writeln("Per-File Coverage Results: (" + coverageThreshold + "% minimum" + customThresholdMsg + ")");
if (status.blanketFail > 0) {
failMsg = "FAIL : " + (status.blanketFail + "/" + status.blanketTotal + " files failed coverage");
grunt.log.write(failMsg.red);
grunt.log.writeln();
ok = false;
} else {
var blanketPassMsg = "PASS : " + status.blanketPass + " files passed coverage ";
grunt.log.write(blanketPassMsg.green);
grunt.log.writeln();
}
var moduleThreshold = grunt.option('moduleThreshold') || options.moduleThreshold;
if (moduleThreshold) {
grunt.log.writeln();
var customModuleThresholdMsg = "";
var numCustomModuleThreshold = Object.keys(customModuleThreshold ).length;
if (numCustomModuleThreshold) {
customModuleThresholdMsg = "; " + numCustomModuleThreshold + " modules used custom threshold";
}
grunt.log.writeln("Per-Module Coverage Results: (" + moduleThreshold + "% minimum" + customModuleThresholdMsg + ")");
if (modulePatternRegex) {
for (var thisModuleName in totals.moduleTotalStatements) {
if (totals.moduleTotalStatements.hasOwnProperty(thisModuleName)) {
var moduleTotalSt = totals.moduleTotalStatements[thisModuleName];
var moduleTotalCovSt = totals.moduleTotalCoveredStatements[thisModuleName];
var threshold = moduleThreshold;
if (customModuleThreshold[thisModuleName]) {
threshold = customModuleThreshold[thisModuleName];
}
printPassFailMessage(thisModuleName, moduleTotalCovSt, moduleTotalSt, threshold, /*printPassing*/true);
}
}
}
}
var globalThreshold = grunt.option('globalThreshold') || options.globalThreshold;
if (globalThreshold) {
grunt.log.writeln();
grunt.log.writeln("Global Coverage Results: (" + globalThreshold + "% minimum)");
printPassFailMessage("global", totals.coveredLines, totals.totalLines, globalThreshold, /*printPassing*/true);
}
grunt.log.writeln();
grunt.log.write("Unit Test Results: ");
var stats = helpers.reduceStats(testStats);
if (stats.failures === 0) {
var okMsg = stats.tests + ' specs passed!' + ' (' + stats.duration + 's)';
growl(okMsg, {
image: asset('growl/ok.png'),
title: 'Tests passed',
priority: 3
});
grunt.log.write(okMsg.green);
grunt.log.writeln();
} else {
ok = false;
failMsg = stats.failures + '/' + stats.tests + ' tests failed (' +
stats.duration + 's)';
// Show Growl notice, if avail
growl(failMsg, {
image: asset('growl/error.png'),
title: 'Failure in ' + grunt.task.current.target,
priority: 3
});
grunt.log.write(failMsg.red);
grunt.log.writeln();
}
if (!ok) {
grunt.warn("Issues were found.");
} else {
grunt.log.ok("No issues found.");
}
// Async test done
done();
});
});
};