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 all 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
18 changes: 12 additions & 6 deletions README.md
Expand Up @@ -146,14 +146,20 @@ 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
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
Expand Down
13 changes: 9 additions & 4 deletions bin/concurrently.ts
Expand Up @@ -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': {
Expand Down
124 changes: 123 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,117 @@ 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(`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());
});
});
43 changes: 30 additions & 13 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 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.
Expand All @@ -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);
}

gustavohenke marked this conversation as resolved.
Show resolved Hide resolved
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);
}

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