const file = require('../file.js').file;
/**
* @module pete/lib/reporters/mico
*/
module.exports = createMiniCompare;
/**
* @private
* @see {@link https://github.com/mochajs/mocha/blob/ca9eba6fd7177dc482756d96a058eb4169292bd5/lib/reporters/base.js#L48}
*/
/* eslint-disable no-magic-numbers */
const COLORS = {
decor : 90,
pass : 32,
fail : 31,
pending : 36,
default : 0,
fast : 32,
medium : 33,
slow : 31,
underline: 4
};
/* eslint-enable no-magic-numbers */
/**
* @private
*/
const SECOND = 1000000000;
/**
* @private
*/
const TIMECONS = ['🕧', '🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘'];
/**
* "Loss" column thresholds.
* Each value is a upper limit to match given category.
* @private
*/
const RESULT_BEST = 1;
const RESULT_FAST = 1.1;
const RESULT_MEDIUM = 1.5;
const RESULT_SLOW = Infinity;
/**
* "Bar" column thresholds.
* Each value is a upper limit to match given category.
* @private
*/
const PERCENTILE_MOST = 7;
const PERCENTILE_RARE = 9;
const BAR_SCORE_FAST = 1;
const BAR_SCORE_MEDIUM = 5;
/**
* "Bar" column characters.
* @private
*/
const RESULT_BAR_75 = ['🮂', '🮂', '🮂', '▀', '▀', '▀', '🮅', '🮅', '🮅', '🮔'];
const RESULT_BAR_90 = ['', '', '', '', '', '', '', '', '', '🮔'];
const RESULT_BAR_99 = ['', '', '', '', '', '', '', '', '', '🮔'];
/**
* Max value for percentage.
* @private
*/
const MAX_PERCENT = 100;
/**
* Max value after which "loss" column will round value to full integer.
* @private
*/
const CROP_LOSS_ABOVE = 10000;
/**
* This is a bit stupid, but eslint...
*
* @private
*/
const HALF = 0.5;
/**
* @private
*/
const FORMATTER = new Intl.NumberFormat();
/**
* @typedef {object} Column
* @private
*/
const COLUMNS = {
string: {
init: function init (report, value) {
this.value = value;
return this;
},
sort: index => (a, b) => {
if (!a[index]) {
return 1;
}
if (!b[index]) {
return -1;
}
return a[index].value.localeCompare(b[index].value, undefined, {numeric: true});
},
showError: false,
align : 'left',
left : '',
right : ' ',
pre : {value: ''},
post : {value: ''}
},
number: {
init: function init (report, value) {
this.failed = Boolean(report.error);
this.score = typeof value === 'undefined' || value === null ? this.fallback : value;
this.value = FORMATTER.format(this.score.toFixed(2)); // eslint-disable-line no-magic-numbers
return this;
},
sort: index => (a, b) => {
if (!a[index] || a[index].failed) {
return 1;
}
if (!b[index] || b[index].failed) {
return -1;
}
return a[index].score - b[index].score;
},
calc: function calc (best) {
if (this.score === Infinity) {
this.pre.value = this.pre.value.replace(/[^\n]/g, ' ');
this.post.value = this.post.value.replace(/[^\n]/g, ' ');
return;
}
if (this.score === best) {
return;
}
if (this.pre.value !== COLUMNS.number.pre.value) {
return;
}
var loss = Number((this.score / best).toFixed(1));
if (loss < 2) { // eslint-disable-line no-magic-numbers
this.pre.value = `${TIMECONS[Math.floor(loss % 1 * 10)]} `; // eslint-disable-line no-magic-numbers
}
else {
this.pre.value = '🌜 ';
}
},
showError: false,
left : ' ',
right : ' ',
align : 'right',
best : Math.min,
worst : Math.max,
pre : {value: '🕛 '},
post : {value: 'ns'},
fallback : Infinity
}
};
COLUMNS.undefined = Object.assign(Object.create(COLUMNS.number), {
init: function init (report) { // eslint-disable-line no-unused-vars
this.score = Infinity;
this.value = '';
return this;
}
});
COLUMNS.status = Object.assign(Object.create(COLUMNS.string), {
init: function init (report) {
this.score = report.error ? 1 : 0;
this.value = report.error ? '✗' : '✔';
this.color = report.error ? 'fail' : 'pass';
return this;
},
showError: true,
best : Math.min,
worst : Math.max,
left : ' ',
right : ' '
});
COLUMNS.name = Object.assign(Object.create(COLUMNS.string), {showError: true});
COLUMNS.mode = Object.assign(Object.create(COLUMNS.string), {
init: function init (report) {
this.value = report.mode === 'regular' ? '' : report.value;
return this;
}
});
COLUMNS.max = Object.assign(Object.create(COLUMNS.number), {
calc: function calc (best) {
COLUMNS.number.calc.call(this, best);
this.pre.value = '';
},
pre: {value: ''}
});
COLUMNS.ops = Object.assign(Object.create(COLUMNS.number), {
init: function init (report, value) {
COLUMNS.number.init.call(this, report, value);
this.score = report.mean ? SECOND / report.mean : this.fallback;
this.value = FORMATTER.format(this.score.toFixed(0));
return this;
},
sort: index => (a, b) => {
if (!a[index] || a[index].failed) {
return 1;
}
if (!b[index] || b[index].failed) {
return -1;
}
return b[index].score - a[index].score;
},
best : Math.max,
worst : Math.min,
pre : {value: '~'},
post : {value: '/s'},
fallback: 0
});
COLUMNS.loss = Object.assign(Object.create(COLUMNS.number), {
init: function init (report, value) {
if (typeof value === 'undefined' || isNaN(value)) {
value = report.mean || Infinity;
}
COLUMNS.number.init.call(this, report, value);
this.srcValue = value;
this.score = value;
this.value = '000.00';
return this;
},
calc: function calc (best, worst) { // eslint-disable-line no-unused-vars
this.score = this.srcValue / (best || 1);
if (isNaN(this.score)) {
this.score = Infinity;
}
this.value = this.score === Infinity ? FORMATTER.format(this.score) : this.score.toFixed(2); // eslint-disable-line no-magic-numbers
if (this.value >= CROP_LOSS_ABOVE) {
this.value = Math.floor(this.value);
}
if (this.score <= RESULT_BEST) {
this.color = 'decor';
}
else if (this.score <= RESULT_FAST) {
this.color = 'fast';
}
else if (this.score <= RESULT_MEDIUM) {
this.color = 'medium';
}
else if (this.score <= RESULT_SLOW) {
this.color = 'slow';
}
if (this.score === Infinity) {
this.pre.value = this.pre.value.replace(/[^\n]/g, ' ');
}
return this;
},
pre : {value: '×'},
post : {value: ''},
fallback: Infinity
});
COLUMNS.bar = Object.assign(Object.create(COLUMNS.number), {
init: function init (report, value) {
COLUMNS.number.init.call(this, report, value);
this.value = '';
this.max = report.max || this.fallback;
this.pvs = Object.keys(report.percentiles)
.map(k => ({p: Number(k.substring(1)), v: report.percentiles[k] || this.fallback}))
.sort((a, b) => a.p - b.p);
return this;
},
calc: function calc (best, worst, cols) {
this.value = cols.c('', 'default');
const cwidth = cols.sumWidth - this.pre.value.length - this.post.value.length;
this.pvs.unshift({p: 0, v: 0});
if (this.pvs[this.pvs.length - 1].p < MAX_PERCENT) {
this.pvs.push({p: MAX_PERCENT, v: this.max});
}
var w = 0;
var width = 0;
var chunks = new Array(this.pvs.length);
for (var i = this.pvs.length - 1; i > 0; i--) {
// Always round up, if bar will get too wide, it will be cropped.
w = Math.max(Math.ceil((this.pvs[i].p - this.pvs[i - 1].p) / MAX_PERCENT * cwidth), 1);
width += w;
if (width > cwidth) {
// Since we're starting from the top percentiles, worst case scenario will crop lowest percentiles.
w -= width - cwidth;
}
chunks[i] = chunk(Math.floor(this.pvs[i].p / MAX_PERCENT * 10), Math.ceil(this.pvs[i].v / best), w); // eslint-disable-line no-magic-numbers
}
this.value = chunks.join('');
/**
* @private
* @param {number} percentile which percentile (1-10)
* @param {number} score value represented by the chunk
* @param {number} chars number of chars in chunk
*/
function chunk (percentile, score, chars) {
if (chars < 1) {
return '';
}
var char = '';
var color = '';
if (percentile <= PERCENTILE_MOST) {
char = RESULT_BAR_75[Math.min(score, RESULT_BAR_75.length - 1)] || '!';
}
else if (percentile <= PERCENTILE_RARE) {
char = RESULT_BAR_90[Math.min(score, RESULT_BAR_90.length - 1)] || '!';
}
else {
char = RESULT_BAR_99[Math.min(score, RESULT_BAR_99.length - 1)] || '!';
}
if (score <= BAR_SCORE_FAST) {
color = 'fast';
}
else if (score <= BAR_SCORE_MEDIUM) {
color = 'medium';
}
else {
color = 'slow';
}
return cols.c(char + char.repeat(chars - 1), color);
}
return this;
},
wholeRow: true,
left : '',
right : '',
color : 'inherit',
pre : {value: ''},
post : {value: ''}
});
COLUMNS.samples = Object.assign(Object.create(COLUMNS.number), {
init: function init (report, value) {
COLUMNS.number.init.call(this, report, value);
this.score = value || this.fallback;
this.value = FORMATTER.format(this.score);
return this;
},
sort: index => (a, b) => {
if (!a[index] || a[index].failed) {
return 1;
}
if (!b[index] || b[index].failed) {
return -1;
}
return b[index].score - a[index].score;
},
best : Math.max,
worst : Math.min,
pre : {value: '|'},
post : {value: '|'},
fallback: 0
});
/**
* Create and return report function that will output reports in "human-friendly" format.
*
* @alias module:pete/lib/reporters/mico
* @param {object} [options]
* @param {object} [options.out] path to file that should be created
* @param {object} [options.callback] to call once on `out` stream `close` event
* @param {string} [options.cols] comma-separated list of columns to output, e.g., "status,name,max,p50,p99,samples,ops,=p99,*p99". "=" means bar instead of column. "*" means calculate "loss" column.
* @param {string} [options.ignore] comma-separated list of run modes to ignore in results, e.g., "warmup,skip"
* @param {string} [options.sort] name of column by which results should be sorted, defaults to last of the columns
* @param {string} [options.colorize] set to "no" or "false" to prevent colorizing output, defaults to "yes" when output `isTTY`
* @return {module:pete/lib/getReporters~report}
*/
function createMiniCompare (options) {
var index = 0;
var out = (options && options.out) || process.stdout;
var callback = (options && options.callback) || null;
var isTTY = out.isTTY;
var reports = [];
if (typeof out === 'string') {
try {
out = file(out, true);
}
catch (e) {
console.error(e);
out = process.stderr;
isTTY = out.isTTY;
}
}
if (typeof callback === 'function') {
out.once('close', callback);
}
return function mico (err, report) {
if (index < 0) {
console.error(new Error('MiCo reporter was called after it was closed'));
return;
}
if (!err && !report) {
if (isTTY) {
out.clearLine(0);
out.cursorTo(0);
}
out.end(compare(reports, options, isTTY));
reports = [];
index = -1;
return;
}
index++;
if (report) {
reports.push(report);
}
else if (err) {
console.error(err);
}
if (isTTY) {
out.cursorTo(0);
process.stdout.write(`${err ? '✗' : '✔'} ${index}. ${report?.name} # ${report?.mode}`);
out.clearLine(1);
}
};
}
/**
* Call this through `array.map`.
*
* @private
* @this {module:pete/lib/createReport~Report}
* @param {string} columnName
* @param {number} index
* @param {string[]} columns
* @return {Column}
*/
function reportToColumn (columnName, index, columns) {
var value = this[columnName];
if (typeof this[columnName] === 'undefined') {
var m = columnName.match(/^(?<mode>[x×*=]|)(?<name>p\d+(?:[.]\d+)?|mean|min|max|stddev)$/);
if (m && m.groups) {
value = this.percentiles[m.groups.name] || this[m.groups.name];
if (m.groups.mode) {
columnName = m.groups.mode === '=' ? 'bar' : 'loss';
}
else {
columnName = m.groups.name;
}
}
}
var c = COLUMNS[columnName] || COLUMNS[typeof value];
if (!c) {
return null;
}
var result = Object.create(c).init(this, value);
if (result.best && !isNaN(result.score) && result.score !== null) {
columns.best = columns.best || [];
columns.best[index] = result.best(typeof columns.best[index] === 'undefined' ? result.score : columns.best[index], result.score);
}
if (result.worst && !isNaN(result.score) && result.score !== null) {
columns.worst = columns.worst || [];
columns.worst[index] = result.worst(typeof columns.worst[index] === 'undefined' ? result.score : columns.worst[index], result.score);
}
if (!result.wholeRow) {
columns.widths = columns.widths || [];
columns.widths[index] = Math.max(columns.widths[index] || 0, result.value.length);
columns.maxWidth = columns.maxWidth || 0;
columns.maxWidth = Math.max(columns.maxWidth, result.value.length);
}
return result;
}
/**
* Call this through `array.map`.
*
* @private
* @this {string[]}
* @param {module:pete/lib/createReport~Report} report
* @return {Column[]}
*/
function getResult (report) {
var columns = this.map(reportToColumn, report);
if (report.error) {
columns.error = report.error;
}
this.sumWidth = Math.max(this.sumWidth || 0, columns.reduce((sum, col, i) => {
if (col.wholeRow) {
columns.bar = i;
return sum;
}
return sum
+ col.left.length
+ col.pre.value.length
+ this.widths[i]
+ col.post.value.length
+ col.right.length;
}, 0));
return columns;
}
/**
* Turn reports into rows of columns of data.
*
* @private
* @param {module:pete/lib/createReport~Report[]} reports
* @param {string[]} columnNames
* @return {Array<Column[]>}
*/
function getResults (reports, columnNames) {
return reports.map(getResult, columnNames);
}
/**
* Turn column data into a "header" value for results table.
*
* @private
* @param {Column} col
* @param {object[]} cols
* @param {string} pad
* @param {number} index
* @return {string}
*/
function columnToHeader (col, cols, pad, index) {
if (col.wholeRow) {
return '';
}
var result = col.left;
var text = cols[index];
var maxWidth = col.pre.value.length + cols.widths[index] + col.post.value.length;
if (maxWidth < 1) {
text = '';
}
if (text.length > maxWidth) {
text = maxWidth < 1 ? '' : text.substring(0, maxWidth);
}
if (col.align === 'left') {
result += text;
result += pad.substring(0, maxWidth - text.length);
}
else {
result += pad.substring(0, Math.ceil((maxWidth * HALF) - (text.length * HALF)));
result += text;
result += pad.substring(0, Math.floor((maxWidth * HALF) - (text.length * HALF)));
}
result += col.right;
return result;
}
/**
* Turn column data into a "cell" value for results table.
*
* @private
* @param {object} score
* @param {object[]} cols
* @param {string} pad
* @return {string}
*/
function scoreToRow (score, cols, pad) {
const c = cols.c;
var errorShown = false;
return score.reduce((result, col, index) => {
if (col.wholeRow) {
return result;
}
if (score.error && (!col || !col.showError)) {
if (!errorShown) {
result += (col && col.left) || '';
result += c(score.error, 'fail');
errorShown = true;
}
return result;
}
if (col.calc) {
col.calc(cols.best && cols.best[index], cols.worst && cols.worst[index], cols);
}
const color = col.color
|| (score.error && 'fail')
|| (typeof col.score === 'undefined' && 'default')
|| (col.score === cols.best[index] ? 'fast' : '')
|| (col.score === cols.worst[index] ? 'slow' : '')
|| 'default';
result += col.left;
if (col.pre && col.pre.value) {
result += c(col.pre.value, col.pre.color === 'inherit' ? color : col.pre.color || 'decor');
}
if (col.align === 'right') {
result += pad.substring(0, cols.widths[index] - col.value.length);
}
result += c(col.value, color);
if (col.post && col.post.value) {
result += c(col.post.value, col.post.color === 'inherit' ? color : col.post.color || 'decor');
}
if (col.align === 'left') {
result += pad.substring(0, cols.widths[index] - col.value.length);
}
result += col.right;
return result;
}, '');
}
/**
* Compare reports and generate table of scores sorted from "best" to "worst".
*
* @private
* @param {module:pete/lib/createReport~Report[]} reports
* @param {object} options
* @param {boolean} useColors
* @return {string}
*/
function compare (reports, options, useColors = false) {
var ignore = (options && options.ignore && options.ignore.split(',')) || ['warmup', 'skip'];
var allowed = reports.filter(report => ignore.indexOf(report.mode) < 0);
var cols = (options && options.cols && options.cols.split(',')) || ['status', 'name', 'p99', 'max', 'ops', 'samples', '*p99', '=p99'];
var sortBy = (options && options.sort) || cols[cols.length - 1];
var scores = getResults(allowed, cols);
if (scores.length < 1) {
return '\n No results found.\n';
}
if (options && options.colorize !== undefined) {
useColors = options.colorize !== 'no' && options.colorize !== 'false';
}
if (sortBy) {
var colIndex = cols.indexOf(sortBy);
var sort = (COLUMNS[sortBy] || scores[0][colIndex] || '').sort;
if (sort) {
scores.sort(sort(colIndex, cols));
}
}
var pad = ' '.repeat(cols.maxWidth);
var c = useColors ? colorize : text => text;
var color = 'default';
var result = '';
scores[0].forEach((col, index) => {
result += columnToHeader(col, cols, pad, index);
});
result = ` ${c(result, 'decor')}\n ${c(result.replace(/[^\n]/g, '-'), 'decor')}\n`;
cols.c = c;
scores.forEach(score => {
result += ' ';
result += scoreToRow(score, cols, pad);
result += '\n';
if (typeof score.bar !== 'undefined') {
const bar = score[score.bar];
if (bar.calc) {
bar.calc(cols.best && cols.best[score.bar], cols.worst && cols.worst[score.bar], cols);
}
result += ' ';
result += c(bar.pre.value, bar.pre.color === 'inherit' ? color : bar.pre.color || 'decor');
result += bar.value;
result += c(bar.post.value, bar.post.color === 'inherit' ? color : bar.post.color || 'decor');
result += '\n';
}
});
var outro = '';
if (reports.length > 0) {
const firstReport = reports[0];
const percentiles = Object.keys(firstReport.percentiles).map(key => key.substring(1));
outro = `\n Results above are based on values of following percentiles:\n ${percentiles.join(', ')}.\n`;
}
return result + outro;
}
/**
* Apply colors and styles to the text.
*
* @private
* @param {string} text
* @param {string[]} types
* @return {string}
*/
function colorize (text, ...types) {
if (!types || types.length < 1) {
return text;
}
const color = types.map(type => COLORS[type]).join('m\u001b[');
return `\u001b[${color}m${text}\u001b[0m`;
}