Skip to content

Commit

Permalink
[CI] Produce junit test reports (#15281)
Browse files Browse the repository at this point in the history
* [mocha] use custom reporter for legible results in jenkins

* [jest] use custom result processor for legible results in jenkins

* [karma] enable junit output on CI

* [mocha/junitReporter] accept rootDirectory as configuration

* [jest/reporter] use reporters option added in jest 20

* [toolingLog] remove black/white specific colors

* [dev/mocha/junit] no reason for junit to be a "reporter"

* typos

* [dev/mocha/junit] use else if

* [karma/junit] use string#replace for explicitness

* [junit] use test file path as "classname"

* [ftr/mocha] no longer a "console" specific reporter
  • Loading branch information
spalger committed Dec 6, 2017
1 parent fa28e75 commit f71ec29
Show file tree
Hide file tree
Showing 32 changed files with 332 additions and 33 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@
"karma-coverage": "1.1.1",
"karma-firefox-launcher": "1.0.1",
"karma-ie-launcher": "1.0.0",
"karma-junit-reporter": "1.2.0",
"karma-mocha": "1.3.0",
"karma-safari-launcher": "1.0.0",
"keymirror": "0.1.1",
Expand All @@ -294,6 +295,7 @@
"supertest-as-promised": "2.0.2",
"tree-kill": "1.1.0",
"webpack-dev-server": "2.9.1",
"xmlbuilder": "9.0.4",
"yeoman-generator": "1.1.1",
"yo": "2.0.0"
},
Expand Down
2 changes: 1 addition & 1 deletion scripts/jest.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@
// See all cli options in https://facebook.github.io/jest/docs/cli.html

require('../src/babel-register');
require('../src/jest/cli');
require('../src/dev/jest/cli');
4 changes: 4 additions & 0 deletions src/dev/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export { createToolingLog } from './tooling_log';
export {
createAutoJunitReporter,
setupJunitReportGeneration,
} from './mocha';
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ const babelJest = require('babel-jest');

module.exports = babelJest.createTransformer({
presets: [
require.resolve('../babel-preset/node')
require.resolve('../../babel-preset/node')
]
});
File renamed without changes.
16 changes: 10 additions & 6 deletions src/jest/config.json → src/dev/jest/config.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"rootDir": "../../",
"rootDir": "../../..",
"roots": [
"<rootDir>/src/ui/public",
"<rootDir>/src/core_plugins",
Expand All @@ -18,12 +18,12 @@
"^ui_framework/services": "<rootDir>/ui_framework/services",
"^ui_framework/src/test": "<rootDir>/ui_framework/src/test",
"^ui/(.*)": "<rootDir>/src/ui/public/$1",
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/src/jest/mocks/file_mock.js",
"\\.(css|less|scss)$": "<rootDir>/src/jest/mocks/style_mock.js"
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/src/dev/jest/mocks/file_mock.js",
"\\.(css|less|scss)$": "<rootDir>/src/dev/jest/mocks/style_mock.js"
},
"setupFiles": [
"<rootDir>/src/jest/setup/babel_polyfill.js",
"<rootDir>/src/jest/setup/request_animation_frame_polyfill.js"
"<rootDir>/src/dev/jest/setup/babel_polyfill.js",
"<rootDir>/src/dev/jest/setup/request_animation_frame_polyfill.js"
],
"coverageDirectory": "<rootDir>/target/jest-coverage",
"coverageReporters": [
Expand All @@ -42,12 +42,16 @@
"<rootDir>/ui_framework/generator-kui/"
],
"transform": {
"^.+\\.js$": "<rootDir>/src/jest/babelTransform.js"
"^.+\\.js$": "<rootDir>/src/dev/jest/babel_transform.js"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.js$"
],
"snapshotSerializers": [
"<rootDir>/node_modules/enzyme-to-json/serializer"
],
"reporters": [
"default",
"<rootDir>/src/dev/jest/junit_reporter.js"
]
}
99 changes: 99 additions & 0 deletions src/dev/jest/junit_reporter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { resolve, dirname, relative } from 'path';
import { writeFileSync } from 'fs';

import mkdirp from 'mkdirp';
import xmlBuilder from 'xmlbuilder';

const ROOT_DIR = dirname(require.resolve('../../../package.json'));

/**
* Jest reporter that produces JUnit report when running on CI
* @class JestJunitReporter
*/
export default class JestJunitReporter {
constructor(globalConfig, options = {}) {
const {
reportName = 'Jest Tests',
rootDirectory = ROOT_DIR
} = options;

this._reportName = reportName;
this._rootDirectory = rootDirectory;
}

/**
* Called by jest when all tests complete
* @param {Object} contexts
* @param {JestResults} results see https://facebook.github.io/jest/docs/en/configuration.html#testresultsprocessor-string
* @return {undefined}
*/
onRunComplete(contexts, results) {
if (!process.env.CI) {
return;
}

const reportName = this._reportName;
const rootDirectory = this._rootDirectory;
const root = xmlBuilder.create(
'testsuites',
{ encoding: 'utf-8' },
{},
{ skipNullAttributes: true }
);

const msToIso = ms => ms ? new Date(ms).toISOString().slice(0, -5) : undefined;
const msToSec = ms => ms ? (ms / 1000).toFixed(3) : undefined;

root.att({
name: 'jest',
timestamp: msToIso(results.startTime),
time: msToSec(Date.now() - results.startTime),
tests: results.numTotalTests,
failures: results.numFailedTests,
skipped: results.numPendingTests,
});

// top level test results are the files/suites
results.testResults.forEach(suite => {
const suiteEl = root.ele('testsuite', {
name: relative(rootDirectory, suite.testFilePath),
timestamp: msToIso(suite.perfStats.start),
time: msToSec(suite.perfStats.end - suite.perfStats.start),
tests: suite.testResults.length,
failures: suite.numFailedTests,
skipped: suite.numPendingTests,
file: suite.testFilePath
});

// nested in there are the tests in that file
const relativePath = dirname(relative(rootDirectory, suite.testFilePath));
const classname = `${reportName}.${relativePath.replace(/\./g, '·')}`;
suite.testResults.forEach(test => {
const testEl = suiteEl.ele('testcase', {
classname,
name: [...test.ancestorTitles, test.title].join(' '),
time: msToSec(test.duration)
});

test.failureMessages.forEach((message) => {
testEl.ele('failure').dat(message);
});

if (test.status === 'pending') {
testEl.ele('skipped');
}
});
});

const reportPath = resolve(rootDirectory, `target/junit/${reportName}.xml`);
const reportXML = root.end({
pretty: true,
indent: ' ',
newline: '\n',
spacebeforeslash: ''
});

mkdirp.sync(dirname(reportPath));
writeFileSync(reportPath, reportXML, 'utf8');
}
}
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Note: In theory importing the polyfill should not be needed, as Babel should
// include the necessary polyfills when using `babel-preset-env`, but for some
// reason it did not work. See https://github.com/elastic/kibana/issues/14506
import '../../babel-register/polyfill';
import '../../../babel-register/polyfill';
18 changes: 18 additions & 0 deletions src/dev/mocha/auto_junit_reporter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import mocha from 'mocha';
import { setupJunitReportGeneration } from './junit_report_generation';

const MochaSpecReporter = mocha.reporters.spec;

export function createAutoJunitReporter(junitReportOptions) {
return class createAutoJunitReporter {
constructor(runner, options) {
// setup a spec reporter for console output
new MochaSpecReporter(runner, options);

// in CI we also setup the Junit reporter
if (process.env.CI) {
setupJunitReportGeneration(runner, junitReportOptions);
}
}
};
}
2 changes: 2 additions & 0 deletions src/dev/mocha/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createAutoJunitReporter } from './auto_junit_reporter';
export { setupJunitReportGeneration } from './junit_report_generation';
136 changes: 136 additions & 0 deletions src/dev/mocha/junit_report_generation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { resolve, dirname, relative } from 'path';
import { writeFileSync } from 'fs';
import { inspect } from 'util';

import mkdirp from 'mkdirp';
import xmlBuilder from 'xmlbuilder';

export function setupJunitReportGeneration(runner, options = {}) {
const {
reportName = 'Unnamed Mocha Tests',
rootDirectory = dirname(require.resolve('../../../package.json')),
} = options;

const rootSuite = runner.suite;
const isTestFailed = test => test.state === 'failed';
const isTestPending = test => !!test.pending;
const returnTrue = () => true;

const getDuration = (node) => (
node.startTime && node.endTime
? ((node.endTime - node.startTime) / 1000).toFixed(3)
: null
);

const getTimestamp = (node) => (
node.startTime
? new Date(node.startTime).toISOString().slice(0, -5)
: null
);

const countTests = (suite, filter = returnTrue) => (
suite.suites.reduce((sum, suite) => (
sum + countTests(suite, filter)
), suite.tests.filter(filter).length)
);

const getFullTitle = node => {
const parentTitle = node.parent && getFullTitle(node.parent);
return parentTitle ? `${parentTitle} ${node.title}` : node.title;
};

const getPath = node => {
if (node.file) {
return relative(rootDirectory, node.file);
}

if (node.parent) {
return getPath(node.parent);
}

return 'unknown';
};

runner.on('start', () => {
rootSuite.startTime = Date.now();
});

runner.on('suite', (suite) => {
suite.startTime = Date.now();
});

runner.on('test', (test) => {
test.startTime = Date.now();
});

runner.on('test end', (test) => {
test.endTime = Date.now();
});

runner.on('suite end', (suite) => {
suite.endTime = Date.now();
});

runner.on('end', () => {
rootSuite.endTime = Date.now();
const builder = xmlBuilder.create(
'testsuites',
{ encoding: 'utf-8' },
{},
{ skipNullAttributes: true }
);

function addSuite(parent, suite) {
const attributes = {
name: suite.title,
timestamp: getTimestamp(suite),
time: getDuration(suite),
tests: countTests(suite),
failures: countTests(suite, isTestFailed),
skipped: countTests(suite, isTestPending),
file: suite.file
};

const el = suite === rootSuite
? parent.att(attributes)
: parent.ele('testsuite', attributes);

suite.suites.forEach(childSuite => {
addSuite(el, childSuite);
});

suite.tests.forEach(test => {
addTest(el, test);
});
}

function addTest(parent, test) {
const el = parent.ele('testcase', {
name: getFullTitle(test),
classname: `${reportName}.${getPath(test).replace(/\./g, '·')}`,
time: getDuration(test),
});

if (isTestFailed(test)) {
el
.ele('failure')
.dat(inspect(test.err));
} else if (isTestPending(test)) {
el.ele('skipped');
}
}

addSuite(builder, rootSuite);

const reportPath = resolve(rootDirectory, `target/junit/${reportName}.xml`);
const reportXML = builder.end({
pretty: true,
indent: ' ',
newline: '\n',
spacebeforeslash: ''
});

mkdirp.sync(dirname(reportPath));
writeFileSync(reportPath, reportXML, 'utf8');
});
}
1 change: 0 additions & 1 deletion src/dev/precommit_hook/casing_check_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ export const TEMPORARILY_IGNORED_PATHS = [
'src/core_plugins/timelion/server/series_functions/__tests__/fixtures/tlConfig.js',
'src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json',
'src/fixtures/vislib/mock_data/terms/_seriesMultiple.js',
'src/jest/babelTransform.js',
'src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/es-ES.json',
'src/ui/public/angular-bootstrap/accordion/accordion-group.html',
'src/ui/public/angular-bootstrap/bindHtml/bindHtml.js',
Expand Down
4 changes: 2 additions & 2 deletions src/dev/tooling_log/tooling_log.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { format } from 'util';
import { PassThrough } from 'stream';

import { magenta, yellow, red, blue, green, brightBlack } from 'ansicolors';
import { magenta, yellow, red, blue, green, dim } from 'chalk';

import { parseLogLevel } from './log_levels';

Expand All @@ -25,7 +25,7 @@ export function createToolingLog(initialLogLevelName = 'silent') {

debug(...args) {
if (!logLevel.flags.debug) return;
this.write(' %s ', brightBlack('debg'), format(...args));
this.write(' %s ', dim('debg'), format(...args));
}

info(...args) {
Expand Down
9 changes: 6 additions & 3 deletions src/functional_test_runner/lib/config/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { resolve, dirname } from 'path';

import Joi from 'joi';

import { ConsoleReporterProvider } from '../reporters';

// valid pattern for ID
// enforced camel-case identifiers for consistency
const ID_PATTERN = /^[a-zA-Z0-9_]+$/;
Expand Down Expand Up @@ -62,7 +60,12 @@ export const schema = Joi.object().keys({
slow: Joi.number().default(30000),
timeout: Joi.number().default(INSPECTING ? Infinity : 120000),
ui: Joi.string().default('bdd'),
reporterProvider: Joi.func().default(ConsoleReporterProvider),
}).default(),

junit: Joi.object().keys({
enabled: Joi.boolean().default(!!process.env.CI),
reportName: Joi.string(),
rootDirectory: Joi.string(),
}).default(),

users: Joi.object().pattern(
Expand Down
Loading

0 comments on commit f71ec29

Please sign in to comment.