From 6d89b6354ccdd7b31a0163d4abb8822b5ac72348 Mon Sep 17 00:00:00 2001 From: Clay Jensen-Reimann Date: Tue, 16 Apr 2019 20:56:33 -0500 Subject: [PATCH] Feature: Ant mode and Jenkins mode (#82) --- README.md | 23 +++- index.js | 204 ++++++++++++++++++++--------- package.json | 3 +- test/mocha-junit-reporter-spec.js | 106 ++++++++++++++- test/resources/JUnit.xsd | 206 ++++++++++++++++++++++++++++++ test/resources/jenkins-junit.xsd | 118 +++++++++++++++++ 6 files changed, 590 insertions(+), 70 deletions(-) create mode 100644 test/resources/JUnit.xsd create mode 100644 test/resources/jenkins-junit.xsd diff --git a/README.md b/README.md index 3ff4293..038a273 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # JUnit Reporter for Mocha -[![Build Status](https://travis-ci.org/michaelleeallen/mocha-junit-reporter.svg?branch=master)](https://travis-ci.org/michaelleeallen/mocha-junit-reporter) -[![npm](https://img.shields.io/npm/v/mocha-junit-reporter.svg?maxAge=2592000)](https://www.npmjs.com/package/mocha-junit-reporter) +[![Build Status](travis-badge)](travis-build) +[![npm](npm-badge)](npm-listing) Produces JUnit-style XML test results. @@ -47,7 +47,7 @@ var mocha = new Mocha({ You can also add properties to the report under `testsuite`. This is useful if you want your CI environment to add extra build props to the report for analytics purposes -``` +```xml @@ -104,10 +104,10 @@ var mocha = new Mocha({ Here is an example of the XML output when using the `testCaseSwitchClassnameAndName` option: -| value | XML output | -|----------------------------------|--------| -| `true` | `` | -| `false` (default) | `` | +| value | XML output | +|-------------------|------------| +| `true` | `` | +| `false` (default) | `` | You can also configure the `testsuites.name` attribute by setting `reporterOptions.testsuitesTitle` and the root suite's `name` attribute by setting `reporterOptions.rootSuiteTitle`. @@ -192,3 +192,12 @@ output line 2 | testsuitesTitle | the name for the `testsuites` tag (defaults to 'Mocha Tests') | | outputs | if set to truthy value will include console output and console error output | | attachments | if set to truthy value will attach files to report in `JUnit Attachments Plugin` format (after console outputs, if any) | +| antMode | set to truthy value to return xml compatible with [Ant JUnit schema][ant-schema] | +| antHostname | hostname to use when running in `antMode` will default to environment `HOSTNAME` | +| jenkinsMode | if set to truthy value will return xml that will display nice results in Jenkins | + +[travis-badge]: https://travis-ci.org/michaelleeallen/mocha-junit-reporter.svg?branch=master +[travis-build]: https://travis-ci.org/michaelleeallen/mocha-junit-reporter +[npm-badge]: https://img.shields.io/npm/v/mocha-junit-reporter.svg?maxAge=2592000 +[npm-listing]: https://www.npmjs.com/package/mocha-junit-reporter +[ant-schema]: http://windyroad.org/dl/Open%20Source/JUnit.xsd diff --git a/index.js b/index.js index d074f05..c49ef29 100644 --- a/index.js +++ b/index.js @@ -19,19 +19,72 @@ function configureDefaults(options) { debug(options); options = options || {}; options = options.reporterOptions || {}; - options.mochaFile = options.mochaFile || process.env.MOCHA_FILE || 'test-results.xml'; - options.properties = options.properties || parsePropertiesFromEnv(process.env.PROPERTIES) || null; - options.attachments = options.attachments || process.env.ATTACHMENTS || false; + options.mochaFile = getSetting(options.mochaFile, 'MOCHA_FILE', 'test-results.xml'); + options.attachments = getSetting(options.attachments, 'ATTACHMENTS', false); + options.antMode = getSetting(options.antMode, 'ANT_MODE', false); + options.jenkinsMode = getSetting(options.jenkinsMode, 'JENKINS_MODE', false); + options.properties = getSetting(options.properties, 'PROPERTIES', null, parsePropertiesFromEnv); options.toConsole = !!options.toConsole; - options.testCaseSwitchClassnameAndName = options.testCaseSwitchClassnameAndName || false; - options.suiteTitleSeparedBy = options.suiteTitleSeparedBy || ' '; - options.suiteTitleSeparatedBy = options.suiteTitleSeparatedBy || options.suiteTitleSeparedBy || ' '; options.rootSuiteTitle = options.rootSuiteTitle || 'Root Suite'; options.testsuitesTitle = options.testsuitesTitle || 'Mocha Tests'; + if (options.antMode) { + updateOptionsForAntMode(options); + } + + if (options.jenkinsMode) { + updateOptionsForJenkinsMode(options); + } + + options.suiteTitleSeparedBy = options.suiteTitleSeparedBy || ' '; + options.suiteTitleSeparatedBy = options.suiteTitleSeparatedBy || options.suiteTitleSeparedBy; + return options; } +function updateOptionsForAntMode(options) { + options.antHostname = getSetting(options.antHostname, 'ANT_HOSTNAME', process.env.HOSTNAME); + + if (!options.properties) { + options.properties = {}; + } +} + +function updateOptionsForJenkinsMode(options) { + if (options.useFullSuiteTitle === undefined) { + options.useFullSuiteTitle = true; + } + debug('jenkins mode - testCaseSwitchClassnameAndName', options.testCaseSwitchClassnameAndName); + if (options.testCaseSwitchClassnameAndName === undefined) { + options.testCaseSwitchClassnameAndName = true; + } + if (options.suiteTitleSeparedBy === undefined) { + options.suiteTitleSeparedBy = '.'; + } +} + +/** + * Determine an option value. + * 1. If `key` is present in the environment, then use the environment value + * 2. If `value` is specified, then use that value + * 3. Fall back to `defaultVal` + * @module mocha-junit-reporter + * @param {Object} value - the value from the reporter options + * @param {String} key - the environment variable to check + * @param {Object} defaultVal - the fallback value + * @param {function} transform - a transformation function to be used when loading values from the environment + */ +function getSetting(value, key, defaultVal, transform) { + if (process.env[key] !== undefined) { + var envVal = process.env[key]; + return (typeof transform === 'function') ? transform(envVal) : envVal; + } + if (value !== undefined) { + return value; + } + return defaultVal; +} + function defaultSuiteTitle(suite) { if (suite.root && suite.title === '') { return stripAnsi(this._options.rootSuiteTitle); @@ -60,35 +113,39 @@ function isInvalidSuite(suite) { } function parsePropertiesFromEnv(envValue) { - var properties = null; - if (envValue) { - properties = {}; - var propertiesArray = envValue.split(','); - for (var i = 0; i < propertiesArray.length; i++) { - var propertyArgs = propertiesArray[i].split(':'); - properties[propertyArgs[0]] = propertyArgs[1]; - } + debug('Parsing from env', envValue); + return envValue.split(',').reduce(function(properties, prop) { + var property = prop.split(':'); + properties[property[0]] = property[1]; + return properties; + }, []); } - return properties; + return null; } function generateProperties(options) { - var properties = []; - for (var propertyName in options.properties) { - if (options.properties.hasOwnProperty(propertyName)) { - properties.push({ - property: { - _attr: { - name: propertyName, - value: options.properties[propertyName] - } - } - }); - } + var props = options.properties; + if (!props) { + return []; + } + return Object.keys(props).reduce(function(properties, name) { + var value = props[name]; + properties.push({ property: { _attr: { name: name, value: value } } }); + return properties; + }, []); +} + +function getJenkinsClassname (test) { + debug('Building jenkins classname for', test); + var parent = test.parent; + var titles = []; + while (parent) { + parent.title && titles.unshift(parent.title); + parent = parent.parent; } - return properties; + return titles.join('.'); } /** @@ -101,6 +158,7 @@ function MochaJUnitReporter(runner, options) { this._options = configureDefaults(options); this._runner = runner; this._generateSuiteTitle = this._options.useFullSuiteTitle ? fullSuiteTitle : defaultSuiteTitle; + this._antId = 0; var testsuites = []; @@ -153,29 +211,35 @@ function MochaJUnitReporter(runner, options) { * @return {Object} - an object representing the xml node */ MochaJUnitReporter.prototype.getTestsuiteData = function(suite) { - var testSuite = { - testsuite: [ - { - _attr: { - name: this._generateSuiteTitle(suite), - timestamp: new Date().toISOString().slice(0,-5), - tests: suite.tests.length - } - } - ] + var antMode = this._options.antMode; + + var _attr = { + name: this._generateSuiteTitle(suite), + timestamp: new Date().toISOString().slice(0,-5), + tests: suite.tests.length }; + var testSuite = { testsuite: [ { _attr: _attr } ] }; + if(suite.file) { testSuite.testsuite[0]._attr.file = suite.file; } var properties = generateProperties(this._options); - if (properties.length) { + if (properties.length || antMode) { testSuite.testsuite.push({ properties: properties }); } + if (antMode) { + _attr.package = _attr.name; + _attr.hostname = this._options.antHostname; + _attr.id = this._antId; + _attr.errors = 0; + this._antId += 1; + } + return testSuite; }; @@ -186,10 +250,11 @@ MochaJUnitReporter.prototype.getTestsuiteData = function(suite) { * @returns {object} */ MochaJUnitReporter.prototype.getTestcaseData = function(test, err) { + var jenkinsMode = this._options.jenkinsMode; var flipClassAndName = this._options.testCaseSwitchClassnameAndName; - var name = stripAnsi(test.fullTitle()); + var name = stripAnsi(jenkinsMode ? getJenkinsClassname(test) : test.fullTitle()); var classname = stripAnsi(test.title); - var config = { + var testcase = { testcase: [{ _attr: { name: flipClassAndName ? classname : name, @@ -213,11 +278,11 @@ MochaJUnitReporter.prototype.getTestcaseData = function(test, err) { )); } if (systemOutLines.length > 0) { - config.testcase.push({'system-out': this.removeInvalidCharacters(stripAnsi(systemOutLines.join('\n')))}); + testcase.testcase.push({'system-out': this.removeInvalidCharacters(stripAnsi(systemOutLines.join('\n')))}); } if (this._options.outputs && (test.consoleErrors && test.consoleErrors.length > 0)) { - config.testcase.push({'system-err': this.removeInvalidCharacters(stripAnsi(test.consoleErrors.join('\n')))}); + testcase.testcase.push({'system-err': this.removeInvalidCharacters(stripAnsi(test.consoleErrors.join('\n')))}); } if (err) { @@ -238,9 +303,9 @@ MochaJUnitReporter.prototype.getTestcaseData = function(test, err) { _cdata: this.removeInvalidCharacters(failureMessage) }; - config.testcase.push({failure: failureElement}); + testcase.testcase.push({failure: failureElement}); } - return config; + return testcase; }; /** @@ -278,14 +343,16 @@ MochaJUnitReporter.prototype.getXml = function(testsuites) { var totalSuitesTime = 0; var totalTests = 0; var stats = this._runner.stats; - var hasProperties = !!this._options.properties; + var antMode = this._options.antMode; + var hasProperties = (!!this._options.properties) || antMode; testsuites.forEach(function(suite) { var _suiteAttr = suite.testsuite[0]._attr; - // properties are added before test cases so we want to make sure that we are grabbing test cases - // at the correct index + // testsuite is an array: [attrs, properties?, testcase, testcase, …] + // we want to make sure that we are grabbing test cases at the correct index var _casesIndex = hasProperties ? 2 : 1; var _cases = suite.testsuite.slice(_casesIndex); + var missingProps; _suiteAttr.failures = 0; _suiteAttr.time = 0; @@ -299,6 +366,20 @@ MochaJUnitReporter.prototype.getXml = function(testsuites) { _suiteAttr.time += testcase.testcase[0]._attr.time; }); + if (antMode) { + missingProps = ['system-out', 'system-err']; + suite.testsuite.forEach(function(item) { + missingProps = missingProps.filter(function(prop) { + return !item[prop]; + }); + }); + missingProps.forEach(function(prop) { + var obj = {}; + obj[prop] = []; + suite.testsuite.push(obj); + }); + } + if (!_suiteAttr.skipped) { delete _suiteAttr.skipped; } @@ -307,22 +388,23 @@ MochaJUnitReporter.prototype.getXml = function(testsuites) { totalTests += _suiteAttr.tests; }); - var rootSuite = { - _attr: { - name: this._options.testsuitesTitle, - time: totalSuitesTime, - tests: totalTests, - failures: stats.failures - } - }; - if (stats.pending) { - rootSuite._attr.skipped = stats.pending; + if (!antMode) { + var rootSuite = { + _attr: { + name: this._options.testsuitesTitle, + time: totalSuitesTime, + tests: totalTests, + failures: stats.failures + } + }; + if (stats.pending) { + rootSuite._attr.skipped = stats.pending; + } + testsuites = [ rootSuite ].concat(testsuites); } - return xml({ - testsuites: [ rootSuite ].concat(testsuites) - }, { declaration: true, indent: ' ' }); + return xml({ testsuites: testsuites }, { declaration: true, indent: ' ' }); }; /** diff --git a/package.json b/package.json index c764c02..4cac311 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "chai": "^3.0.0", "chai-xml": "^0.3.0", "eslint": "^4.0.0", - "mocha": "^3.0.0", + "libxmljs": "^0.19.5", + "mocha": "^5.0.0", "test-console": "^1.0.0" }, "dependencies": { diff --git a/test/mocha-junit-reporter-spec.js b/test/mocha-junit-reporter-spec.js index 39e8708..9f81419 100644 --- a/test/mocha-junit-reporter-spec.js +++ b/test/mocha-junit-reporter-spec.js @@ -10,6 +10,7 @@ var path = require('path'); var chai = require('chai'); var expect = chai.expect; +var libxml = require("libxmljs"); var chaiXML = require('chai-xml'); var mockXml = require('./mock-results'); var testConsole = require('test-console'); @@ -268,7 +269,6 @@ describe('mocha-junit-reporter', function() { return reporter; } - }); describe('when "outputs" option is specified', function() { @@ -556,4 +556,108 @@ describe('mocha-junit-reporter', function() { expect(testCase.testcase[0]._attr.classname).to.equal(mockedTestCase.fullTitle()); }); }); + + describe('XML format', function () { + var suites = [ + {testsuite: + {title: '', root: true, suites: [2], tests: [0]} + }, + {testsuite: + {title: 'Inner Suite', suites: [1], tests: [1]}, pass: [ + {title: 'test', fullTitle: 'Inner Suite test'} + ] + }, + {testsuite: + {title: 'Another Suite', suites: [1], tests: [1]}, fail: [ + {title: 'fail test', fullTitle: 'Another Suite fail test', error: new Error('failed test')} + ] + } + ]; + + it('generates Jenkins compatible XML when in jenkinsMode', function() { + var reporter = configureReporter({jenkinsMode: true }, suites); + var xml = reporter.getXml(reporter.suites); + var xsd = fs.readFileSync(path.join(__dirname, 'resources', 'jenkins-junit.xsd')); + + var xsdDoc = libxml.parseXml(xsd); + var xmlDoc = libxml.parseXml(xml); + + xmlDoc.validate(xsdDoc); + + expect(xmlDoc.validationErrors).to.be.deep.equal([]); + }); + + it('generates Ant compatible XML when in antMode', function() { + var reporter = configureReporter({antMode: true }, suites); + var xml = reporter.getXml(reporter.suites); + var xsd = fs.readFileSync(path.join(__dirname, 'resources', 'JUnit.xsd')); + + var xsdDoc = libxml.parseXml(xsd); + var xmlDoc = libxml.parseXml(xml); + + xmlDoc.validate(xsdDoc); + + expect(xmlDoc.validationErrors).to.be.deep.equal([]); + }); + + describe('Jenkins format', function () { + var suites = [ + { + testsuite: { + title: 'Inner Suite', + suites: [1], + tests: [1] + }, + pass: [ {title: 'test', fullTitle: 'Inner Suite test'} ], + suites: [ { + testsuite: { + title: 'Another Suite', + suites: [1], + tests: [1] + }, + fail: [ {title: 'fail test', fullTitle: 'Another Suite fail test', error: new Error('failed test')}] + } ] + }, + ]; + + it('generates Jenkins compatible classnames and suite name', function() { + var reporter = configureReporter({jenkinsMode: true}, suites); + + debug('testcase', reporter.suites[0].testsuite[1].testcase[0]) + expect(reporter.suites[0].testsuite[0]._attr.name).to.equal(suites[0].testsuite.title); + expect(reporter.suites[0].testsuite[1].testcase[0]._attr.name).to.equal(suites[0].pass[0].title); + expect(reporter.suites[0].testsuite[1].testcase[0]._attr.classname).to.equal(suites[0].testsuite.title); + expect(reporter.suites[1].testsuite[0]._attr.name).to.equal(suites[0].testsuite.title + '.' + suites[0].suites[0].testsuite.title); + expect(reporter.suites[1].testsuite[1].testcase[0]._attr.name).to.equal(suites[0].suites[0].fail[0].title); + expect(reporter.suites[1].testsuite[1].testcase[0]._attr.classname).to.equal(suites[0].testsuite.title + '.' + suites[0].suites[0].testsuite.title); + }); + }); + + function configureReporter(options, suites) { + var reporter = createReporter(options); + + reporter.flush = function(suites) { + reporter.suites = suites; + }; + + (suites || []).forEach(startSuite.bind(this, null)); + runner.end(); + + return reporter; + } + + function startSuite (parent, suite) { + runner.startSuite(suite.testsuite); + ['pass', 'fail', 'pending'].forEach(function (key) { + if (suite[key]) { + suite[key].forEach(function (test) { + var instance = new Test(test.fullTitle || test.title, test.title, 1); + instance.parent = suite.testsuite; + runner[key](instance, test.error); + }); + } + }); + (suite.suites || []).forEach(startSuite.bind(this, suite)); + } + }); }); diff --git a/test/resources/JUnit.xsd b/test/resources/JUnit.xsd new file mode 100644 index 0000000..44b841f --- /dev/null +++ b/test/resources/JUnit.xsd @@ -0,0 +1,206 @@ + + + + + JUnit test result schema for the Apache Ant JUnit and JUnitReport tasks +Copyright © 2011, Windy Road Technology Pty. Limited +The Apache Ant JUnit XML Schema is distributed under the terms of the GNU Lesser General Public License (LGPL) http://www.gnu.org/licenses/lgpl.html +Permission to waive conditions of this license may be requested from Windy Road Support (http://windyroad.org/support). + + + + + + + + + + Contains an aggregation of testsuite results + + + + + + + + + + Derived from testsuite/@name in the non-aggregated documents + + + + + Starts at '0' for the first testsuite and is incremented by 1 for each following testsuite + + + + + + + + + + + + Contains the results of exexuting a testsuite + + + + + Properties (e.g., environment settings) set during test execution + + + + + + + + + + + + + + + + + + + + + + + + Indicates that the test errored. An errored test is one that had an unanticipated problem. e.g., an unchecked throwable; or a problem with the implementation of the test. Contains as a text node relevant data for the error, e.g., a stack trace + + + + + + + The error message. e.g., if a java exception is thrown, the return value of getMessage() + + + + + The type of error that occured. e.g., if a java execption is thrown the full class name of the exception. + + + + + + + + + Indicates that the test failed. A failure is a test which the code has explicitly failed by using the mechanisms for that purpose. e.g., via an assertEquals. Contains as a text node relevant data for the failure, e.g., a stack trace + + + + + + + The message specified in the assert + + + + + The type of the assert. + + + + + + + + + + Name of the test method + + + + + Full class name for the class the test method is in. + + + + + Time taken (in seconds) to execute the test + + + + + + + Data that was written to standard out while the test was executed + + + + + + + + + + Data that was written to standard error while the test was executed + + + + + + + + + + + Full class name of the test for non-aggregated testsuite documents. Class name without the package for aggregated testsuites documents + + + + + + + + + + when the test was executed. Timezone may not be specified. + + + + + Host on which the tests were executed. 'localhost' should be used if the hostname cannot be determined. + + + + + + + + + + The total number of tests in the suite + + + + + The total number of tests in the suite that failed. A failure is a test which the code has explicitly failed by using the mechanisms for that purpose. e.g., via an assertEquals + + + + + The total number of tests in the suite that errorrd. An errored test is one that had an unanticipated problem. e.g., an unchecked throwable; or a problem with the implementation of the test. + + + + + Time taken (in seconds) to execute the tests in the suite + + + + + + + + + \ No newline at end of file diff --git a/test/resources/jenkins-junit.xsd b/test/resources/jenkins-junit.xsd new file mode 100644 index 0000000..bf815d8 --- /dev/null +++ b/test/resources/jenkins-junit.xsd @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file