lab
Version:
Test utility
483 lines (359 loc) • 14.8 kB
JavaScript
'use strict';
// Load modules
const Diff = require('diff');
const StringifySafe = require('json-stringify-safe');
const StableStringify = require('json-stable-stringify');
// Declare internals
const internals = {
width: 50
};
internals.stringify = (obj, serializer, indent) => {
return StableStringify(obj,
{ space: indent, replacer: StringifySafe.getSerialize(serializer) });
};
exports = module.exports = internals.Reporter = function (options) {
this.settings = options;
this.count = 0;
this.last = [];
this.colors = internals.colors(options.colors);
};
internals.Reporter.prototype.start = function (notebook) {
};
internals.Reporter.prototype.test = function (test) {
if (this.settings.progress === 0) {
return;
}
if (this.settings['silent-skips'] && (test.skipped || test.todo)) {
return;
}
if (this.settings.progress === 1) {
// ..x....x.-..
if (!this.count) {
this.report('\n ');
}
this.count++;
if ((this.count - 1) % internals.width === 0) {
this.report('\n ');
}
this.report(test.err ? this.colors.red('x') : (test.skipped || test.todo ? this.colors.magenta('-') : '.'));
}
else {
// Can't be fully covereed but it's ok
// $lab:coverage:off$
const check = process.platform === 'win32' ? '\u221A' : '\u2714';
const asterisk = process.platform === 'win32' ? '\u00D7' : '\u2716';
// $lab:coverage:on$
// Verbose (Spec reporter)
for (let i = 0; i < test.path.length; ++i) {
if (test.path[i] !== this.last[i] || (i > 0 && test.path[i - 1] !== this.last[i - 1])) {
this.report(internals.spacer(i * 2) + test.path[i] + '\n');
}
}
this.last = test.path;
const spacer = internals.spacer(test.path.length * 2);
if (test.err) {
this.report(spacer + this.colors.red(asterisk + ' ' + test.id + ') ' + test.relativeTitle) + '\n');
}
else {
const symbol = test.skipped || test.todo ? this.colors.magenta('-') : this.colors.green(check);
const assertion = test.assertions === undefined ? '' : ' and ' + test.assertions + ' assertions';
this.report(spacer + symbol + ' ' + this.colors.gray(test.id + ') ' + test.relativeTitle +
' (' + test.duration + ' ms' + assertion + ')') + '\n');
}
}
};
internals.spacer = function (length) {
return new Array(length + 1).join(' ');
};
internals.stringifyReplacer = function (key, value) {
// Show usually invisible values from JSON.stringify in a different way,
// follow the bracket format of json-stringify-safe.
if (value === undefined) {
return '[undefined]';
}
if (typeof value === 'function' || value === Infinity || value === -Infinity) {
return '[' + value.toString() + ']';
}
/* $lab:coverage:off$ */ // There is no way to cover that in node 0.10
if (typeof value === 'symbol') {
return '[' + value.toString() + ']';
}
/* $lab:coverage:on$ */
return value;
};
internals.Reporter.prototype.end = function (notebook) {
if (this.settings.progress) {
this.report('\n\n');
}
// Colors
const red = this.colors.red;
const green = this.colors.green;
const gray = this.colors.gray;
const yellow = this.colors.yellow;
const whiteRedBg = this.colors.whiteRedBg;
const blackGreenBg = this.colors.blackGreenBg;
const magenta = this.colors.magenta;
// Tests
const notes = notebook.tests.filter(internals.filterNotes);
const failures = notebook.tests.filter(internals.filterFailures);
const skipped = notebook.tests.filter(internals.filterSkipped);
let output = '';
const totalTests = notebook.tests.length - skipped.length;
const errors = notebook.errors || [];
if (errors.length) {
output += 'Test script errors:\n\n';
errors.forEach((err = {}) => {
output += red(err.message) + '\n';
if (err.stack) {
const stack = err.stack.slice(err.stack.indexOf('\n') + 1)
.replace(/^/gm, ' ')
.split('\n')
.filter(internals.filterNodeModules) // Remove node_modules files
.slice(0, 5) // Show only first 5 stack lines
.join('\n');
output += gray(stack) + '\n';
}
output += '\n';
});
output += red('There were ' + errors.length + ' test script error(s).') + '\n\n';
}
if (failures.length) {
output += 'Failed tests:\n\n';
for (let i = 0; i < failures.length; ++i) {
const test = failures[i];
const message = test.err.message || '';
output += ' ' + test.id + ') ' + test.title + ':\n\n';
// Actual vs Expected
if (test.err.actual !== undefined &&
test.err.expected !== undefined) {
const actual = internals.stringify(test.err.actual, internals.stringifyReplacer, 2);
const expected = internals.stringify(test.err.expected, internals.stringifyReplacer, 2);
output += ' ' + whiteRedBg('actual') + ' ' + blackGreenBg('expected') + '\n\n ';
const comparison = Diff.diffWords(actual, expected);
for (let j = 0; j < comparison.length; ++j) {
const item = comparison[j];
const value = item.value;
const lines = value.split('\n');
for (let k = 0; k < lines.length; ++k) {
if (k) {
output += '\n ';
}
if (item.added || item.removed) {
output += item.added ? blackGreenBg(lines[k]) : whiteRedBg(lines[k]);
}
else {
output += lines[k];
}
}
}
output += '\n\n ' + yellow(message);
output += '\n\n';
}
else {
output += ' ' + red(message) + '\n\n';
}
if (test.err.at) {
output += gray(' at ' + test.err.at.filename + ':' + test.err.at.line + ':' + test.err.at.column) + '\n';
}
else if (!test.timeout &&
test.err.stack) {
let stack = test.err.stack;
if (stack.indexOf(message) !== -1) {
stack = stack.slice(stack.indexOf(message) + message.length + 1);
}
output += gray(stack.replace(/^/gm, ' ')) + '\n';
}
if (test.err.data) {
const isObject = typeof test.err.data === 'object' && !Array.isArray(test.err.data);
let errorData = internals.stringify(test.err.data, null, isObject ? 4 : null);
if (isObject) {
errorData = errorData.replace(/(\n\s*)"(.*)"\:/g, '$1$2:').split('\n').slice(1, -1).join('\n');
}
output += gray('\n Additional error data:\n' + errorData.replace(/^/gm, ' ')) + '\n';
}
output += '\n';
}
output += '\n' + red(failures.length + ' of ' + totalTests + ' tests failed');
}
else {
output += green(totalTests + ' tests complete');
}
if (skipped.length) {
output += magenta(` (${skipped.length} skipped)`);
}
output += '\n';
output += 'Test duration: ' + notebook.ms + ' ms\n';
// Assertions
if (notebook.assertions !== undefined) {
output += 'Assertions count: ' + notebook.assertions + ' (verbosity: ' + (notebook.assertions / totalTests).toFixed(2) + ')\n';
}
// Leaks
if (notebook.leaks) {
if (notebook.leaks.length) {
output += red('The following leaks were detected:' + notebook.leaks.join(', ')) + '\n';
}
else {
output += green('No global variable leaks detected') + '\n';
}
}
if (notebook.shuffle) {
output += 'Randomized with seed: ' + notebook.seed + '. Use --shuffle --seed ' + notebook.seed + ' to run tests in same order again.\n';
}
// Coverage
const coverage = notebook.coverage;
if (coverage) {
const status = 'Coverage: ' + coverage.percent.toFixed(2) + '%';
output += coverage.percent === 100 ? green(status) : red(status + ' (' + (coverage.sloc - coverage.hits) + '/' + coverage.sloc + ')');
if (coverage.percent < 100) {
coverage.files.forEach((file) => {
let missingLines;
if (file.sourcemaps) {
const missingLinesByFile = {};
Object.keys(file.source).forEach((lineNumber) => {
const line = file.source[lineNumber];
if (line.miss) {
missingLines = missingLinesByFile[line.originalFilename] = missingLinesByFile[line.originalFilename] || [];
missingLines.push(line.originalLine);
}
});
const files = Object.keys(missingLinesByFile);
if (files.length) {
output += red('\n' + file.filename + ' missing coverage from file(s):');
files.forEach((filename) => {
output += red('\n\t' + filename + ' on line(s): ' + missingLinesByFile[filename].join(', '));
});
}
}
else {
missingLines = [];
Object.keys(file.source).forEach((lineNumber) => {
const line = file.source[lineNumber];
if (line.miss) {
missingLines.push(parseInt(lineNumber, 10));
}
});
if (missingLines.length) {
// Lines missing coverage are reported as a list of
// spans, e.g. "1, 3-8, 10, 13-15".
const missingLinesReport = [];
const span = {
start: missingLines[0],
end: missingLines[0]
};
for (let i = 1; i <= missingLines.length; ++i) {
const line = missingLines[i];
if (line === span.end + 1) {
// Extend the current span.
span.end = line;
}
else {
// Flush current span to output.
if (span.start === span.end) {
missingLinesReport.push(span.start);
}
else if (span.start + 1 === span.end) {
missingLinesReport.push(span.start);
missingLinesReport.push(span.end);
}
else {
missingLinesReport.push(span.start + '-' + span.end);
}
// Start a new span.
span.start = line;
span.end = line;
}
}
output += yellow('\n' + file.filename + ' missing coverage on line(s): ' + missingLinesReport.join(', '));
}
}
});
if (coverage.percent < this.settings.threshold) {
output += red('\nCode coverage below threshold: ' + coverage.percent.toFixed(2) + ' < ' + this.settings.threshold);
}
}
output += '\n';
}
const lint = notebook.lint;
if (lint) {
output += 'Linting results:';
let hasErrors = false;
lint.lint.forEach((entry) => {
// Don't show anything if there aren't issues
if (!entry.errors || !entry.errors.length) {
return;
}
hasErrors = true;
output += gray('\n\t' + entry.filename + ':');
entry.errors.forEach((err) => {
output += (err.severity === 'ERROR' ? red : yellow)('\n\t\tLine ' + err.line + ': ' + err.message);
});
});
if (!hasErrors) {
output += green(' No issues\n');
}
}
if (notes.length) {
output += '\n\nTest notes:\n';
notes.forEach((test) => {
output += gray(test.relativeTitle) + '\n';
test.notes.forEach((note) => {
output += yellow(`\t* ${note}`);
});
});
}
output += '\n';
this.report(output);
};
internals.color = function (name, code, enabled) {
if (enabled && Array.isArray(code)) {
const color = '\u001b[' + code[0] + ';' + code[1] + 'm';
return function (text) {
return color + text + '\u001b[0m';
};
}
else if (enabled) {
const color = '\u001b[' + code + 'm';
return function (text) {
return color + text + '\u001b[0m';
};
}
return function (text) {
return text;
};
};
internals.colors = function (enabled) {
if (enabled === null) {
enabled = require('supports-color').stdout;
}
const codes = {
black: 0,
gray: 90,
red: 31,
green: 32,
yellow: 33,
magenta: 35,
redBg: 41,
greenBg: 42,
whiteRedBg: [37, 41],
blackGreenBg: [30, 42]
};
const colors = {};
const names = Object.keys(codes);
for (let i = 0; i < names.length; ++i) {
const name = names[i];
colors[name] = internals.color(name, codes[name], enabled);
}
return colors;
};
internals.filterNotes = function (test) {
return test.notes && test.notes.length;
};
internals.filterFailures = function (test) {
return !!test.err;
};
internals.filterNodeModules = function (line) {
return !(/\/node_modules\//.test(line));
};
internals.filterSkipped = function (test) {
return test.skipped || test.todo;
};