Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add command-{name|index} and !command-{name|index} condition to --success #318

Merged
merged 7 commits into from May 15, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 11 additions & 5 deletions README.md
Expand Up @@ -146,11 +146,17 @@ General
--name-separator The character to split <names> 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
command with that name or index;
- "!command-{name}"/"!command-{index}" for all
commands but the one 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]
Expand Down
12 changes: 8 additions & 4 deletions bin/concurrently.ts
Expand Up @@ -48,10 +48,14 @@ 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' +
'- "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': {
Expand Down
80 changes: 79 additions & 1 deletion 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);
});

Expand All @@ -15,12 +20,18 @@ const createController = (successCondition?: SuccessCondition) =>
scheduler,
});

const emitFakeCloseEvent = (
command: FakeCommand,
event?: Partial<CloseEvent>,
) => 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();

Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -80,10 +95,73 @@ 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();

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 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());
});
});
35 changes: 21 additions & 14 deletions src/completion-listener.ts
Expand Up @@ -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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* - `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 name or index.
* - `!command-{name|index}`: all commands but the ones 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.
Expand All @@ -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.
gustavohenke marked this conversation as resolved.
Show resolved Hide resolved
return events.every(({ exitCode }) => exitCode === 0);
}

gustavohenke marked this conversation as resolved.
Show resolved Hide resolved
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)
gustavohenke marked this conversation as resolved.
Show resolved Hide resolved
: targetCommandEvent && targetCommandEvent.exitCode === 0;
gustavohenke marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand All @@ -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),
),
Expand Down