diff --git a/README.md b/README.md index bc7249a4..e9ab67fe 100644 --- a/README.md +++ b/README.md @@ -146,14 +146,20 @@ General --name-separator The character to split on. Example usage: concurrently -n "styles|scripts|server" --name-separator "|" [default: ","] - -s, --success Return exit code of zero or one based on the - success or failure of the "first" child to - terminate, the "last child", or succeed only if - "all" child processes succeed. - [choices: "first", "last", "all"] [default: "all"] + -s, --success Which command(s) must exit with code 0 in order + for concurrently exit with code 0 too. Options + are: + - "first" for the first command to exit; + - "last" for the last command to exit; + - "all" for all commands; + - "command-{name}"/"command-{index}" for the + commands with that name or index; + - "!command-{name}"/"!command-{index}" for all + commands but the ones with that name or index. + [default: "all"] -r, --raw Output only raw output of processes, disables prettifying and concurrently coloring. [boolean] - --no-color Disables colors from logging. [boolean] + --no-color Disables colors from logging [boolean] --hide Comma-separated list of processes to hide the output. The processes can be identified by their name or diff --git a/bin/concurrently.ts b/bin/concurrently.ts index 29c86b3b..4b492c8f 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -48,10 +48,15 @@ const args = yargs(argsBeforeSep) 'success': { alias: 's', describe: - 'Return exit code of zero or one based on the success or failure ' + - 'of the "first" child to terminate, the "last child", or succeed ' + - 'only if "all" child processes succeed.', - choices: ['first', 'last', 'all'] as const, + 'Which command(s) must exit with code 0 in order for concurrently exit with ' + + 'code 0 too. Options are:\n' + + '- "first" for the first command to exit;\n' + + '- "last" for the last command to exit;\n' + + '- "all" for all commands;\n' + + // Note: not a typo. Multiple commands can have the same name. + '- "command-{name}"/"command-{index}" for the commands with that name or index;\n' + + '- "!command-{name}"/"!command-{index}" for all commands but the ones with that ' + + 'name or index.\n', default: defaults.success, }, 'raw': { diff --git a/src/completion-listener.spec.ts b/src/completion-listener.spec.ts index bba17f5a..aeed8fb5 100644 --- a/src/completion-listener.spec.ts +++ b/src/completion-listener.spec.ts @@ -1,11 +1,16 @@ import { TestScheduler } from 'rxjs/testing'; +import { CloseEvent } from './command'; import { CompletionListener, SuccessCondition } from './completion-listener'; import { createFakeCloseEvent, FakeCommand } from './fixtures/fake-command'; let commands: FakeCommand[]; let scheduler: TestScheduler; beforeEach(() => { - commands = [new FakeCommand('foo'), new FakeCommand('bar')]; + commands = [ + new FakeCommand('foo', 'echo', 0), + new FakeCommand('bar', 'echo', 1), + new FakeCommand('baz', 'echo', 2), + ]; scheduler = new TestScheduler(() => true); }); @@ -15,12 +20,18 @@ const createController = (successCondition?: SuccessCondition) => scheduler, }); +const emitFakeCloseEvent = ( + command: FakeCommand, + event?: Partial, +) => command.close.next(createFakeCloseEvent({ ...event, command, index: command.index })); + describe('with default success condition set', () => { it('succeeds if all processes exited with code 0', () => { const result = createController().listen(commands); commands[0].close.next(createFakeCloseEvent({ exitCode: 0 })); commands[1].close.next(createFakeCloseEvent({ exitCode: 0 })); + commands[2].close.next(createFakeCloseEvent({ exitCode: 0 })); scheduler.flush(); @@ -32,6 +43,7 @@ describe('with default success condition set', () => { commands[0].close.next(createFakeCloseEvent({ exitCode: 0 })); commands[1].close.next(createFakeCloseEvent({ exitCode: 1 })); + commands[2].close.next(createFakeCloseEvent({ exitCode: 0 })); scheduler.flush(); @@ -45,6 +57,7 @@ describe('with success condition set to first', () => { commands[1].close.next(createFakeCloseEvent({ exitCode: 0 })); commands[0].close.next(createFakeCloseEvent({ exitCode: 1 })); + commands[2].close.next(createFakeCloseEvent({ exitCode: 1 })); scheduler.flush(); @@ -56,6 +69,7 @@ describe('with success condition set to first', () => { commands[1].close.next(createFakeCloseEvent({ exitCode: 1 })); commands[0].close.next(createFakeCloseEvent({ exitCode: 0 })); + commands[2].close.next(createFakeCloseEvent({ exitCode: 0 })); scheduler.flush(); @@ -69,6 +83,7 @@ describe('with success condition set to last', () => { commands[1].close.next(createFakeCloseEvent({ exitCode: 1 })); commands[0].close.next(createFakeCloseEvent({ exitCode: 0 })); + commands[2].close.next(createFakeCloseEvent({ exitCode: 0 })); scheduler.flush(); @@ -80,6 +95,7 @@ describe('with success condition set to last', () => { commands[1].close.next(createFakeCloseEvent({ exitCode: 0 })); commands[0].close.next(createFakeCloseEvent({ exitCode: 1 })); + commands[2].close.next(createFakeCloseEvent({ exitCode: 1 })); scheduler.flush(); @@ -87,3 +103,109 @@ describe('with success condition set to last', () => { }); }); + +describe.each([ + // Use the middle command for both cases to make it more difficult to make a mess up + // in the implementation cause false passes. + ['command-bar' as const, 'bar'], + ['command-1' as const, 1], +])('with success condition set to %s', (condition, nameOrIndex) => { + it(`succeeds if command ${nameOrIndex} exits with code 0`, () => { + const result = createController(condition).listen(commands); + + emitFakeCloseEvent(commands[0], { exitCode: 1 }); + emitFakeCloseEvent(commands[1], { exitCode: 0 }); + emitFakeCloseEvent(commands[2], { exitCode: 1 }); + + scheduler.flush(); + + return expect(result).resolves.toEqual(expect.anything()); + }); + + it(`succeeds if all commands ${nameOrIndex} exit with code 0`, () => { + commands = [commands[0], commands[1], commands[1]]; + const result = createController(condition).listen(commands); + + emitFakeCloseEvent(commands[0], { exitCode: 1 }); + emitFakeCloseEvent(commands[1], { exitCode: 0 }); + emitFakeCloseEvent(commands[2], { exitCode: 0 }); + + scheduler.flush(); + + return expect(result).resolves.toEqual(expect.anything()); + }); + + it(`fails if command ${nameOrIndex} exits with non-0 code`, () => { + const result = createController(condition).listen(commands); + + emitFakeCloseEvent(commands[0], { exitCode: 0 }); + emitFakeCloseEvent(commands[1], { exitCode: 1 }); + emitFakeCloseEvent(commands[2], { exitCode: 0 }); + + scheduler.flush(); + + return expect(result).rejects.toEqual(expect.anything()); + }); + + it(`fails if some commands ${nameOrIndex} exit with non-0 code`, () => { + commands = [commands[0], commands[1], commands[1]]; + const result = createController(condition).listen(commands); + + emitFakeCloseEvent(commands[0], { exitCode: 1 }); + emitFakeCloseEvent(commands[1], { exitCode: 0 }); + emitFakeCloseEvent(commands[2], { exitCode: 1 }); + + scheduler.flush(); + + return expect(result).resolves.toEqual(expect.anything()); + }); + + it(`fails if command ${nameOrIndex} doesn't exist`, () => { + const result = createController(condition).listen([commands[0]]); + + emitFakeCloseEvent(commands[0], { exitCode: 0 }); + scheduler.flush(); + + return expect(result).rejects.toEqual(expect.anything()); + }); +}); + +describe.each([ + // Use the middle command for both cases to make it more difficult to make a mess up + // in the implementation cause false passes. + ['!command-bar' as const, 'bar'], + ['!command-1' as const, 1], +])('with success condition set to %s', (condition, nameOrIndex) => { + it(`succeeds if all commands but ${nameOrIndex} exit with code 0`, () => { + const result = createController(condition).listen(commands); + + emitFakeCloseEvent(commands[0], { exitCode: 0 }); + emitFakeCloseEvent(commands[1], { exitCode: 1 }); + emitFakeCloseEvent(commands[2], { exitCode: 0 }); + + scheduler.flush(); + + return expect(result).resolves.toEqual(expect.anything()); + }); + + it(`fails if any commands but ${nameOrIndex} exit with non-0 code`, () => { + const result = createController(condition).listen(commands); + + emitFakeCloseEvent(commands[0], { exitCode: 1 }); + emitFakeCloseEvent(commands[1], { exitCode: 1 }); + emitFakeCloseEvent(commands[2], { exitCode: 0 }); + + scheduler.flush(); + + return expect(result).rejects.toEqual(expect.anything()); + }); + + it(`succeeds if command ${nameOrIndex} doesn't exist`, () => { + const result = createController(condition).listen([commands[0]]); + + emitFakeCloseEvent(commands[0], { exitCode: 0 }); + scheduler.flush(); + + return expect(result).resolves.toEqual(expect.anything()); + }); +}); diff --git a/src/completion-listener.ts b/src/completion-listener.ts index 732d0fdd..60a10faf 100644 --- a/src/completion-listener.ts +++ b/src/completion-listener.ts @@ -8,8 +8,10 @@ import { CloseEvent, Command } from './command'; * - `first`: only the first specified command; * - `last`: only the last specified command; * - `all`: all commands. + * - `command-{name|index}`: only the commands with the specified names or index. + * - `!command-{name|index}`: all commands but the ones with the specified names or index. */ -export type SuccessCondition = 'first' | 'last' | 'all'; +export type SuccessCondition = 'first' | 'last' | 'all' | `command-${string|number}` | `!command-${string|number}`; /** * Provides logic to determine whether lists of commands ran successfully. @@ -36,19 +38,34 @@ export class CompletionListener { this.scheduler = scheduler; } - private isSuccess(exitCodes: (string | number)[]) { - switch (this.successCondition) { - /* eslint-disable indent */ - case 'first': - return exitCodes[0] === 0; - - case 'last': - return exitCodes[exitCodes.length - 1] === 0; + private isSuccess(events: CloseEvent[]) { + if (this.successCondition === 'first') { + return events[0].exitCode === 0; + } else if (this.successCondition === 'last') { + return events[events.length - 1].exitCode === 0; + } else if (!/^!?command-.+$/.test(this.successCondition)) { + // If not a `command-` syntax, then it's an 'all' condition or it's treated as such. + return events.every(({ exitCode }) => exitCode === 0); + } - default: - return exitCodes.every(exitCode => exitCode === 0); - /* eslint-enable indent */ + // Check `command-` syntax condition. + // Note that a command's `name` is not necessarily unique, + // in which case all of them must meet the success condition. + const [, nameOrIndex] = this.successCondition.split('-'); + const targetCommandsEvents = events.filter(({ command, index }) => ( + command.name === nameOrIndex + || index === Number(nameOrIndex) + )); + if (this.successCondition.startsWith('!')) { + // All commands except the specified ones must exit succesfully + return events.every((event) => ( + targetCommandsEvents.includes(event) + || event.exitCode === 0 + )); } + // Only the specified commands must exit succesfully + return targetCommandsEvents.length > 0 + && targetCommandsEvents.every(event => event.exitCode === 0); } /** @@ -62,7 +79,7 @@ export class CompletionListener { .pipe( bufferCount(closeStreams.length), switchMap(exitInfos => - this.isSuccess(exitInfos.map(({ exitCode }) => exitCode)) + this.isSuccess(exitInfos) ? Rx.of(exitInfos, this.scheduler) : Rx.throwError(exitInfos, this.scheduler), ),