var forEach = function (obj, callback) { for (var key in obj) { if (obj.hasOwnProperty(key)) { if (callback(key, obj[key])) { break } } } } var palette = { bold: 1, faint: 2, red: 31, green: 32, cyan: 36, //TODO: add others? } var groupPrefix = '█' var detailsPrefix = '↳' var succMark = '✓' var failMark = '✗' var defaultOptions = { indent: ' ', enableColors: true, summaryTimeUnit: null, summaryTrendStats: null, } // strWidth tries to return the actual width the string will take up on the // screen, without any terminal formatting, unicode ligatures, etc. function strWidth(s) { // TODO: determine if NFC or NFKD are not more appropriate? or just give up? https://hsivonen.fi/string-length/ var data = s.normalize('NFKC') // This used to be NFKD in Go, but this should be better var inEscSeq = false var inLongEscSeq = false var width = 0 for (var char of data) { if (char.done) { break } // Skip over ANSI escape codes. if (char == '\x1b') { inEscSeq = true continue } if (inEscSeq && char == '[') { inLongEscSeq = true continue } if (inEscSeq && inLongEscSeq && char.charCodeAt(0) >= 0x40 && char.charCodeAt(0) <= 0x7e) { inEscSeq = false inLongEscSeq = false continue } if (inEscSeq && !inLongEscSeq && char.charCodeAt(0) >= 0x40 && char.charCodeAt(0) <= 0x5f) { inEscSeq = false continue } if (!inEscSeq && !inLongEscSeq) { width++ } } return width } function summarizeCheck(indent, check, decorate) { if (check.fails == 0) { return decorate(indent + succMark + ' ' + check.name, palette.green) } var succPercent = Math.floor((100 * check.passes) / (check.passes + check.fails)) return decorate( indent + failMark + ' ' + check.name + '\n' + indent + ' ' + detailsPrefix + ' ' + succPercent + '% — ' + succMark + ' ' + check.passes + ' / ' + failMark + ' ' + check.fails, palette.red ) } function summarizeGroup(indent, group, decorate) { var result = [] if (group.name != '') { result.push(indent + groupPrefix + ' ' + group.name + '\n') indent = indent + ' ' } for (var i = 0; i < group.checks.length; i++) { result.push(summarizeCheck(indent, group.checks[i], decorate)) } if (group.checks.length > 0) { result.push('') } for (var i = 0; i < group.groups.length; i++) { Array.prototype.push.apply(result, summarizeGroup(indent, group.groups[i], decorate)) } return result } function displayNameForMetric(name) { var subMetricPos = name.indexOf('{') if (subMetricPos >= 0) { return '{ ' + name.substring(subMetricPos + 1, name.length - 1) + ' }' } return name } function indentForMetric(name) { if (name.indexOf('{') >= 0) { return ' ' } return '' } function humanizeBytes(bytes) { var units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] var base = 1000 if (bytes < 10) { return bytes + ' B' } var e = Math.floor(Math.log(bytes) / Math.log(base)) var suffix = units[e | 0] var val = Math.floor((bytes / Math.pow(base, e)) * 10 + 0.5) / 10 return val.toFixed(val < 10 ? 1 : 0) + ' ' + suffix } var unitMap = { s: { unit: 's', coef: 0.001 }, ms: { unit: 'ms', coef: 1 }, us: { unit: 'µs', coef: 1000 }, } function toFixedNoTrailingZeros(val, prec) { // TODO: figure out something better? return parseFloat(val.toFixed(prec)).toString() } function toFixedNoTrailingZerosTrunc(val, prec) { var mult = Math.pow(10, prec) return toFixedNoTrailingZeros(Math.trunc(mult * val) / mult, prec) } function humanizeGenericDuration(dur) { if (dur === 0) { return '0s' } if (dur < 0.001) { // smaller than a microsecond, print nanoseconds return Math.trunc(dur * 1000000) + 'ns' } if (dur < 1) { // smaller than a millisecond, print microseconds return toFixedNoTrailingZerosTrunc(dur * 1000, 2) + 'µs' } if (dur < 1000) { // duration is smaller than a second return toFixedNoTrailingZerosTrunc(dur, 2) + 'ms' } var result = toFixedNoTrailingZerosTrunc((dur % 60000) / 1000, dur > 60000 ? 0 : 2) + 's' var rem = Math.trunc(dur / 60000) if (rem < 1) { // less than a minute return result } result = (rem % 60) + 'm' + result rem = Math.trunc(rem / 60) if (rem < 1) { // less than an hour return result } return rem + 'h' + result } function humanizeDuration(dur, timeUnit) { if (timeUnit !== '' && unitMap.hasOwnProperty(timeUnit)) { return (dur * unitMap[timeUnit].coef).toFixed(2) + unitMap[timeUnit].unit } return humanizeGenericDuration(dur) } function humanizeValue(val, metric, timeUnit) { if (metric.type == 'rate') { // Truncate instead of round when decreasing precision to 2 decimal places return (Math.trunc(val * 100 * 100) / 100).toFixed(2) + '%' } switch (metric.contains) { case 'data': return humanizeBytes(val) case 'time': return humanizeDuration(val, timeUnit) default: return toFixedNoTrailingZeros(val, 6) } } function nonTrendMetricValueForSum(metric, timeUnit) { switch (metric.type) { case 'counter': return [ humanizeValue(metric.values.count, metric, timeUnit), humanizeValue(metric.values.rate, metric, timeUnit) + '/s', ] case 'gauge': return [ humanizeValue(metric.values.value, metric, timeUnit), 'min=' + humanizeValue(metric.values.min, metric, timeUnit), 'max=' + humanizeValue(metric.values.max, metric, timeUnit), ] case 'rate': return [ humanizeValue(metric.values.rate, metric, timeUnit), succMark + ' ' + metric.values.passes, failMark + ' ' + metric.values.fails, ] default: return ['[no data]'] } } function summarizeMetrics(options, data, decorate) { var indent = options.indent + ' ' var result = [] var names = [] var nameLenMax = 0 var nonTrendValues = {} var nonTrendValueMaxLen = 0 var nonTrendExtras = {} var nonTrendExtraMaxLens = [0, 0] var trendCols = {} var numTrendColumns = options.summaryTrendStats.length var trendColMaxLens = new Array(numTrendColumns).fill(0) forEach(data.metrics, function (name, metric) { names.push(name) // When calculating widths for metrics, account for the indentation on submetrics. var displayName = indentForMetric(name) + displayNameForMetric(name) var displayNameWidth = strWidth(displayName) if (displayNameWidth > nameLenMax) { nameLenMax = displayNameWidth } if (metric.type == 'trend') { var cols = [] for (var i = 0; i < numTrendColumns; i++) { var tc = options.summaryTrendStats[i] var value = metric.values[tc] if (tc === 'count') { value = value.toString() } else { value = humanizeValue(value, metric, options.summaryTimeUnit) } var valLen = strWidth(value) if (valLen > trendColMaxLens[i]) { trendColMaxLens[i] = valLen } cols[i] = value } trendCols[name] = cols return } var values = nonTrendMetricValueForSum(metric, options.summaryTimeUnit) nonTrendValues[name] = values[0] var valueLen = strWidth(values[0]) if (valueLen > nonTrendValueMaxLen) { nonTrendValueMaxLen = valueLen } nonTrendExtras[name] = values.slice(1) for (var i = 1; i < values.length; i++) { var extraLen = strWidth(values[i]) if (extraLen > nonTrendExtraMaxLens[i - 1]) { nonTrendExtraMaxLens[i - 1] = extraLen } } }) // sort all metrics but keep sub metrics grouped with their parent metrics names.sort(function (metric1, metric2) { var parent1 = metric1.split('{', 1)[0] var parent2 = metric2.split('{', 1)[0] var result = parent1.localeCompare(parent2) if (result !== 0) { return result } var sub1 = metric1.substring(parent1.length) var sub2 = metric2.substring(parent2.length) return sub1.localeCompare(sub2) }) var getData = function (name) { if (trendCols.hasOwnProperty(name)) { var cols = trendCols[name] var tmpCols = new Array(numTrendColumns) for (var i = 0; i < cols.length; i++) { tmpCols[i] = options.summaryTrendStats[i] + '=' + decorate(cols[i], palette.cyan) + ' '.repeat(trendColMaxLens[i] - strWidth(cols[i])) } return tmpCols.join(' ') } var value = nonTrendValues[name] var fmtData = decorate(value, palette.cyan) + ' '.repeat(nonTrendValueMaxLen - strWidth(value)) var extras = nonTrendExtras[name] if (extras.length == 1) { fmtData = fmtData + ' ' + decorate(extras[0], palette.cyan, palette.faint) } else if (extras.length > 1) { var parts = new Array(extras.length) for (var i = 0; i < extras.length; i++) { parts[i] = decorate(extras[i], palette.cyan, palette.faint) + ' '.repeat(nonTrendExtraMaxLens[i] - strWidth(extras[i])) } fmtData = fmtData + ' ' + parts.join(' ') } return fmtData } for (var name of names) { var metric = data.metrics[name] var mark = ' ' var markColor = function (text) { return text } // noop if (metric.thresholds) { mark = succMark markColor = function (text) { return decorate(text, palette.green) } forEach(metric.thresholds, function (name, threshold) { if (!threshold.ok) { mark = failMark markColor = function (text) { return decorate(text, palette.red) } return true // break } }) } var fmtIndent = indentForMetric(name) var fmtName = displayNameForMetric(name) fmtName = fmtName + decorate( '.'.repeat(nameLenMax - strWidth(fmtName) - strWidth(fmtIndent) + 3) + ':', palette.faint ) result.push(indent + fmtIndent + markColor(mark) + ' ' + fmtName + ' ' + getData(name)) } return result } function generateTextSummary(data, options) { var mergedOpts = Object.assign({}, defaultOptions, data.options, options) var lines = [] // TODO: move all of these functions into an object with methods? var decorate = function (text) { return text } if (mergedOpts.enableColors) { decorate = function (text, color /*, ...rest*/) { var result = '\x1b[' + color for (var i = 2; i < arguments.length; i++) { result += ';' + arguments[i] } return result + 'm' + text + '\x1b[0m' } } Array.prototype.push.apply( lines, summarizeGroup(mergedOpts.indent + ' ', data.root_group, decorate) ) Array.prototype.push.apply(lines, summarizeMetrics(mergedOpts, data, decorate)) return lines.join('\n') } exports.humanizeValue = humanizeValue exports.textSummary = generateTextSummary var replacements = { '&': '&', '<': '<', '>': '>', "'": ''', '"': '"', } function escapeHTML(str) { // TODO: something more robust? return str.replace(/[&<>'"]/g, function (char) { return replacements[char] }) } function generateJUnitXML(data, options) { var failures = 0 var cases = [] forEach(data.metrics, function (metricName, metric) { if (!metric.thresholds) { return } forEach(metric.thresholds, function (thresholdName, threshold) { if (threshold.ok) { cases.push( '' ) } else { failures++ cases.push( '' ) } }) }) var name = options && options.name ? escapeHTML(options.name) : 'k6 thresholds' return ( '\n\n' + '' + cases.join('\n') + '\n\n' ) } exports.jUnit = generateJUnitXML