diff --git a/bin/concurrently.js b/bin/concurrently.js index 134d03bc..5722e0ea 100755 --- a/bin/concurrently.js +++ b/bin/concurrently.js @@ -55,6 +55,10 @@ const args = yargs describe: 'Disables colors from logging', type: 'boolean' }, + 'group': { + describe: 'Order the output as if the commands were run sequentially.', + type: 'boolean' + }, // Kill others 'k': { @@ -133,7 +137,7 @@ const args = yargs 'Can be either the index or the name of the process.' } }) - .group(['m', 'n', 'name-separator', 'raw', 's', 'no-color'], 'General') + .group(['m', 'n', 'name-separator', 'raw', 's', 'no-color', 'group'], 'General') .group(['p', 'c', 'l', 't'], 'Prefix styling') .group(['i', 'default-input-target'], 'Input handling') .group(['k', 'kill-others-on-fail'], 'Killing other processes') @@ -167,7 +171,8 @@ concurrently(args._.map((command, index) => { restartDelay: args.restartAfter, restartTries: args.restartTries, successCondition: args.success, - timestampFormat: args.timestampFormat + timestampFormat: args.timestampFormat, + group: args.group }).then( () => process.exit(0), () => process.exit(1) diff --git a/index.js b/index.js index 26f4661f..0f0772d3 100644 --- a/index.js +++ b/index.js @@ -11,7 +11,6 @@ const Logger = require('./src/logger'); module.exports = (commands, options = {}) => { const logger = new Logger({ - outputStream: options.outputStream || process.stdout, prefixFormat: options.prefix, prefixLength: options.prefixLength, raw: options.raw, @@ -23,6 +22,9 @@ module.exports = (commands, options = {}) => { raw: options.raw, successCondition: options.successCondition, cwd: options.cwd, + logger, + outputStream: options.outputStream || process.stdout, + group: options.group, controllers: [ new LogError({ logger }), new LogOutput({ logger }), diff --git a/src/command.js b/src/command.js index 6f1ffeb3..667579be 100644 --- a/src/command.js +++ b/src/command.js @@ -19,6 +19,7 @@ module.exports = class Command { this.close = new Rx.Subject(); this.stdout = new Rx.Subject(); this.stderr = new Rx.Subject(); + this.exited = false; } start() { @@ -32,6 +33,7 @@ module.exports = class Command { }); Rx.fromEvent(child, 'close').subscribe(([exitCode, signal]) => { this.process = undefined; + this.exited = true; this.close.next({ command: { command: this.command, diff --git a/src/concurrently.js b/src/concurrently.js index 31d97a72..346f7ef0 100644 --- a/src/concurrently.js +++ b/src/concurrently.js @@ -11,6 +11,7 @@ const CompletionListener = require('./completion-listener'); const getSpawnOpts = require('./get-spawn-opts'); const Command = require('./command'); +const OutputWriter = require('./output-writer'); const defaults = { spawn, @@ -60,9 +61,22 @@ module.exports = (commands, options) => { maybeRunMore(commandsLeft); } + if (options.logger) { + const outputWriter = new OutputWriter({ + outputStream: options.outputStream, + group: options.group, + commands, + }); + options.logger.observable.subscribe(({command, text}) => outputWriter.write(command, text)); + } + return new CompletionListener({ successCondition: options.successCondition }).listen(commands); }; +/** + * @param {string | CommandInfo} command + * @returns {CommandInfo} + */ function mapToCommandInfo(command) { return { command: command.command || command, @@ -73,13 +87,20 @@ function mapToCommandInfo(command) { }; } -function parseCommand(command, parsers) { +/** + * @param {CommandInfo} rawCommand + * @param {{ parse: (command: CommandInfo) => CommandInfo[]}[]} parsers + */ +function parseCommand(rawCommand, parsers) { return parsers.reduce( (commands, parser) => _.flatMap(commands, command => parser.parse(command)), - _.castArray(command) + _.castArray(rawCommand) ); } +/** + * @param {Command[]} commandsLeft + */ function maybeRunMore(commandsLeft) { const command = commandsLeft.shift(); if (!command) { @@ -91,3 +112,12 @@ function maybeRunMore(commandsLeft) { maybeRunMore(commandsLeft); }); } + +/** + * @typedef {object} CommandInfo + * @property {string} command + * @property {string} name + * @property {string} prefixColor + * @property {object} env + * @property {string} cwd + */ \ No newline at end of file diff --git a/src/defaults.js b/src/defaults.js index a162a062..5df1cc10 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -18,6 +18,8 @@ module.exports = { // How many bytes we'll show on the command prefix prefixLength: 10, raw: false, + // Whether to display output ordered as if the commands were sequential + group: false, // Number of attempts of restarting a process, if it exits with non-0 code restartTries: 0, // How many milliseconds concurrently should wait before restarting a process. diff --git a/src/logger.js b/src/logger.js index 9273769c..228f593e 100644 --- a/src/logger.js +++ b/src/logger.js @@ -1,16 +1,19 @@ const chalk = require('chalk'); const _ = require('lodash'); const formatDate = require('date-fns/format'); +const Rx = require('rxjs'); const defaults = require('./defaults'); +/** @typedef {import('./command.js')} Command */ module.exports = class Logger { - constructor({ outputStream, prefixFormat, prefixLength, raw, timestampFormat }) { + constructor({ prefixFormat, prefixLength, raw, timestampFormat }) { this.raw = raw; - this.outputStream = outputStream; this.prefixFormat = prefixFormat; this.prefixLength = prefixLength || defaults.prefixLength; this.timestampFormat = timestampFormat || defaults.timestampFormat; + /** @type {Rx.Subject<{ command: Command, text: string }>} */ + this.observable = new Rx.Subject(); } shortenText(text) { @@ -76,7 +79,7 @@ module.exports = class Logger { logCommandText(text, command) { const prefix = this.colorText(command, this.getPrefix(command)); - return this.log(prefix + (prefix ? ' ' : ''), text); + return this.log(prefix + (prefix ? ' ' : ''), text, command); } logGlobalEvent(text) { @@ -84,12 +87,12 @@ module.exports = class Logger { return; } - this.log(chalk.gray.dim('-->') + ' ', chalk.gray.dim(text) + '\n'); + this.log(chalk.gray.dim('-->') + ' ', chalk.gray.dim(text) + '\n', null); } - log(prefix, text) { + log(prefix, text, command) { if (this.raw) { - return this.outputStream.write(text); + return this.emit(command, text); } // #70 - replace some ANSI code that would impact clearing lines @@ -105,10 +108,18 @@ module.exports = class Logger { }); if (!this.lastChar || this.lastChar === '\n') { - this.outputStream.write(prefix); + this.emit(command, prefix); } this.lastChar = text[text.length - 1]; - this.outputStream.write(lines.join('\n')); + this.emit(command, lines.join('\n')); + } + + /** + * @param {Command} command + * @param {string} text + */ + emit(command, text) { + this.observable.next({ command, text }); } }; diff --git a/src/logger.spec.js b/src/logger.spec.js index 2c371f57..3d7a43c2 100644 --- a/src/logger.spec.js +++ b/src/logger.spec.js @@ -1,54 +1,48 @@ -const { Writable } = require('stream'); const chalk = require('chalk'); -const { createMockInstance } = require('jest-create-mock-instance'); const Logger = require('./logger'); -let outputStream; -beforeEach(() => { - outputStream = createMockInstance(Writable); -}); - const createLogger = options => { - const logger = new Logger(Object.assign({ outputStream }, options)); + const logger = new Logger(Object.assign({}, options)); jest.spyOn(logger, 'log'); + jest.spyOn(logger, 'emit'); return logger; }; describe('#log()', () => { it('writes prefix + text to the output stream', () => { - const logger = new Logger({ outputStream }); - logger.log('foo', 'bar'); + const logger = createLogger({}); + logger.log('foo', 'bar', {}); - expect(outputStream.write).toHaveBeenCalledTimes(2); - expect(outputStream.write).toHaveBeenCalledWith('foo'); - expect(outputStream.write).toHaveBeenCalledWith('bar'); + expect(logger.emit).toHaveBeenCalledTimes(2); + expect(logger.emit).toHaveBeenCalledWith({}, 'foo'); + expect(logger.emit).toHaveBeenCalledWith({}, 'bar'); }); it('writes multiple lines of text with prefix on each', () => { - const logger = new Logger({ outputStream }); - logger.log('foo', 'bar\nbaz\n'); + const logger = createLogger({}); + logger.log('foo', 'bar\nbaz\n', {}); - expect(outputStream.write).toHaveBeenCalledTimes(2); - expect(outputStream.write).toHaveBeenCalledWith('foo'); - expect(outputStream.write).toHaveBeenCalledWith('bar\nfoobaz\n'); + expect(logger.emit).toHaveBeenCalledTimes(2); + expect(logger.emit).toHaveBeenCalledWith({}, 'foo'); + expect(logger.emit).toHaveBeenCalledWith({}, 'bar\nfoobaz\n'); }); it('does not prepend prefix if last call did not finish with a LF', () => { - const logger = new Logger({ outputStream }); - logger.log('foo', 'bar'); - outputStream.write.mockClear(); - logger.log('foo', 'baz'); + const logger = createLogger({}); + logger.log('foo', 'bar', {}); + logger.emit.mockClear(); + logger.log('foo', 'baz', {}); - expect(outputStream.write).toHaveBeenCalledTimes(1); - expect(outputStream.write).toHaveBeenCalledWith('baz'); + expect(logger.emit).toHaveBeenCalledTimes(1); + expect(logger.emit).toHaveBeenCalledWith({}, 'baz'); }); it('does not prepend prefix or handle text if logger is in raw mode', () => { - const logger = new Logger({ outputStream, raw: true }); - logger.log('foo', 'bar\nbaz\n'); + const logger = createLogger({ raw: true }); + logger.log('foo', 'bar\nbaz\n', {}); - expect(outputStream.write).toHaveBeenCalledTimes(1); - expect(outputStream.write).toHaveBeenCalledWith('bar\nbaz\n'); + expect(logger.emit).toHaveBeenCalledTimes(1); + expect(logger.emit).toHaveBeenCalledWith({}, 'bar\nbaz\n'); }); }); @@ -66,7 +60,8 @@ describe('#logGlobalEvent()', () => { expect(logger.log).toHaveBeenCalledWith( chalk.gray.dim('-->') + ' ', - chalk.gray.dim('foo') + '\n' + chalk.gray.dim('foo') + '\n', + null ); }); }); @@ -74,40 +69,45 @@ describe('#logGlobalEvent()', () => { describe('#logCommandText()', () => { it('logs with name if no prefixFormat is set', () => { const logger = createLogger(); - logger.logCommandText('foo', { name: 'bla' }); + const cmd = { name: 'bla' }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[bla]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[bla]') + ' ', 'foo', cmd); }); it('logs with index if no prefixFormat is set, and command has no name', () => { const logger = createLogger(); - logger.logCommandText('foo', { index: 2 }); + const cmd = { index: 2 }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[2]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[2]') + ' ', 'foo', cmd); }); it('logs with prefixFormat set to pid', () => { const logger = createLogger({ prefixFormat: 'pid' }); - logger.logCommandText('foo', { + const cmd = { pid: 123, info: {} - }); + }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[123]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[123]') + ' ', 'foo', cmd); }); it('logs with prefixFormat set to name', () => { const logger = createLogger({ prefixFormat: 'name' }); - logger.logCommandText('foo', { name: 'bar' }); + const cmd = { name: 'bar' }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[bar]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[bar]') + ' ', 'foo', cmd); }); it('logs with prefixFormat set to index', () => { const logger = createLogger({ prefixFormat: 'index' }); - logger.logCommandText('foo', { index: 3 }); + const cmd = { index: 3 }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[3]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[3]') + ' ', 'foo', cmd); }); it('logs with prefixFormat set to time (with timestampFormat)', () => { @@ -115,64 +115,72 @@ describe('#logCommandText()', () => { logger.logCommandText('foo', {}); const year = new Date().getFullYear(); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim(`[${year}]`) + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim(`[${year}]`) + ' ', 'foo', {}); }); it('logs with templated prefixFormat', () => { const logger = createLogger({ prefixFormat: '{index}-{name}' }); - logger.logCommandText('foo', { index: 0, name: 'bar' }); + const cmd = { index: 0, name: 'bar' }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('0-bar') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('0-bar') + ' ', 'foo', cmd); }); it('does not strip spaces from beginning or end of prefixFormat', () => { const logger = createLogger({ prefixFormat: ' {index}-{name} ' }); - logger.logCommandText('foo', { index: 0, name: 'bar' }); + const cmd = { index: 0, name: 'bar' }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim(' 0-bar ') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim(' 0-bar ') + ' ', 'foo', cmd); }); it('logs with no prefix', () => { const logger = createLogger({ prefixFormat: 'none' }); - logger.logCommandText('foo', { command: 'echo foo' }); + const cmd = { command: 'echo foo' }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim(''), 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim(''), 'foo', cmd); }); it('logs prefix using command line itself', () => { const logger = createLogger({ prefixFormat: 'command' }); - logger.logCommandText('foo', { command: 'echo foo' }); + const cmd = { command: 'echo foo' }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[echo foo]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[echo foo]') + ' ', 'foo', cmd); }); it('logs prefix using command line itself, capped at prefixLength bytes', () => { const logger = createLogger({ prefixFormat: 'command', prefixLength: 6 }); - logger.logCommandText('foo', { command: 'echo foo' }); + const cmd = { command: 'echo foo' }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[ec..oo]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[ec..oo]') + ' ', 'foo', cmd); }); it('logs prefix using prefixColor from command', () => { const logger = createLogger(); - logger.logCommandText('foo', { prefixColor: 'blue', index: 1 }); + const cmd = { prefixColor: 'blue', index: 1 }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.blue('[1]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.blue('[1]') + ' ', 'foo', cmd); }); it('logs prefix in gray dim if prefixColor from command does not exist', () => { const logger = createLogger(); - logger.logCommandText('foo', { prefixColor: 'blue.fake', index: 1 }); + const cmd = { prefixColor: 'blue.fake', index: 1 }; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[1]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[1]') + ' ', 'foo', cmd); }); it('logs prefix using prefixColor from command if prefixColor is a hex value', () => { const logger = createLogger(); const prefixColor = '#32bd8a'; - logger.logCommandText('foo', {prefixColor, index: 1}); + const cmd = {prefixColor, index: 1}; + logger.logCommandText('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.hex(prefixColor)('[1]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.hex(prefixColor)('[1]') + ' ', 'foo', cmd); }); }); @@ -186,8 +194,9 @@ describe('#logCommandEvent()', () => { it('logs text in gray dim', () => { const logger = createLogger(); - logger.logCommandEvent('foo', { index: 1 }); + const cmd = { index: 1 }; + logger.logCommandEvent('foo', cmd); - expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[1]') + ' ', chalk.gray.dim('foo') + '\n'); + expect(logger.log).toHaveBeenCalledWith(chalk.gray.dim('[1]') + ' ', chalk.gray.dim('foo') + '\n', cmd); }); }); diff --git a/src/output-writer.js b/src/output-writer.js new file mode 100644 index 00000000..2b9d3070 --- /dev/null +++ b/src/output-writer.js @@ -0,0 +1,60 @@ +// @ts-check +const Rx = require('rxjs'); +/** @typedef {import('./command')} Command */ + +module.exports = class OutputWriter { + /** + * @param {object} options + * @param {NodeJS.WriteStream} options.outputStream + * @param {boolean} options.group + * @param {Command[]} options.commands + */ + constructor({ outputStream, group, commands }) { + this.outputStream = outputStream; + this.group = group; + this.commands = commands; + this.buffers = this.commands.map(() => []); + this.activeCommandIndex = 0; + + if (this.group) { + Rx.merge(...this.commands.map(c => c.close)) + .subscribe(command => { + if (command.index !== this.activeCommandIndex) { + return; + } + for (let i = command.index + 1; i < this.commands.length; i++) { + this.activeCommandIndex = i; + this.flushBuffer(i); + if (!this.commands[i].exited) { + break; + } + } + }); + } + } + + /** + * @param {Command} command + * @param {string} text + */ + write(command, text) { + if (this.group && command) { + if (command.index <= this.activeCommandIndex) { + this.outputStream.write(text); + } else { + this.buffers[command.index].push(text); + } + } else { + // "global" logs (command=null) are output out of order + this.outputStream.write(text); + } + } + + /** + * @param {number} index + */ + flushBuffer(index) { + this.buffers[index].forEach(t => this.outputStream.write(t)); + this.buffers[index] = []; + } +}; diff --git a/src/output-writer.spec.js b/src/output-writer.spec.js new file mode 100644 index 00000000..7d17c902 --- /dev/null +++ b/src/output-writer.spec.js @@ -0,0 +1,87 @@ +const { Writable } = require('stream'); +const { createMockInstance } = require('jest-create-mock-instance'); +const Command = require('./command'); +const OutputWriter = require('./output-writer'); + +function createWriter(overrides=null) { + const options = Object.assign({ + outputStream: createMockInstance(Writable), + group: false, + commands: [new Command({index: 0}), new Command({index: 1}), new Command({index: 2})], + }, overrides); + return new OutputWriter(options); +} + +function closeCommand(command) { + command.exited = true; + command.close.next(command); +} + +describe('#write group=false', () => { + it('writes instantly', () => { + const writer = createWriter({ group: false }); + writer.write({index: 2}, 'hello'); + expect(writer.outputStream.write).toHaveBeenCalledTimes(1); + expect(writer.outputStream.write).toHaveBeenCalledWith('hello'); + }); +}); + +describe('#write group=true', () => { + it('writes for null commands', () => { + const writer = createWriter({ group: true }); + writer.write(null, 'hello'); + expect(writer.outputStream.write).toHaveBeenCalledTimes(1); + expect(writer.outputStream.write).toHaveBeenCalledWith('hello'); + }); + + it('does not write instantly for non-active command', () => { + const writer = createWriter({ group: true }); + writer.write({index: 2}, 'hello'); + expect(writer.outputStream.write).toHaveBeenCalledTimes(0); + expect(writer.buffers[2]).toEqual(['hello']); + }); + + it('write instantly for active command', () => { + const writer = createWriter({ group: true }); + writer.write({index: 0}, 'hello'); + expect(writer.outputStream.write).toHaveBeenCalledTimes(1); + expect(writer.outputStream.write).toHaveBeenCalledWith('hello'); + }); + + it('does not wait for write from next command to flush', () => { + const writer = createWriter({ group: true }); + writer.write({index: 1}, 'hello'); + writer.write({index: 1}, 'foo bar'); + expect(writer.outputStream.write).toHaveBeenCalledTimes(0); + closeCommand(writer.commands[0]); + expect(writer.outputStream.write).toHaveBeenCalledTimes(2); + expect(writer.activeCommandIndex).toBe(1); + writer.outputStream.write.mockClear(); + + writer.write({index: 1}, 'blah'); + expect(writer.outputStream.write).toHaveBeenCalledTimes(1); + }); + + it('does not flush for non-active command', () => { + const writer = createWriter({ group: true }); + writer.write({index: 1}, 'hello'); + writer.write({index: 1}, 'foo bar'); + expect(writer.outputStream.write).toHaveBeenCalledTimes(0); + closeCommand(writer.commands[1]); + expect(writer.outputStream.write).toHaveBeenCalledTimes(0); + closeCommand(writer.commands[0]); + expect(writer.outputStream.write).toHaveBeenCalledTimes(2); + }); + + it('flushes multiple commands at a time if necessary', () => { + const writer = createWriter({ group: true }); + writer.write({index: 2}, 'hello'); + closeCommand(writer.commands[1]); + closeCommand(writer.commands[2]); + expect(writer.outputStream.write).toHaveBeenCalledTimes(0); + closeCommand(writer.commands[0]); + expect(writer.outputStream.write).toHaveBeenCalledTimes(1); + expect(writer.outputStream.write).toHaveBeenCalledWith('hello'); + expect(writer.activeCommandIndex).toBe(2); + }); +});