From 093f58ba48023c7cc33a767092f8dcd847f5d54c Mon Sep 17 00:00:00 2001 From: Gustavo Henke Date: Sat, 7 May 2022 20:25:30 +1000 Subject: [PATCH 1/5] Add more control to --success flag --- README.md | 15 ++++--- bin/concurrently.ts | 12 +++-- src/completion-listener.spec.ts | 80 ++++++++++++++++++++++++++++++++- src/completion-listener.ts | 35 +++++++++------ 4 files changed, 118 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 15e42a64..1cdaaa86 100644 --- a/README.md +++ b/README.md @@ -148,11 +148,16 @@ General "|" [default: ","] -r, --raw Output only raw output of processes, disables prettifying and concurrently coloring. [boolean] - -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 command + with that name or index; + - "!command-{name}"/"!command-{index}" for all commands + but the one with that name or index. + [default: "all"] --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 index. diff --git a/bin/concurrently.ts b/bin/concurrently.ts index f7dd870c..d2976b7c 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -38,10 +38,14 @@ const args = yargs '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' + + '- "command-{name}"/"command-{index}" for the command with that name or index;\n' + + '- "!command-{name}"/"!command-{index}" for all commands but the one 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..c520e302 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,65 @@ 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(`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()); + }); +}); + +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()); + }); +}); diff --git a/src/completion-listener.ts b/src/completion-listener.ts index 732d0fdd..01380501 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 command with the specified name or index. + * - `!command-{name|index}`: all commands but the one with the specified name 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,24 @@ 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; - - default: - return exitCodes.every(exitCode => exitCode === 0); - /* eslint-enable indent */ + 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. + return events.every(({ exitCode }) => exitCode === 0); } + + const [, nameOrIndex] = this.successCondition.split('-'); + const targetCommandEvent = events.find(({ command, index }) => ( + command.name === nameOrIndex + || index === Number(nameOrIndex) + )); + return this.successCondition.startsWith('!') + ? events.every((event) => event === targetCommandEvent || event.exitCode === 0) + : targetCommandEvent && targetCommandEvent.exitCode === 0; } /** @@ -62,7 +69,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), ), From f8a216e2af863fe5d94e87228d89117c29a0125c Mon Sep 17 00:00:00 2001 From: Gustavo Henke Date: Fri, 13 May 2022 17:53:22 +1000 Subject: [PATCH 2/5] Apply suggestions from code review Co-authored-by: Pascal Jufer --- src/completion-listener.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/completion-listener.ts b/src/completion-listener.ts index 01380501..7f41ccaa 100644 --- a/src/completion-listener.ts +++ b/src/completion-listener.ts @@ -44,17 +44,20 @@ export class CompletionListener { } 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. + // If not a `command-` syntax, then it's an 'all' condition or it's treated as such. return events.every(({ exitCode }) => exitCode === 0); } + // Check `command-` syntax condition const [, nameOrIndex] = this.successCondition.split('-'); const targetCommandEvent = events.find(({ command, index }) => ( command.name === nameOrIndex || index === Number(nameOrIndex) )); return this.successCondition.startsWith('!') + // All commands except the specified one must exit succesfully ? events.every((event) => event === targetCommandEvent || event.exitCode === 0) + // Only the specified command must exit succesfully : targetCommandEvent && targetCommandEvent.exitCode === 0; } From dbca8f653412291367f2881c1d440bd02349d823 Mon Sep 17 00:00:00 2001 From: Gustavo Henke Date: Fri, 13 May 2022 17:59:43 +1000 Subject: [PATCH 3/5] Add tests for inexistent commands --- src/completion-listener.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/completion-listener.spec.ts b/src/completion-listener.spec.ts index c520e302..b31c1063 100644 --- a/src/completion-listener.spec.ts +++ b/src/completion-listener.spec.ts @@ -133,6 +133,15 @@ describe.each([ return expect(result).rejects.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([ @@ -164,4 +173,13 @@ describe.each([ 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()); + }); }); From 2660b38e34b761e69280663da676ac48fa302c15 Mon Sep 17 00:00:00 2001 From: Gustavo Henke Date: Fri, 13 May 2022 21:53:27 +1000 Subject: [PATCH 4/5] Support multiple commands with same name --- bin/concurrently.ts | 5 +++-- src/completion-listener.spec.ts | 26 ++++++++++++++++++++++++++ src/completion-listener.ts | 21 ++++++++++++++------- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/bin/concurrently.ts b/bin/concurrently.ts index 04bba5d6..4b492c8f 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -53,8 +53,9 @@ const args = yargs(argsBeforeSep) '- "first" for the first command to exit;\n' + '- "last" for the last command to exit;\n' + '- "all" for all commands;\n' + - '- "command-{name}"/"command-{index}" for the command with that name or index;\n' + - '- "!command-{name}"/"!command-{index}" for all commands but the one with that ' + + // 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, }, diff --git a/src/completion-listener.spec.ts b/src/completion-listener.spec.ts index b31c1063..aeed8fb5 100644 --- a/src/completion-listener.spec.ts +++ b/src/completion-listener.spec.ts @@ -122,6 +122,19 @@ describe.each([ 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); @@ -134,6 +147,19 @@ describe.each([ 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]]); diff --git a/src/completion-listener.ts b/src/completion-listener.ts index 7f41ccaa..00b5a4a2 100644 --- a/src/completion-listener.ts +++ b/src/completion-listener.ts @@ -48,17 +48,24 @@ export class CompletionListener { return events.every(({ exitCode }) => exitCode === 0); } - // Check `command-` syntax condition + // 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 targetCommandEvent = events.find(({ command, index }) => ( + const targetCommandsEvents = events.filter(({ command, index }) => ( command.name === nameOrIndex || index === Number(nameOrIndex) )); - return this.successCondition.startsWith('!') - // All commands except the specified one must exit succesfully - ? events.every((event) => event === targetCommandEvent || event.exitCode === 0) - // Only the specified command must exit succesfully - : targetCommandEvent && targetCommandEvent.exitCode === 0; + 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); } /** From 7e0c8554d68c26cea590fe6ed78d2ce68b7f9c18 Mon Sep 17 00:00:00 2001 From: Gustavo Henke Date: Sun, 15 May 2022 11:03:33 +1000 Subject: [PATCH 5/5] Use plural everywhere --- README.md | 6 +++--- src/completion-listener.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4644c8f5..e9ab67fe 100644 --- a/README.md +++ b/README.md @@ -153,13 +153,13 @@ General - "last" for the last command to exit; - "all" for all commands; - "command-{name}"/"command-{index}" for the - command with that name or index; + commands with that name or index; - "!command-{name}"/"!command-{index}" for all - commands but the one with that name or index. + 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/src/completion-listener.ts b/src/completion-listener.ts index 00b5a4a2..60a10faf 100644 --- a/src/completion-listener.ts +++ b/src/completion-listener.ts @@ -8,8 +8,8 @@ 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 command with the specified name or index. - * - `!command-{name|index}`: all commands but the one with the specified name or index. + * - `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' | `command-${string|number}` | `!command-${string|number}`;