diff --git a/README.md b/README.md index 2396da9f..6b94e44c 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,6 @@ Options: -h, --help Show help [boolean] -v, -V, --version Show version number [boolean] - Examples: - Output nothing more than stdout+stderr of child processes @@ -320,10 +319,9 @@ concurrently can be used programmatically by using the API documented below: - `prefix`: the prefix type to use when logging processes output. Possible values: `index`, `pid`, `time`, `command`, `name`, `none`, or a template (eg `[{time} process: {pid}]`). Default: the name of the process, or its index if no name is set. - - `prefixColors`: a list of colors as supported by [chalk](https://www.npmjs.com/package/chalk). - If concurrently would run more commands than there are colors, the last color is repeated. + - `prefixColors`: a list of colors as supported by [chalk](https://www.npmjs.com/package/chalk) or `auto` to automatically pick a color. + If concurrently would run more commands than there are colors, the last color is repeated, unless if the last colour value is `auto` which means following colors are automatically picked to vary. Prefix colors specified per-command take precedence over this list. - - `colors`: let colours be selected to vary automatically where not explicitly defined - `prefixLength`: how many characters to show when prefixing with `command`. Default: `10` - `raw`: whether raw mode should be used, meaning strictly process output will be logged, without any prefixes, colouring or extra stuff. diff --git a/bin/concurrently.ts b/bin/concurrently.ts index b90167d8..b0d0064e 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -125,19 +125,13 @@ const args = yargs(argsBeforeSep) 'Comma-separated list of chalk colors to use on prefixes. ' + 'If there are more commands than colors, the last color will be repeated.\n' + '- Available modifiers: reset, bold, dim, italic, underline, inverse, hidden, strikethrough\n' + - '- Available colors: black, red, green, yellow, blue, magenta, cyan, white, gray \n' + - 'or any hex values for colors, eg #23de43\n' + + '- Available colors: black, red, green, yellow, blue, magenta, cyan, white, gray, \n' + + 'any hex values for colors, eg #23de43 or auto to automatically pic a color\n' + '- Available background colors: bgBlack, bgRed, bgGreen, bgYellow, bgBlue, bgMagenta, bgCyan, bgWhite\n' + 'See https://www.npmjs.com/package/chalk for more information.', default: defaults.prefixColors, type: 'string', }, - color: { - describe: - 'Automatically adds varying prefix colors where commands do not have a prefix color defined', - default: defaults.color, - type: 'boolean', - }, 'prefix-length': { alias: 'l', describe: @@ -218,7 +212,6 @@ concurrently( group: args.group, prefix: args.prefix, prefixColors: args.prefixColors.split(','), - color: args.color, prefixLength: args.prefixLength, restartDelay: args.restartAfter, restartTries: args.restartTries, diff --git a/src/concurrently.ts b/src/concurrently.ts index e1b95b01..680be02a 100644 --- a/src/concurrently.ts +++ b/src/concurrently.ts @@ -9,12 +9,12 @@ import { ExpandArguments } from './command-parser/expand-arguments'; import { ExpandNpmShortcut } from './command-parser/expand-npm-shortcut'; import { ExpandNpmWildcard } from './command-parser/expand-npm-wildcard'; import { StripQuotes } from './command-parser/strip-quotes'; -import { PrefixColorSelector } from './prefix-color-selector'; import { CompletionListener, SuccessCondition } from './completion-listener'; import { FlowController } from './flow-control/flow-controller'; import { getSpawnOpts } from './get-spawn-opts'; import { Logger } from './logger'; import { OutputWriter } from './output-writer'; +import { PrefixColorSelector } from './prefix-color-selector'; const defaults: ConcurrentlyOptions = { spawn, @@ -136,7 +136,7 @@ export function concurrently( const options = _.defaults(baseOptions, defaults); - const prefixColorSelector = new PrefixColorSelector(options.prefixColors, options.color); + const prefixColorSelector = new PrefixColorSelector(options.prefixColors); const commandParsers: CommandParser[] = [ new StripQuotes(), @@ -155,7 +155,7 @@ export function concurrently( return new Command( { index, - prefixColor: prefixColorSelector.getNextColor(index), + prefixColor: prefixColorSelector.getNextColor(), ...command, }, getSpawnOpts({ diff --git a/src/prefix-color-selector.spec.ts b/src/prefix-color-selector.spec.ts index 0515497c..ab4972a5 100644 --- a/src/prefix-color-selector.spec.ts +++ b/src/prefix-color-selector.spec.ts @@ -1,71 +1,206 @@ import { PrefixColorSelector } from './prefix-color-selector'; -it('does not produce a color if it should not', () => { - const prefixColorSelector = new PrefixColorSelector([], false); - - let selectedColor = prefixColorSelector.getNextColor(0); - expect(selectedColor).toBe(''); - selectedColor = prefixColorSelector.getNextColor(1); - expect(selectedColor).toBe(''); - selectedColor = prefixColorSelector.getNextColor(2); - expect(selectedColor).toBe(''); -}); +function assertSelectedColors({ + prefixColorSelector, + expectedColors, +}: { + prefixColorSelector: PrefixColorSelector; + expectedColors: string[]; +}) { + expectedColors.forEach(expectedColor => { + expect(prefixColorSelector.getNextColor()).toBe(expectedColor); + }); +} -it('uses user defined prefix colors only if not allowed to use auto colors', () => { - const prefixColorSelector = new PrefixColorSelector(['red', 'green', 'blue'], false); - - let selectedColor = prefixColorSelector.getNextColor(0); - expect(selectedColor).toBe('red'); - selectedColor = prefixColorSelector.getNextColor(1); - expect(selectedColor).toBe('green'); - selectedColor = prefixColorSelector.getNextColor(2); - expect(selectedColor).toBe('blue'); - - // uses last color if no more user defined colors - selectedColor = prefixColorSelector.getNextColor(3); - expect(selectedColor).toBe('blue'); - selectedColor = prefixColorSelector.getNextColor(4); - expect(selectedColor).toBe('blue'); +afterEach(() => { + jest.restoreAllMocks(); }); -it('uses user defined colors then recurring auto colors without repeating consecutive colors', () => { - const prefixColorSelector = new PrefixColorSelector(['red', 'green'], true); +describe('#getNextColor', function () { + it('does not produce a color if prefixColors empty', () => { + assertSelectedColors({ + prefixColorSelector: new PrefixColorSelector([]), + expectedColors: ['', '', ''], + }); + }); - jest.spyOn(prefixColorSelector, 'ACCEPTABLE_CONSOLE_COLORS', 'get').mockReturnValue([ - 'green', - 'blue', - ]); + it('does not produce a color if prefixColors undefined', () => { + assertSelectedColors({ + prefixColorSelector: new PrefixColorSelector(), + expectedColors: ['', '', ''], + }); + }); - let selectedColor = prefixColorSelector.getNextColor(0); - expect(selectedColor).toBe('red'); - selectedColor = prefixColorSelector.getNextColor(1); - expect(selectedColor).toBe('green'); + it('uses user defined prefix colors only, if no auto is used', () => { + assertSelectedColors({ + prefixColorSelector: new PrefixColorSelector(['red', 'green', 'blue']), + expectedColors: [ + 'red', + 'green', + 'blue', - // auto colors now, does not repeat last user color of green - selectedColor = prefixColorSelector.getNextColor(2); - expect(selectedColor).toBe('blue'); + // uses last color if last color is not "auto" + 'blue', + 'blue', + 'blue', + ], + }); + }); - selectedColor = prefixColorSelector.getNextColor(3); - expect(selectedColor).toBe('green'); + it('picks varying colors when user defines an auto color', () => { + jest.spyOn(PrefixColorSelector, 'ACCEPTABLE_CONSOLE_COLORS', 'get').mockReturnValue([ + 'green', + 'blue', + ]); - selectedColor = prefixColorSelector.getNextColor(4); - expect(selectedColor).toBe('blue'); -}); + assertSelectedColors({ + prefixColorSelector: new PrefixColorSelector([ + 'red', + 'green', + 'auto', + 'green', + 'auto', + 'green', + 'auto', + 'blue', + 'auto', + 'orange', + ]), + expectedColors: [ + // custom colors + 'red', + 'green', + 'blue', // picks auto color "blue", not repeating consecutive "green" color + 'green', // manual + 'blue', // auto picks "blue" not to repeat last + 'green', // manual + 'blue', // auto picks "blue" again not to repeat last + 'blue', // manual + 'green', // auto picks "green" again not to repeat last + 'orange', -it('has more than 1 auto color defined', () => { - const prefixColorSelector = new PrefixColorSelector([], true); - // ! code assumes this always has more than one entry, so make sure - expect(prefixColorSelector.ACCEPTABLE_CONSOLE_COLORS.length).toBeGreaterThan(1); -}); + // uses last color if last color is not "auto" + 'orange', + 'orange', + 'orange', + ], + }); + }); -it('can use only auto colors and does not repeat consecutive colors', () => { - const prefixColorSelector = new PrefixColorSelector([], true); + it('uses user defined colors then recurring auto colors without repeating consecutive colors', () => { + jest.spyOn(PrefixColorSelector, 'ACCEPTABLE_CONSOLE_COLORS', 'get').mockReturnValue([ + 'green', + 'blue', + ]); + + assertSelectedColors({ + prefixColorSelector: new PrefixColorSelector(['red', 'green', 'auto']), + expectedColors: [ + // custom colors + 'red', + 'green', + + // picks auto colors, not repeating consecutive "green" color + 'blue', + 'green', + 'blue', + 'green', + ], + }); + }); + + it('can sometimes produce consecutive colors', () => { + jest.spyOn(PrefixColorSelector, 'ACCEPTABLE_CONSOLE_COLORS', 'get').mockReturnValue([ + 'green', + 'blue', + ]); + + assertSelectedColors({ + prefixColorSelector: new PrefixColorSelector(['blue', 'auto']), + expectedColors: [ + // custom colors + 'blue', + + // picks auto colors + 'green', + // does not repeat custom colors for initial auto colors, ie does not use "blue" again so soon + 'green', // consecutive color picked, however practically there would be a lot of colors that need to be set in a particular order for this to occur + 'blue', + 'green', + 'blue', + 'green', + 'blue', + ], + }); + }); + + it('considers the Bright variants of colors equal to the normal colors to avoid similar colors', function () { + jest.spyOn(PrefixColorSelector, 'ACCEPTABLE_CONSOLE_COLORS', 'get').mockReturnValue([ + 'greenBright', + 'blueBright', + 'green', + 'blue', + 'magenta', + ]); + + assertSelectedColors({ + prefixColorSelector: new PrefixColorSelector(['green', 'blue', 'auto']), + expectedColors: [ + // custom colors + 'green', + 'blue', + + // picks auto colors, not repeating green and blue colors and variants initially + 'magenta', + + // picks auto colors + 'greenBright', + 'blueBright', + 'green', + 'blue', + 'magenta', + ], + }); + }); + + it('does not repeat consecutive colors when last prefixColor is auto', () => { + const prefixColorSelector = new PrefixColorSelector(['auto']); + + // pick auto colors over 2 sets + const expectedColors: string[] = [ + ...PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS, + ...PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS, + ]; + + expectedColors.reduce((previousColor, currentExpectedColor) => { + const actualSelectedColor = prefixColorSelector.getNextColor(); + expect(actualSelectedColor).not.toBe(previousColor); // no consecutive colors + expect(actualSelectedColor).toBe(currentExpectedColor); // expected color + return actualSelectedColor; + }, ''); + }); + + it('handles when more individual auto prefixColors exist than acceptable console colors', () => { + // pick auto colors over 2 sets + const expectedColors: string[] = [ + ...PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS, + ...PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS, + ]; + + const prefixColorSelector = new PrefixColorSelector(expectedColors.map(() => 'auto')); + + expectedColors.reduce((previousColor, currentExpectedColor) => { + const actualSelectedColor = prefixColorSelector.getNextColor(); + expect(actualSelectedColor).not.toBe(previousColor); // no consecutive colors + expect(actualSelectedColor).toBe(currentExpectedColor); // expected color + return actualSelectedColor; + }, ''); + }); +}); - let previousColor; - let selectedColor: string; - Array(prefixColorSelector.ACCEPTABLE_CONSOLE_COLORS.length * 2).forEach((_, index) => { - previousColor = selectedColor; - selectedColor = prefixColorSelector.getNextColor(index); - expect(selectedColor).not.toBe(previousColor); +describe('PrefixColorSelector#ACCEPTABLE_CONSOLE_COLORS', () => { + it('has more than 1 auto color defined', () => { + // ! code assumes this always has more than one entry, so make sure + expect(PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS.length).toBeGreaterThan(1); }); }); diff --git a/src/prefix-color-selector.ts b/src/prefix-color-selector.ts index 6ec48864..5fb6eead 100644 --- a/src/prefix-color-selector.ts +++ b/src/prefix-color-selector.ts @@ -1,77 +1,99 @@ import chalk from 'chalk'; -export class PrefixColorSelector { - lastColor: string; - autoColors: string[]; +function getConsoleColorsWithoutCustomColors(customColors: string[]): string[] { + return PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS.filter( + // consider the "Bright" variants of colors to be the same as the plain color to avoid similar colors + color => !customColors.includes(color.replace(/Bright$/, '')) + ); +} - get ACCEPTABLE_CONSOLE_COLORS() { - // Colors picked randomly, can be amended if required - return ( - [ - chalk.cyan, - chalk.yellow, - chalk.magenta, - chalk.grey, - chalk.bgBlueBright, - chalk.bgMagenta, - chalk.magentaBright, - chalk.bgBlack, - chalk.bgWhite, - chalk.bgCyan, - chalk.bgGreen, - chalk.bgYellow, - chalk.bgRed, - chalk.bgGreenBright, - chalk.bgGrey, - chalk.blueBright, - ] - // Filter out duplicates - .filter((chalkColor, index, arr) => { - return arr.indexOf(chalkColor) === index; - }) - .map(chalkColor => chalkColor.bold.toString()) - ); +/** + * Creates a generator that yields an infinite stream of colours + */ +function* createColorGenerator(customColors: string[]): Generator { + // custom colors should be used as is, except for "auto" + const nextAutoColors: string[] = getConsoleColorsWithoutCustomColors(customColors); + let lastColor: string; + for (const customColor of customColors) { + let currentColor = customColor; + if (currentColor !== 'auto') { + yield currentColor; // manual color + } else { + // find the first auto color that is not the same as the last color + while (currentColor === 'auto' || lastColor === currentColor) { + if (!nextAutoColors.length) { + // there could be more "auto" values than auto colors so this needs to be able to refill + nextAutoColors.push(...PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS); + } + currentColor = nextAutoColors.shift(); + } + yield currentColor; // auto color + } + lastColor = currentColor; } - constructor(private readonly prefixColors?: string[], private readonly color?: boolean) {} - - getNextColor(index?: number) { - const cannotSelectColor = !this.prefixColors?.length && !this.color; - if (cannotSelectColor) { - return ''; + const lastCustomColor = customColors[customColors.length - 1] || ''; + if (lastCustomColor !== 'auto') { + while (true) { + yield lastCustomColor; // if last custom color was not "auto" then return same color forever, to maintain existing behaviour } + } - const userDefinedColorForCurrentCommand = - this.prefixColors && typeof index === 'number' && this.prefixColors[index]; + // finish the initial set(s) of auto colors to avoid repetition + for (const color of nextAutoColors) { + yield color; + } - if (!this.color) { - // Use documented behaviour of repeating last color - // when specifying more commands than colors - this.lastColor = userDefinedColorForCurrentCommand || this.lastColor; - return this.lastColor; + // yield an infinite stream of acceptable console colors + // if the given custom colors use every ACCEPTABLE_CONSOLE_COLORS except one then there is a chance a color will be repeated, + // however its highly unlikely and low consequence so not worth the extra complexity to account for it + while (true) { + for (const color of PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS) { + yield color; // repeat colors forever } + } +} - // User preference takes priority if defined - if (userDefinedColorForCurrentCommand) { - this.lastColor = userDefinedColorForCurrentCommand; - return userDefinedColorForCurrentCommand; - } +export class PrefixColorSelector { + private colorGenerator: Generator; - // Auto selection requested and no user preference defined, select next auto color - if (!this.autoColors || !this.autoColors.length) { - this.refillAutoColors(); - } + constructor(customColors: string[] = []) { + this.colorGenerator = createColorGenerator(customColors); + } - // Prevent consecutive colors from being the same - // (i.e. when transitioning from user colours to auto colours) - const nextColor = this.autoColors.shift(); + /** A list of colours that are readable in a terminal */ + public static get ACCEPTABLE_CONSOLE_COLORS() { + // Colors picked randomly, can be amended if required + return [ + // prevent duplicates, incase the list becomes significantly large + ...new Set([ + // text colors + 'cyan', + 'yellow', + 'greenBright', + 'blueBright', + 'magentaBright', + 'white', + 'grey', + 'red', - this.lastColor = nextColor !== this.lastColor ? nextColor : this.getNextColor(); - return this.lastColor; + // bg colors + 'bgCyan', + 'bgYellow', + 'bgGreenBright', + 'bgBlueBright', + 'bgMagenta', + 'bgWhiteBright', + 'bgGrey', + 'bgRed', + ]), + ]; } - refillAutoColors() { - // Make sure auto colors are not empty after refill - this.autoColors = [...this.ACCEPTABLE_CONSOLE_COLORS]; + /** + * @returns The given custom colors then a set of acceptable console colors indefinitely + */ + getNextColor(): string { + return this.colorGenerator.next().value; } }