From 7d97daae9a3852fdc0cd9c8815e50d313dd8e3c6 Mon Sep 17 00:00:00 2001 From: Santiago Aguiar Date: Sat, 2 Apr 2016 19:56:37 -0300 Subject: [PATCH] Added support for multiple reporters. --- bin/_mocha | 60 +++++++++++++++------ lib/mocha.js | 88 ++++++++++++++++++++++--------- lib/reporters/base.js | 70 ++++++++++++++++++++++++- lib/reporters/html-cov.js | 15 ++++-- lib/reporters/json-cov.js | 17 ++++-- lib/reporters/json.js | 15 ++++-- lib/reporters/markdown.js | 20 +++++-- lib/reporters/xunit.js | 55 +++----------------- test/integration/reporters.js | 41 +++++++++++++++ test/reporters/json.js | 2 +- test/reporters/multiple.js | 98 +++++++++++++++++++++++++++++++++++ 11 files changed, 374 insertions(+), 107 deletions(-) create mode 100644 test/reporters/multiple.js diff --git a/bin/_mocha b/bin/_mocha index 9004ca763b..73ae1ecf80 100755 --- a/bin/_mocha +++ b/bin/_mocha @@ -62,8 +62,8 @@ program .option('-c, --colors', 'force enabling of colors') .option('-C, --no-colors', 'force disabling of colors') .option('-G, --growl', 'enable growl notification support') - .option('-O, --reporter-options ', 'reporter-specific options') - .option('-R, --reporter ', 'specify the reporter to use', 'spec') + .option('-O, --reporter-options ', 'reporter-specific options, use name1:{k=v,k2=v2,...},name2:{...} for multiple reporters') + .option('-R, --reporter ', 'specify the reporter to use, or ,,... for multiple reporters.', list, ['spec']) .option('-S, --sort', "sort test files") .option('-b, --bail', "bail after first test failure") .option('-d, --debug', "enable node's debugger, synonym for node --debug") @@ -190,16 +190,41 @@ Error.stackTraceLimit = Infinity; // TODO: config var reporterOptions = {}; if (program.reporterOptions !== undefined) { - program.reporterOptions.split(",").forEach(function(opt) { - var L = opt.split("="); - if (L.length > 2 || L.length === 0) { - throw new Error("invalid reporter option '" + opt + "'"); - } else if (L.length === 2) { - reporterOptions[L[0]] = L[1]; - } else { - reporterOptions[L[0]] = true; + var reporterOptionsParser; + var multipleReporterFormat = /([^,:]+):{([^}]+)}/g; + if (program.reporterOptions.match(multipleReporterFormat)) { + // multiple reporter config: + // spec:{a=1,b=2},dot:{c=3,d=4} + var match; + while((match = multipleReporterFormat.exec(program.reporterOptions))) { + if (match.length !== 3) { + throw new Error("invalid multiple reporter options format '" + program.reporterOptions + "'"); + } + + var reporterName = match[1]; + var reporterOptionStr = match[2]; + + reporterOptionStr.split(',').forEach(function(reporterOpt) { + var split = reporterOpt.indexOf('='); + if (split === -1) { + throw new Error("invalid reporter option '" + reporterOpt + "'"); } + reporterOptions[reporterName] = reporterOptions[reporterName] || {}; + reporterOptions[reporterName][reporterOpt.substr(0, split)] = reporterOpt.substr(split + 1); + }); + }; + } else { + // single reporter config: + // a=1,b=2 + program.reporterOptions.split(',').forEach(function(opt) { + var L = opt.split('='); + if (L.length !== 2) { + throw new Error("invalid reporter option '" + opt + "'"); + } + reporterOptions._default = reporterOptions._default || {}; + reporterOptions._default[L[0]] = L[1]; }); + } } // reporter @@ -208,16 +233,17 @@ mocha.reporter(program.reporter, reporterOptions); // load reporter -var Reporter = null; -try { - Reporter = require('../lib/reporters/' + program.reporter); -} catch (err) { +program.reporter.forEach(function(reporterName) { try { - Reporter = require(program.reporter); + require('../lib/reporters/' + reporterName); } catch (err) { - throw new Error('reporter "' + program.reporter + '" does not exist'); + try { + require(reporterName); + } catch (err2) { + throw new Error('reporter "' + reporterName + '" does not exist'); + } } -} +}); // --no-colors diff --git a/lib/mocha.js b/lib/mocha.js index fc493d2d25..0cd3bfba78 100644 --- a/lib/mocha.js +++ b/lib/mocha.js @@ -10,7 +10,7 @@ var escapeRe = require('escape-string-regexp'); var path = require('path'); -var reporters = require('./reporters'); +var builtInReporters = require('./reporters'); var utils = require('./utils'); /** @@ -34,7 +34,7 @@ if (!process.browser) { exports.utils = utils; exports.interfaces = require('./interfaces'); -exports.reporters = reporters; +exports.reporters = builtInReporters; exports.Runnable = require('./runnable'); exports.Context = require('./context'); exports.Runner = require('./runner'); @@ -143,23 +143,40 @@ Mocha.prototype.addFile = function(file) { }; /** - * Set reporter to `reporter`, defaults to "spec". + * Set reporters to `reporters`, defaults to ['spec']. * - * @param {String|Function} reporter name or constructor - * @param {Object} reporterOptions optional options + * @param {String|Function|Array of strings|Array of functions} reporter name as + * string, reporter constructor, or array of constructors or reporter names as + * strings. + * @param {Object} reporterOptions optional options, keyed by reporter name, or + * '_default' for options to use when per-name options are not given. * @api public - * @param {string|Function} reporter name or constructor - * @param {Object} reporterOptions optional options */ -Mocha.prototype.reporter = function(reporter, reporterOptions) { - if (typeof reporter === 'function') { - this._reporter = reporter; - } else { - reporter = reporter || 'spec'; +Mocha.prototype.reporter = function(reporters, reporterOptions) { + // if no reporter is given as input, default to spec reporter + reporters = reporters || ['spec']; + reporterOptions = reporterOptions || {}; + + if (!Array.isArray(reporters)) { + if ((typeof reporters === 'string') + || (typeof reporters === 'function')) { + reporters = [reporters]; + } + } + + // Load each reporter + this._reporters = reporters.map(function(reporter) { + if (typeof reporter === 'function') { + return { + fn: reporter, + options: reporterOptions + }; + } + var _reporter; // Try to load a built-in reporter. - if (reporters[reporter]) { - _reporter = reporters[reporter]; + if (builtInReporters[reporter]) { + _reporter = builtInReporters[reporter]; } // Try to load reporters from process.cwd() and node_modules if (!_reporter) { @@ -179,9 +196,12 @@ Mocha.prototype.reporter = function(reporter, reporterOptions) { if (!_reporter) { throw new Error('invalid reporter "' + reporter + '"'); } - this._reporter = _reporter; - } - this.options.reporterOptions = reporterOptions; + return { + fn: _reporter, + options: reporterOptions[reporter] || reporterOptions._default || {} + }; + }, this); + return this; }; @@ -471,7 +491,14 @@ Mocha.prototype.run = function(fn) { var options = this.options; options.files = this.files; var runner = new exports.Runner(suite, options.delay); - var reporter = new this._reporter(runner, options); + + // For each loaded reporter constructor, create + // an instance and initialize it with the runner + var reporters = this._reporters.map(function(reporterConfig) { + var _reporter = reporterConfig.fn; + options.reporterOptions = reporterConfig.options; + return new _reporter(runner, options); + }); runner.ignoreLeaks = options.ignoreLeaks !== false; runner.fullStackTrace = options.fullStackTrace; runner.asyncOnly = options.asyncOnly; @@ -482,21 +509,32 @@ Mocha.prototype.run = function(fn) { if (options.globals) { runner.globals(options.globals); } + // Use only the first reporter for growl, since we don't want to + // send several notifications for the same test suite if (options.growl) { - this._growl(runner, reporter); + this._growl(runner, reporters[0]); } if (options.useColors !== undefined) { exports.reporters.Base.useColors = options.useColors; } exports.reporters.Base.inlineDiffs = options.useInlineDiffs; - function done(failures) { - if (reporter.done) { - reporter.done(failures, fn); - } else { - fn && fn(failures); + function runnerDone(failures) { + function reporterDone(failures) { + if (--remain === 0) { + fn && fn(failures); + } } + + var remain = reporters.length; + reporters.forEach(function(reporter) { + if (reporter.done) { + reporter.done(failures, reporterDone); + } else { + reporterDone(failures); + } + }); } - return runner.run(done); + return runner.run(runnerDone); }; diff --git a/lib/reporters/base.js b/lib/reporters/base.js index 5fe0eb71a6..2d533ceb55 100644 --- a/lib/reporters/base.js +++ b/lib/reporters/base.js @@ -3,6 +3,10 @@ */ var tty = require('tty'); +var fs = require('fs'); +var mkdirp = require('mkdirp'); +var path = require('path'); +var EOL = require('os').EOL; var diff = require('diff'); var ms = require('../ms'); var utils = require('../utils'); @@ -231,13 +235,16 @@ exports.list = function(failures) { * of tests passed / failed etc. * * @param {Runner} runner + * @param {Object} options runner optional options * @api public */ -function Base(runner) { +function Base(runner, options) { var stats = this.stats = { suites: 0, tests: 0, passes: 0, pending: 0, failures: 0 }; var failures = this.failures = []; + this.reporterOptions = options ? options.reporterOptions : null; + if (!runner) { return; } @@ -332,6 +339,67 @@ Base.prototype.epilogue = function() { console.log(); }; +/** + * Opens for writing the file referenced on the `optionName` option + * of the reporter options, if any. + * Call this method to support writing to this file when calling + * write or writeLine. + * + * @param {string} optionName the name of the option, defaults to 'output'. + * @api public + */ +Base.prototype.openOutput = function(optionName) { + var output = this.reporterOptions && this.reporterOptions[optionName || 'output']; + if (output) { + if (!fs.createWriteStream) { + throw new Error('file output not supported in browser'); + } + + mkdirp.sync(path.dirname(output)); + this.fileStream = fs.createWriteStream(output); + } +}; + +/** + * Write to reporter output stream. + * + * @param {string} str + * @api public + */ +Base.prototype.write = function(str) { + if (this.fileStream) { + this.fileStream.write(str); + } else { + process.stdout.write(str); + } +}; + +/** + * Write to reporter output stream, adding an EOL at the end. + * + * @param {string} line + * @api public + */ +Base.prototype.writeLine = function(line) { + this.write(line + EOL); +}; + +/** + * Close the output stream and callback with failures. + * + * @param failures + * @param {Function} fn + */ +Base.prototype.done = function(failures, fn) { + if (this.fileStream) { + this.fileStream.end(function() { + fn(failures); + }); + } else { + fn(failures); + } +}; + /** * Pad the given `str` to `len`. * diff --git a/lib/reporters/html-cov.js b/lib/reporters/html-cov.js index e3f2dd91e3..95fb54a83c 100644 --- a/lib/reporters/html-cov.js +++ b/lib/reporters/html-cov.js @@ -3,6 +3,7 @@ */ var JSONCov = require('./json-cov'); +var inherits = require('../utils').inherits; var readFileSync = require('fs').readFileSync; var join = require('path').join; @@ -17,24 +18,30 @@ exports = module.exports = HTMLCov; * * @api public * @param {Runner} runner + * @param {Object} options Runner optional options */ -function HTMLCov(runner) { +function HTMLCov(runner, options) { + JSONCov.call(this, runner, options, false); + var jade = require('jade'); var file = join(__dirname, '/templates/coverage.jade'); var str = readFileSync(file, 'utf8'); var fn = jade.compile(str, { filename: file }); var self = this; - JSONCov.call(this, runner, false); - runner.on('end', function() { - process.stdout.write(fn({ + self.write(fn({ cov: self.cov, coverageClass: coverageClass })); }); } +/** + * Inherit from `JSONCov.prototype`. + */ +inherits(HTMLCov, JSONCov); + /** * Return coverage class for a given coverage percentage. * diff --git a/lib/reporters/json-cov.js b/lib/reporters/json-cov.js index 5a32569f03..1f355f0d06 100644 --- a/lib/reporters/json-cov.js +++ b/lib/reporters/json-cov.js @@ -3,6 +3,7 @@ */ var Base = require('./base'); +var inherits = require('../utils').inherits; /** * Expose `JSONCov`. @@ -15,10 +16,11 @@ exports = module.exports = JSONCov; * * @api public * @param {Runner} runner - * @param {boolean} output + * @param {Object} options Runner optional options + * @param {boolean} output Whether or not write output. */ -function JSONCov(runner, output) { - Base.call(this, runner); +function JSONCov(runner, options, output) { + Base.call(this, runner, options); output = arguments.length === 1 || output; var self = this; @@ -26,6 +28,8 @@ function JSONCov(runner, output) { var failures = []; var passes = []; + this.openOutput(); + runner.on('test end', function(test) { tests.push(test); }); @@ -48,10 +52,15 @@ function JSONCov(runner, output) { if (!output) { return; } - process.stdout.write(JSON.stringify(result, null, 2)); + self.write(JSON.stringify(result, null, 2)); }); } +/** + * Inherit from `Base.prototype`. + */ +inherits(JSONCov, Base); + /** * Map jscoverage data to a JSON structure * suitable for reporting. diff --git a/lib/reporters/json.js b/lib/reporters/json.js index cd9ec286b7..94990beb04 100644 --- a/lib/reporters/json.js +++ b/lib/reporters/json.js @@ -3,6 +3,7 @@ */ var Base = require('./base'); +var inherits = require('../utils').inherits; /** * Expose `JSON`. @@ -15,9 +16,10 @@ exports = module.exports = JSONReporter; * * @api public * @param {Runner} runner + * @param {Object} options Runner optional options */ -function JSONReporter(runner) { - Base.call(this, runner); +function JSONReporter(runner, options) { + Base.call(this, runner, options); var self = this; var tests = []; @@ -25,6 +27,8 @@ function JSONReporter(runner) { var failures = []; var passes = []; + this.openOutput(); + runner.on('test end', function(test) { tests.push(test); }); @@ -52,10 +56,15 @@ function JSONReporter(runner) { runner.testResults = obj; - process.stdout.write(JSON.stringify(obj, null, 2)); + self.write(JSON.stringify(obj, null, 2)); }); } +/** + * Inherit from `Base.prototype`. + */ +inherits(JSONReporter, Base); + /** * Return a plain-object representation of `test` * free of cyclic properties etc. diff --git a/lib/reporters/markdown.js b/lib/reporters/markdown.js index 680c55d709..98ca9a6235 100644 --- a/lib/reporters/markdown.js +++ b/lib/reporters/markdown.js @@ -4,6 +4,7 @@ var Base = require('./base'); var utils = require('../utils'); +var inherits = utils.inherits; /** * Constants @@ -22,13 +23,17 @@ exports = module.exports = Markdown; * * @api public * @param {Runner} runner + * @param {Object} options runner optional options */ -function Markdown(runner) { - Base.call(this, runner); +function Markdown(runner, options) { + Base.call(this, runner, options); + var self = this; var level = 0; var buf = ''; + this.openOutput(); + function title(str) { return Array(level).join('#') + ' ' + str; } @@ -90,8 +95,13 @@ function Markdown(runner) { }); runner.on('end', function() { - process.stdout.write('# TOC\n'); - process.stdout.write(generateTOC(runner.suite)); - process.stdout.write(buf); + self.write('# TOC\n'); + self.write(generateTOC(runner.suite)); + self.write(buf); }); } + +/** + * Inherit from `Base.prototype`. + */ +inherits(Markdown, Base); diff --git a/lib/reporters/xunit.js b/lib/reporters/xunit.js index 875d592876..315fbfb27e 100644 --- a/lib/reporters/xunit.js +++ b/lib/reporters/xunit.js @@ -5,10 +5,7 @@ var Base = require('./base'); var utils = require('../utils'); var inherits = utils.inherits; -var fs = require('fs'); var escape = utils.escape; -var mkdirp = require('mkdirp'); -var path = require('path'); /** * Save timer references to avoid Sinon interfering (see GH-237). @@ -33,21 +30,16 @@ exports = module.exports = XUnit; * * @api public * @param {Runner} runner + * @param {Object} options Runner optional options */ function XUnit(runner, options) { - Base.call(this, runner); + Base.call(this, runner, options); var stats = this.stats; var tests = []; var self = this; - if (options.reporterOptions && options.reporterOptions.output) { - if (!fs.createWriteStream) { - throw new Error('file output not supported in browser'); - } - mkdirp.sync(path.dirname(options.reporterOptions.output)); - self.fileStream = fs.createWriteStream(options.reporterOptions.output); - } + self.openOutput(); runner.on('pending', function(test) { tests.push(test); @@ -62,7 +54,7 @@ function XUnit(runner, options) { }); runner.on('end', function() { - self.write(tag('testsuite', { + self.writeLine(tag('testsuite', { name: 'Mocha Tests', tests: stats.tests, failures: stats.failures, @@ -76,7 +68,7 @@ function XUnit(runner, options) { self.test(t); }); - self.write(''); + self.writeLine(''); }); } @@ -85,37 +77,6 @@ function XUnit(runner, options) { */ inherits(XUnit, Base); -/** - * Override done to close the stream (if it's a file). - * - * @param failures - * @param {Function} fn - */ -XUnit.prototype.done = function(failures, fn) { - if (this.fileStream) { - this.fileStream.end(function() { - fn(failures); - }); - } else { - fn(failures); - } -}; - -/** - * Write out the given line. - * - * @param {string} line - */ -XUnit.prototype.write = function(line) { - if (this.fileStream) { - this.fileStream.write(line + '\n'); - } else if (typeof process === 'object' && process.stdout) { - process.stdout.write(line + '\n'); - } else { - console.log(line); - } -}; - /** * Output tag for the given `test.` * @@ -130,11 +91,11 @@ XUnit.prototype.test = function(test) { if (test.state === 'failed') { var err = test.err; - this.write(tag('testcase', attrs, false, tag('failure', {}, false, cdata(escape(err.message) + '\n' + err.stack)))); + this.writeLine(tag('testcase', attrs, false, tag('failure', {}, false, cdata(escape(err.message) + '\n' + err.stack)))); } else if (test.isPending()) { - this.write(tag('testcase', attrs, false, tag('skipped', {}, true))); + this.writeLine(tag('testcase', attrs, false, tag('skipped', {}, true))); } else { - this.write(tag('testcase', attrs, true)); + this.writeLine(tag('testcase', attrs, true)); } }; diff --git a/test/integration/reporters.js b/test/integration/reporters.js index 19cf0bdd1e..80514ac0da 100644 --- a/test/integration/reporters.js +++ b/test/integration/reporters.js @@ -60,4 +60,45 @@ describe('reporters', function() { }); }); }); + + describe('json', function() { + it('prints test cases with --reporter-options output', function(done) { + var randomStr = crypto.randomBytes(8).toString('hex'); + var tmpDir = os.tmpDir().replace(new RegExp(path.sep + '$'), ''); + var tmpFile = tmpDir + path.sep + 'test-json-reporter-options-' + randomStr + '.json'; + + var args = ['--reporter=json', '--reporter-options', 'output=' + tmpFile]; + run('passing.js', args, function(err, result) { + if (err) return done(err); + + var json = fs.readFileSync(tmpFile, 'utf8'); + fs.unlinkSync(tmpFile); + var results = JSON.parse(json); + assert(results.passes && results.passes.length === 2, 'JSON did not contain the expected results.'); + done(err); + }); + }); + }); + + describe('multiple', function() { + it('supports multiple reporters and reporter options', function(done) { + var randomStr = crypto.randomBytes(8).toString('hex'); + var tmpDir = os.tmpDir().replace(new RegExp(path.sep + '$'), ''); + var tmpJsonFile = tmpDir + path.sep + 'test-multiple-reporters-' + randomStr + '.json'; + var tmpXmlFile = tmpDir + path.sep + 'test-multiple-reporters-' + randomStr + '.xml'; + + var args = ['--reporter=json,xunit', '--reporter-options', 'xunit:{output=' + tmpXmlFile + '},json:{output=' + tmpJsonFile + '}']; + run('passing.js', args, function(err, result) { + if (err) return done(err); + + var json = fs.readFileSync(tmpJsonFile, 'utf8'); + fs.unlinkSync(tmpJsonFile); + var xml = fs.readFileSync(tmpXmlFile, 'utf8'); + fs.unlinkSync(tmpXmlFile); + assert(!!json, 'JSON file is empty.'); + assert(!!xml, 'XML file is empty'); + done(err); + }); + }); + }); }); diff --git a/test/reporters/json.js b/test/reporters/json.js index e7b8955757..bb4e8648e9 100644 --- a/test/reporters/json.js +++ b/test/reporters/json.js @@ -12,7 +12,7 @@ describe('json reporter', function(){ }); suite = new Suite('JSON suite', 'root'); runner = new Runner(suite); - var mochaReporter = new mocha._reporter(runner); + var mochaReporter = new mocha._reporters[0].fn(runner); }); it('should have 1 test failure', function(done){ diff --git a/test/reporters/multiple.js b/test/reporters/multiple.js new file mode 100644 index 0000000000..4b2b621359 --- /dev/null +++ b/test/reporters/multiple.js @@ -0,0 +1,98 @@ +var fs = require('fs'); +var os = require('os'); +var path = require('path'); +var Mocha = require('../../'); +var Suite = Mocha.Suite; +var Runner = Mocha.Runner; +var Test = Mocha.Test; +var should = require('should'); + +describe('multiple reporters', function() { + var suite; + var runner; + var specReporter; + var jsonReporter; + + it('should have 1 test failure', function(done) { + var mocha = new Mocha({ + reporter: ['spec', 'json'] + }); + suite = new Suite('Multiple reporters suite', 'root'); + runner = new Runner(suite); + specReporter = new mocha._reporters[0].fn(runner); + jsonReporter = new mocha._reporters[1].fn(runner); + + var testTitle = 'json test 1'; + var error = { message: 'oh shit' }; + + suite.addTest(new Test(testTitle, function(done) { + done(new Error(error.message)); + })); + + runner.run(function() { + // Verify that each reporter ran + specReporter.should.have.property('failures'); + specReporter.failures.should.be.an.instanceOf(Array); + specReporter.failures.should.have.a.lengthOf(1); + + jsonReporter.should.have.property('failures'); + jsonReporter.failures.should.be.an.instanceOf(Array); + jsonReporter.failures.should.have.a.lengthOf(1); + done(); + }); + }); + + it('should pass correct reporter options and path to each reporter', function(done) { + var mocha = new Mocha({ + reporter: [ + 'spec', + 'dot', + 'json' + ], + reporterOptions: { + spec: { foo: 'bar' }, + json: { bar: 'baz' } + } + }); + + // specReporter + mocha._reporters[0].fn = function(runner, options) { + options.reporterOptions.should.have.property('foo', 'bar'); + }; + + // dot (no options) + mocha._reporters[1].fn = function(runner, options) { + options.reporterOptions.should.eql({}); + }; + + // json + mocha._reporters[2].fn = function(runner, options) { + options.reporterOptions.should.have.property('bar', 'baz'); + done(); + }; + + mocha.run(); + }); + + it('should pass _default reporter options to each reporter', function(done) { + var mocha = new Mocha({ + reporter: ['spec', 'json'], + reporterOptions: { + _default: { foo: 'bar' } + } + }); + + // specReporter + mocha._reporters[0].fn = function(runner, options) { + options.reporterOptions.should.have.property('foo', 'bar'); + }; + + // json + mocha._reporters[1].fn = function(runner, options) { + options.reporterOptions.should.have.property('foo', 'bar'); + done(); + }; + + mocha.run(); + }); +});