src/bin/cli-main.js
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');
const JsonErrRegex = /Unexpected token/;
const JsonFileRegex = /\.json{1}$/;
const htmlJsonExtRegex = /\.(?:html|json)$/;
const mapJsonErrors = errors => errors.map(e => ` ${e.message}`).join('\n');
const ERRORS = {
NOT_FOUND: ' File not found.',
GENERIC: ' There was a problem loading mochawesome data.',
INVALID_JSON: errMsgs => mapJsonErrors(errMsgs),
};
let validFiles;
/**
* Validate the data file
*
* @typedef {Object} File
* @property {string} filename Name of the file
* @property {object} data JSON test data
* @property {object} err Error object
*
* @param {string} file File to load/validate
*
* @return {File} Validated file with test data, `err` will be null if valid
*/
function validateFile(file) {
let data;
let err = null;
// Try to read and parse the file
try {
data = JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch (e) {
if (e.code === 'ENOENT') {
err = ERRORS.NOT_FOUND;
} else if (JsonErrRegex.test(e.message)) {
err = ERRORS.INVALID_JSON([e]);
} else {
err = ERRORS.GENERIC;
}
}
// If the file was loaded successfully,
// validate the json against the TestReport schema
if (data) {
const validationResult = t.validate(data, types.TestReport, {
strict: true,
});
if (!validationResult.isValid()) {
err = ERRORS.INVALID_JSON(validationResult.errors);
} else {
validFiles += 1;
}
}
return {
filename: file,
data,
err,
};
}
/**
* Set exit code and throw caught errors
*
* @param {Object|string} err Error object or error message
*
*/
function handleError(err) {
process.exitCode = 1;
throw new Error(err);
}
/**
* Loop through resolved promises to log the appropriate messages
*
* @param {Array} resolvedValues Result of promise.all
*
* @return {Array} Array of resolved promise values
*/
function handleResolved(resolvedValues) {
const saved = [];
const errors = [];
resolvedValues.forEach(value => {
if (value.err) {
errors.push(value);
} else {
saved.push(value[0]);
}
});
if (saved.length) {
logger.info(chalk.green('\n✓ Reports saved:'));
logger.info(
saved.map(savedFile => `${chalk.underline(savedFile)}`).join('\n')
);
}
if (errors.length) {
logger.info(chalk.red('\n✘ Some files could not be processed:'));
logger.info(
errors
.map(e => `${chalk.underline(e.filename)}\n${chalk.dim(e.err)}`)
.join('\n\n')
);
process.exitCode = 1;
}
if (!validFiles && !errors.length) {
logger.info(chalk.yellow('\nDid not find any JSON files to process.'));
process.exitCode = 1;
}
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 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, 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)
.replace(htmlJsonExtRegex, '');
return outFilename;
}
/**
* Process arguments, recursing through any directories,
* to find and validate JSON files
*
* @param {array} args Array of paths
* @param {array} files Array to populate
*
* @return {array} File objects to be processed
*/
function processArgs(args, files = []) {
return args.reduce((acc, arg) => {
let stats;
try {
stats = fs.statSync(arg);
} catch (err) {
// Do nothing
}
// If argument is a directory, process the files inside
if (stats && stats.isDirectory()) {
return processArgs(
fs.readdirSync(arg).map(file => path.join(arg, file)),
files
);
}
// If `statSync` failed, validating will handle the error
// If the argument is a file, check if its a JSON file before validating
if (!stats || JsonFileRegex.test(arg)) {
acc.push(validateFile(arg));
}
return acc;
}, files);
}
/**
* Main CLI Program
*
* @param {Object} args CLI arguments
*
* @return {Promise} Resolved promises with saved files or errors
*/
function marge(args) {
// Reset valid files count
validFiles = 0;
const newArgs = Object.assign({}, args);
// Get the array of JSON files to process
const files = processArgs(args._);
// When there are multiple valid files OR the timestamp option is set
// we must force `overwrite` to `false` to ensure all reports are created
/* istanbul ignore else */
if (validFiles > 1 || args.timestamp !== false) {
newArgs.overwrite = false;
}
const promises = files.map(file => {
// Files with errors we just resolve
if (file.err) {
return Promise.resolve(file);
}
// Valid files get created but first we need to pass correct filename option
// Default value is name of file
// If a filename option was provided, all files get that name
const reportFilename = getReportFilename(file, newArgs);
return report.create(
file.data,
Object.assign({}, newArgs, { reportFilename })
);
});
return Promise.all(promises)
.then(handleResolved)
.catch(handleError);
}
module.exports = marge;