var _ = require('lodash'), sdk = require('postman-collection'), colors = require('colors/safe'), Table = require('cli-table3'), format = require('util').format, util = require('../../util'), cliUtils = require('./cli-utils'), print = require('../../print'), pad = cliUtils.padLeft, LF = '\n', SPC = ' ', DOT = '.', E = '', CACHED_TIMING_PHASE = '(cache)', TIMING_TABLE_HEADERS = { prepare: 'prepare', wait: 'wait', dns: 'dns-lookup', tcp: 'tcp-handshake', secureHandshake: 'ssl-handshake', firstByte: 'transfer-start', download: 'download', process: 'process', total: 'total' }, BODY_CLIP_SIZE = 2048, PostmanCLIReporter, timestamp, extractSNR; // sets theme for colors for console logging colors.setTheme({ log: 'grey', info: 'cyan', warn: 'yellow', debug: 'blue', error: 'red' }); extractSNR = function (executions) { var snr; // eslint-disable-next-line lodash/collection-method-value _.isArray(executions) && _.forEachRight(executions, function (execution) { var nextReq = _.get(execution, 'result.return.nextRequest'); if (nextReq) { snr = nextReq; return false; } }); return snr; }; /** * CLI reporter * * @param {EventEmitter} emitter - An EventEmitter instance with event handler attachers to trigger reporting. * @param {Object} reporterOptions - CLI reporter options object. * @param {Boolean=} reporterOptions.silent - Boolean flag to turn off CLI reporting altogether, if set to true. * @param {Boolean=} reporterOptions.noAssertions - Boolean flag to turn off assertion reporting, if set to true. * @param {Boolean=} reporterOptions.noSuccessAssertions - Boolean flag, if true, turn off report successful assertions. * @param {Boolean=} reporterOptions.noSummary - Boolean flag to turn off summary reporting altogether, if set to true. * @param {Boolean=} reporterOptions.noFailures - Boolean flag to turn off failure reporting altogether, if set to true. * @param {Boolean=} reporterOptions.noConsole - Boolean flag to turn off console logging, if set to true. * @param {Boolean=} reporterOptions.noBanner - Boolean flag to turn off newman banner, if set to true. * @param {Object} options - A set of generic collection run options. * @returns {*} */ PostmanCLIReporter = function (emitter, reporterOptions, options) { var currentGroup = options.collection, inspect = cliUtils.inspector(options), wrap = cliUtils.wrapper(), symbols = cliUtils.symbols(options.disableUnicode); // respect silent option to not report anything if (reporterOptions.silent || options.silent) { return; // we simply do not register anything! } // disable colors based on `noTTY`. cliUtils.noTTY(options.color) && colors.disable(); // we register the `done` listener first so that in case user does not want to show results of collection run, we // simply do not register the other events emitter.on('done', function () { // for some reason, if there is no run summary, it is unexpected and hence don't validate this var run = this.summary.run; // show the summary table (provided option does not say it is not to be shown) if (!reporterOptions.noSummary) { print(LF + PostmanCLIReporter.parseStatistics(run.stats, run.timings, run.transfers, options) + LF); } // show the failures table (provided option does not say it is not to be shown) if (!reporterOptions.noFailures && run.failures && run.failures.length) { print(LF + PostmanCLIReporter.parseFailures(run.failures) + LF); } }); emitter.on('start', function () { var collectionIdentifier = currentGroup && (currentGroup.name || currentGroup.id); if (!reporterOptions.noBanner) { // print the newman banner print('%s\n\n', colors.reset('newman')); } // print the collection name and newman info line collectionIdentifier && print.lf('%s', colors.reset(collectionIdentifier)); }); emitter.on('beforeIteration', function (err, o) { if (err || o.cursor.cycles <= 1) { return; // do not print iteration banner if it is a single iteration run } // print the iteration info line print.lf(LF + colors.gray.underline('Iteration %d/%d'), o.cursor.iteration + 1, o.cursor.cycles); }); emitter.on('test', function (err, o) { if (err) { return; } var snr = extractSNR(o.executions); if (snr) { print.lf(LF + colors.gray('Attempting to set next request to', snr)); } }); emitter.on('beforeItem', function (err, o) { if (err) { return; } var itemGroup = o.item.parent(), root = !itemGroup || (itemGroup === options.collection); // in case this item belongs to a separate folder, print that folder name if (itemGroup && (currentGroup !== itemGroup)) { !root && print('\n%s %s', symbols.folder, colors.reset(util.getFullName(itemGroup))); // set the flag that keeps track of the currently running group currentGroup = itemGroup; } // we print the item name. the symbol prefix denotes if the item is in root or under folder. // @todo - when we do indentation, we would not need symbolic representation o.item && print.lf('\n%s %s', (root ? symbols.root : symbols.sub), colors.reset(o.item.name || E)); }); // print out the request name to be executed and start a spinner emitter.on('beforeRequest', function (err, o) { if (err || !o.request) { return; } if (reporterOptions.showTimestamps) { var currentTime = new Date(); timestamp = '[' + currentTime.toLocaleString() + ']'; print(' %s %s %s ', colors.gray(timestamp), colors.gray(o.request.method), colors.gray(o.request.url.toString())); } else { print(' %s %s ', colors.gray(o.request.method), colors.gray(o.request.url.toString())); } !options.disableUnicode && print().wait(colors.gray); }); // output the response code, reason and time emitter.on('request', function (err, o) { if (err) { print.lf(colors.red('[errored]')); print.lf(colors.red(' %s'), err.message); return; } if (!(o.request && o.response)) { print.lf(colors.red('[errored]')); print.lf(colors.red(' %s'), 'Internal error! Could not read response data.'); return; } // quickly print out basic non verbose response meta and exit if (!options.verbose) { print.lf(colors.gray('[%d %s, %s, %s]'), o.response.code, o.response.reason(), util.filesize(o.response.size().total), util.prettyms(o.response.responseTime)); return; } // this point onwards the output is verbose. a tonne of variables are created here for // keeping the output clean and readable let req = o.request, res = o.response, // set values here with abundance of caution to avoid erroring out reqSize = util.filesize(req.size().total), resSize = util.filesize(res.size().total), code = res.code, reason = res.reason(), mime = res.contentInfo() || {}, timings = _.last(_.get(o, 'history.execution.data')), reqHeadersLen = _.get(req, 'headers.members.length'), resHeadersLen = _.get(res, 'headers.members.length'), resTime = util.prettyms(res.responseTime || 0), reqText = (options.verbose && req.body) ? req.body.toString() : E, reqTextLen = req.size().body || Buffer.byteLength(reqText), resText = options.verbose ? res.text() : E, resTextLen = res.size().body || Buffer.byteLength(resText), reqBodyMode = _.get(req, 'body.mode', ''), resSummary = [ `${mime.contentType}`, `${mime.mimeType}`, `${mime.mimeFormat}`, `${mime.charset}` ].join(` ${colors.gray(symbols.star)} `); print.lf(SPC); // also flushes out the circling progress icon // for clean readability of code. this section compiles the cli string for one line of // req-res combined summary. this looks somewhat like below: // >> 200 OK ★ 979ms time ★ 270B↑ 793B↓ size ★ 7↑ 7↓ headers ★ 0 cookies print.lf(SPC + SPC + [ `${code} ${reason}`, `${resTime} ${colors.gray('time')}`, `${reqSize}${colors.gray(symbols.up)} ${resSize}${colors.gray(symbols.down)} ${colors.gray('size')}`, `${reqHeadersLen}${colors.gray(symbols.up)} ` + `${resHeadersLen}${colors.gray(symbols.down)} ${colors.gray('headers')}`, `${_.get(res, 'cookies.members.length')} ${colors.gray('cookies')}` ].join(` ${colors.gray(symbols.star)} `)); // print request body if (reqTextLen) { // truncate very large request (is 2048 large enough?) if (reqTextLen > BODY_CLIP_SIZE) { reqText = reqText.substr(0, BODY_CLIP_SIZE) + colors.brightWhite(`\n(showing ${util.filesize(BODY_CLIP_SIZE)}/${util.filesize(reqTextLen)})`); } reqText = wrap(reqText, ` ${colors.white(symbols.console.middle)} `); // eslint-disable-next-line max-len print.buffer(` ${colors.white(symbols.console.top)} ${colors.white(symbols.up)} ${reqBodyMode} ${colors.gray(symbols.star)} ${util.filesize(reqTextLen)}\n`, colors.white(` ${symbols.console.bottom}`)) // tweak the message to ensure that its surrounding is not brightly coloured. // also ensure to remove any blank lines generated due to util.inspect .nobuffer(colors.gray(reqText.replace(/\n\s*\n/g, LF) + LF)); print.lf(SPC); // visual tweak: flushes out the buffer of wrapping body above } // print response body if (resTextLen) { // truncate very large response (is 2048 large enough?) if (resTextLen > BODY_CLIP_SIZE) { resText = resText.substr(0, BODY_CLIP_SIZE) + colors.brightWhite(`\n(showing ${util.filesize(BODY_CLIP_SIZE)}/${util.filesize(resTextLen)})`); } resText = wrap(resText, ` ${colors.white(symbols.console.middle)} `); // eslint-disable-next-line max-len print.buffer(` ${colors.white(symbols.console.top)} ${colors.white(symbols.down)} ${resSummary} ${colors.gray(symbols.star)} ${util.filesize(resTextLen)}\n`, colors.white(` ${symbols.console.bottom}`)) // tweak the message to ensure that its surrounding is not brightly coloured. // also ensure to remove any blank lines generated due to util.inspect .nobuffer(colors.gray(resText.replace(/\n\s*\n/g, LF) + LF)); } // print the line of response body meta one liner if there is no response body // if there is one, we would already print it across the body braces above. else { // we need to do some newline related shenanigans here so that the output looks clean // in the absence of the request body block print.lf(` ${symbols.down} ${resSummary}`); } // print timing info of the request timings = timings && timings.timings; // if there are redirects, get timings for the last request sent if (timings) { // adds nice units to all time data in the object let timingPhases = util.beautifyTime(sdk.Response.timingPhases(timings)), timingTable = new Table({ chars: _.defaults({ mid: '', middle: '' }, cliUtils.cliTableTemplate_Blank), colAligns: _.fill(Array(_.size(timingPhases)), 'left'), style: { 'padding-left': 2 } }); timingPhases = _.transform(TIMING_TABLE_HEADERS, (result, header, key) => { if (_.has(timingPhases, key)) { result.headers.push(colors.white(header)); result.values.push(colors.log(timingPhases[key] || CACHED_TIMING_PHASE)); } }, { headers: [], values: [] }); timingTable.push(timingPhases.headers); // add name of phases in the table timingTable.push(timingPhases.values); // add time of phases in the table print(LF + timingTable + LF + LF); } }); // Print script errors in real time emitter.on('script', function (err, o) { err && print.lf(colors.red.bold('%s⠄ %s in %s-script'), pad(this.summary.run.failures.length, 3, SPC), err.name, o.event && o.event.listen || 'unknown'); }); !reporterOptions.noAssertions && emitter.on('assertion', function (err, o) { var passed = !err; // handle skipped test display if (o.skipped && !reporterOptions.noSuccessAssertions) { print.lf('%s %s', colors.cyan(' - '), colors.cyan('[skipped] ' + o.assertion)); return; } if (passed && reporterOptions.noSuccessAssertions) { return; } // print each test assertions if (reporterOptions.showTimestamps) { timestamp = '[' + new Date().toLocaleTimeString() + ']'; print.lf(' %s%s %s', colors.gray(timestamp), passed ? colors.green(` ${symbols.ok} `) : colors.red.bold(pad(this.summary.run.failures.length, 2, SPC) + symbols.dot), passed ? colors.gray(o.assertion) : colors.red.bold(o.assertion)); } else { print.lf('%s %s', passed ? colors.green(` ${symbols.ok} `) : colors.red.bold(pad(this.summary.run.failures.length, 3, SPC) + symbols.dot), passed ? colors.gray(o.assertion) : colors.red.bold(o.assertion)); } }); // show user console logs in a neatly formatted way (provided user has not disabled the same) !reporterOptions.noConsole && emitter.on('console', function (err, o) { if (err) { return; } var color = colors[o.level] || colors.gray, message; // we first merge all messages to a string. while merging we run the values to util.inspect to colour code the // messages based on data type message = wrap(_.reduce(o.messages, function (log, message) { // wrap the whole message to the window size return (log += (log ? colors.white(', ') : '') + inspect(message)); }, E), ` ${color(symbols.console.middle)} `); // add an indentation line at the beginning // print the timestamp if the falg is present if (reporterOptions.showTimestamps) { print(LF + ' %s', colors.gray('[' + new Date().toLocaleTimeString() + ']' + LF)); } print.buffer(color(` ${symbols.console.top}\n`), color(` ${symbols.console.bottom}\n`)) // tweak the message to ensure that its surrounding is not brightly coloured. // also ensure to remove any blank lines generated due to util.inspect .nobuffer(colors.gray(message.replace(/\n\s*\n/g, LF) + LF)); }); }; _.assignIn(PostmanCLIReporter, { // @todo: change function signature to accept run object and options, thereby reducing parameters /** * A CLI reporter method to parse collection run statistics into a CLI table. * * @param {Object} stats - The cumulative collection run status object. * @param {Object} stats.iterations - A set of values for total, pending, and failed iterations. * @param {Number} stats.iterations.total - Total iterations in the current collection run. * @param {Number} stats.iterations.pending - Pending iterations in the current collection run. * @param {Number} stats.iterations.failed - Failed iterations in the current collection run. * @param {Object} stats.requests - A set of values for total, pending, and failed requests. * @param {Number} stats.requests.total - Total requests in the current collection run. * @param {Number} stats.requests.pending - Pending requests in the current collection run. * @param {Number} stats.requests.failed - Failed requests in the current collection run. * @param {Object} stats.testScripts - A set of values for total, pending, and failed testScripts. * @param {Number} stats.testScripts.total - Total testScripts in the current collection run. * @param {Number} stats.testScripts.pending - Pending testScripts in the current collection run. * @param {Number} stats.testScripts.failed - Failed testScripts in the current collection run. * @param {Object} stats.prerequestScripts - A set of values for total, pending, and failed prerequestScripts. * @param {Number} stats.prerequestScripts.total - Total prerequestScripts in the current collection run. * @param {Number} stats.prerequestScripts.pending - Pending prerequestScripts in the current collection run. * @param {Number} stats.prerequestScripts.failed - Failed prerequestScripts in the current collection run. * @param {Object} stats.assertions - A set of values for total, pending, and failed assertions. * @param {Number} stats.assertions.total - Total assertions in the current collection run. * @param {Number} stats.assertions.pending - Pending assertions in the current collection run. * @param {Number} stats.assertions.failed - Failed assertions in the current collection run. * @param {Object} timings - A set of values for the timings of the current collection run. * @param {Number} timings.completed - The end timestamp for the current collection run. * @param {Number} timings.started - The start timestamp for the current collection run * @param {String} timings.responseAverage - The average response time across all requests * @param {String} timings.responseMin - The minimum response time across all requests * @param {String} timings.responseMax - The maximum response time across all requests * @param {String} timings.responseSd - Standard deviation of response time across all requests * @param {String} timings.dnsAverage - The average DNS lookup time of the run * @param {String} timings.dnsMin - The minimum DNS lookup time of the run * @param {String} timings.dnsMax - The maximum DNS lookup time of the run * @param {String} timings.dnsSd - Standard deviation of DNS lookup time of the run * @param {String} timings.firstByteAverage - The average first byte time of the run * @param {String} timings.firstByteMin - The minimum first byte time of the run * @param {String} timings.firstByteMax - The maximum first byte time of the run * @param {String} timings.firstByteSd - Standard deviation of first byte time of the run * @param {Object} transfers - A set of details on the network usage for the current collection run. * @param {String} transfers.responseTotal - The net extent of the data transfer achieved during the collection run. * @param {Object} options - The set of generic collection run options. * @returns {Table} - The constructed collection run statistics table. */ parseStatistics (stats, timings, transfers, options) { var summaryTable; // create the summary table summaryTable = new Table({ chars: options.disableUnicode && cliUtils.cliTableTemplateFallback, style: { head: [] }, head: [E, 'executed', ' failed'], colAligns: ['right', 'right', 'right'], colWidths: [25] }); // add specific rows to show in summary stats && _.forEach([{ source: 'iterations', label: 'iterations' }, { source: 'requests', label: 'requests' }, { source: 'testScripts', label: 'test-scripts' }, { source: 'prerequestScripts', label: 'prerequest-scripts' }, { source: 'assertions', label: 'assertions' }], function (row) { var metric = stats[row.source], label = row.label; // colour the label based on the failure or pending count of the metric label = metric.failed ? colors.red(label) : (metric.pending ? label : colors.green(label)); // push the statistics summaryTable.push([ label, metric.total, (metric.failed ? colors.red(metric.failed) : metric.failed) // @todo - add information of pending scripts // (metric.failed ? colors.red(metric.failed) : metric.failed) + // (metric.pending ? format(' (%d pending)', metric.pending) : E) ]); }); // add the total execution time to summary timings && summaryTable.push([{ colSpan: 3, content: format('total run duration: %s', util.prettyms(timings.completed - timings.started)), hAlign: 'left' // since main style was set to right }]); // add row to show total data received transfers && summaryTable.push([{ colSpan: 3, content: format('total data received: %s (approx)', util.filesize(transfers.responseTotal)), hAlign: 'left' }]); // add rows containing average time of different request phases timings && _.forEach({ response: 'average response time:', dns: 'average DNS lookup time:', firstByte: 'average first byte time:' }, (value, key) => { timings[`${key}Average`] && summaryTable.push([{ colSpan: 3, content: format(`${value} %s [min: %s, max: %s, s.d.: %s]`, util.prettyms(timings[`${key}Average`]), util.prettyms(timings[`${key}Min`]), util.prettyms(timings[`${key}Max`]), util.prettyms(timings[`${key}Sd`])), hAlign: 'left' }]); }); return summaryTable; }, /** * A CLI reporter method to parse collection run failure statistics into a CLI table. * * @param {Array} failures - An array of failure objects. * @returns {Table} - The constructed CLI failure Table object. */ parseFailures (failures) { var failureTable = new Table({ head: [{ hAlign: 'right', content: colors.red.underline('#') }, colors.red.underline('failure'), colors.red.underline('detail')], chars: cliUtils.cliTableTemplate_Blank, wordWrap: true, colAligns: ['right'], colWidths: cliUtils.noTTY() ? [] : (function (size, indexOrder) { var colWidths = []; if (size.exists && size.width && (size.width > 20)) { colWidths[0] = indexOrder + 3; colWidths[1] = parseInt((size.width - colWidths[0]) * 0.2, 10); colWidths[2] = parseInt(size.width - (colWidths[0] + colWidths[1] + 5), 10); } return colWidths; }(cliUtils.dimension(), Number(failures.length.toString().length))) }); _.forEach(failures, function (failure, index) { var name = failure.error && failure.error.name || E, message = failure.error && failure.error.test || E; // augment name with iteration information failure.cursor && (failure.cursor.cycles > 1) && (name += LF + colors.gray('iteration: ' + (failure.cursor.iteration + 1))); // include the assertion error message in the failure details failure.error && (message += LF + colors.gray(failure.error.message || E)); // augment the message with stack information failure.at && (message += LF + colors.gray('at ' + failure.at)); // augment message with item information failure.source && (message += format(colors.gray('\ninside "%s"'), util.getFullName(failure.source))); failureTable.push([pad(Number(index + 1), Number(failures.length.toString().length)).toString() + DOT, name, message]); }); return failureTable; } }); // Mark the CLI reporter as dominant, so that no two dominant reporters are together PostmanCLIReporter.prototype.dominant = true; module.exports = PostmanCLIReporter;