diff --git a/bin/concurrently.js b/bin/concurrently.js index 9dc31fc6..82b3030e 100755 --- a/bin/concurrently.js +++ b/bin/concurrently.js @@ -62,6 +62,11 @@ const args = yargs default: defaults.hide, type: 'string' }, + 'timings': { + describe: 'Show timing information for all processes', + type: 'boolean', + default: defaults.timings + }, // Kill others 'k': { @@ -142,7 +147,7 @@ const args = yargs 'Can be either the index or the name of the process.' } }) - .group(['m', 'n', 'name-separator', 'raw', 's', 'no-color', 'hide'], 'General') + .group(['m', 'n', 'name-separator', 'raw', 's', 'no-color', 'hide', 'timings'], 'General') .group(['p', 'c', 'l', 't'], 'Prefix styling') .group(['i', 'default-input-target'], 'Input handling') .group(['k', 'kill-others-on-fail'], 'Killing other processes') @@ -172,6 +177,7 @@ concurrently(args._.map((command, index) => ({ restartTries: args.restartTries, successCondition: args.success, timestampFormat: args.timestampFormat, + timings: args.timings }).then( () => process.exit(0), () => process.exit(1) diff --git a/bin/concurrently.spec.js b/bin/concurrently.spec.js index aa1b84af..591bdc3f 100644 --- a/bin/concurrently.spec.js +++ b/bin/concurrently.spec.js @@ -346,7 +346,6 @@ describe('--handle-input', () => { }, done); }); - it('forwards input to process --default-input-target', done => { const lines = []; const child = run('-ki --default-input-target 1 "node fixtures/read-echo.js" "node fixtures/read-echo.js"'); @@ -383,3 +382,47 @@ describe('--handle-input', () => { }, done); }); }); + +describe('--timings', () => { + const defaultTimestampFormatRegex = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}/; + const processStartedMessageRegex = (index, command) => { + return new RegExp( `^\\[${ index }] ${ command } started at ${ defaultTimestampFormatRegex.source }$` ); + }; + const processStoppedMessageRegex = (index, command) => { + return new RegExp( `^\\[${ index }] ${ command } stopped at ${ defaultTimestampFormatRegex.source } after (\\d|,)+ms$` ); + }; + const expectLinesForProcessStartAndStop = (lines, index, command) => { + const escapedCommand = _.escapeRegExp(command); + expect(lines).toContainEqual(expect.stringMatching(processStartedMessageRegex(index, escapedCommand))); + expect(lines).toContainEqual(expect.stringMatching(processStoppedMessageRegex(index, escapedCommand))); + }; + + const expectLinesForTimingsTable = (lines) => { + const tableTopBorderRegex = /┌[─┬]+┐/g; + expect(lines).toContainEqual(expect.stringMatching(tableTopBorderRegex)); + const tableHeaderRowRegex = /(\W+(name|duration|exit code|killed|command)\W+){5}/g; + expect(lines).toContainEqual(expect.stringMatching(tableHeaderRowRegex)); + const tableBottomBorderRegex = /└[─┴]+┘/g; + expect(lines).toContainEqual(expect.stringMatching(tableBottomBorderRegex)); + }; + + it('shows timings on success', done => { + const child = run('--timings "sleep 0.5" "exit 0"'); + child.log.pipe(buffer(child.close)).subscribe(lines => { + expectLinesForProcessStartAndStop(lines, 0, 'sleep 0.5'); + expectLinesForProcessStartAndStop(lines, 1, 'exit 0'); + expectLinesForTimingsTable(lines); + done(); + }, done); + }); + + it('shows timings on failure', done => { + const child = run('--timings "sleep 0.75" "exit 1"'); + child.log.pipe(buffer(child.close)).subscribe(lines => { + expectLinesForProcessStartAndStop(lines, 0, 'sleep 0.75'); + expectLinesForProcessStartAndStop(lines, 1, 'exit 1'); + expectLinesForTimingsTable(lines); + done(); + }, done); + }); +}); diff --git a/index.js b/index.js index 5abc24f0..35a94ecc 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,7 @@ const RestartProcess = require('./src/flow-control/restart-process'); const concurrently = require('./src/concurrently'); const Logger = require('./src/logger'); +const LogTimings = require( './src/flow-control/log-timings' ); module.exports = exports = (commands, options = {}) => { const logger = new Logger({ @@ -43,9 +44,14 @@ module.exports = exports = (commands, options = {}) => { new KillOthers({ logger, conditions: options.killOthers + }), + new LogTimings({ + logger: options.timings ? logger: null, + timestampFormat: options.timestampFormat, }) ], - prefixColors: options.prefixColors || [] + prefixColors: options.prefixColors || [], + timings: options.timings }); }; @@ -60,3 +66,4 @@ exports.LogError = LogError; exports.LogExit = LogExit; exports.LogOutput = LogOutput; exports.RestartProcess = RestartProcess; +exports.LogTimings = LogTimings; diff --git a/src/command-parser/expand-npm-wildcard.spec.js b/src/command-parser/expand-npm-wildcard.spec.js index cf72eb67..27d1aebf 100644 --- a/src/command-parser/expand-npm-wildcard.spec.js +++ b/src/command-parser/expand-npm-wildcard.spec.js @@ -1,4 +1,5 @@ const ExpandNpmWildcard = require('./expand-npm-wildcard'); +const fs = require('fs'); let parser, readPkg; @@ -7,6 +8,34 @@ beforeEach(() => { parser = new ExpandNpmWildcard(readPkg); }); +describe('ExpandNpmWildcard#readPackage', () => { + it('can read package', () => { + const expectedPackage = { + 'name': 'concurrently', + 'version': '6.4.0', + }; + jest.spyOn(fs, 'readFileSync').mockImplementation((path, options) => { + if (path === 'package.json') { + return JSON.stringify(expectedPackage); + } + return null; + }); + + const actualReadPackage = ExpandNpmWildcard.readPackage(); + expect(actualReadPackage).toEqual(expectedPackage); + }); + + it('can handle errors reading package', () => { + jest.spyOn(fs, 'readFileSync').mockImplementation(() => { + throw new Error('Error reading package'); + }); + + expect(() => ExpandNpmWildcard.readPackage()).not.toThrow(); + expect(ExpandNpmWildcard.readPackage()).toEqual({}); + }); + +}); + it('returns same command if not an npm run command', () => { const commandInfo = { command: 'npm test' diff --git a/src/command.js b/src/command.js index f39c3b2f..9f2a60f7 100644 --- a/src/command.js +++ b/src/command.js @@ -17,6 +17,7 @@ module.exports = class Command { this.killed = false; this.error = new Rx.Subject(); + this.timer = new Rx.Subject(); this.close = new Rx.Subject(); this.stdout = new Rx.Subject(); this.stderr = new Rx.Subject(); @@ -26,13 +27,21 @@ module.exports = class Command { const child = this.spawn(this.command, this.spawnOpts); this.process = child; this.pid = child.pid; + const startDate = new Date(Date.now()); + const highResStartTime = process.hrtime(); + this.timer.next({startDate}); Rx.fromEvent(child, 'error').subscribe(event => { this.process = undefined; + const endDate = new Date(Date.now()); + this.timer.next({startDate, endDate}); this.error.next(event); }); Rx.fromEvent(child, 'close').subscribe(([exitCode, signal]) => { this.process = undefined; + const endDate = new Date(Date.now()); + this.timer.next({startDate, endDate}); + const [durationSeconds, durationNanoSeconds] = process.hrtime(highResStartTime); this.close.next({ command: { command: this.command, @@ -43,6 +52,11 @@ module.exports = class Command { index: this.index, exitCode: exitCode === null ? signal : exitCode, killed: this.killed, + timings: { + startDate, + endDate, + durationSeconds: durationSeconds + (durationNanoSeconds / 1e9), + } }); }); child.stdout && pipeTo(Rx.fromEvent(child.stdout, 'data'), this.stdout); diff --git a/src/command.spec.js b/src/command.spec.js index 9d64cc19..f111ad95 100644 --- a/src/command.spec.js +++ b/src/command.spec.js @@ -1,4 +1,5 @@ const EventEmitter = require('events'); +const process = require('process'); const Command = require('./command'); const createProcess = () => { @@ -54,6 +55,72 @@ describe('#start()', () => { process.emit('error', 'foo'); }); + it('shares start and close timing events to the timing stream', done => { + const process = createProcess(); + const command = new Command({ spawn: () => process }); + + const startDate = new Date(); + const endDate = new Date(startDate.getTime() + 1000); + jest.spyOn(Date, 'now') + .mockReturnValueOnce(startDate.getTime()) + .mockReturnValueOnce(endDate.getTime()); + + let callCount = 0; + command.timer.subscribe(({startDate: actualStartDate, endDate: actualEndDate}) => { + switch (callCount) { + case 0: + expect(actualStartDate).toStrictEqual(startDate); + expect(actualEndDate).toBeUndefined(); + break; + case 1: + expect(actualStartDate).toStrictEqual(startDate); + expect(actualEndDate).toStrictEqual(endDate); + done(); + break; + default: + throw new Error('Unexpected call count'); + } + callCount++; + }); + + command.start(); + process.emit('close', 0, null); + + }); + + it('shares start and error timing events to the timing stream', done => { + const process = createProcess(); + const command = new Command({ spawn: () => process }); + + const startDate = new Date(); + const endDate = new Date(startDate.getTime() + 1000); + jest.spyOn(Date, 'now') + .mockReturnValueOnce(startDate.getTime()) + .mockReturnValueOnce(endDate.getTime()); + + let callCount = 0; + command.timer.subscribe(({startDate: actualStartDate, endDate: actualEndDate}) => { + switch (callCount) { + case 0: + expect(actualStartDate).toStrictEqual(startDate); + expect(actualEndDate).toBeUndefined(); + break; + case 1: + expect(actualStartDate).toStrictEqual(startDate); + expect(actualEndDate).toStrictEqual(endDate); + done(); + break; + default: + throw new Error('Unexpected call count'); + } + callCount++; + }); + + command.start(); + process.emit('error', 0, null); + + }); + it('shares closes to the close stream with exit code', done => { const process = createProcess(); const command = new Command({ spawn: () => process }); @@ -83,6 +150,31 @@ describe('#start()', () => { process.emit('close', null, 'SIGKILL'); }); + it('shares closes to the close stream with timing information', done => { + const process1 = createProcess(); + const command = new Command({ spawn: () => process1 }); + + const startDate = new Date(); + const endDate = new Date(startDate.getTime() + 1000); + jest.spyOn(Date, 'now') + .mockReturnValueOnce(startDate.getTime()) + .mockReturnValueOnce(endDate.getTime()); + + jest.spyOn(process, 'hrtime') + .mockReturnValueOnce([0, 0]) + .mockReturnValueOnce([1, 1e8]); + + command.close.subscribe(data => { + expect(data.timings.startDate).toStrictEqual(startDate); + expect(data.timings.endDate).toStrictEqual(endDate); + expect(data.timings.durationSeconds).toBe(1.1); + done(); + }); + + command.start(); + process1.emit('close', null, 'SIGKILL'); + }); + it('shares closes to the close stream with command info and index', done => { const process = createProcess(); const commandInfo = { @@ -170,7 +262,7 @@ describe('#kill()', () => { it('marks the command as killed', done => { command.start(); - + command.close.subscribe(data => { expect(data.exitCode).toBe(1); expect(data.killed).toBe(true); diff --git a/src/completion-listener.js b/src/completion-listener.js index 791a49aa..75478c7e 100644 --- a/src/completion-listener.js +++ b/src/completion-listener.js @@ -32,7 +32,7 @@ module.exports = class CompletionListener { ? Rx.of(exitInfos, this.scheduler) : Rx.throwError(exitInfos, this.scheduler) ), - take(1) + take(1), ) .toPromise(); } diff --git a/src/completion-listener.spec.js b/src/completion-listener.spec.js index 5e54e8b7..8a8007bc 100644 --- a/src/completion-listener.spec.js +++ b/src/completion-listener.spec.js @@ -85,4 +85,5 @@ describe('with success condition set to last', () => { return expect(result).rejects.toEqual([{ exitCode: 0 }, { exitCode: 1 }]); }); + }); diff --git a/src/concurrently.js b/src/concurrently.js index 06b3574e..2b77cc73 100644 --- a/src/concurrently.js +++ b/src/concurrently.js @@ -18,6 +18,7 @@ const defaults = { raw: false, controllers: [], cwd: undefined, + timings: false }; module.exports = (commands, options) => { @@ -50,6 +51,7 @@ module.exports = (commands, options) => { prefixColor: lastColor, killProcess: options.kill, spawn: options.spawn, + timings: options.timings, }, command) ); }) @@ -73,7 +75,9 @@ module.exports = (commands, options) => { maybeRunMore(commandsLeft); } - return new CompletionListener({ successCondition: options.successCondition }) + return new CompletionListener({ + successCondition: options.successCondition, + }) .listen(commands) .finally(() => { handleResult.onFinishCallbacks.forEach((onFinish) => onFinish()); @@ -86,6 +90,7 @@ function mapToCommandInfo(command) { name: command.name || '', env: command.env || {}, cwd: command.cwd || '', + }, command.prefixColor ? { prefixColor: command.prefixColor, } : {}); diff --git a/src/defaults.js b/src/defaults.js index 695c0dd1..dee15904 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -29,5 +29,7 @@ module.exports = { // Refer to https://date-fns.org/v2.0.1/docs/format timestampFormat: 'yyyy-MM-dd HH:mm:ss.SSS', // Current working dir passed as option to spawn command. Default: process.cwd() - cwd: undefined + cwd: undefined, + // Whether to show timing information for processes in console output + timings: false, }; diff --git a/src/flow-control/base-handler.spec.js b/src/flow-control/base-handler.spec.js new file mode 100644 index 00000000..99a44190 --- /dev/null +++ b/src/flow-control/base-handler.spec.js @@ -0,0 +1,22 @@ +const stream = require('stream'); +const { createMockInstance } = require('jest-create-mock-instance'); + +const Logger = require('../logger'); +const createFakeCommand = require('./fixtures/fake-command'); +const BaseHandler = require('./base-handler'); + +let commands, controller, inputStream, logger; + +beforeEach(() => { + commands = [ + createFakeCommand('foo', 'echo foo', 0), + createFakeCommand('bar', 'echo bar', 1), + ]; + inputStream = new stream.PassThrough(); + logger = createMockInstance(Logger); + controller = new BaseHandler({ logger }); +}); + +it('returns same commands and null onFinish callback by default', () => { + expect(controller.handle(commands)).toMatchObject({ commands, onFinish: null }); +}); \ No newline at end of file diff --git a/src/flow-control/fixtures/fake-command.js b/src/flow-control/fixtures/fake-command.js index 7c057ab1..a5fbb53c 100644 --- a/src/flow-control/fixtures/fake-command.js +++ b/src/flow-control/fixtures/fake-command.js @@ -10,6 +10,7 @@ module.exports = (name = 'foo', command = 'echo foo', index = 0) => ({ error: new Subject(), stderr: new Subject(), stdout: new Subject(), + timer: new Subject(), stdin: createMockInstance(Writable), start: jest.fn(), kill: jest.fn() diff --git a/src/flow-control/log-timings.js b/src/flow-control/log-timings.js new file mode 100644 index 00000000..7293bb6d --- /dev/null +++ b/src/flow-control/log-timings.js @@ -0,0 +1,64 @@ +const formatDate = require('date-fns/format'); +const Rx = require('rxjs'); +const { bufferCount, take } = require('rxjs/operators'); +const _ = require('lodash'); +const BaseHandler = require('./base-handler'); + +module.exports = class LogTimings extends BaseHandler { + constructor({ logger, timestampFormat }) { + super({ logger }); + + this.timestampFormat = timestampFormat; + } + + printExitInfoTimingTable(exitInfos) { + const exitInfoTable = _(exitInfos) + .sortBy(({ timings }) => timings.durationSeconds) + .reverse() + .map(({ command, timings, killed, exitCode }) => { + const readableDurationMs = (timings.endDate - timings.startDate).toLocaleString(); + return { + name: command.name, + duration: `${readableDurationMs}ms`, + 'exit code': exitCode, + killed, + command: command.command, + }; + }) + .value(); + + this.logger.logGlobalEvent('Timings:'); + this.logger.logTable(exitInfoTable); + return exitInfos; + }; + + handle(commands) { + if (!this.logger) { + return { commands }; + } + + // individual process timings + commands.forEach(command => { + command.timer.subscribe(({ startDate, endDate }) => { + if (!endDate) { + const formattedStartDate = formatDate(startDate, this.timestampFormat); + this.logger.logCommandEvent(`${command.command} started at ${formattedStartDate}`, command); + } else { + const durationMs = endDate.getTime() - startDate.getTime(); + const formattedEndDate = formatDate(endDate, this.timestampFormat); + this.logger.logCommandEvent(`${command.command} stopped at ${formattedEndDate} after ${durationMs.toLocaleString()}ms`, command); + } + }); + }); + + // overall summary timings + const closeStreams = commands.map(command => command.close); + this.allProcessesClosed = Rx.merge(...closeStreams).pipe( + bufferCount(closeStreams.length), + take(1), + ); + this.allProcessesClosed.subscribe((exitInfos) => this.printExitInfoTimingTable(exitInfos)); + + return { commands }; + } +}; diff --git a/src/flow-control/log-timings.spec.js b/src/flow-control/log-timings.spec.js new file mode 100644 index 00000000..0eedc090 --- /dev/null +++ b/src/flow-control/log-timings.spec.js @@ -0,0 +1,137 @@ +const { createMockInstance } = require('jest-create-mock-instance'); +const formatDate = require('date-fns/format'); +const Logger = require('../logger'); +const LogTimings = require( './log-timings' ); +const createFakeCommand = require('./fixtures/fake-command'); + +// shown in timing order +const startDate0 = new Date(); +const startDate1 = new Date(startDate0.getTime() + 1000); +const endDate1 = new Date(startDate0.getTime() + 3000); +const endDate0 = new Date(startDate0.getTime() + 5000); + +const timestampFormat = 'yyyy-MM-dd HH:mm:ss.SSS'; +const getDurationText = (startDate, endDate) => `${(endDate.getTime() - startDate.getTime()).toLocaleString()}ms`; +const command0DurationTextMs = getDurationText(startDate0, endDate0); +const command1DurationTextMs = getDurationText(startDate1, endDate1); + +const exitInfoToTimingInfo = ({ command, timings, killed, exitCode }) => { + const readableDurationMs = getDurationText(timings.startDate, timings.endDate); + return { + name: command.name, + duration: readableDurationMs, + 'exit code': exitCode, + killed, + command: command.command, + }; +}; + +let controller, logger, commands, command0ExitInfo, command1ExitInfo; + +beforeEach(() => { + commands = [ + createFakeCommand('foo', 'command 1', 0), + createFakeCommand('bar', 'command 2', 1), + ]; + + command0ExitInfo = { + command: commands[0].command, + timings: { + startDate: startDate0, + endDate: endDate0, + }, + index: commands[0].index, + killed: false, + exitCode: 0, + }; + + command1ExitInfo = { + command: commands[1].command, + timings: { + startDate: startDate1, + endDate: endDate1, + }, + index: commands[1].index, + killed: false, + exitCode: 0, + }; + + logger = createMockInstance(Logger); + controller = new LogTimings({ logger, timestampFormat }); +}); + +it('returns same commands', () => { + expect(controller.handle(commands)).toMatchObject({ commands }); +}); + +it('does not log timings and doesn\'t throw if no logger is provided', () => { + controller = new LogTimings({ }); + controller.handle(commands); + + commands[0].timer.next({ startDate: startDate0 }); + commands[1].timer.next({ startDate: startDate1 }); + commands[1].timer.next({ startDate: startDate1, endDate: endDate1 }); + commands[0].timer.next({ startDate: startDate0, endDate: endDate0 }); + + expect(logger.logCommandEvent).toHaveBeenCalledTimes(0); +}); + +it('logs the timings at the start and end (ie complete or error) event of each command', () => { + controller.handle(commands); + + commands[0].timer.next({ startDate: startDate0 }); + commands[1].timer.next({ startDate: startDate1 }); + commands[1].timer.next({ startDate: startDate1, endDate: endDate1 }); + commands[0].timer.next({ startDate: startDate0, endDate: endDate0 }); + + expect(logger.logCommandEvent).toHaveBeenCalledTimes(4); + expect(logger.logCommandEvent).toHaveBeenCalledWith( + `${commands[0].command} started at ${formatDate(startDate0, timestampFormat)}`, + commands[0] + ); + expect(logger.logCommandEvent).toHaveBeenCalledWith( + `${commands[1].command} started at ${formatDate(startDate1, timestampFormat)}`, + commands[1] + ); + expect(logger.logCommandEvent).toHaveBeenCalledWith( + `${commands[1].command} stopped at ${formatDate(endDate1, timestampFormat)} after ${command1DurationTextMs}`, + commands[1] + ); + expect(logger.logCommandEvent).toHaveBeenCalledWith( + `${commands[0].command} stopped at ${formatDate(endDate0, timestampFormat)} after ${command0DurationTextMs}`, + commands[0] + ); +}); + +it('does not log timings summary if there was an error', () => { + controller.handle(commands); + + commands[0].close.next(command0ExitInfo); + commands[1].error.next(); + + expect(logger.logTable).toHaveBeenCalledTimes(0); + +}); + +it('logs the sorted timings summary when all processes close successfully', () => { + jest.spyOn(controller, 'printExitInfoTimingTable'); + controller.handle(commands); + + commands[0].close.next(command0ExitInfo); + commands[1].close.next(command1ExitInfo); + + expect(logger.logTable).toHaveBeenCalledTimes(1); + + // un-sorted ie by finish order + expect(controller.printExitInfoTimingTable).toHaveBeenCalledWith([ + command0ExitInfo, + command1ExitInfo + ]); + + // sorted by duration + expect(logger.logTable).toHaveBeenCalledWith([ + exitInfoToTimingInfo(command1ExitInfo), + exitInfoToTimingInfo(command0ExitInfo) + ]); + +}); diff --git a/src/flow-control/restart-process.spec.js b/src/flow-control/restart-process.spec.js index 51d69e84..b529cd74 100644 --- a/src/flow-control/restart-process.spec.js +++ b/src/flow-control/restart-process.spec.js @@ -69,7 +69,15 @@ it('restarts processes up to tries', () => { expect(commands[0].start).toHaveBeenCalledTimes(2); }); -it.todo('restart processes forever, if tries is negative'); +it('restart processes forever, if tries is negative', () => { + controller = new RestartProcess({ + logger, + scheduler, + delay: 100, + tries: -1 + }); + expect(controller.tries).toBe(Infinity); +}); it('restarts processes until they succeed', () => { controller.handle(commands); diff --git a/src/logger.js b/src/logger.js index 7b3b936f..c8f0435f 100644 --- a/src/logger.js +++ b/src/logger.js @@ -96,6 +96,63 @@ module.exports = class Logger { this.log(chalk.reset('-->') + ' ', chalk.reset(text) + '\n'); } + logTable(tableContents) { + // For now, can only print array tables with some content. + if (this.raw || !Array.isArray(tableContents) || !tableContents.length) { + return; + } + + let nextColIndex = 0; + const headers = {}; + const contentRows = tableContents.map(row => { + const rowContents = []; + Object.keys(row).forEach((col) => { + if (!headers[col]) { + headers[col] = { + index: nextColIndex++, + // + length: col.length, + }; + } + + const colIndex = headers[col].index; + const formattedValue = String(row[col] == null ? '' : row[col]); + // Update the column length in case this rows value is longer than the previous length for the column. + headers[col].length = Math.max(formattedValue.length, headers[col].length); + rowContents[colIndex] = formattedValue; + return rowContents; + }); + return rowContents; + }); + + const headersFormatted = Object + .keys(headers) + .map(header => header.padEnd(headers[header].length, ' ')); + + if (!headersFormatted.length) { + // No columns exist. + return; + } + + const borderRowFormatted = headersFormatted.map(header => '─'.padEnd(header.length, '─')); + + this.logGlobalEvent(`┌─${borderRowFormatted.join('─┬─')}─┐`); + this.logGlobalEvent(`│ ${headersFormatted.join(' │ ')} │`); + this.logGlobalEvent(`├─${borderRowFormatted.join('─┼─')}─┤`); + + contentRows.forEach(contentRow => { + const contentRowFormatted = headersFormatted.map((header, colIndex) => { + // If the table was expanded after this row was processed, it won't have this column. + // Use an empty string in this case. + const col = contentRow[colIndex] || ''; + return col.padEnd(header.length, ' '); + }); + this.logGlobalEvent(`│ ${contentRowFormatted.join(' │ ')} │`); + }); + + this.logGlobalEvent(`└─${borderRowFormatted.join('─┴─')}─┘`); + } + log(prefix, text) { if (this.raw) { return this.outputStream.write(text); diff --git a/src/logger.spec.js b/src/logger.spec.js index d3f201dd..ca903dc9 100644 --- a/src/logger.spec.js +++ b/src/logger.spec.js @@ -221,3 +221,98 @@ describe('#logCommandEvent()', () => { expect(logger.log).toHaveBeenCalledWith(chalk.reset('[1]') + ' ', chalk.reset('foo') + '\n'); }); }); + +describe('#logTable()', () => { + it('does not log anything in raw mode', () => { + const logger = createLogger({ raw: true }); + logger.logTable([{ foo: 1, bar: 2 }]); + + expect(logger.log).not.toHaveBeenCalled(); + }); + + it('does not log anything if value is not an array', () => { + const logger = createLogger(); + logger.logTable({}); + logger.logTable(null); + logger.logTable(0); + logger.logTable(''); + + expect(logger.log).not.toHaveBeenCalled(); + }); + + it('does not log anything if array is empy', () => { + const logger = createLogger(); + logger.logTable([]); + + expect(logger.log).not.toHaveBeenCalled(); + }); + + it('does not log anything if array items have no properties', () => { + const logger = createLogger(); + logger.logTable([{}]); + + expect(logger.log).not.toHaveBeenCalled(); + }); + + it('logs a header for each item\'s properties', () => { + const logger = createLogger(); + logger.logTable([{ foo: 1, bar: 2 }]); + + expect(logger.log).toHaveBeenCalledWith( + chalk.reset('-->') + ' ', + chalk.reset('│ foo │ bar │') + '\n', + ); + }); + + it('logs padded headers according to longest column\'s value', () => { + const logger = createLogger(); + logger.logTable([{ a: 'foo', b: 'barbaz' }]); + + expect(logger.log).toHaveBeenCalledWith( + chalk.reset('-->') + ' ', + chalk.reset('│ a │ b │') + '\n', + ); + }); + + it('logs each items\'s values', () => { + const logger = createLogger(); + logger.logTable([{ foo: 123 }, { foo: 456 }]); + + expect(logger.log).toHaveBeenCalledWith( + chalk.reset('-->') + ' ', + chalk.reset('│ 123 │') + '\n', + ); + expect(logger.log).toHaveBeenCalledWith( + chalk.reset('-->') + ' ', + chalk.reset('│ 456 │') + '\n', + ); + }); + + it('logs each items\'s values padded according to longest column\'s value', () => { + const logger = createLogger(); + logger.logTable([{ foo: 1 }, { foo: 123 }]); + + expect(logger.log).toHaveBeenCalledWith( + chalk.reset('-->') + ' ', + chalk.reset('│ 1 │') + '\n', + ); + }); + + it('logs items with different properties in each', () => { + const logger = createLogger(); + logger.logTable([{ foo: 1 }, { bar: 2 }]); + + expect(logger.log).toHaveBeenCalledWith( + chalk.reset('-->') + ' ', + chalk.reset('│ foo │ bar │') + '\n', + ); + expect(logger.log).toHaveBeenCalledWith( + chalk.reset('-->') + ' ', + chalk.reset('│ 1 │ │') + '\n', + ); + expect(logger.log).toHaveBeenCalledWith( + chalk.reset('-->') + ' ', + chalk.reset('│ │ 2 │') + '\n', + ); + }); +});