TakenPilot/mocha-spec-reporter-async

View on GitHub
index.js

Summary

Maintainability
C
1 day
Test Coverage
/* jshint -W100 */

var tty = require('tty'),
  util = require('util'),
  diff = require('diff'),
  ms = require('mocha/lib/ms'),
  _ = require('lodash'),
  utils = require('mocha/lib/utils');

exports = module.exports = Base;

/**
 * Enable coloring by default.
 */
exports.useColors = (tty.isatty(1) && tty.isatty(2)) || (process.env.MOCHA_COLORS !== undefined);

/**
 * Inline diffs instead of +/-
 */
exports.inlineDiffs = false;

/**
 * Default color map.
 */
var colors = {
  'pass': 90,
  'fail': 31,
  'bright pass': 92,
  'bright fail': 91,
  'bright yellow': 93,
  'pending': 36,
  'suite': 0,
  'error title': 0,
  'error message': 31,
  'error stack': 90,
  'checkmark': 32,
  'fast': 90,
  'medium': 33,
  'slow': 31,
  'green': 32,
  'light': 90,
  'diff gutter': 90,
  'diff added': 42,
  'diff removed': 41
};

/**
 * Color `str` with the given `type`,
 * allowing colors to be disabled,
 * as well as user-defined color
 * schemes.
 *
 * @param {String} type
 * @param {String} str
 * @return {String}
 * @api private
 */

function color(type, str) {
  if (!exports.useColors) {
    return str;
  }
  return '\u001b[' + colors[type] + 'm' + str + '\u001b[0m';
}

function indent(indents) {
  var str = "";
  for (var i = 1; i < indents; i++) {
    str += '  ';
  }
  return str;
}

function onStart() {
  var stats = this.stats;

  stats.start = new Date();
  this.log(); //blank line
}

function onSuite(suite) {
  var stats = this.stats,
    title = suite.title || "";

  stats.suites = stats.suites || 0;
  if (!suite.root) {
    stats.suites++;
  }
  this.indents += 1;
  this.log(color('suite', '%s%s'), indent(this.indents), title);
}

function onSuiteEnd() {
  this.indents -= 1;
  if (1 === this.indents) {
    this.log();
  }
}

function onTestEnd() {
  var stats = this.stats;

  stats.tests = stats.tests || 0;
  stats.tests++;
}

function getTestSpeed(test) {
  var result, slow, medium, duration = test.duration;
  if (test && _.isFunction(test.slow)) {
    slow = test.slow();
    medium = slow / 2;
    if (duration > slow) {
      result = 'slow';
    } else if (duration > medium) {
      result = 'medium';
    } else {
      result = 'fast';
    }
  } else {
    result = 'fast';
  }
  return result;
}

function onPass(test) {
  var stats = this.stats;

  stats.passes = stats.passes || 0;
  test.speed = getTestSpeed(test);
  stats.passes += 1;

  this.logPass(test);
}

function onFail(test, err) {
  var stats = this.stats,
    failures = this.failures;

  stats.failures = stats.failures || 0;
  stats.failures++;
  test.err = err;
  failures.push(test);

  this.logFail(test);
}

function onEnd() {
  var stats = this.stats;

  stats.end = new Date();
  stats.duration = new Date() - stats.start;
  this.epilogue();
}

function onPending(test) {
  var stats = this.stats,
    indents = this.indents;

  stats.pending++;
  var fmt = indent(indents) + color('pending', '  - %s');
  this.log(fmt, test.title || "");
}

/**
 * Initialize a new `Base` reporter.
 *
 * All other reporters generally
 * inherit from this reporter, providing
 * stats such as test duration, number
 * of tests passed / failed etc.
 *
 * @param {Runner} runner
 * @api public
 */
function Base(runner) {
  this.indents = 0;
  this.n = 0;
  this.stats = {
    suites: 0,
    tests: 0,
    passes: 0,
    pending: 0,
    failures: 0
  };
  this.failures = [];
  this.runner = runner;

  if (!runner) {
    return;
  }

  runner.stats = this.stats;
  runner.on('start', onStart.bind(this));
  runner.on('suite', onSuite.bind(this));
  runner.on('suite end', onSuiteEnd.bind(this));
  runner.on('test end', onTestEnd.bind(this));
  runner.on('pass', onPass.bind(this));
  runner.on('fail', onFail.bind(this));
  runner.on('end', onEnd.bind(this));
  runner.on('pending', onPending.bind(this));
}
Base.prototype = {
  getSymbols: function () {
    if ('win32' === process.platform) {
      // With node.js on Windows: use symbols available in terminal default fonts
      return {
        ok: '\u221A',
        err: '\u00D7',
        dot: '․'
      };
    } else {
      return {
        ok: '✓',
        err: '✖',
        dot: '․'
      };
    }
  },
  getBuffer: function () {
    return this._buffer || '';
  },
  print: function () {
    var buffer = this._buffer;
    console.log(buffer);
    this._buffer = "";
  },
  error: function () {
    var buffer = this._buffer || "";
    buffer += util.format.apply(util, Array.prototype.slice.call(arguments)) + '\n';
    this._buffer = buffer;
  },
  log: function () {
    var buffer = this._buffer || "";
    buffer += util.format.apply(util, Array.prototype.slice.call(arguments)) + '\n';
    this._buffer = buffer;
  },
  getTestTitle: function (test) {
    return typeof test.fullTitle === 'function' && test.fullTitle() || test.title || '';
  },
  indentAllLinesAfterFirst: function (str, index) {
    return str.slice(index ? index + 1 : index).replace(/^/gm, '  ');
  },
  addErrorToMessage: function (err, msg, escape) {
    var message = err.message || '';
    var match = message.match(/^([^:]+): expected/);
    msg = '\n      ' + color('error message', match ? match[1] : msg);
    msg += exports.inlineDiffs ? inlineDiff(err, escape) : unifiedDiff(err, escape);
    return msg;
  },
  logPass: function (test) {
    var fmt,
      symbols = this.getSymbols(),
      indents = this.indents,
      title = test.title || "";
    if ('fast' === test.speed) {
      fmt = indent(indents) + color('checkmark', '  ' + symbols.ok) + color('pass', ' %s ');
      this.log(fmt, title);
    } else {
      fmt = indent(indents) + color('checkmark', '  ' + symbols.ok) + color('pass', ' %s ') + color(test.speed, '(%dms)');
      this.log(fmt, title, test.duration);
    }
  },
  logPasses: function () {
    var fmt,
      stats = this.stats;
    if (stats.duration) {
      fmt = color('bright pass', ' ') + color('green', ' %d passing') + color('light', ' (%s)');
      this.log(fmt, stats.passes || 0, ms(stats.duration));
    } else {
      fmt = color('bright pass', ' ') + color('green', ' %d passing');
      this.log(fmt, stats.passes || 0);
    }
  },
  logPending: function () {
    var fmt,
      stats = this.stats;
    if (stats.pending) {
      fmt = color('pending', ' ') + color('pending', ' %d pending');
      this.log(fmt, stats.pending);
    }
  },
  logFail: function (test) {
    var indents = this.indents;
    this.n += 1; //count logged to screen, pretend we're keeping track
    this.log(indent(indents) + color('fail', '  %d) %s'), this.n, test.title || "");
  },
  logFailures: function () {
    var fmt,
      stats = this.stats;
    if (stats.failures) {
      fmt = color('fail', '  %d failing');
      this.error(fmt, stats.failures);
      this.error(); //blank line
      this.failures.forEach(this.logFailure.bind(this));
      this.error(); //blank line
    }
  },
  logFailure: function (test, i) {
    // format
    var fmt = color('error title', '  %s) %s:\n') + color('error message', '     %s') +
      color('error stack', '\n%s\n');

    // msg
    var err = test.err,
      message = err.message || '',
      stack = err.stack || message,
      index = stack.indexOf(message) + message.length,
      msg = stack.slice(0, index),
      escape = true;

    // uncaught
    if (err.uncaught) {
      msg = 'Uncaught ' + msg;
    }

    // explicitly show diff
    if (err.showDiff && sameType(err.actual, err.expected)) {
      escape = false;
      err.actual = utils.stringify(err.actual);
      err.expected = utils.stringify(err.expected);
    }

    // actual / expected diff
    if ('string' === typeof err.actual && 'string' === typeof err.expected) {
      fmt = color('error title', '  %s) %s:\n%s') + color('error stack', '\n%s\n');
      msg = this.addErrorToMessage(err, msg, escape);
    }

    // indent stack trace without msg
    stack = this.indentAllLinesAfterFirst(stack, index);

    this.error(fmt, (i + 1), this.getTestTitle(test), msg, stack);
  },
  epilogue: function () {
    this.log(); //blank line

    this.logPasses();
    this.logPending();
    this.logFailures();

    this.log(); //blank line

    this.print(); //output to screen
  }
};

/**
 * Pad the given `str` to `len`.
 *
 * @param {String} str
 * @param {Number} len
 * @return {String}
 * @api private
 */
function pad(str, len) {
  len = len - str.length + 1;
  var padding = "";
  for (var i = 0; i < len; i++) {
    padding += ' ';
  }
  str = String(str);
  return padding + str;
}


/**
 * Returns an inline diff between 2 strings with coloured ANSI output
 *
 * @param {{actual: *, expected: *}} err Error with actual/expected
 * @param {boolean} escape
 * @return {String} Diff
 * @api private
 */
function inlineDiff(err, escape) {
  var msg = errorDiff(err, 'WordsWithSpace', escape);

  // line numbers
  var lines = msg.split('\n');
  if (lines.length > 4) {
    var width = String(lines.length).length;
    msg = lines.map(function (str, i) {
      i++;
      return pad(i, width) + ' |' + ' ' + str;
    }).join('\n');
  }

  // legend
  msg = '\n' + color('diff removed', 'actual') + ' ' + color('diff added', 'expected') +
    '\n\n' + msg + '\n';

  // indent
  msg = msg.replace(/^/gm, '      ');
  return msg;
}

function cleanUp(escape, indent, line) {
  if (escape) {
    line = escapeInvisibles(line);
  }
  if (line[0] === '+') {
    return indent + colorLines('diff added', line);
  }
  if (line[0] === '-') {
    return indent + colorLines('diff removed', line);
  }
  if (line.match(/\@\@/)) {
    return null;
  }
  if (line.match(/\\ No newline/)) {
    return null;
  } else {
    return indent + line;
  }
}

function notBlank(line) {
  return line !== null;
}

/**
 * Returns a unified diff between 2 strings
 *
 * @param {{actual: *, expected: *}} err Error with actual/expected
 * @param {String} escape
 * @return {String} difference
 * @api private
 */
function unifiedDiff(err, escape) {
  var indent = '      ';
  var msg = diff.createPatch('string', err.actual, err.expected);
  var lines = msg.split('\n').splice(4);
  return '\n      ' +
    colorLines('diff added', '+ expected') + ' ' +
    colorLines('diff removed', '- actual') + '\n\n' +
    lines.map(cleanUp.bind(null, escape, indent)).filter(notBlank).join('\n');
}

/**
 * Return a character diff for `err`.
 *
 * @param {{actual: *, expected: *}} err
 * @param {String} type
 * @param {Boolean} escape
 * @return {String}
 * @api private
 */
function errorDiff(err, type, escape) {
  var actual = escape ? escapeInvisibles(err.actual) : err.actual;
  var expected = escape ? escapeInvisibles(err.expected) : err.expected;
  return diff['diff' + type](actual, expected).map(function (str) {
    if (str.added) {
      return colorLines('diff added', str.value);
    }
    if (str.removed) {
      return colorLines('diff removed', str.value);
    }
    return str.value;
  }).join('');
}

/**
 * Returns a string with all invisible characters in plain text
 *
 * @param {String} line
 * @return {String}
 * @api private
 */
function escapeInvisibles(line) {
  return line.replace(/\t/g, '<tab>').replace(/\r/g, '<CR>').replace(/\n/g, '<LF>\n');
}

/**
 * Color lines for `str`, using the color `name`.
 *
 * @param {String} name
 * @param {String} str
 * @return {String}
 * @api private
 */
function colorLines(name, str) {
  return str.split('\n').map(function (str) {
    return color(name, str);
  }).join('\n');
}

/**
 * Check that a / b have the same type.
 *
 * @param {Object} a
 * @param {Object} b
 * @return {Boolean}
 * @api private
 */
function sameType(a, b) {
  a = Object.prototype.toString.call(a);
  b = Object.prototype.toString.call(b);
  return a == b;
}