diff --git a/README.md b/README.md index e6a94db8..a6106751 100644 --- a/README.md +++ b/README.md @@ -191,8 +191,9 @@ Prefix styling - Available modifiers: reset, bold, dim, italic, underline, inverse, hidden, strikethrough - Available colors: black, red, green, yellow, blue, - magenta, cyan, white, gray - or any hex values for colors, eg #23de43 + magenta, cyan, white, gray, + any hex values for colors (e.g. #23de43) or auto for + an automatically picked color - Available background colors: bgBlack, bgRed, bgGreen, bgYellow, bgBlue, bgMagenta, bgCyan, bgWhite See https://www.npmjs.com/package/chalk for more @@ -250,6 +251,14 @@ Examples: $ concurrently --names "HTTP,WATCH" -c "bgBlue.bold,bgMagenta.bold" "http-server" "npm run watch" + - Auto varying colored prefixes + + $ concurrently -c "auto" "npm run watch" "http-server" + + - Mixing auto and manual colored prefixes + + $ concurrently -c "red,auto" "npm run watch" "http-server" "echo hello" + - Configuring via environment variables with CONCURRENTLY_ prefix $ CONCURRENTLY_RAW=true CONCURRENTLY_KILL_OTHERS=true concurrently "echo @@ -324,12 +333,12 @@ For more details, visit https://github.com/open-cli-tools/concurrently - `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` for an automatically picked color. + If concurrently would run more commands than there are colors, the last color is repeated, unless if the last color value is `auto` which means following colors are automatically picked to vary. Prefix colors specified per-command take precedence over this list. - `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. + be logged, without any prefixes, coloring or extra stuff. - `successCondition`: the condition to consider the run was successful. If `first`, only the first process to exit will make up the success of the run; if `last`, the last process that exits will determine whether the run succeeds. Anything else means all processes should exit successfully. diff --git a/bin/concurrently.ts b/bin/concurrently.ts index 23d0e4cd..db84ca0e 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -67,7 +67,7 @@ const args = yargs(argsBeforeSep) 'and concurrently coloring.', type: 'boolean', }, - // This one is provided for free. Chalk reads this itself and removes colours. + // This one is provided for free. Chalk reads this itself and removes colors. // https://www.npmjs.com/package/chalk#chalksupportscolor 'no-color': { describe: 'Disables colors from logging', @@ -126,8 +126,8 @@ 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 (e.g. #23de43) or auto for an automatically picked 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, diff --git a/bin/epilogue.ts b/bin/epilogue.ts index f39e1828..72726c88 100644 --- a/bin/epilogue.ts +++ b/bin/epilogue.ts @@ -18,6 +18,14 @@ const examples = [ example: '$ $0 --names "HTTP,WATCH" -c "bgBlue.bold,bgMagenta.bold" "http-server" "npm run watch"', }, + { + description: 'Auto varying colored prefixes', + example: '$ $0 -c "auto" "npm run watch" "http-server"', + }, + { + description: 'Mixing auto and manual colored prefixes', + example: '$ $0 -c "red,auto" "npm run watch" "http-server" "echo hello"', + }, { description: 'Configuring via environment variables with CONCURRENTLY_ prefix', example: diff --git a/src/command.ts b/src/command.ts index bd283242..77196b9c 100644 --- a/src/command.ts +++ b/src/command.ts @@ -29,7 +29,7 @@ export interface CommandInfo { cwd?: string; /** - * Color to use on prefix of command. + * Color to use on prefix of the command. */ prefixColor?: string; } diff --git a/src/concurrently.ts b/src/concurrently.ts index 8c3056c9..a1d8ee38 100644 --- a/src/concurrently.ts +++ b/src/concurrently.ts @@ -15,6 +15,7 @@ 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, @@ -131,6 +132,8 @@ export function concurrently( const options = _.defaults(baseOptions, defaults); + const prefixColorSelector = new PrefixColorSelector(options.prefixColors); + const commandParsers: CommandParser[] = [ new StripQuotes(), new ExpandNpmShortcut(), @@ -141,17 +144,14 @@ export function concurrently( commandParsers.push(new ExpandArguments(options.additionalArguments)); } - let lastColor = ''; let commands = _(baseCommands) .map(mapToCommandInfo) .flatMap((command) => parseCommand(command, commandParsers)) .map((command, index) => { - // Use documented behaviour of repeating last color when specifying more commands than colors - lastColor = (options.prefixColors && options.prefixColors[index]) || lastColor; return new Command( { index, - prefixColor: lastColor, + prefixColor: prefixColorSelector.getNextColor(), ...command, }, getSpawnOpts({ diff --git a/src/logger.spec.ts b/src/logger.spec.ts index 329f786a..b9156c9e 100644 --- a/src/logger.spec.ts +++ b/src/logger.spec.ts @@ -6,7 +6,7 @@ import { Logger } from './logger'; let emitSpy: jest.SpyInstance; beforeEach(() => { - // Force chalk to use colours, otherwise tests may pass when they were supposed to be failing. + // Force chalk to use colors, otherwise tests may pass when they were supposed to be failing. chalk.level = 3; }); diff --git a/src/prefix-color-selector.spec.ts b/src/prefix-color-selector.spec.ts new file mode 100644 index 00000000..aaa5e383 --- /dev/null +++ b/src/prefix-color-selector.spec.ts @@ -0,0 +1,173 @@ +import chalk from 'chalk'; + +import { PrefixColorSelector } from './prefix-color-selector'; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('#getNextColor', function () { + const customTests: Record< + string, + { + acceptableConsoleColors?: Array; + customColors?: string[]; + expectedColors: string[]; + } + > = { + 'does not produce a color if prefixColors empty': { + customColors: [], + expectedColors: ['', '', ''], + }, + 'does not produce a color if prefixColors undefined': { + expectedColors: ['', '', ''], + }, + 'uses user defined prefix colors only, if no auto is used': { + customColors: ['red', 'green', 'blue'], + expectedColors: [ + 'red', + 'green', + 'blue', + + // Uses last color if last color is not "auto" + 'blue', + 'blue', + 'blue', + ], + }, + 'picks varying colors when user defines an auto color': { + acceptableConsoleColors: ['green', 'blue'], + customColors: [ + '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', + + // Uses last color if last color is not "auto" + 'orange', + 'orange', + 'orange', + ], + }, + 'uses user defined colors then recurring auto colors without repeating consecutive colors': + { + acceptableConsoleColors: ['green', 'blue'], + customColors: ['red', 'green', 'auto'], + expectedColors: [ + // Custom colors + 'red', + 'green', + + // Picks auto colors, not repeating consecutive "green" color + 'blue', + 'green', + 'blue', + 'green', + ], + }, + 'can sometimes produce consecutive colors': { + acceptableConsoleColors: ['green', 'blue'], + customColors: ['blue', 'auto'], + expectedColors: [ + // Custom colors + 'blue', + + // Picks auto colors + 'green', + // Does not repeat custom colors for initial auto colors, i.e. 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', + ], + }, + 'considers the Bright variants of colors equal to the normal colors to avoid similar colors': + { + acceptableConsoleColors: ['greenBright', 'blueBright', 'green', 'blue', 'magenta'], + customColors: ['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.each(Object.entries(customTests))( + '%s', + (_, { acceptableConsoleColors, customColors, expectedColors }) => { + if (acceptableConsoleColors) { + jest.spyOn(PrefixColorSelector, 'ACCEPTABLE_CONSOLE_COLORS', 'get').mockReturnValue( + acceptableConsoleColors + ); + } + const prefixColorSelector = new PrefixColorSelector(customColors); + const prefixColorSelectorValues = expectedColors.map(() => + prefixColorSelector.getNextColor() + ); + + expect(prefixColorSelectorValues).toEqual(expectedColors); + } + ); + + const autoTests = { + 'does not repeat consecutive colors when last prefixColor is auto': false, + 'handles when more individual auto prefixColors exist than acceptable console colors': true, + }; + it.each(Object.entries(autoTests))('%s', (_, map) => { + // Pick auto colors over 2 sets + const expectedColors: string[] = [ + ...PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS, + ...PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS, + ]; + + const prefixColorSelector = new PrefixColorSelector( + map ? expectedColors.map(() => 'auto') : ['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; + }, ''); + }); +}); + +describe('PrefixColorSelector#ACCEPTABLE_CONSOLE_COLORS', () => { + it('has more than 1 auto color defined', () => { + // (!) The current implementation is based on the assumption that 'ACCEPTABLE_CONSOLE_COLORS' + // always has more than one entry, which is what we enforce via this test + expect(PrefixColorSelector.ACCEPTABLE_CONSOLE_COLORS.length).toBeGreaterThan(1); + }); +}); diff --git a/src/prefix-color-selector.ts b/src/prefix-color-selector.ts new file mode 100644 index 00000000..94338ec5 --- /dev/null +++ b/src/prefix-color-selector.ts @@ -0,0 +1,100 @@ +import chalk from 'chalk'; + +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$/, '')) + ); +} + +/** + * Creates a generator that yields an infinite stream of colors. + */ +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; + } + + 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 + } + } + + // Finish the initial set(s) of auto colors to avoid repetition + for (const color of nextAutoColors) { + yield color; + } + + // 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 + } + } +} + +export class PrefixColorSelector { + private colorGenerator: Generator; + + constructor(customColors: string[] = []) { + this.colorGenerator = createColorGenerator(customColors); + } + + /** A list of colors that are readable in a terminal. */ + public static get ACCEPTABLE_CONSOLE_COLORS() { + // Colors picked randomly, can be amended if required + return [ + // Prevent duplicates, in case the list becomes significantly large + ...new Set([ + // Text colors + 'cyan', + 'yellow', + 'greenBright', + 'blueBright', + 'magentaBright', + 'white', + 'grey', + 'red', + + // Background colors + 'bgCyan', + 'bgYellow', + 'bgGreenBright', + 'bgBlueBright', + 'bgMagenta', + 'bgWhiteBright', + 'bgGrey', + 'bgRed', + ]), + ]; + } + + /** + * @returns The given custom colors then a set of acceptable console colors indefinitely. + */ + getNextColor(): string { + return this.colorGenerator.next().value; + } +}