diff --git a/.travis.yml b/.travis.yml index 4796715..c3a66e3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,3 @@ language: node_js node_js: - - "node" \ No newline at end of file + - "11.10.1" \ No newline at end of file diff --git a/README.md b/README.md index c6a58ba..9e81398 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Please note that all configuration properties are optional. | `executionTimeWarningThreshold` | `NUMBER` | The threshold for test execution time (in seconds) in each test suite that will render a warning on the report page. 5 seconds is the default timeout in Jest. | `5` | `dateFormat` | `STRING` | The format in which date/time should be formatted in the test report. Have a look in the [documentation](https://github.com/Hargne/jest-html-reporter/wiki/Date-Format) for the available date format variables. | `"yyyy-mm-dd HH:MM:ss"` | `sort` | `STRING` | Sorts the test results using the given method. Available sorting methods can be found in the [documentation](https://github.com/Hargne/jest-html-reporter/wiki/Sorting-Methods). | `"default"` +| `statusIgnoreFilter` | `STRING` | A comma-separated string of the test result statuses that should be ignored when rendering the report. Available statuses are: `"passed"`, `"pending"`, `"failed"` | `null` +| `boilerplate` | `STRING` | The path to a boilerplate file that should be used to render the body of the test results into. `{jesthtmlreporter-content}` within the boilerplate will be replaced with the test results | `null` > *The plugin will search for the *styleOverridePath* from the root directory, therefore there is no need to prepend the string with `./` or `../` - You can read more about the themes in the [documentation](https://github.com/Hargne/jest-html-reporter/wiki/Test-Report-Themes). diff --git a/package.json b/package.json index 915d232..3a46f12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jest-html-reporter", - "version": "2.4.5", + "version": "2.5.0", "description": "Jest test results processor for generating a summary in HTML", "main": "dist/main", "unpkg": "dist/main.min.js", diff --git a/src/config.js b/src/config.js index 83b1073..ccc55b2 100644 --- a/src/config.js +++ b/src/config.js @@ -34,6 +34,13 @@ const setup = () => { const getOutputFilepath = () => process.env.JEST_HTML_REPORTER_OUTPUT_PATH || config.outputPath || path.join(process.cwd(), 'test-report.html'); +/** + * 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} @@ -112,6 +119,13 @@ const getDateFormat = () => 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, @@ -125,7 +139,9 @@ module.exports = { shouldIncludeConsoleLog, shouldUseCssFile, getExecutionTimeWarningThreshold, + getBoilerplatePath, getTheme, getDateFormat, getSort, + getStatusIgnoreFilter, }; diff --git a/src/reportGenerator.js b/src/reportGenerator.js index 7bf26fd..4414935 100644 --- a/src/reportGenerator.js +++ b/src/reportGenerator.js @@ -1,7 +1,7 @@ -const fs = require('fs'); const dateFormat = require('dateformat'); const stripAnsi = require('strip-ansi'); +const xmlbuilder = require('xmlbuilder'); const utils = require('./utils'); const sorting = require('./sorting'); @@ -46,27 +46,34 @@ class ReportGenerator { })); } + /** + * 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 pathToStylesheet = this.config.getStylesheetFilepath(); - return new Promise((resolve, reject) => { - fs.readFile(pathToStylesheet, 'utf8', (err, content) => { - if (err) { - return reject(new Error(`Could not locate the stylesheet: '${pathToStylesheet}': ${err}`)); - } - return resolve(content); - }); - }); + const filePath = this.config.getStylesheetFilepath(); + return utils.getFileContent({ filePath }); } /** * Returns a HTML containing the test report. - * @param {String} stylesheet - * @param {Object} data The test result data + * @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 }) { @@ -76,104 +83,142 @@ class ReportGenerator { // 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)); + } - // Create an xmlbuilder object with HTML and Body tags - - const htmlOutput = utils.createHtmlBase({ - pageTitle, - stylesheet, - stylesheetPath, - }); - - // HEADER - const header = htmlOutput.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 }); + // ** (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(``); } + return resolve(htmlOutput); + }); + } - // METADATA - const metaDataContainer = htmlOutput.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 - `); - - // Apply the configured sorting of test data - const sortedTestData = sorting.sortSuiteResults({ - testData: data.testResults, - sortMethod: this.config.getSort(), - }); + /** + * 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 }); + } - // Test Suites - sortedTestData.forEach((suite) => { - if (!suite.testResults || suite.testResults.length <= 0) { return; } - - // Suite Information - const suiteInfo = htmlOutput.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 = htmlOutput.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 - testTr.ele('td', { class: 'result' }, (test.status === 'passed') ? `${test.status} in ${test.duration / 1000}s` : test.status); - }); + // ** 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(), + }); - // Test Suite console.logs - if (suite.console && suite.console.length > 0 && (this.config.shouldIncludeConsoleLog())) { - // Console Log Container - const consoleLogContainer = htmlOutput.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)); + // ** 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 + testTr.ele('td', { class: 'result' }, (test.status === 'passed') ? `${test.status} in ${test.duration / 1000}s` : test.status); }); - // Custom Javascript - const customScript = this.config.getCustomScriptFilepath(); - if (customScript) { - htmlOutput.raw(``); + + // 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 resolve(htmlOutput); }); + + return reportBody; } } diff --git a/src/utils.js b/src/utils.js index cac72d6..224b767 100644 --- a/src/utils.js +++ b/src/utils.js @@ -42,6 +42,19 @@ const writeFile = ({ filePath, content }) => new Promise((resolve, reject) => { }); }); +/** + * 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} @@ -77,6 +90,7 @@ const sortAlphabetically = ({ a, b, reversed }) => { module.exports = { logMessage, writeFile, + getFileContent, createHtmlBase, sortAlphabetically, }; diff --git a/test/boilerplate.html b/test/boilerplate.html new file mode 100644 index 0000000..0428b81 --- /dev/null +++ b/test/boilerplate.html @@ -0,0 +1,9 @@ + + + jest-html-reporter Boilerplate Test + + + This is a boilerplate test. The body of the report should be rendered below. + {jesthtmlreporter-content} + + \ No newline at end of file diff --git a/test/config.spec.js b/test/config.spec.js index bb6f23a..93b2cd6 100644 --- a/test/config.spec.js +++ b/test/config.spec.js @@ -10,6 +10,7 @@ describe('config', () => { config.setConfigData({ outputPath: null, theme: null, + boilerplate: null, styleOverridePath: null, pageTitle: null, logo: null, @@ -19,9 +20,11 @@ describe('config', () => { dateFormat: null, sort: null, executionMode: null, + statusIgnoreFilter: null, }); delete process.env.JEST_HTML_REPORTER_OUTPUT_PATH; delete process.env.JEST_HTML_REPORTER_THEME; + delete process.env.JEST_HTML_REPORTER_BOILERPLATE; delete process.env.JEST_HTML_REPORTER_STYLE_OVERRIDE_PATH; delete process.env.JEST_HTML_REPORTER_PAGE_TITLE; delete process.env.JEST_HTML_REPORTER_LOGO; @@ -31,6 +34,7 @@ describe('config', () => { delete process.env.JEST_HTML_REPORTER_DATE_FORMAT; delete process.env.JEST_HTML_REPORTER_SORT; delete process.env.JEST_HTML_REPORTER_EXECUTION_MODE; + delete process.env.JEST_HTML_REPORTER_STATUS_FILTER; }); describe('setup', () => { @@ -182,4 +186,32 @@ describe('config', () => { expect(config.getSort()).toEqual('default'); }); }); + + describe('getBoilerplatePath', () => { + it('should return the value from package.json or jesthtmlreporter.config.json', () => { + config.setConfigData({ boilerplate: 'setInJson' }); + expect(config.getBoilerplatePath()).toEqual('setInJson'); + }); + it('should return the environment variable', () => { + process.env.JEST_HTML_REPORTER_BOILERPLATE = 'setInEnv'; + expect(config.getBoilerplatePath()).toEqual('setInEnv'); + }); + it('should return the default value if no setting was provided', () => { + expect(config.getBoilerplatePath()).toBeNull(); + }); + }); + + describe('getStatusIgnoreFilter', () => { + it('should return the value from package.json or jesthtmlreporter.config.json', () => { + config.setConfigData({ statusIgnoreFilter: 'setInJson' }); + expect(config.getStatusIgnoreFilter()).toEqual('setInJson'); + }); + it('should return the environment variable', () => { + process.env.JEST_HTML_REPORTER_STATUS_FILTER = 'setInEnv'; + expect(config.getStatusIgnoreFilter()).toEqual('setInEnv'); + }); + it('should return the default value if no setting was provided', () => { + expect(config.getStatusIgnoreFilter()).toBeNull(); + }); + }); }); diff --git a/test/reportGenerator.spec.js b/test/reportGenerator.spec.js index eeb3555..c5942cd 100644 --- a/test/reportGenerator.spec.js +++ b/test/reportGenerator.spec.js @@ -1,44 +1,46 @@ const mockdata = require('./mockdata'); const ReportGenerator = require('../src/reportGenerator'); +const mockedConfig = { + getOutputFilepath: () => 'test-report.html', + getBoilerplatePath: () => 'test/boilerplate.html', + getStylesheetFilepath: () => '../style/defaultTheme.css', + getPageTitle: () => 'Test Report', + getLogo: () => 'testLogo.png', + getDateFormat: () => 'yyyy-mm-dd HH:MM:ss', + getSort: () => 'default', + shouldIncludeFailureMessages: () => true, + getExecutionTimeWarningThreshold: () => 5, + getCustomScriptFilepath: () => 'test.js', + shouldUseCssFile: () => false, + getStatusIgnoreFilter: () => null, +}; + describe('reportGenerator', () => { describe('renderHtmlReport', () => { it('should return a HTML report based on the given input data', () => { - const mockedConfig = { - getOutputFilepath: () => 'test-report.html', - getStylesheetFilepath: () => '../style/defaultTheme.css', - getPageTitle: () => 'Test Report', - getLogo: () => 'testLogo.png', - getDateFormat: () => 'yyyy-mm-dd HH:MM:ss', - getSort: () => 'default', - shouldIncludeFailureMessages: () => true, - getExecutionTimeWarningThreshold: () => 5, - getCustomScriptFilepath: () => 'test.js', - shouldUseCssFile: () => false, - }; const reportGenerator = new ReportGenerator(mockedConfig); return reportGenerator.renderHtmlReport({ data: mockdata.jestResponse.multipleTestResults, stylesheet: '' }) - .then(xmlBuilderOutput => - expect(xmlBuilderOutput).not.toBeNull()); + .then(xmlBuilderOutput => expect(xmlBuilderOutput.substring(0, 6)).toEqual('')); }); it('should return reject the promise if no data was provided', () => { expect.assertions(1); - const mockedConfig = { - getOutputFilepath: () => 'test-report.html', - getStylesheetFilepath: () => '../style/defaultTheme.css', - getPageTitle: () => 'Test Report', - getDateFormat: () => 'yyyy-mm-dd HH:MM:ss', - getSort: () => 'default', - shouldIncludeFailureMessages: () => true, - getExecutionTimeWarningThreshold: () => 5, - getCustomScriptFilepath: () => 'test.js', - }; const reportGenerator = new ReportGenerator(mockedConfig); return expect(reportGenerator.renderHtmlReport({ data: null, stylesheet: null })).rejects .toHaveProperty('message', 'Test data missing or malformed'); }); }); + + describe('getReportBody', () => { + it('should filter the ignored result statuses', () => { + mockedConfig.getStatusIgnoreFilter = () => 'passed'; + const reportGenerator = new ReportGenerator(mockedConfig); + + const result = reportGenerator.getReportBody({ data: mockdata.jestResponse.multipleTestResults, pageTitle: '' }); + return expect(result.toString().indexOf('') !== -1).toEqual(false); + }); + }); });