adamgruber/mochawesome-report-generator

View on GitHub
src/bin/cli-main.js

Summary

Maintainability
A
55 mins
Test Coverage
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;