UNPKG

jest-html-reporter

Version:

Jest test results processor for generating a summary in HTML

709 lines (635 loc) 21.2 kB
'use strict'; function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var fs = _interopDefault(require('fs')); var path = _interopDefault(require('path')); var mkdirp = _interopDefault(require('mkdirp')); var xmlbuilder = _interopDefault(require('xmlbuilder')); var dateformat = _interopDefault(require('dateformat')); var stripAnsi = _interopDefault(require('strip-ansi')); function createCommonjsModule(fn, module) { return module = { exports: {} }, fn(module, module.exports), module.exports; } var utils = createCommonjsModule(function (module) { /** * Logs a message of a given type in the terminal * @param {String} type * @param {String} msg * @return {Object} */ const logMessage = ({ type, msg, ignoreConsole }) => { const logTypes = { default: '\x1b[37m%s\x1b[0m', success: '\x1b[32m%s\x1b[0m', error: '\x1b[31m%s\x1b[0m', }; const logColor = (!logTypes[type]) ? logTypes.default : logTypes[type]; const logMsg = `jest-html-reporter >> ${msg}`; if (!ignoreConsole) { console.log(logColor, logMsg); // eslint-disable-line } return { logColor, logMsg }; // Return for testing purposes }; /** * Creates a file at the given destination * @param {String} filePath * @param {Any} content */ const writeFile = ({ filePath, content }) => new Promise((resolve, reject) => { mkdirp(path.dirname(filePath), (mkdirpError) => { if (mkdirpError) { return reject(new Error(`Something went wrong when creating the folder: ${mkdirpError}`)); } return fs.writeFile(filePath, content, (writeFileError) => { if (writeFileError) { return reject(new Error(`Something went wrong when creating the file: ${writeFileError}`)); } return resolve(filePath); }); }); }); /** * Reads and returns the content of a given file * @param {String} filePath */ const getFileContent = ({ filePath }) => new Promise((resolve, reject) => { fs.readFile(filePath, 'utf8', (err, content) => { if (err) { return reject(new Error(`Could not locate file: '${filePath}': ${err}`)); } return resolve(content); }); }); /** * Sets up a basic HTML page to apply the content to * @return {xmlbuilder} */ const createHtmlBase = ({ pageTitle, stylesheet, stylesheetPath }) => { const htmlBase = { html: { head: { meta: { '@charset': 'utf-8' }, title: { '#text': pageTitle }, }, }, }; if (stylesheetPath) { htmlBase.html.head.link = { '@rel': 'stylesheet', '@type': 'text/css', '@href': stylesheetPath }; } else { htmlBase.html.head.style = { '@type': 'text/css', '#text': stylesheet }; } return xmlbuilder.create(htmlBase); }; const sortAlphabetically = ({ a, b, reversed }) => { if ((!reversed && a < b) || (reversed && a > b)) { return -1; } else if ((!reversed && a > b) || (reversed && a < b)) { return 1; } return 0; }; module.exports = { logMessage, writeFile, getFileContent, createHtmlBase, sortAlphabetically, }; }); var utils_1 = utils.logMessage; var utils_2 = utils.writeFile; var utils_3 = utils.getFileContent; var utils_4 = utils.createHtmlBase; var utils_5 = utils.sortAlphabetically; var sorting = createCommonjsModule(function (module) { /** * Splits test suites apart based on individual test status and sorts by that status: * 1. Pending * 2. Failed * 3. Passed * @param {Array} suiteResults */ const byStatus = (suiteResults) => { const pendingSuites = []; const failingSuites = []; const passingSuites = []; suiteResults.forEach((suiteResult) => { const pending = []; const failed = []; const passed = []; suiteResult.testResults.forEach((x) => { if (x.status === 'pending') { pending.push(x); } else if (x.status === 'failed') { failed.push(x); } else { passed.push(x); } }); if (pending.length) { pendingSuites.push(Object.assign({}, suiteResult, { testResults: pending })); } if (failed.length) { failingSuites.push(Object.assign({}, suiteResult, { testResults: failed })); } if (passed.length) { passingSuites.push(Object.assign({}, suiteResult, { testResults: passed })); } }); return [].concat(pendingSuites, failingSuites, passingSuites); }; /** * Sorts by Execution Time | Descending * @param {Array} suiteResults */ const byExecutionDesc = (suiteResults) => { if (suiteResults) { suiteResults.sort((a, b) => (b.perfStats.end - b.perfStats.start) - (a.perfStats.end - a.perfStats.start)); } return suiteResults; }; /** * Sorts by Execution Time | Ascending * @param {Array} suiteResults */ const byExecutionAsc = (suiteResults) => { if (suiteResults) { suiteResults.sort((a, b) => (a.perfStats.end - a.perfStats.start) - (b.perfStats.end - b.perfStats.start)); } return suiteResults; }; /** * Sorts by Suite filename and Test name | Descending * @param {Array} suiteResults */ const byTitleDesc = (suiteResults) => { if (suiteResults) { // Sort Suites const sorted = suiteResults.sort((a, b) => utils.sortAlphabetically({ a: a.testFilePath, b: b.testFilePath, reversed: true })); // Sort Suite testResults sorted.forEach((suite) => { suite.testResults.sort((a, b) => utils.sortAlphabetically({ a: a.ancestorTitles.join(' '), b: b.ancestorTitles.join(' '), reversed: true, })); }); return sorted; } return suiteResults; }; /** * Sorts by Suite filename and Test name | Ascending * @param {Array} suiteResults */ const byTitleAsc = (suiteResults) => { if (suiteResults) { // Sort Suites const sorted = suiteResults.sort((a, b) => utils.sortAlphabetically({ a: a.testFilePath, b: b.testFilePath })); // Sort Suite testResults sorted.forEach((suite) => { suite.testResults.sort((a, b) => utils.sortAlphabetically({ a: a.ancestorTitles.join(' '), b: b.ancestorTitles.join(' '), })); }); return sorted; } return suiteResults; }; /** * Sorts test suite results with the given method * @param {Object} testData * @param {String} sortMethod */ const sortSuiteResults = ({ testData, sortMethod }) => { if (sortMethod) { switch (sortMethod.toLowerCase()) { case 'status': return byStatus(testData); case 'executiondesc': return byExecutionDesc(testData); case 'executionasc': return byExecutionAsc(testData); case 'titledesc': return byTitleDesc(testData); case 'titleasc': return byTitleAsc(testData); default: return testData; } } return testData; }; module.exports = { sortSuiteResults, }; }); var sorting_1 = sorting.sortSuiteResults; class ReportGenerator { constructor(config) { this.config = config; } /** * Generates and writes HTML report to a given path * @param {Object} data Jest test information data * @return {Promise} */ generate({ data, ignoreConsole }) { const fileDestination = this.config.getOutputFilepath(); const useCssFile = this.config.shouldUseCssFile(); const shouldGetStylesheetContent = this.config.shouldGetStylesheetContent(); let stylesheetPath = null; let stylesheetContent = null; if (useCssFile) { stylesheetPath = this.config.getStylesheetFilepath(); } if (shouldGetStylesheetContent) { stylesheetContent = () => this.getStylesheetContent(); } else { stylesheetContent = () => Promise.resolve(); } return stylesheetContent() .then(stylesheet => this.renderHtmlReport({ data, stylesheet, stylesheetPath, })) .then(xmlBuilderOutput => utils.writeFile({ filePath: fileDestination, content: xmlBuilderOutput, })) .then(() => utils.logMessage({ type: 'success', msg: `Report generated (${fileDestination})`, ignoreConsole, })) .catch(error => utils.logMessage({ type: 'error', msg: error, ignoreConsole, })); } /** * Reads the boilerplate file given from config * and attempts to replace any occurrence of {jesthtmlreporter-content} * with the given content. * @return {Promise} */ integrateContentIntoBoilerplate({ content }) { const filePath = this.config.getBoilerplatePath(); return new Promise((resolve, reject) => utils.getFileContent({ filePath }) .then(boilerplateContent => resolve(boilerplateContent.replace('{jesthtmlreporter-content}', content))) .catch(err => reject(err))); } /** * Returns the stylesheet to be required in the test report. * If styleOverridePath is not defined, it will return the defined theme file. * @return {Promise} */ getStylesheetContent() { const filePath = this.config.getStylesheetFilepath(); return utils.getFileContent({ filePath }); } /** * Returns a HTML containing the test report. * @param {Object} data The test result data (required) * @param {String} stylesheet Optional stylesheet content * @param {String} stylesheetPath Optional path to an external stylesheet * @return {xmlbuilder} */ renderHtmlReport({ data, stylesheet, stylesheetPath }) { return new Promise((resolve, reject) => { // Make sure that test data was provided if (!data) { return reject(new Error('Test data missing or malformed')); } // Fetch Page Title from config const pageTitle = this.config.getPageTitle(); // Create Report Body const reportContent = this.getReportBody({ data, pageTitle }); // ** (CUSTOM) BOILERPLATE OPTION // Check if a boilerplate has been specified const boilerplatePath = this.config.getBoilerplatePath(); if (boilerplatePath) { return this.integrateContentIntoBoilerplate({ content: reportContent }) .then(output => resolve(output)); } // ** (DEFAULT) PREDEFINED HTML OPTION // Create an xmlbuilder object with HTML and HEAD tag const htmlOutput = utils.createHtmlBase({ pageTitle, stylesheet, stylesheetPath }); // Body tag const body = htmlOutput.ele('body'); // Add report content to body body.raw(reportContent); // Custom Javascript const customScript = this.config.getCustomScriptFilepath(); if (customScript) { body.raw(`<script src="${customScript}"></script>`); } return resolve(htmlOutput); }); } /** * Returns a HTML containing the test report body contentt. * @param {Object} data The test result data * @param {String} pageTitle The title of the report * @return {xmlbuilder} */ getReportBody({ data, pageTitle }) { const reportBody = xmlbuilder.begin().element('div', { id: 'jesthtml-content' }); // HEADER // ** const header = reportBody.ele('header'); // Page Title header.ele('h1', { id: 'title' }, pageTitle); // Logo const logo = this.config.getLogo(); if (logo) { header.ele('img', { id: 'logo', src: logo }); } // ** METADATA const metaDataContainer = reportBody.ele( 'div', { id: 'metadata-container' }, ); // Timestamp const timestamp = new Date(data.startTime); metaDataContainer.ele( 'div', { id: 'timestamp' }, `Start: ${dateformat(timestamp, this.config.getDateFormat())}`, ); // Test Summary metaDataContainer.ele( 'div', { id: 'summary' }, `${data.numTotalTests} tests -- ${data.numPassedTests} passed / ${data.numFailedTests} failed / ${data.numPendingTests} pending`, ); // ** SORTING // Apply the configured sorting of test data const sortedTestData = sorting.sortSuiteResults({ testData: data.testResults, sortMethod: this.config.getSort(), }); // ** IGNORED STATUSES FILTER // Setup ignored Test Result Statuses const statusIgnoreFilter = this.config.getStatusIgnoreFilter(); let ignoredStatuses = []; if (statusIgnoreFilter) { ignoredStatuses = statusIgnoreFilter.replace(/\s/g, '').toLowerCase().split(','); } // ** TEST SUITES sortedTestData.forEach((suite) => { // Filter out the test results with statuses that equals the statusIgnoreFilter for (let i = suite.testResults.length - 1; i >= 0; i -= 1) { if (ignoredStatuses.includes(suite.testResults[i].status)) { suite.testResults.splice(i, 1); } } // Ignore this suite if there are no results if (!suite.testResults || suite.testResults.length <= 0) { return; } // Suite Information const suiteInfo = reportBody.ele('div', { class: 'suite-info' }); // Suite Path suiteInfo.ele('div', { class: 'suite-path' }, suite.testFilePath); // Suite execution time const executionTime = (suite.perfStats.end - suite.perfStats.start) / 1000; suiteInfo.ele('div', { class: `suite-time${executionTime > 5 ? ' warn' : ''}` }, `${executionTime}s`); // Suite Test Table const suiteTable = reportBody.ele('table', { class: 'suite-table', cellspacing: '0', cellpadding: '0' }); // Test Results suite.testResults.forEach((test) => { const testTr = suiteTable.ele('tr', { class: test.status }); // Suite Name(s) testTr.ele('td', { class: 'suite' }, test.ancestorTitles.join(' > ')); // Test name const testTitleTd = testTr.ele('td', { class: 'test' }, test.title); // Test Failure Messages if (test.failureMessages && (this.config.shouldIncludeFailureMessages())) { const failureMsgDiv = testTitleTd.ele('div', { class: 'failureMessages' }); test.failureMessages.forEach((failureMsg) => { failureMsgDiv.ele('pre', { class: 'failureMsg' }, stripAnsi(failureMsg)); }); } // Append data to <tr> testTr.ele('td', { class: 'result' }, (test.status === 'passed') ? `${test.status} in ${test.duration / 1000}s` : test.status); }); // Test Suite console.logs if (suite.console && suite.console.length > 0 && (this.config.shouldIncludeConsoleLog())) { // Console Log Container const consoleLogContainer = reportBody.ele('div', { class: 'suite-consolelog' }); // Console Log Header consoleLogContainer.ele('div', { class: 'suite-consolelog-header' }, 'Console Log'); // Logs suite.console.forEach((log) => { const logElement = consoleLogContainer.ele('div', { class: 'suite-consolelog-item' }); logElement.ele('pre', { class: 'suite-consolelog-item-origin' }, stripAnsi(log.origin)); logElement.ele('pre', { class: 'suite-consolelog-item-message' }, stripAnsi(log.message)); }); } }); return reportBody; } } var reportGenerator = ReportGenerator; var config_1 = createCommonjsModule(function (module) { // Initialize an empty config object const config = {}; /** * Assigns the given data to the config object * @param {Object} data */ const setConfigData = data => Object.assign(config, data); const setup = () => { // Attempt to locate and assign configurations from jesthtmlreporter.config.json try { const jesthtmlreporterconfig = fs.readFileSync(path.join(process.cwd(), 'jesthtmlreporter.config.json'), 'utf8'); if (jesthtmlreporterconfig) { return setConfigData(JSON.parse(jesthtmlreporterconfig)); } } catch (e) { /** do nothing */ } // Attempt to locate and assign configurations from package.json try { const packageJson = fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'); if (packageJson) { return setConfigData(JSON.parse(packageJson)['jest-html-reporter']); } } catch (e) { /** do nothing */ } return config; }; /** * Returns the output path for the test report * @return {String} */ const getOutputFilepath = () => { const filepath = process.env.JEST_HTML_REPORTER_OUTPUT_PATH || config.outputPath || path.join(process.cwd(), 'test-report.html'); return filepath.replace(/<rootdir>/ig, '.'); }; /** * Returns the configured path to a boilerplate file to be used * @return {String} */ const getBoilerplatePath = () => process.env.JEST_HTML_REPORTER_BOILERPLATE || config.boilerplate || null; /** * Returns the configured name of theme to be used for styling the report * @return {String} */ const getTheme = () => process.env.JEST_HTML_REPORTER_THEME || config.theme || 'defaultTheme'; /** * Returns the style-override path for the test report * @return {String} */ const getStylesheetFilepath = () => process.env.JEST_HTML_REPORTER_STYLE_OVERRIDE_PATH || config.styleOverridePath || path.join(__dirname, `../style/${getTheme()}.css`); /** * Returns the Custom Script path that should be injected into the test report * @return {String} */ const getCustomScriptFilepath = () => process.env.JEST_HTML_REPORTER_CUSTOM_SCRIPT_PATH || config.customScriptPath || null; /** * Returns the configured test report title * @return {String} */ const getPageTitle = () => process.env.JEST_HTML_REPORTER_PAGE_TITLE || config.pageTitle || 'Test report'; /** * Returns the configured logo image path * @return {String} */ const getLogo = () => process.env.JEST_HTML_REPORTER_LOGO || config.logo || null; /** * Returns whether there is a user defined stylesheet override path */ const getHasStyleOverridePath = () => Boolean(process.env.JEST_HTML_REPORTER_STYLE_OVERRIDE_PATH) || Boolean(config.styleOverridePath); /** * Returns whether the report should contain failure messages or not * @return {Boolean} */ const shouldIncludeFailureMessages = () => process.env.JEST_HTML_REPORTER_INCLUDE_FAILURE_MSG || config.includeFailureMsg || false; /** * Returns whether the report should contain console.logs or not * @return {Boolean} */ const shouldIncludeConsoleLog = () => process.env.JEST_HTML_REPORTER_INCLUDE_CONSOLE_LOG || config.includeConsoleLog || false; /** * Returns whether the report should use a dedicated .css file * @return {Boolean} */ const shouldUseCssFile = () => process.env.JEST_HTML_REPORTER_USE_CSS_FILE || config.useCssFile || false; /** * Returns whether the report should load the stylesheet content */ const shouldGetStylesheetContent = () => !(getHasStyleOverridePath() && shouldUseCssFile()); /** * Returns the configured threshold (in seconds) when to apply a warning * @return {Number} */ const getExecutionTimeWarningThreshold = () => process.env.JEST_HTML_REPORTER_EXECUTION_TIME_WARNING_THRESHOLD || config.executionTimeWarningThreshold || 5; /** * Returns the configured date/time format. * Uses DateFormat - https://github.com/felixge/node-dateformat * @return {String} */ const getDateFormat = () => process.env.JEST_HTML_REPORTER_DATE_FORMAT || config.dateFormat || 'yyyy-mm-dd HH:MM:ss'; /** * Returns the configured sorting method * @return {String} */ const getSort = () => process.env.JEST_HTML_REPORTER_SORT || config.sort || 'default'; /** * Returns the filter of test result statuses to be ignored * @return {String} */ const getStatusIgnoreFilter = () => process.env.JEST_HTML_REPORTER_STATUS_FILTER || config.statusIgnoreFilter || null; module.exports = { config, setup, setConfigData, getOutputFilepath, getStylesheetFilepath, getHasStyleOverridePath, getCustomScriptFilepath, getPageTitle, getLogo, shouldIncludeFailureMessages, shouldIncludeConsoleLog, shouldUseCssFile, shouldGetStylesheetContent, getExecutionTimeWarningThreshold, getBoilerplatePath, getTheme, getDateFormat, getSort, getStatusIgnoreFilter, }; }); var config_2 = config_1.config; var config_3 = config_1.setup; var config_4 = config_1.setConfigData; var config_5 = config_1.getOutputFilepath; var config_6 = config_1.getStylesheetFilepath; var config_7 = config_1.getHasStyleOverridePath; var config_8 = config_1.getCustomScriptFilepath; var config_9 = config_1.getPageTitle; var config_10 = config_1.getLogo; var config_11 = config_1.shouldIncludeFailureMessages; var config_12 = config_1.shouldIncludeConsoleLog; var config_13 = config_1.shouldUseCssFile; var config_14 = config_1.shouldGetStylesheetContent; var config_15 = config_1.getExecutionTimeWarningThreshold; var config_16 = config_1.getBoilerplatePath; var config_17 = config_1.getTheme; var config_18 = config_1.getDateFormat; var config_19 = config_1.getSort; var config_20 = config_1.getStatusIgnoreFilter; function JestHtmlReporter(globalConfig, options) { // Initiate the config and setup the Generator class config_1.setup(); const reportGenerator$$1 = new reportGenerator(config_1); /** * If the first parameter has a property named 'testResults', * the script is being run as a 'testResultsProcessor'. * We then need to return the test results as they were received from Jest * https://facebook.github.io/jest/docs/en/configuration.html#testresultsprocessor-string */ if (Object.prototype.hasOwnProperty.call(globalConfig, 'testResults')) { // Generate Report reportGenerator$$1.generate({ data: globalConfig }); // Return the results as required by Jest return globalConfig; } /** * The default behaviour - run as Custom Reporter, in parallel with Jest. * This should eventually be turned into a proper class (whenever the testResultsProcessor option is phased out) * https://facebook.github.io/jest/docs/en/configuration.html#reporters-array-modulename-modulename-options */ this.jestConfig = globalConfig; this.jestOptions = options; this.onRunComplete = (contexts, testResult) => { // Apply the configuration within jest.config.json to the current config config_1.setConfigData(this.jestOptions); // Apply the updated config reportGenerator$$1.config = config_1; // Generate Report return reportGenerator$$1.generate({ data: testResult }); }; } var src = JestHtmlReporter; module.exports = src;