diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..2e1fa2d5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 66dc9f86..573c53c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # mochawesome-report-generator changelog ## [Unreleased] +### Added +- `reportFilename` option: support replacement tokens (`[name]`, `[status]`, `[datetime]`) ## [6.0.1] - 2021-11-05 ### Fixed diff --git a/README.md b/README.md index 454e7276..3c1ac05f 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ mochawesome-report/ Flag | Type | Default | Description :--- | :--- | :------ | :---------- --f, --reportFilename | string | | Filename of saved report +-f, --reportFilename | string | mochawesome | Filename of saved report. *See [notes](#reportFilename) for available token replacements.* -o, --reportDir | string | [cwd]/mochawesome-report | Path to save report -t, --reportTitle | string | mochawesome | Report title -p, --reportPageTitle | string | mochawesome-report | Browser title @@ -100,12 +100,34 @@ Flag | Type | Default | Description *Boolean options can be negated by adding `--no` before the option. For example: `--no-code` would set `code` to `false`.* -#### Overwrite +#### reportFilename replacement tokens +Using the following tokens it is possible to dynamically alter the filename of the generated report. + +- **[name]** will be replaced with the spec filename when possible. +- **[status]** will be replaced with the status (pass/fail) of the test run. +- **[datetime]** will be replaced with a timestamp. The format can be - specified using the `timestamp` option. + +For example, given the spec `cypress/integration/sample.spec.js` and the following config: +``` +{ + reporter: "mochawesome", + reporterOptions: { + reportFilename: "[status]_[datetime]-[name]-report", + timestamp: "longDate" + } +} +``` + +The resulting report file will be named `pass_February_23_2022-sample-report.html` + +**Note:** The `[name]` replacement only occurs when mocha is running one spec file per process and outputting a separate report for each spec. The most common use-case is with Cypress. + +#### overwrite By default, report files are overwritten by subsequent report generation. Passing `--overwrite=false` will not replace existing files. Instead, if a duplicate filename is found, the report will be saved with a counter digit added to the filename. (ie. `mochawesome_001.html`). **Note:** `overwrite` will always be `false` when passing multiple files or using the `timestamp` option. -#### Timestamp +#### timestamp The `timestamp` option can be used to append a timestamp to the report filename. It uses [dateformat][] to parse format strings so you can pass any valid string that [dateformat][] accepts with a few exceptions. In order to produce valid filenames, the following replacements are made: diff --git a/src/bin/cli-main.js b/src/bin/cli-main.js index 7ccd9bc8..38f8a53b 100644 --- a/src/bin/cli-main.js +++ b/src/bin/cli-main.js @@ -2,6 +2,7 @@ const fs = require('fs-extra'); const path = require('path'); const chalk = require('chalk'); const t = require('tcomb-validation'); +const dateFormat = require('dateformat'); const report = require('../lib/main'); const types = require('./types'); const logger = require('./logger'); @@ -120,25 +121,76 @@ function handleResolved(resolvedValues) { return resolvedValues; } +/** + * Get the dateformat format string based on the timestamp option + * + * @param {string|boolean} ts Timestamp option value + * + * @return {string} Valid dateformat format string + */ +function getTimestampFormat(ts) { + return ts === undefined || + ts === true || + ts === 'true' || + ts === false || + ts === 'false' + ? 'isoDateTime' + : ts; +} + /** * Get the reportFilename option to be passed to `report.create` * * Returns the `reportFilename` option if provided otherwise * it returns the base filename stripped of path and extension * - * @param {Object} file.filename Name of file to be processed + * @param {Object} file File object + * @param {string} file.filename Name of file to be processed + * @param {Object} file.data JSON test data * @param {Object} args CLI process arguments * * @return {string} Filename */ -function getReportFilename({ filename }, { reportFilename }) { - return ( - reportFilename || - filename - .split(path.sep) - .pop() - .replace(JsonFileRegex, '') - ); +function getReportFilename({ filename, data }, { reportFilename, timestamp }) { + const DEFAULT_FILENAME = filename + .split(path.sep) + .pop() + .replace(JsonFileRegex, ''); + const NAME_REPLACE = '[name]'; + const STATUS_REPLACE = '[status]'; + const DATETIME_REPLACE = '[datetime]'; + const STATUSES = { + Pass: 'pass', + Fail: 'fail', + }; + + let outFilename = reportFilename || DEFAULT_FILENAME; + + const hasDatetimeReplacement = outFilename.includes(DATETIME_REPLACE); + const tsFormat = getTimestampFormat(timestamp); + const ts = dateFormat(new Date(), tsFormat) + // replace commas, spaces or comma-space combinations with underscores + .replace(/(,\s*)|,|\s+/g, '_') + // replace forward and back slashes with hyphens + .replace(/\\|\//g, '-') + // remove colons + .replace(/:/g, ''); + + if (timestamp) { + if (!hasDatetimeReplacement) { + outFilename = `${outFilename}_${DATETIME_REPLACE}`; + } + } + + // Special handling of replacement tokens + const status = data.stats.failures > 0 ? STATUSES.Fail : STATUSES.Pass; + + outFilename = outFilename + .replace(NAME_REPLACE, DEFAULT_FILENAME) + .replace(STATUS_REPLACE, status) + .replace(DATETIME_REPLACE, ts); + + return outFilename; } /** diff --git a/src/lib/main.js b/src/lib/main.js index 0b6f3b2c..6419f949 100644 --- a/src/lib/main.js +++ b/src/lib/main.js @@ -64,44 +64,70 @@ function loadFile(filename) { /** * Get the dateformat format string based on the timestamp option * - * @param {string|boolean} timestamp Timestamp option value + * @param {string|boolean} ts Timestamp option value * * @return {string} Valid dateformat format string */ -function getTimestampFormat(timestamp) { - switch (timestamp) { - case '': - case 'true': - case true: - return 'isoDateTime'; - default: - return timestamp; - } +function getTimestampFormat(ts) { + return ts === '' || + ts === true || + ts === 'true' || + ts === false || + ts === 'false' + ? 'isoDateTime' + : ts; } /** * Construct the path/name of the HTML/JSON file to be saved * - * @param {Object} reportOptions Options object + * @param {object} reportOptions Options object * @param {string} reportOptions.reportDir Directory to save report to * @param {string} reportOptions.reportFilename Filename to save report to * @param {string} reportOptions.timestamp Timestamp format to be appended to the filename + * @param {object} reportData JSON test data * * @return {string} Fully resolved path without extension */ -function getFilename({ reportDir, reportFilename = 'mochawesome', timestamp }) { - let ts = ''; +function getFilename({ reportDir, reportFilename, timestamp }, reportData) { + const DEFAULT_FILENAME = 'mochawesome'; + const NAME_REPLACE = '[name]'; + const STATUS_REPLACE = '[status]'; + const DATETIME_REPLACE = '[datetime]'; + const STATUSES = { + Pass: 'pass', + Fail: 'fail', + }; + + let filename = reportFilename || DEFAULT_FILENAME; + + const hasDatetimeReplacement = filename.includes(DATETIME_REPLACE); + const tsFormat = getTimestampFormat(timestamp); + const ts = dateFormat(new Date(), tsFormat) + // replace commas, spaces or comma-space combinations with underscores + .replace(/(,\s*)|,|\s+/g, '_') + // replace forward and back slashes with hyphens + .replace(/\\|\//g, '-') + // remove colons + .replace(/:/g, ''); + if (timestamp !== false && timestamp !== 'false') { - const format = getTimestampFormat(timestamp); - ts = `_${dateFormat(new Date(), format)}` - // replace commas, spaces or comma-space combinations with underscores - .replace(/(,\s*)|,|\s+/g, '_') - // replace forward and back slashes with hyphens - .replace(/\\|\//g, '-') - // remove colons - .replace(/:/g, ''); + if (!hasDatetimeReplacement) { + filename = `${filename}_${DATETIME_REPLACE}`; + } } - const filename = `${reportFilename.replace(fileExtRegex, '')}${ts}`; + + const specFilename = path + .basename(reportData.results[0].file || '') + .replace(/\..+/, ''); + + const status = reportData.stats.failures > 0 ? STATUSES.Fail : STATUSES.Pass; + + filename = filename + .replace(NAME_REPLACE, specFilename || DEFAULT_FILENAME) + .replace(STATUS_REPLACE, status) + .replace(DATETIME_REPLACE, ts); + return path.resolve(process.cwd(), reportDir, filename); } @@ -109,19 +135,20 @@ function getFilename({ reportDir, reportFilename = 'mochawesome', timestamp }) { * Get report options by extending base options * with user provided options * - * @param {Object} opts Report options + * @param {object} opts Report options + * @param {object} reportData JSON test data * - * @return {Object} User options merged with default options + * @return {object} User options merged with default options */ -function getOptions(opts) { +function getOptions(opts, reportData) { const mergedOptions = getMergedOptions(opts || {}); // For saving JSON from mochawesome reporter if (mergedOptions.saveJson) { - mergedOptions.jsonFile = `${getFilename(mergedOptions)}.json`; + mergedOptions.jsonFile = `${getFilename(mergedOptions, reportData)}.json`; } - mergedOptions.htmlFile = `${getFilename(mergedOptions)}.html`; + mergedOptions.htmlFile = `${getFilename(mergedOptions, reportData)}.html`; return mergedOptions; } @@ -159,7 +186,7 @@ function _shouldCopyAssets(assetsDir) { /** * Copy the report assets to the report dir, ignoring inline assets * - * @param {Object} opts Report options + * @param {object} opts Report options */ function copyAssets({ assetsDir }) { if (_shouldCopyAssets(assetsDir)) { @@ -172,8 +199,8 @@ function copyAssets({ assetsDir }) { /** * Get the report assets object * - * @param {Object} reportOptions Options - * @return {Object} Object with assets props + * @param {object} reportOptions Options + * @return {object} Object with assets props */ function getAssets(reportOptions) { const { assetsDir, cdn, dev, inlineAssets, reportDir } = reportOptions; @@ -220,20 +247,14 @@ function getAssets(reportOptions) { /** * Prepare options, assets, and html for saving * - * @param {string} reportData JSON test data - * @param {Object} opts Report options + * @param {object} reportData JSON test data + * @param {object} opts Report options * - * @return {Object} Prepared data for saving + * @return {object} Prepared data for saving */ function prepare(reportData, opts) { - // Stringify the data if needed - let data = reportData; - if (typeof data === 'object') { - data = JSON.stringify(reportData); - } - // Get the options - const reportOptions = getOptions(opts); + const reportOptions = getOptions(opts, reportData); // Stop here if we're not generating an HTML report if (!reportOptions.saveHtml) { @@ -245,7 +266,7 @@ function prepare(reportData, opts) { // Render basic template to string const renderedHtml = renderMainHTML({ - data, + data: JSON.stringify(reportData), options: reportOptions, title: reportOptions.reportPageTitle, useInlineAssets: reportOptions.inlineAssets && !reportOptions.cdn, @@ -259,8 +280,8 @@ function prepare(reportData, opts) { /** * Create the report * - * @param {string} data JSON test data - * @param {Object} opts Report options + * @param {object} data JSON test data + * @param {object} opts Report options * * @return {Promise} Resolves if report was created successfully */ @@ -302,8 +323,8 @@ function create(data, opts) { /** * Create the report synchronously * - * @param {string} data JSON test data - * @param {Object} opts Report options + * @param {object} data JSON test data + * @param {object} opts Report options * */ function createSync(data, opts) { diff --git a/test/sample-data/test.json b/test/sample-data/test.json index 9f9ff7f1..f16c5960 100644 --- a/test/sample-data/test.json +++ b/test/sample-data/test.json @@ -101,8 +101,8 @@ "uuid": "85326d2a-1546-4657-a3dc-8a210b578804", "beforeHooks": [], "afterHooks": [], - "fullFile": "", - "file": "", + "fullFile": "/Users/adamgruber/Sites/ma-test/cases/test.js", + "file": "/cases/test.js", "passes": [], "failures": [], "skipped": [], diff --git a/test/spec/cli/cli-main.test.js b/test/spec/cli/cli-main.test.js index bf68b6d8..1a6068c3 100644 --- a/test/spec/cli/cli-main.test.js +++ b/test/spec/cli/cli-main.test.js @@ -4,6 +4,7 @@ import sinon from 'sinon'; import chai, { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import fs from 'fs-extra'; +import dateFormat from 'dateformat'; import invalidTestData from 'sample-data/invalid.json'; @@ -25,6 +26,12 @@ const cli = proxyquire('../../../src/bin/cli-main', { const error = { code: 12345, message: 'Err' }; const getArgs = (files, args) => Object.assign({}, { _: files }, args); +const cleanDateStr = fmt => + dateFormat(new Date(), fmt) + .replace(/(,\s*)|,|\s+/g, '_') + .replace(/\\|\//g, '-') + .replace(/:/g, ''); + afterEach(() => { createStub.reset(); logger.info.resetHistory(); @@ -204,6 +211,33 @@ describe('bin/cli', () => { ); }); }); + + it('should handle replacement tokens', () => { + const args = getArgs(['test/sample-data/test.json'], { + reportFilename: '[status]-[name]-[datetime]', + }); + + return cli(args).then(() => { + expect(createStub.args[0][1]).to.have.property( + 'reportFilename', + `fail-test-${cleanDateStr('isoDateTime')}` + ); + }); + }); + + it('should handle replacement tokens and timestamp', () => { + const args = getArgs(['test/sample-data/test.json'], { + timestamp: 'fullDate', + reportFilename: '[status]-[name]-[datetime]', + }); + + return cli(args).then(() => { + expect(createStub.args[0][1]).to.have.property( + 'reportFilename', + `fail-test-${cleanDateStr('fullDate')}` + ); + }); + }); }); }); }); diff --git a/test/spec/lib/main.test.js b/test/spec/lib/main.test.js index b595be6c..f7b9134d 100644 --- a/test/spec/lib/main.test.js +++ b/test/spec/lib/main.test.js @@ -36,6 +36,12 @@ const mareport = proxyquire('../../../src/lib/main', { './main-html': rendererStub, }); +const cleanDateStr = fmt => + dateFormat(new Date(), fmt) + .replace(/(,\s*)|,|\s+/g, '_') + .replace(/\\|\//g, '-') + .replace(/:/g, ''); + let opts; beforeEach(() => { @@ -131,12 +137,6 @@ describe('lib/main', () => { const getExpectedName = dateTimeStr => path.resolve(process.cwd(), 'test', `test${dateTimeStr}{_###}.html`); - const cleanDateStr = fmt => - dateFormat(new Date(), fmt) - .replace(/(,\s*)|,|\s+/g, '_') - .replace(/\\|\//g, '-') - .replace(/:/g, ''); - beforeEach(() => { // Set clock to 2017-03-29T19:30:59.913Z clock = sinon.useFakeTimers(1490815859913); @@ -211,6 +211,97 @@ describe('lib/main', () => { }); }); + describe('reportFilename tokens', () => { + it('[name]', () => { + opts.reportFilename = '[name].spec'; + const expectedHtmlFile = path.resolve( + process.cwd(), + 'test', + 'test.spec.html' + ); + outputFileStub.onCall(0).resolves(expectedHtmlFile); + const promise = mareport.create(testData, opts); + return expect(promise).to.become([expectedHtmlFile, null]); + }); + + it('[name], no spec name', () => { + const modifiedTestData = { + ...testData, + results: [ + { + ...testData.results[0], + file: '', + fullFile: '', + }, + ], + }; + opts.reportFilename = '[name].spec'; + const expectedHtmlFile = path.resolve( + process.cwd(), + 'test', + 'mochawesome.spec.html' + ); + outputFileStub.onCall(0).resolves(expectedHtmlFile); + const promise = mareport.create(modifiedTestData, opts); + return expect(promise).to.become([expectedHtmlFile, null]); + }); + + it('[status], pass', () => { + const modifiedTestData = { + ...testData, + stats: { + failures: 0, + }, + }; + opts.reportFilename = '[status]-[name].spec'; + const expectedHtmlFile = path.resolve( + process.cwd(), + 'test', + 'pass-test.spec.html' + ); + outputFileStub.onCall(0).resolves(expectedHtmlFile); + const promise = mareport.create(modifiedTestData, opts); + return expect(promise).to.become([expectedHtmlFile, null]); + }); + + it('[status], fail', () => { + opts.reportFilename = '[status]-[name].spec'; + const expectedHtmlFile = path.resolve( + process.cwd(), + 'test', + 'fail-test.spec.html' + ); + outputFileStub.onCall(0).resolves(expectedHtmlFile); + const promise = mareport.create(testData, opts); + return expect(promise).to.become([expectedHtmlFile, null]); + }); + + it('[datetime], no timestamp option', () => { + opts.reportFilename = '[status]_[datetime]-[name].spec'; + const expectedHtmlFile = path.resolve( + process.cwd(), + 'test', + `fail_${cleanDateStr('isoDateTime')}-test.spec.html` + ); + outputFileStub.onCall(0).resolves(expectedHtmlFile); + const promise = mareport.create(testData, opts); + return expect(promise).to.become([expectedHtmlFile, null]); + }); + + it('[datetime], with timestamp option', () => { + opts.reportFilename = '[status]_[datetime]-[name].spec'; + opts.timestamp = 'fullDate'; + const expectedHtmlFile = path.resolve( + process.cwd(), + 'test', + `fail_${cleanDateStr('fullDate')}-test.spec.html` + ); + outputFileStub.onCall(0).resolves(expectedHtmlFile); + const promise = mareport.create(testData, opts); + return expect(promise).to.become([expectedHtmlFile, null]); + }); + }); + it('with overwrite:false', () => { opts = { overwrite: false, @@ -286,13 +377,6 @@ describe('lib/main', () => { mareport.createSync(testData, opts); expect(openerStub.called).to.equal(true); }); - - it('with data as string', () => { - mareport.createSync(JSON.stringify(testData), opts); - expect(outputFileSyncStub.calledWith(expectedFilenameWithOpts)).to.equal( - true - ); - }); }); describe('copyAssets', () => {