diff --git a/bin/concurrently.spec.ts b/bin/concurrently.spec.ts index 7ec3ddcd..4bb41086 100644 --- a/bin/concurrently.spec.ts +++ b/bin/concurrently.spec.ts @@ -184,6 +184,21 @@ describe('--hide', () => { }); }); +describe('--group', () => { + it('groups output per process', done => { + const child = run('--group "echo foo && sleep 1 && echo bar" "echo baz"'); + child.log.pipe(buffer(child.close)).subscribe(lines => { + expect(lines.slice(0, 4)).toEqual([ + expect.stringContaining('foo'), + expect.stringContaining('bar'), + expect.any(String), + expect.stringContaining('baz'), + ]); + done(); + }, done); + }); +}); + describe('--names', () => { it('is aliased to -n', done => { const child = run('-n foo,bar "echo foo" "echo bar"'); diff --git a/bin/concurrently.ts b/bin/concurrently.ts index 94915a9f..55dc06f3 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -65,6 +65,11 @@ const args = yargs default: defaults.hide, type: 'string' }, + 'group': { + alias: 'g', + describe: 'Order the output as if the commands were run sequentially.', + type: 'boolean' + }, 'timings': { describe: 'Show timing information for all processes', type: 'boolean', @@ -150,7 +155,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', 'timings'], 'General') + .group(['m', 'n', 'name-separator', 'raw', 's', 'no-color', 'hide', 'group', '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) => ({ maxProcesses: args.maxProcesses, raw: args.raw, hide: args.hide.split(','), + group: args.group, prefix: args.prefix, prefixColors: args['prefix-colors'].split(','), prefixLength: args.prefixLength, diff --git a/index.js b/index.js index 35a94ecc..71c7b137 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,6 @@ const LogTimings = require( './src/flow-control/log-timings' ); module.exports = exports = (commands, options = {}) => { const logger = new Logger({ hide: options.hide, - outputStream: options.outputStream || process.stdout, prefixFormat: options.prefix, prefixLength: options.prefixLength, raw: options.raw, @@ -25,6 +24,9 @@ module.exports = 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.ts b/src/command.ts index cdb693a4..a449c705 100644 --- a/src/command.ts +++ b/src/command.ts @@ -59,6 +59,7 @@ export class Command implements CommandInfo { stdin?: Writable; pid?: number; killed = false; + exited = false; get killable() { return !!this.process; @@ -96,6 +97,8 @@ export class Command implements CommandInfo { }); Rx.fromEvent<[number | null, NodeJS.Signals | null]>(child, 'close').subscribe(([exitCode, signal]) => { this.process = undefined; + this.exited = true; + const endDate = new Date(Date.now()); this.timer.next({ startDate, endDate }); const [durationSeconds, durationNanoSeconds] = process.hrtime(highResStartTime); diff --git a/src/concurrently.js b/src/concurrently.js index 6ef44bab..7e01360f 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, @@ -63,6 +64,16 @@ module.exports = (commands, options) => { ); commands = handleResult.commands; + + if (options.logger) { + const outputWriter = new OutputWriter({ + outputStream: options.outputStream, + group: options.group, + commands, + }); + options.logger.output.subscribe(({command, text}) => outputWriter.write(command, text)); + } + const commandsLeft = commands.slice(); const maxProcesses = Math.max(1, Number(options.maxProcesses) || commandsLeft.length); for (let i = 0; i < maxProcesses; i++) { diff --git a/src/logger.js b/src/logger.js index c8f0435f..2ac8d7d2 100644 --- a/src/logger.js +++ b/src/logger.js @@ -1,20 +1,21 @@ const chalk = require('chalk'); const _ = require('lodash'); const formatDate = require('date-fns/format'); +const Rx = require('rxjs'); const defaults = require('./defaults'); module.exports = class Logger { - constructor({ hide, outputStream, prefixFormat, prefixLength, raw, timestampFormat }) { + constructor({ hide, prefixFormat, prefixLength, raw, timestampFormat }) { // To avoid empty strings from hiding the output of commands that don't have a name, // keep in the list of commands to hide only strings with some length. // This might happen through the CLI when no `--hide` argument is specified, for example. this.hide = _.castArray(hide).filter(name => name || name === 0).map(String); this.raw = raw; - this.outputStream = outputStream; this.prefixFormat = prefixFormat; this.prefixLength = prefixLength || defaults.prefixLength; this.timestampFormat = timestampFormat || defaults.timestampFormat; + this.output = new Rx.Subject(); } shortenText(text) { @@ -85,7 +86,7 @@ module.exports = class Logger { } const prefix = this.colorText(command, this.getPrefix(command)); - return this.log(prefix + (prefix ? ' ' : ''), text); + return this.log(prefix + (prefix ? ' ' : ''), text, command); } logGlobalEvent(text) { @@ -93,7 +94,7 @@ module.exports = class Logger { return; } - this.log(chalk.reset('-->') + ' ', chalk.reset(text) + '\n'); + this.log(chalk.reset('-->') + ' ', chalk.reset(text) + '\n', null); } logTable(tableContents) { @@ -153,9 +154,9 @@ module.exports = class Logger { this.logGlobalEvent(`└─${borderRowFormatted.join('─┴─')}─┘`); } - 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 @@ -171,10 +172,14 @@ 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')); + } + + emit(command, text) { + this.output.next({ command, text }); } }; diff --git a/src/logger.spec.js b/src/logger.spec.js index ca903dc9..139a461d 100644 --- a/src/logger.spec.js +++ b/src/logger.spec.js @@ -1,56 +1,53 @@ -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); // Force chalk to use colours, otherwise tests may pass when they were supposed to be failing. chalk.level = 3; }); 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'); }); }); @@ -68,7 +65,8 @@ describe('#logGlobalEvent()', () => { expect(logger.log).toHaveBeenCalledWith( chalk.reset('-->') + ' ', - chalk.reset('foo') + '\n' + chalk.reset('foo') + '\n', + null ); }); }); @@ -76,40 +74,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.reset('[bla]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset('[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.reset('[2]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset('[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.reset('[123]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset('[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.reset('[bar]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset('[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.reset('[3]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset('[3]') + ' ', 'foo', cmd); }); it('logs with prefixFormat set to time (with timestampFormat)', () => { @@ -117,64 +120,72 @@ describe('#logCommandText()', () => { logger.logCommandText('foo', {}); const year = new Date().getFullYear(); - expect(logger.log).toHaveBeenCalledWith(chalk.reset(`[${year}]`) + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset(`[${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.reset('0-bar') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset('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.reset(' 0-bar ') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset(' 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.reset(''), 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset(''), '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.reset('[echo foo]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset('[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.reset('[ec..oo]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset('[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.reset('[1]') + ' ', 'foo'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset('[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); }); it('does nothing if command is hidden by name', () => { @@ -216,9 +227,10 @@ 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.reset('[1]') + ' ', chalk.reset('foo') + '\n'); + expect(logger.log).toHaveBeenCalledWith(chalk.reset('[1]') + ' ', chalk.reset('foo') + '\n', cmd); }); }); @@ -261,6 +273,7 @@ describe('#logTable()', () => { expect(logger.log).toHaveBeenCalledWith( chalk.reset('-->') + ' ', chalk.reset('│ foo │ bar │') + '\n', + null, ); }); @@ -271,6 +284,7 @@ describe('#logTable()', () => { expect(logger.log).toHaveBeenCalledWith( chalk.reset('-->') + ' ', chalk.reset('│ a │ b │') + '\n', + null, ); }); @@ -281,10 +295,12 @@ describe('#logTable()', () => { expect(logger.log).toHaveBeenCalledWith( chalk.reset('-->') + ' ', chalk.reset('│ 123 │') + '\n', + null, ); expect(logger.log).toHaveBeenCalledWith( chalk.reset('-->') + ' ', chalk.reset('│ 456 │') + '\n', + null, ); }); @@ -295,6 +311,7 @@ describe('#logTable()', () => { expect(logger.log).toHaveBeenCalledWith( chalk.reset('-->') + ' ', chalk.reset('│ 1 │') + '\n', + null, ); }); @@ -305,14 +322,17 @@ describe('#logTable()', () => { expect(logger.log).toHaveBeenCalledWith( chalk.reset('-->') + ' ', chalk.reset('│ foo │ bar │') + '\n', + null, ); expect(logger.log).toHaveBeenCalledWith( chalk.reset('-->') + ' ', chalk.reset('│ 1 │ │') + '\n', + null, ); expect(logger.log).toHaveBeenCalledWith( chalk.reset('-->') + ' ', chalk.reset('│ │ 2 │') + '\n', + null, ); }); }); diff --git a/src/output-writer.spec.ts b/src/output-writer.spec.ts new file mode 100644 index 00000000..b19cdbac --- /dev/null +++ b/src/output-writer.spec.ts @@ -0,0 +1,101 @@ +import { Writable } from 'stream'; +import { createMockInstance } from 'jest-create-mock-instance'; +import { Command } from './command'; +import { OutputWriter } from './output-writer'; + +function createWriter(overrides=null) { + const options = Object.assign({ + outputStream, + group: false, + commands, + }, overrides); + return new OutputWriter(options); +} + +const createCommand = (index: number) => new Command( + { index, name: '', command: 'echo foo' }, + {}, + jest.fn(), + jest.fn(), +); + +function closeCommand(command) { + command.exited = true; + command.close.next(command); +} + +let outputStream: jest.Mocked; +let commands: Command[]; +beforeEach(() => { + outputStream = createMockInstance(Writable); + commands = [createCommand(0), createCommand(1), createCommand(2)] +}); + +describe('#write group=false', () => { + it('writes instantly', () => { + const writer = createWriter({ group: false }); + writer.write(commands[2], 'hello'); + expect(outputStream.write).toHaveBeenCalledTimes(1); + expect(outputStream.write).toHaveBeenCalledWith('hello'); + }); +}); + +describe('#write group=true', () => { + it('writes for null commands', () => { + const writer = createWriter({ group: true }); + writer.write(null, 'hello'); + expect(outputStream.write).toHaveBeenCalledTimes(1); + expect(outputStream.write).toHaveBeenCalledWith('hello'); + }); + + it('does not write instantly for non-active command', () => { + const writer = createWriter({ group: true }); + writer.write(commands[2], 'hello'); + expect(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(outputStream.write).toHaveBeenCalledTimes(1); + expect(outputStream.write).toHaveBeenCalledWith('hello'); + }); + + it('does not wait for write from next command to flush', () => { + const writer = createWriter({ group: true }); + writer.write(commands[1], 'hello'); + writer.write(commands[1], 'foo bar'); + expect(outputStream.write).toHaveBeenCalledTimes(0); + closeCommand(commands[0]); + expect(outputStream.write).toHaveBeenCalledTimes(2); + expect(writer.activeCommandIndex).toBe(1); + outputStream.write.mockClear(); + + writer.write({index: 1}, 'blah'); + expect(outputStream.write).toHaveBeenCalledTimes(1); + }); + + it('does not flush for non-active command', () => { + const writer = createWriter({ group: true }); + writer.write(commands[1], 'hello'); + writer.write(commands[1], 'foo bar'); + expect(outputStream.write).toHaveBeenCalledTimes(0); + closeCommand(commands[1]); + expect(outputStream.write).toHaveBeenCalledTimes(0); + closeCommand(commands[0]); + expect(outputStream.write).toHaveBeenCalledTimes(2); + }); + + it('flushes multiple commands at a time if necessary', () => { + const writer = createWriter({ group: true }); + writer.write({index: 2}, 'hello'); + closeCommand(commands[1]); + closeCommand(commands[2]); + expect(outputStream.write).toHaveBeenCalledTimes(0); + closeCommand(commands[0]); + expect(outputStream.write).toHaveBeenCalledTimes(1); + expect(outputStream.write).toHaveBeenCalledWith('hello'); + expect(writer.activeCommandIndex).toBe(2); + }); +}); diff --git a/src/output-writer.ts b/src/output-writer.ts new file mode 100644 index 00000000..f76b141e --- /dev/null +++ b/src/output-writer.ts @@ -0,0 +1,54 @@ +import { Writable } from "stream"; +import { Command } from "./command"; +import * as Rx from 'rxjs'; + +export class OutputWriter { + private readonly outputStream: Writable; + private readonly group: boolean; + readonly buffers: string[][]; + activeCommandIndex = 0; + + constructor({ outputStream, group, commands }: { + outputStream: Writable, + group: boolean, + commands: Command[], + }) { + this.outputStream = outputStream; + this.group = group; + this.buffers = commands.map(() => []); + + if (this.group) { + Rx.merge(...commands.map(c => c.close)) + .subscribe(command => { + if (command.index !== this.activeCommandIndex) { + return; + } + for (let i = command.index + 1; i < commands.length; i++) { + this.activeCommandIndex = i; + this.flushBuffer(i); + if (!commands[i].exited) { + break; + } + } + }); + } + } + + 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); + } + } + + flushBuffer(index) { + this.buffers[index].forEach(t => this.outputStream.write(t)); + this.buffers[index] = []; + } +};