Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: open-cli-tools/concurrently
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: becac73c7e768db2e4457f08f9409ec8cd0bd318
Choose a base ref
...
head repository: open-cli-tools/concurrently
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 041e0908cadd00c478ab3b8a6cd3d7af9da198bb
Choose a head ref

Commits on May 27, 2021

  1. Correctly reexport flow controllers

    Fixes #278
    gustavohenke authored May 27, 2021

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    10ff00c View commit details

Commits on Jul 28, 2021

  1. Add GH actions workflow

    aivenkimmob committed Jul 28, 2021

    Unverified

    The committer email address is not verified.
    Copy the full SHA
    fe5c01c View commit details

Commits on Jul 29, 2021

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    f574848 View commit details

Commits on Aug 3, 2021

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    07a7de1 View commit details

Commits on Aug 8, 2021

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    c295062 View commit details
  2. 6.2.1

    gustavohenke committed Aug 8, 2021
    Copy the full SHA
    0bc490f View commit details

Commits on Sep 25, 2021

  1. Copy the full SHA
    b16585f View commit details
  2. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    24e51ad View commit details
  3. Copy the full SHA
    8bcdf7f View commit details

Commits on Sep 27, 2021

  1. Remove read-pkg

    Fixes #274
    gustavohenke committed Sep 27, 2021
    Copy the full SHA
    7263ffe View commit details
  2. Fix linting

    gustavohenke committed Sep 27, 2021
    Copy the full SHA
    875d375 View commit details
  3. 6.2.2

    gustavohenke committed Sep 27, 2021
    Copy the full SHA
    105445c View commit details

Commits on Oct 2, 2021

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    ed8d792 View commit details
  2. 6.3.0

    gustavohenke committed Oct 2, 2021
    Copy the full SHA
    08eda4f View commit details
  3. Add option and CLI flag to hide command output (#173)

    Oliver Vartiainen authored Oct 2, 2021

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    88b8d19 View commit details
  4. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    475fb53 View commit details

Commits on Oct 3, 2021

  1. Simplify .gitignore

    gustavohenke authored Oct 3, 2021

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    66ed4bf View commit details

Commits on Nov 13, 2021

  1. 6.4.0

    gustavohenke committed Nov 13, 2021
    Copy the full SHA
    0da5d93 View commit details

Commits on Nov 18, 2021

  1. Copy the full SHA
    59de6e4 View commit details

Commits on Dec 5, 2021

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    c04740a View commit details

Commits on Dec 17, 2021

  1. Add support for options in environment variables (#289)

    Co-authored-by: Gustavo Henke <guhenke@gmail.com>
    aidansteele and gustavohenke authored Dec 17, 2021

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    7578774 View commit details
  2. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    f8119bf View commit details
  3. npm audit fix

    gustavohenke committed Dec 17, 2021
    Copy the full SHA
    ce799d6 View commit details
  4. 6.5.0

    gustavohenke committed Dec 17, 2021
    Copy the full SHA
    ecc5fa0 View commit details

Commits on Dec 19, 2021

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    dd54b9f View commit details
  2. 6.5.1

    gustavohenke committed Dec 19, 2021
    Copy the full SHA
    041e090 View commit details
Showing with 1,193 additions and 297 deletions.
  1. +2 −12 .editorconfig
  2. +6 −2 .eslintrc.json
  3. +56 −0 .github/workflows/test.yml
  4. +0 −20 .gitignore
  5. +0 −10 .travis.yml
  6. +2 −4 CONTRIBUTING.md
  7. +37 −21 README.md
  8. +0 −18 appveyor.yml
  9. +24 −14 bin/concurrently.js
  10. +67 −4 bin/concurrently.spec.js
  11. +7 −3 bin/epilogue.txt
  12. +13 −3 index.js
  13. +153 −77 package-lock.json
  14. +2 −3 package.json
  15. +25 −7 src/command-parser/expand-npm-wildcard.js
  16. +48 −2 src/command-parser/expand-npm-wildcard.spec.js
  17. +14 −0 src/command.js
  18. +93 −1 src/command.spec.js
  19. +1 −1 src/completion-listener.js
  20. +1 −0 src/completion-listener.spec.js
  21. +42 −19 src/concurrently.js
  22. +34 −2 src/concurrently.spec.js
  23. +5 −1 src/defaults.js
  24. +16 −0 src/flow-control/base-handler.js
  25. +22 −0 src/flow-control/base-handler.spec.js
  26. +1 −0 src/flow-control/fixtures/fake-command.js
  27. +13 −0 src/flow-control/fixtures/fake-handler.js
  28. +16 −5 src/flow-control/input-handler.js
  29. +31 −9 src/flow-control/input-handler.spec.js
  30. +17 −12 src/flow-control/kill-on-signal.js
  31. +3 −3 src/flow-control/kill-on-signal.spec.js
  32. +7 −4 src/flow-control/kill-others.js
  33. +2 −2 src/flow-control/kill-others.spec.js
  34. +3 −5 src/flow-control/log-error.js
  35. +1 −1 src/flow-control/log-error.spec.js
  36. +3 −5 src/flow-control/log-exit.js
  37. +1 −1 src/flow-control/log-exit.spec.js
  38. +3 −5 src/flow-control/log-output.js
  39. +1 −1 src/flow-control/log-output.spec.js
  40. +64 −0 src/flow-control/log-timings.js
  41. +137 −0 src/flow-control/log-timings.spec.js
  42. +18 −14 src/flow-control/restart-process.js
  43. +13 −5 src/flow-control/restart-process.spec.js
  44. +66 −1 src/logger.js
  45. +123 −0 src/logger.spec.js
14 changes: 2 additions & 12 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -7,22 +7,12 @@ root = true
[*]
end_of_line = lf
insert_final_newline = true

# 4 space indentation
[*.py]
indent_style = space
indent_size = 4

# Tab indentation (no size specified)
[*.js]
indent_style = space
indent_size = 4

# Matches the exact files package.json and .travis.yml
[{package.json,.travis.yml,Gruntfile.js}]
indent_style = space
# Matches the exact files package.json
[package.json]
indent_size = 2

[Makefile]
indent_style = tab
indent_size = 4
8 changes: 6 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@
"node": true
},
"parserOptions": {
"ecmaVersion": 6
"ecmaVersion": 8
},
"rules": {
"block-spacing": "error",
@@ -18,6 +18,10 @@
"quotes": ["error", "single"],
"semi": "error",
"space-before-blocks": "error",
"space-before-function-paren": ["error", "never"]
"space-before-function-paren": ["error", {
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}]
}
}
56 changes: 56 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Tests
on: [push, pull_request]
env:
CI: true

jobs:
run:
name: Node ${{ matrix.node }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}

strategy:
fail-fast: false
matrix:
node: [12, 14, 15]
os: [ubuntu-latest, windows-latest, macOS-latest]

steps:
- name: Clone repository
uses: actions/checkout@v1

- name: Set Node.js version
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}

- run: node --version
- run: npm --version

- name: Install npm dependencies
run: npm ci

- name: Run tests
run: npm test
shell: bash
env:
SHELL: "/bin/bash"

- name: Lint
run: npm run lint

- name: Submit coverage
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.github_token }}
flag-name: Node.js ${{ matrix.node }}
parallel: true
teardown:
needs: run
name: Submitting coverage
runs-on: ubuntu-latest
steps:
- name: Coveralls Finished
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.github_token }}
parallel-finished: true
20 changes: 0 additions & 20 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,31 +1,11 @@
# Logs
logs
*.log

# Runtime data
pids
*.pid
*.seed

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release

# Dependency directory
# Deployed apps should consider commenting this line out:
# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
node_modules

# OS X
.DS_Store

# Vagrant directory
.vagrant
10 changes: 0 additions & 10 deletions .travis.yml

This file was deleted.

6 changes: 2 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -13,11 +13,9 @@ updated.
Tests can be run with command:

```bash
npm run test
npm test
```

## Release

* Commit all changes
* Run `./node_modules/.bin/releasor --bump minor`, which will create new tag and publish code to GitHub and npm. See https://github.com/kimmobrunfeldt/releasor for options
* Edit GitHub release notes
Use [np](https://www.npmjs.com/package/np) to create a new release
58 changes: 37 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Concurrently

[![Travis Build Status](https://travis-ci.org/kimmobrunfeldt/concurrently.svg)](https://travis-ci.org/kimmobrunfeldt/concurrently) [![AppVeyor Build Status](https://ci.appveyor.com/api/projects/status/github/kimmobrunfeldt/concurrently?branch=master&svg=true)](https://ci.appveyor.com/project/kimmobrunfeldt/concurrently) *master branch status*
[![Build Status](https://github.com/open-cli-tools/concurrently/workflows/Tests/badge.svg)](https://github.com/open-cli-tools/concurrently/actions?workflow=Tests)
[![Coverage Status](https://coveralls.io/repos/github/open-cli-tools/concurrently/badge.svg?branch=master)](https://coveralls.io/github/open-cli-tools/concurrently?branch=master)

[![NPM Badge](https://nodei.co/npm/concurrently.png?downloads=true)](https://www.npmjs.com/package/concurrently)

@@ -20,7 +21,7 @@ Like `npm run watch-js & npm run watch-less` but better.

## Why

I like [task automation with npm](http://substack.net/task_automation_with_npm_run)
I like [task automation with npm](https://github.com/substack/blog/blob/master/npm_run.markdown)
but the usual way to run multiple commands concurrently is
`npm run watch-js & npm run watch-css`. That's fine but it's hard to keep
on track of different outputs. Also if one process fails, others still keep running
@@ -115,21 +116,25 @@ Help:
concurrently [options] <command ...>
General
-m, --max-processes How many processes should run at once.
New processes only spawn after all restart tries of a
process. [number]
-n, --names List of custom names to be used in prefix template.
Example names: "main,browser,server" [string]
--name-separator The character to split <names> on. Example usage:
concurrently -n "styles|scripts|server" --name-separator
"|" [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.
-m, --max-processes How many processes should run at once.
New processes only spawn after all restart tries of a
process. [number]
-n, --names List of custom names to be used in prefix template.
Example names: "main,browser,server" [string]
--name-separator The character to split <names> on. Example usage:
concurrently -n "styles|scripts|server" --name-separator
"|" [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"]
--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 index.
[string] [default: ""]
Prefix styling
-p, --prefix Prefix used in logging for each process.
@@ -198,6 +203,10 @@ Examples:
$ concurrently --names "HTTP,WATCH" -c "bgBlue.bold,bgMagenta.bold"
"http-server" "npm run watch"
- Configuring via environment variables with CONCURRENTLY_ prefix
$ CONCURRENTLY_RAW=true CONCURRENTLY_KILL_OTHERS=true concurrently "echo hello" "echo world"
- Send input to default
$ concurrently --handle-input "nodemon" "npm run watch-js"
@@ -217,26 +226,30 @@ Examples:
$ concurrently npm:watch-node npm:watch-js npm:watch-css
- Shortened NPM run command with wildcard
- Shortened NPM run command with wildcard (make sure to wrap it in quotes!)
$ concurrently npm:watch-*
$ concurrently "npm:watch-*"
For more details, visit https://github.com/kimmobrunfeldt/concurrently
For more details, visit https://github.com/open-cli-tools/concurrently
```

## Programmatic Usage
concurrently can be used programmatically by using the API documented below:

### `concurrently(commands[, options])`

- `commands`: an array of either strings (containing the commands to run) or objects
with the shape `{ command, name, prefixColor, env, cwd }`.

- `options` (optional): an object containing any of the below:
- `cwd`: the working directory to be used by all commands. Can be overriden per command.
Default: `process.cwd()`.
- `defaultInputTarget`: the default input target when reading from `inputStream`.
Default: `0`.
- `handleInput`: when `true`, reads input from `process.stdin`.
- `inputStream`: a [`Readable` stream](https://nodejs.org/dist/latest-v10.x/docs/api/stream.html#stream_readable_streams)
to read the input from, eg `process.stdin`.
to read the input from. Should only be used in the rare instance you would like to stream anything other than `process.stdin`. Overrides `handleInput`.
- `pauseInputStreamOnFinish`: by default, pauses the input stream (`process.stdin` when `handleInput` is enabled, or `inputStream` if provided) when all of the processes have finished. If you need to read from the input stream after `concurrently` has finished, set this to `false`. ([#252](https://github.com/kimmobrunfeldt/concurrently/issues/252)).
- `killOthers`: an array of exitting conditions that will cause a process to kill others.
Can contain any of `success` or `failure`.
- `maxProcesses`: how many processes should run at once.
@@ -245,6 +258,9 @@ concurrently can be used programmatically by using the API documented below:
- `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.
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.
@@ -259,7 +275,7 @@ concurrently can be used programmatically by using the API documented below:
> Returns: a `Promise` that resolves if the run was successful (according to `successCondition` option),
> or rejects, containing an array of objects with information for each command that has been run, in the order
> that the commands terminated. The objects have the shape `{ command, index, exitCode, killed }`, where `command` is the object
> passed in the `commands` array, `index` its index there and `killed` indicates if the process was killed as a result of
> passed in the `commands` array, `index` its index there and `killed` indicates if the process was killed as a result of
> `killOthers`. Default values (empty strings or objects) are returned for the fields that were not specified.
Example:
18 changes: 0 additions & 18 deletions appveyor.yml

This file was deleted.

38 changes: 24 additions & 14 deletions bin/concurrently.js
Original file line number Diff line number Diff line change
@@ -11,6 +11,8 @@ const args = yargs
.version('v', require('../package.json').version)
.alias('v', 'V')
.alias('v', 'version')
// TODO: Add some tests for this.
.env('CONCURRENTLY')
.options({
// General
'm': {
@@ -55,6 +57,18 @@ const args = yargs
describe: 'Disables colors from logging',
type: 'boolean'
},
'hide': {
describe:
'Comma-separated list of processes to hide the output.\n' +
'The processes can be identified by their name or index.',
default: defaults.hide,
type: 'string'
},
'timings': {
describe: 'Show timing information for all processes',
type: 'boolean',
default: defaults.timings
},

// Kill others
'k': {
@@ -135,7 +149,7 @@ const args = yargs
'Can be either the index or the name of the process.'
}
})
.group(['m', 'n', 'name-separator', 'raw', 's', 'no-color'], 'General')
.group(['m', 'n', 'name-separator', 'raw', 's', 'no-color', 'hide', 'timings'], 'General')
.group(['p', 'c', 'l', 't'], 'Prefix styling')
.group(['i', 'default-input-target'], 'Input handling')
.group(['k', 'kill-others-on-fail'], 'Killing other processes')
@@ -144,32 +158,28 @@ const args = yargs
.epilogue(fs.readFileSync(__dirname + '/epilogue.txt', { encoding: 'utf8' }))
.argv;

const prefixColors = args.prefixColors.split(',');
const names = (args.names || '').split(args.nameSeparator);

let lastColor;
concurrently(args._.map((command, index) => {
// Use documented behaviour of repeating last colour when specifying more commands than colours
lastColor = prefixColors[index] || lastColor;
return {
command,
prefixColor: lastColor,
name: names[index]
};
}), {
inputStream: args.handleInput && process.stdin,
concurrently(args._.map((command, index) => ({
command,
name: names[index]
})), {
handleInput: args.handleInput,
defaultInputTarget: args.defaultInputTarget,
killOthers: args.killOthers
? ['success', 'failure']
: (args.killOthersOnFail ? ['failure'] : []),
maxProcesses: args.maxProcesses,
raw: args.raw,
hide: args.hide.split(','),
prefix: args.prefix,
prefixColors: args.prefixColors.split(','),
prefixLength: args.prefixLength,
restartDelay: args.restartAfter,
restartTries: args.restartTries,
successCondition: args.success,
timestampFormat: args.timestampFormat
timestampFormat: args.timestampFormat,
timings: args.timings
}).then(
() => process.exit(0),
() => process.exit(1)
71 changes: 67 additions & 4 deletions bin/concurrently.spec.js
Original file line number Diff line number Diff line change
@@ -163,6 +163,26 @@ describe('--raw', () => {
});
});

describe('--hide', () => {
it('hides the output of a process by its index', done => {
const child = run('--hide 1 "echo foo" "echo bar"');
child.log.pipe(buffer(child.close)).subscribe(lines => {
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).not.toContainEqual(expect.stringContaining('bar'));
done();
}, done);
});

it('hides the output of a process by its name', done => {
const child = run('-n foo,bar --hide bar "echo foo" "echo bar"');
child.log.pipe(buffer(child.close)).subscribe(lines => {
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).not.toContainEqual(expect.stringContaining('bar'));
done();
}, done);
});
});

describe('--names', () => {
it('is aliased to -n', done => {
const child = run('-n foo,bar "echo foo" "echo bar"');
@@ -193,7 +213,7 @@ describe('--names', () => {
});

describe('--prefix', () => {
it('is alised to -p', done => {
it('is aliased to -p', done => {
const child = run('-p command "echo foo" "echo bar"');
child.log.pipe(buffer(child.close)).subscribe(lines => {
expect(lines).toContainEqual(expect.stringContaining('[echo foo] foo'));
@@ -213,7 +233,7 @@ describe('--prefix', () => {
});

describe('--prefix-length', () => {
it('is alised to -l', done => {
it('is aliased to -l', done => {
const child = run('-p command -l 5 "echo foo" "echo bar"');
child.log.pipe(buffer(child.close)).subscribe(lines => {
expect(lines).toContainEqual(expect.stringContaining('[ec..o] foo'));
@@ -247,7 +267,7 @@ describe('--restart-tries', () => {
});

describe('--kill-others', () => {
it('is alised to -k', done => {
it('is aliased to -k', done => {
const child = run('-k "sleep 10" "exit 0"');
child.log.pipe(buffer(child.close)).subscribe(lines => {
expect(lines).toContainEqual(expect.stringContaining('[1] exit 0 exited with code 0'));
@@ -326,7 +346,6 @@ describe('--handle-input', () => {
}, done);
});


it('forwards input to process --default-input-target', done => {
const lines = [];
const child = run('-ki --default-input-target 1 "node fixtures/read-echo.js" "node fixtures/read-echo.js"');
@@ -363,3 +382,47 @@ describe('--handle-input', () => {
}, done);
});
});

describe('--timings', () => {
const defaultTimestampFormatRegex = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}/;
const processStartedMessageRegex = (index, command) => {
return new RegExp( `^\\[${ index }] ${ command } started at ${ defaultTimestampFormatRegex.source }$` );
};
const processStoppedMessageRegex = (index, command) => {
return new RegExp( `^\\[${ index }] ${ command } stopped at ${ defaultTimestampFormatRegex.source } after (\\d|,)+ms$` );
};
const expectLinesForProcessStartAndStop = (lines, index, command) => {
const escapedCommand = _.escapeRegExp(command);
expect(lines).toContainEqual(expect.stringMatching(processStartedMessageRegex(index, escapedCommand)));
expect(lines).toContainEqual(expect.stringMatching(processStoppedMessageRegex(index, escapedCommand)));
};

const expectLinesForTimingsTable = (lines) => {
const tableTopBorderRegex = /[]+/g;
expect(lines).toContainEqual(expect.stringMatching(tableTopBorderRegex));
const tableHeaderRowRegex = /(\W+(name|duration|exit code|killed|command)\W+){5}/g;
expect(lines).toContainEqual(expect.stringMatching(tableHeaderRowRegex));
const tableBottomBorderRegex = /[]+/g;
expect(lines).toContainEqual(expect.stringMatching(tableBottomBorderRegex));
};

it('shows timings on success', done => {
const child = run('--timings "sleep 0.5" "exit 0"');
child.log.pipe(buffer(child.close)).subscribe(lines => {
expectLinesForProcessStartAndStop(lines, 0, 'sleep 0.5');
expectLinesForProcessStartAndStop(lines, 1, 'exit 0');
expectLinesForTimingsTable(lines);
done();
}, done);
});

it('shows timings on failure', done => {
const child = run('--timings "sleep 0.75" "exit 1"');
child.log.pipe(buffer(child.close)).subscribe(lines => {
expectLinesForProcessStartAndStop(lines, 0, 'sleep 0.75');
expectLinesForProcessStartAndStop(lines, 1, 'exit 1');
expectLinesForTimingsTable(lines);
done();
}, done);
});
});
10 changes: 7 additions & 3 deletions bin/epilogue.txt
Original file line number Diff line number Diff line change
@@ -16,6 +16,10 @@ Examples:

$ $0 --names "HTTP,WATCH" -c "bgBlue.bold,bgMagenta.bold" "http-server" "npm run watch"

- Configuring via environment variables with CONCURRENTLY_ prefix

$ CONCURRENTLY_RAW=true CONCURRENTLY_KILL_OTHERS=true $0 "echo hello" "echo world"

- Send input to default

$ $0 --handle-input "nodemon" "npm run watch-js"
@@ -35,8 +39,8 @@ Examples:

$ $0 npm:watch-node npm:watch-js npm:watch-css

- Shortened NPM run command with wildcard
- Shortened NPM run command with wildcard (make sure to wrap it in quotes!)

$ $0 npm:watch-*
$ $0 "npm:watch-*"

For more details, visit https://github.com/kimmobrunfeldt/concurrently
For more details, visit https://github.com/open-cli-tools/concurrently
16 changes: 13 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
@@ -8,9 +8,11 @@ const RestartProcess = require('./src/flow-control/restart-process');

const concurrently = require('./src/concurrently');
const Logger = require('./src/logger');
const LogTimings = require( './src/flow-control/log-timings' );

module.exports = (commands, options = {}) => {
module.exports = exports = (commands, options = {}) => {
const logger = new Logger({
hide: options.hide,
outputStream: options.outputStream || process.stdout,
prefixFormat: options.prefix,
prefixLength: options.prefixLength,
@@ -30,7 +32,8 @@ module.exports = (commands, options = {}) => {
new InputHandler({
logger,
defaultInputTarget: options.defaultInputTarget,
inputStream: options.inputStream,
inputStream: options.inputStream || (options.handleInput && process.stdin),
pauseInputStreamOnFinish: options.pauseInputStreamOnFinish,
}),
new KillOnSignal({ process }),
new RestartProcess({
@@ -41,8 +44,14 @@ module.exports = (commands, options = {}) => {
new KillOthers({
logger,
conditions: options.killOthers
}),
new LogTimings({
logger: options.timings ? logger: null,
timestampFormat: options.timestampFormat,
})
]
],
prefixColors: options.prefixColors || [],
timings: options.timings
});
};

@@ -57,3 +66,4 @@ exports.LogError = LogError;
exports.LogExit = LogExit;
exports.LogOutput = LogOutput;
exports.RestartProcess = RestartProcess;
exports.LogTimings = LogTimings;
230 changes: 153 additions & 77 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "concurrently",
"version": "6.2.0",
"version": "6.5.1",
"description": "Run commands concurrently",
"main": "index.js",
"bin": {
@@ -16,7 +16,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/kimmobrunfeldt/concurrently.git"
"url": "https://github.com/open-cli-tools/concurrently.git"
},
"keywords": [
"bash",
@@ -32,7 +32,6 @@
"chalk": "^4.1.0",
"date-fns": "^2.16.1",
"lodash": "^4.17.21",
"read-pkg": "^5.2.0",
"rxjs": "^6.6.3",
"spawn-command": "^0.0.2-1",
"supports-color": "^8.1.0",
32 changes: 25 additions & 7 deletions src/command-parser/expand-npm-wildcard.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
const _ = require('lodash');
const readPkg = require('read-pkg');
const fs = require('fs');

module.exports = class ExpandNpmWildcard {
constructor(readPackage = readPkg.sync) {
static readPackage() {
try {
const json = fs.readFileSync('package.json', { encoding: 'utf-8' });
return JSON.parse(json);
} catch (e) {
return {};
}
}

constructor(readPackage = ExpandNpmWildcard.readPackage) {
this.readPackage = readPackage;
}

@@ -23,12 +32,21 @@ module.exports = class ExpandNpmWildcard {
const preWildcard = _.escapeRegExp(cmdName.substr(0, wildcardPosition));
const postWildcard = _.escapeRegExp(cmdName.substr(wildcardPosition + 1));
const wildcardRegex = new RegExp(`^${preWildcard}(.*?)${postWildcard}$`);
const currentName = commandInfo.name || '';

return this.scripts
.filter(script => wildcardRegex.test(script))
.map(script => Object.assign({}, commandInfo, {
command: `${npmCmd} run ${script}${args}`,
name: script
}));
.map(script => {
const match = script.match(wildcardRegex);

if (match) {
return Object.assign({}, commandInfo, {
command: `${npmCmd} run ${script}${args}`,
// Will use an empty command name if command has no name and the wildcard match is empty,
// e.g. if `npm:watch-*` matches `npm run watch-`.
name: currentName + match[1],
});
}
})
.filter(Boolean);
}
};
50 changes: 48 additions & 2 deletions src/command-parser/expand-npm-wildcard.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const ExpandNpmWildcard = require('./expand-npm-wildcard');
const fs = require('fs');

let parser, readPkg;

@@ -7,6 +8,34 @@ beforeEach(() => {
parser = new ExpandNpmWildcard(readPkg);
});

describe('ExpandNpmWildcard#readPackage', () => {
it('can read package', () => {
const expectedPackage = {
'name': 'concurrently',
'version': '6.4.0',
};
jest.spyOn(fs, 'readFileSync').mockImplementation((path, options) => {
if (path === 'package.json') {
return JSON.stringify(expectedPackage);
}
return null;
});

const actualReadPackage = ExpandNpmWildcard.readPackage();
expect(actualReadPackage).toEqual(expectedPackage);
});

it('can handle errors reading package', () => {
jest.spyOn(fs, 'readFileSync').mockImplementation(() => {
throw new Error('Error reading package');
});

expect(() => ExpandNpmWildcard.readPackage()).not.toThrow();
expect(ExpandNpmWildcard.readPackage()).toEqual({});
});

});

it('returns same command if not an npm run command', () => {
const commandInfo = {
command: 'npm test'
@@ -42,8 +71,25 @@ for (const npmCmd of ['npm', 'yarn', 'pnpm']) {
});

expect(parser.parse({ command: `${npmCmd} run foo-*-baz qux` })).toEqual([
{ name: 'foo-bar-baz', command: `${npmCmd} run foo-bar-baz qux` },
{ name: 'foo--baz', command: `${npmCmd} run foo--baz qux` },
{ name: 'bar', command: `${npmCmd} run foo-bar-baz qux` },
{ name: '', command: `${npmCmd} run foo--baz qux` },
]);
});

it('uses existing command name as prefix to the wildcard match', () => {
readPkg.mockReturnValue({
scripts: {
'watch-js': '',
'watch-css': '',
}
});

expect(parser.parse({
name: 'w:',
command: `${npmCmd} run watch-*`,
})).toEqual([
{ name: 'w:js', command: `${npmCmd} run watch-js` },
{ name: 'w:css', command: `${npmCmd} run watch-css` },
]);
});

14 changes: 14 additions & 0 deletions src/command.js
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ module.exports = class Command {
this.killed = false;

this.error = new Rx.Subject();
this.timer = new Rx.Subject();
this.close = new Rx.Subject();
this.stdout = new Rx.Subject();
this.stderr = new Rx.Subject();
@@ -26,13 +27,21 @@ module.exports = class Command {
const child = this.spawn(this.command, this.spawnOpts);
this.process = child;
this.pid = child.pid;
const startDate = new Date(Date.now());
const highResStartTime = process.hrtime();
this.timer.next({startDate});

Rx.fromEvent(child, 'error').subscribe(event => {
this.process = undefined;
const endDate = new Date(Date.now());
this.timer.next({startDate, endDate});
this.error.next(event);
});
Rx.fromEvent(child, 'close').subscribe(([exitCode, signal]) => {
this.process = undefined;
const endDate = new Date(Date.now());
this.timer.next({startDate, endDate});
const [durationSeconds, durationNanoSeconds] = process.hrtime(highResStartTime);
this.close.next({
command: {
command: this.command,
@@ -43,6 +52,11 @@ module.exports = class Command {
index: this.index,
exitCode: exitCode === null ? signal : exitCode,
killed: this.killed,
timings: {
startDate,
endDate,
durationSeconds: durationSeconds + (durationNanoSeconds / 1e9),
}
});
});
child.stdout && pipeTo(Rx.fromEvent(child.stdout, 'data'), this.stdout);
94 changes: 93 additions & 1 deletion src/command.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const EventEmitter = require('events');
const process = require('process');
const Command = require('./command');

const createProcess = () => {
@@ -54,6 +55,72 @@ describe('#start()', () => {
process.emit('error', 'foo');
});

it('shares start and close timing events to the timing stream', done => {
const process = createProcess();
const command = new Command({ spawn: () => process });

const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
jest.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());

let callCount = 0;
command.timer.subscribe(({startDate: actualStartDate, endDate: actualEndDate}) => {
switch (callCount) {
case 0:
expect(actualStartDate).toStrictEqual(startDate);
expect(actualEndDate).toBeUndefined();
break;
case 1:
expect(actualStartDate).toStrictEqual(startDate);
expect(actualEndDate).toStrictEqual(endDate);
done();
break;
default:
throw new Error('Unexpected call count');
}
callCount++;
});

command.start();
process.emit('close', 0, null);

});

it('shares start and error timing events to the timing stream', done => {
const process = createProcess();
const command = new Command({ spawn: () => process });

const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
jest.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());

let callCount = 0;
command.timer.subscribe(({startDate: actualStartDate, endDate: actualEndDate}) => {
switch (callCount) {
case 0:
expect(actualStartDate).toStrictEqual(startDate);
expect(actualEndDate).toBeUndefined();
break;
case 1:
expect(actualStartDate).toStrictEqual(startDate);
expect(actualEndDate).toStrictEqual(endDate);
done();
break;
default:
throw new Error('Unexpected call count');
}
callCount++;
});

command.start();
process.emit('error', 0, null);

});

it('shares closes to the close stream with exit code', done => {
const process = createProcess();
const command = new Command({ spawn: () => process });
@@ -83,6 +150,31 @@ describe('#start()', () => {
process.emit('close', null, 'SIGKILL');
});

it('shares closes to the close stream with timing information', done => {
const process1 = createProcess();
const command = new Command({ spawn: () => process1 });

const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
jest.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());

jest.spyOn(process, 'hrtime')
.mockReturnValueOnce([0, 0])
.mockReturnValueOnce([1, 1e8]);

command.close.subscribe(data => {
expect(data.timings.startDate).toStrictEqual(startDate);
expect(data.timings.endDate).toStrictEqual(endDate);
expect(data.timings.durationSeconds).toBe(1.1);
done();
});

command.start();
process1.emit('close', null, 'SIGKILL');
});

it('shares closes to the close stream with command info and index', done => {
const process = createProcess();
const commandInfo = {
@@ -170,7 +262,7 @@ describe('#kill()', () => {

it('marks the command as killed', done => {
command.start();

command.close.subscribe(data => {
expect(data.exitCode).toBe(1);
expect(data.killed).toBe(true);
2 changes: 1 addition & 1 deletion src/completion-listener.js
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ module.exports = class CompletionListener {
? Rx.of(exitInfos, this.scheduler)
: Rx.throwError(exitInfos, this.scheduler)
),
take(1)
take(1),
)
.toPromise();
}
1 change: 1 addition & 0 deletions src/completion-listener.spec.js
Original file line number Diff line number Diff line change
@@ -85,4 +85,5 @@ describe('with success condition set to last', () => {

return expect(result).rejects.toEqual([{ exitCode: 0 }, { exitCode: 1 }]);
});

});
61 changes: 42 additions & 19 deletions src/concurrently.js
Original file line number Diff line number Diff line change
@@ -18,6 +18,7 @@ const defaults = {
raw: false,
controllers: [],
cwd: undefined,
timings: false
};

module.exports = (commands, options) => {
@@ -32,45 +33,67 @@ module.exports = (commands, options) => {
new ExpandNpmWildcard()
];

let lastColor = '';
commands = _(commands)
.map(mapToCommandInfo)
.flatMap(command => parseCommand(command, commandParsers))
.map((command, index) => new Command(
Object.assign({
index,
spawnOpts: getSpawnOpts({
raw: options.raw,
env: command.env,
cwd: command.cwd || options.cwd,
}),
killProcess: options.kill,
spawn: options.spawn,
}, command)
))
.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(
Object.assign({
index,
spawnOpts: getSpawnOpts({
raw: options.raw,
env: command.env,
cwd: command.cwd || options.cwd,
}),
prefixColor: lastColor,
killProcess: options.kill,
spawn: options.spawn,
timings: options.timings,
}, command)
);
})
.value();

commands = options.controllers.reduce(
(prevCommands, controller) => controller.handle(prevCommands),
commands
const handleResult = options.controllers.reduce(
({ commands: prevCommands, onFinishCallbacks }, controller) => {
const { commands, onFinish } = controller.handle(prevCommands);
return {
commands,
onFinishCallbacks: _.concat(onFinishCallbacks, onFinish ? [onFinish] : [])
};
},
{ commands, onFinishCallbacks: [] }
);
commands = handleResult.commands;

const commandsLeft = commands.slice();
const maxProcesses = Math.max(1, Number(options.maxProcesses) || commandsLeft.length);
for (let i = 0; i < maxProcesses; i++) {
maybeRunMore(commandsLeft);
}

return new CompletionListener({ successCondition: options.successCondition }).listen(commands);
return new CompletionListener({
successCondition: options.successCondition,
})
.listen(commands)
.finally(() => {
handleResult.onFinishCallbacks.forEach((onFinish) => onFinish());
});
};

function mapToCommandInfo(command) {
return {
return Object.assign({
command: command.command || command,
name: command.name || '',
prefixColor: command.prefixColor || '',
env: command.env || {},
cwd: command.cwd || '',
};

}, command.prefixColor ? {
prefixColor: command.prefixColor,
} : {});
}

function parseCommand(command, parsers) {
36 changes: 34 additions & 2 deletions src/concurrently.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const EventEmitter = require('events');

const createFakeCommand = require('./flow-control/fixtures/fake-command');
const FakeHandler = require('./flow-control/fixtures/fake-handler');
const concurrently = require('./concurrently');

let spawn, kill, controllers, processes = [];
@@ -18,7 +19,7 @@ beforeEach(() => {
return process;
});
kill = jest.fn();
controllers = [{ handle: jest.fn(arg => arg) }, { handle: jest.fn(arg => arg) }];
controllers = [new FakeHandler(), new FakeHandler()];
});

it('fails if commands is not an array', () => {
@@ -83,9 +84,22 @@ it('runs commands with a name or prefix color', () => {
});
});

it('runs commands with a list of colors', () => {
create(['echo', 'kill'], {
prefixColors: ['red']
});

controllers.forEach(controller => {
expect(controller.handle).toHaveBeenCalledWith([
expect.objectContaining({ command: 'echo', prefixColor: 'red' }),
expect.objectContaining({ command: 'kill', prefixColor: 'red' }),
]);
});
});

it('passes commands wrapped from a controller to the next one', () => {
const fakeCommand = createFakeCommand('banana', 'banana');
controllers[0].handle.mockReturnValue([fakeCommand]);
controllers[0].handle.mockReturnValue({ commands: [fakeCommand] });

create(['echo']);

@@ -165,3 +179,21 @@ it('uses overridden cwd option for each command if specified', () => {
cwd: 'foobar',
}));
});

it('runs onFinish hook after all commands run', async () => {
const promise = create(['foo', 'bar'], { maxProcesses: 1 });
expect(spawn).toHaveBeenCalledTimes(1);
expect(controllers[0].onFinish).not.toHaveBeenCalled();
expect(controllers[1].onFinish).not.toHaveBeenCalled();

processes[0].emit('close', 0, null);
expect(spawn).toHaveBeenCalledTimes(2);
expect(controllers[0].onFinish).not.toHaveBeenCalled();
expect(controllers[1].onFinish).not.toHaveBeenCalled();

processes[1].emit('close', 0, null);
await promise;

expect(controllers[0].onFinish).toHaveBeenCalled();
expect(controllers[1].onFinish).toHaveBeenCalled();
});
6 changes: 5 additions & 1 deletion src/defaults.js
Original file line number Diff line number Diff line change
@@ -10,6 +10,8 @@ module.exports = {
handleInput: false,
// How many processes to run at once
maxProcesses: 0,
// Indices and names of commands whose output to be not logged
hide: '',
nameSeparator: ',',
// Which prefix style to use when logging processes output.
prefix: '',
@@ -27,5 +29,7 @@ module.exports = {
// Refer to https://date-fns.org/v2.0.1/docs/format
timestampFormat: 'yyyy-MM-dd HH:mm:ss.SSS',
// Current working dir passed as option to spawn command. Default: process.cwd()
cwd: undefined
cwd: undefined,
// Whether to show timing information for processes in console output
timings: false,
};
16 changes: 16 additions & 0 deletions src/flow-control/base-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module.exports = class BaseHandler {
constructor(options = {}) {
const { logger } = options;

this.logger = logger;
}

handle(commands) {
return {
commands,
// an optional callback to call when all commands have finished
// (either successful or not)
onFinish: null,
};
}
};
22 changes: 22 additions & 0 deletions src/flow-control/base-handler.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const stream = require('stream');
const { createMockInstance } = require('jest-create-mock-instance');

const Logger = require('../logger');
const createFakeCommand = require('./fixtures/fake-command');
const BaseHandler = require('./base-handler');

let commands, controller, inputStream, logger;

beforeEach(() => {
commands = [
createFakeCommand('foo', 'echo foo', 0),
createFakeCommand('bar', 'echo bar', 1),
];
inputStream = new stream.PassThrough();
logger = createMockInstance(Logger);
controller = new BaseHandler({ logger });
});

it('returns same commands and null onFinish callback by default', () => {
expect(controller.handle(commands)).toMatchObject({ commands, onFinish: null });
});
1 change: 1 addition & 0 deletions src/flow-control/fixtures/fake-command.js
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ module.exports = (name = 'foo', command = 'echo foo', index = 0) => ({
error: new Subject(),
stderr: new Subject(),
stdout: new Subject(),
timer: new Subject(),
stdin: createMockInstance(Writable),
start: jest.fn(),
kill: jest.fn()
13 changes: 13 additions & 0 deletions src/flow-control/fixtures/fake-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const BaseHandler = require('../base-handler');

module.exports = class FakeHandler extends BaseHandler {
constructor() {
super();

this.handle = jest.fn(commands => ({
commands,
onFinish: this.onFinish,
}));
this.onFinish = jest.fn();
}
};
21 changes: 16 additions & 5 deletions src/flow-control/input-handler.js
Original file line number Diff line number Diff line change
@@ -2,17 +2,20 @@ const Rx = require('rxjs');
const { map } = require('rxjs/operators');

const defaults = require('../defaults');
const BaseHandler = require('./base-handler');

module.exports = class InputHandler extends BaseHandler {
constructor({ defaultInputTarget, inputStream, pauseInputStreamOnFinish, logger }) {
super({ logger });

module.exports = class InputHandler {
constructor({ defaultInputTarget, inputStream, logger }) {
this.defaultInputTarget = defaultInputTarget || defaults.defaultInputTarget;
this.inputStream = inputStream;
this.logger = logger;
this.pauseInputStreamOnFinish = pauseInputStreamOnFinish !== false;
}

handle(commands) {
if (!this.inputStream) {
return commands;
return { commands };
}

Rx.fromEvent(this.inputStream, 'data')
@@ -34,6 +37,14 @@ module.exports = class InputHandler {
}
});

return commands;
return {
commands,
onFinish: () => {
if (this.pauseInputStreamOnFinish) {
// https://github.com/kimmobrunfeldt/concurrently/issues/252
this.inputStream.pause();
}
},
};
}
};
40 changes: 31 additions & 9 deletions src/flow-control/input-handler.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const EventEmitter = require('events');
const stream = require('stream');
const { createMockInstance } = require('jest-create-mock-instance');

const Logger = require('../logger');
@@ -12,7 +12,7 @@ beforeEach(() => {
createFakeCommand('foo', 'echo foo', 0),
createFakeCommand('bar', 'echo bar', 1),
];
inputStream = new EventEmitter();
inputStream = new stream.PassThrough();
logger = createMockInstance(Logger);
controller = new InputHandler({
defaultInputTarget: 0,
@@ -22,16 +22,16 @@ beforeEach(() => {
});

it('returns same commands', () => {
expect(controller.handle(commands)).toBe(commands);
expect(controller.handle(commands)).toMatchObject({ commands });

controller = new InputHandler({ logger });
expect(controller.handle(commands)).toBe(commands);
expect(controller.handle(commands)).toMatchObject({ commands });
});

it('forwards input stream to default target ID', () => {
controller.handle(commands);

inputStream.emit('data', Buffer.from('something'));
inputStream.write('something');

expect(commands[0].stdin.write).toHaveBeenCalledTimes(1);
expect(commands[0].stdin.write).toHaveBeenCalledWith('something');
@@ -41,7 +41,7 @@ it('forwards input stream to default target ID', () => {
it('forwards input stream to target index specified in input', () => {
controller.handle(commands);

inputStream.emit('data', Buffer.from('1:something'));
inputStream.write('1:something');

expect(commands[0].stdin.write).not.toHaveBeenCalled();
expect(commands[1].stdin.write).toHaveBeenCalledTimes(1);
@@ -63,7 +63,7 @@ it('forwards input stream to target index specified in input when input contains
it('forwards input stream to target name specified in input', () => {
controller.handle(commands);

inputStream.emit('data', Buffer.from('bar:something'));
inputStream.write('bar:something');

expect(commands[0].stdin.write).not.toHaveBeenCalled();
expect(commands[1].stdin.write).toHaveBeenCalledTimes(1);
@@ -74,7 +74,7 @@ it('logs error if command has no stdin open', () => {
commands[0].stdin = null;
controller.handle(commands);

inputStream.emit('data', Buffer.from('something'));
inputStream.write('something');

expect(commands[1].stdin.write).not.toHaveBeenCalled();
expect(logger.logGlobalEvent).toHaveBeenCalledWith('Unable to find command 0, or it has no stdin open\n');
@@ -83,9 +83,31 @@ it('logs error if command has no stdin open', () => {
it('logs error if command is not found', () => {
controller.handle(commands);

inputStream.emit('data', Buffer.from('foobar:something'));
inputStream.write('foobar:something');

expect(commands[0].stdin.write).not.toHaveBeenCalled();
expect(commands[1].stdin.write).not.toHaveBeenCalled();
expect(logger.logGlobalEvent).toHaveBeenCalledWith('Unable to find command foobar, or it has no stdin open\n');
});

it('pauses input stream when finished', () => {
expect(inputStream.readableFlowing).toBeNull();

const { onFinish } = controller.handle(commands);
expect(inputStream.readableFlowing).toBe(true);

onFinish();
expect(inputStream.readableFlowing).toBe(false);
});

it('does not pause input stream when pauseInputStreamOnFinish is set to false', () => {
controller = new InputHandler({ inputStream, pauseInputStreamOnFinish: false });

expect(inputStream.readableFlowing).toBeNull();

const { onFinish } = controller.handle(commands);
expect(inputStream.readableFlowing).toBe(true);

onFinish();
expect(inputStream.readableFlowing).toBe(true);
});
29 changes: 17 additions & 12 deletions src/flow-control/kill-on-signal.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
const { map } = require('rxjs/operators');

const BaseHandler = require('./base-handler');

module.exports = class KillOnSignal {
module.exports = class KillOnSignal extends BaseHandler {
constructor({ process }) {
super();

this.process = process;
}

@@ -15,16 +18,18 @@ module.exports = class KillOnSignal {
});
});

return commands.map(command => {
const closeStream = command.close.pipe(map(exitInfo => {
const exitCode = caughtSignal === 'SIGINT' ? 0 : exitInfo.exitCode;
return Object.assign({}, exitInfo, { exitCode });
}));
return new Proxy(command, {
get(target, prop) {
return prop === 'close' ? closeStream : target[prop];
}
});
});
return {
commands: commands.map(command => {
const closeStream = command.close.pipe(map(exitInfo => {
const exitCode = caughtSignal === 'SIGINT' ? 0 : exitInfo.exitCode;
return Object.assign({}, exitInfo, { exitCode });
}));
return new Proxy(command, {
get(target, prop) {
return prop === 'close' ? closeStream : target[prop];
}
});
})
};
}
};
6 changes: 3 additions & 3 deletions src/flow-control/kill-on-signal.spec.js
Original file line number Diff line number Diff line change
@@ -14,7 +14,7 @@ beforeEach(() => {
});

it('returns commands that keep non-close streams from original commands', () => {
const newCommands = controller.handle(commands);
const { commands: newCommands } = controller.handle(commands);
newCommands.forEach((newCommand, i) => {
expect(newCommand.close).not.toBe(commands[i].close);
expect(newCommand.error).toBe(commands[i].error);
@@ -24,7 +24,7 @@ it('returns commands that keep non-close streams from original commands', () =>
});

it('returns commands that map SIGINT to exit code 0', () => {
const newCommands = controller.handle(commands);
const { commands: newCommands } = controller.handle(commands);
expect(newCommands).not.toBe(commands);
expect(newCommands).toHaveLength(commands.length);

@@ -40,7 +40,7 @@ it('returns commands that map SIGINT to exit code 0', () => {
});

it('returns commands that keep non-SIGINT exit codes', () => {
const newCommands = controller.handle(commands);
const { commands: newCommands } = controller.handle(commands);
expect(newCommands).not.toBe(commands);
expect(newCommands).toHaveLength(commands.length);

11 changes: 7 additions & 4 deletions src/flow-control/kill-others.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
const _ = require('lodash');
const { filter, map } = require('rxjs/operators');

module.exports = class KillOthers {
const BaseHandler = require('./base-handler');

module.exports = class KillOthers extends BaseHandler {
constructor({ logger, conditions }) {
this.logger = logger;
super({ logger });

this.conditions = _.castArray(conditions);
}

@@ -14,7 +17,7 @@ module.exports = class KillOthers {
));

if (!conditions.length) {
return commands;
return { commands };
}

const closeStates = commands.map(command => command.close.pipe(
@@ -30,6 +33,6 @@ module.exports = class KillOthers {
}
}));

return commands;
return { commands };
}
};
4 changes: 2 additions & 2 deletions src/flow-control/kill-others.spec.js
Original file line number Diff line number Diff line change
@@ -20,8 +20,8 @@ const createWithConditions = conditions => new KillOthers({
});

it('returns same commands', () => {
expect(createWithConditions(['foo']).handle(commands)).toBe(commands);
expect(createWithConditions(['failure']).handle(commands)).toBe(commands);
expect(createWithConditions(['foo']).handle(commands)).toMatchObject({ commands });
expect(createWithConditions(['failure']).handle(commands)).toMatchObject({ commands });
});

it('does not kill others if condition does not match', () => {
8 changes: 3 additions & 5 deletions src/flow-control/log-error.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
const { of } = require('rxjs');

module.exports = class LogExit {
constructor({ logger }) {
this.logger = logger;
}
const BaseHandler = require('./base-handler');

module.exports = class LogExit extends BaseHandler {
handle(commands) {
commands.forEach(command => command.error.subscribe(event => {
this.logger.logCommandEvent(
@@ -15,6 +13,6 @@ module.exports = class LogExit {
this.logger.logCommandEvent(event.stack || event, command);
}));

return commands;
return { commands };
}
};
2 changes: 1 addition & 1 deletion src/flow-control/log-error.spec.js
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ beforeEach(() => {
});

it('returns same commands', () => {
expect(controller.handle(commands)).toBe(commands);
expect(controller.handle(commands)).toMatchObject({ commands });
});

it('logs the error event of each command', () => {
8 changes: 3 additions & 5 deletions src/flow-control/log-exit.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
module.exports = class LogExit {
constructor({ logger }) {
this.logger = logger;
}
const BaseHandler = require('./base-handler');

module.exports = class LogExit extends BaseHandler {
handle(commands) {
commands.forEach(command => command.close.subscribe(({ exitCode }) => {
this.logger.logCommandEvent(`${command.command} exited with code ${exitCode}`, command);
}));

return commands;
return { commands };
}
};
2 changes: 1 addition & 1 deletion src/flow-control/log-exit.spec.js
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ beforeEach(() => {
});

it('returns same commands', () => {
expect(controller.handle(commands)).toBe(commands);
expect(controller.handle(commands)).toMatchObject({ commands });
});

it('logs the close event of each command', () => {
8 changes: 3 additions & 5 deletions src/flow-control/log-output.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
module.exports = class LogOutput {
constructor({ logger }) {
this.logger = logger;
}
const BaseHandler = require('./base-handler');

module.exports = class LogOutput extends BaseHandler {
handle(commands) {
commands.forEach(command => {
command.stdout.subscribe(text => this.logger.logCommandText(text.toString(), command));
command.stderr.subscribe(text => this.logger.logCommandText(text.toString(), command));
});

return commands;
return { commands };
}
};
2 changes: 1 addition & 1 deletion src/flow-control/log-output.spec.js
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ beforeEach(() => {
});

it('returns same commands', () => {
expect(controller.handle(commands)).toBe(commands);
expect(controller.handle(commands)).toMatchObject({ commands });
});

it('logs the stdout of each command', () => {
64 changes: 64 additions & 0 deletions src/flow-control/log-timings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const formatDate = require('date-fns/format');
const Rx = require('rxjs');
const { bufferCount, take } = require('rxjs/operators');
const _ = require('lodash');
const BaseHandler = require('./base-handler');

module.exports = class LogTimings extends BaseHandler {
constructor({ logger, timestampFormat }) {
super({ logger });

this.timestampFormat = timestampFormat;
}

printExitInfoTimingTable(exitInfos) {
const exitInfoTable = _(exitInfos)
.sortBy(({ timings }) => timings.durationSeconds)
.reverse()
.map(({ command, timings, killed, exitCode }) => {
const readableDurationMs = (timings.endDate - timings.startDate).toLocaleString();
return {
name: command.name,
duration: `${readableDurationMs}ms`,
'exit code': exitCode,
killed,
command: command.command,
};
})
.value();

this.logger.logGlobalEvent('Timings:');
this.logger.logTable(exitInfoTable);
return exitInfos;
};

handle(commands) {
if (!this.logger) {
return { commands };
}

// individual process timings
commands.forEach(command => {
command.timer.subscribe(({ startDate, endDate }) => {
if (!endDate) {
const formattedStartDate = formatDate(startDate, this.timestampFormat);
this.logger.logCommandEvent(`${command.command} started at ${formattedStartDate}`, command);
} else {
const durationMs = endDate.getTime() - startDate.getTime();
const formattedEndDate = formatDate(endDate, this.timestampFormat);
this.logger.logCommandEvent(`${command.command} stopped at ${formattedEndDate} after ${durationMs.toLocaleString()}ms`, command);
}
});
});

// overall summary timings
const closeStreams = commands.map(command => command.close);
this.allProcessesClosed = Rx.merge(...closeStreams).pipe(
bufferCount(closeStreams.length),
take(1),
);
this.allProcessesClosed.subscribe((exitInfos) => this.printExitInfoTimingTable(exitInfos));

return { commands };
}
};
137 changes: 137 additions & 0 deletions src/flow-control/log-timings.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
const { createMockInstance } = require('jest-create-mock-instance');
const formatDate = require('date-fns/format');
const Logger = require('../logger');
const LogTimings = require( './log-timings' );
const createFakeCommand = require('./fixtures/fake-command');

// shown in timing order
const startDate0 = new Date();
const startDate1 = new Date(startDate0.getTime() + 1000);
const endDate1 = new Date(startDate0.getTime() + 3000);
const endDate0 = new Date(startDate0.getTime() + 5000);

const timestampFormat = 'yyyy-MM-dd HH:mm:ss.SSS';
const getDurationText = (startDate, endDate) => `${(endDate.getTime() - startDate.getTime()).toLocaleString()}ms`;
const command0DurationTextMs = getDurationText(startDate0, endDate0);
const command1DurationTextMs = getDurationText(startDate1, endDate1);

const exitInfoToTimingInfo = ({ command, timings, killed, exitCode }) => {
const readableDurationMs = getDurationText(timings.startDate, timings.endDate);
return {
name: command.name,
duration: readableDurationMs,
'exit code': exitCode,
killed,
command: command.command,
};
};

let controller, logger, commands, command0ExitInfo, command1ExitInfo;

beforeEach(() => {
commands = [
createFakeCommand('foo', 'command 1', 0),
createFakeCommand('bar', 'command 2', 1),
];

command0ExitInfo = {
command: commands[0].command,
timings: {
startDate: startDate0,
endDate: endDate0,
},
index: commands[0].index,
killed: false,
exitCode: 0,
};

command1ExitInfo = {
command: commands[1].command,
timings: {
startDate: startDate1,
endDate: endDate1,
},
index: commands[1].index,
killed: false,
exitCode: 0,
};

logger = createMockInstance(Logger);
controller = new LogTimings({ logger, timestampFormat });
});

it('returns same commands', () => {
expect(controller.handle(commands)).toMatchObject({ commands });
});

it('does not log timings and doesn\'t throw if no logger is provided', () => {
controller = new LogTimings({ });
controller.handle(commands);

commands[0].timer.next({ startDate: startDate0 });
commands[1].timer.next({ startDate: startDate1 });
commands[1].timer.next({ startDate: startDate1, endDate: endDate1 });
commands[0].timer.next({ startDate: startDate0, endDate: endDate0 });

expect(logger.logCommandEvent).toHaveBeenCalledTimes(0);
});

it('logs the timings at the start and end (ie complete or error) event of each command', () => {
controller.handle(commands);

commands[0].timer.next({ startDate: startDate0 });
commands[1].timer.next({ startDate: startDate1 });
commands[1].timer.next({ startDate: startDate1, endDate: endDate1 });
commands[0].timer.next({ startDate: startDate0, endDate: endDate0 });

expect(logger.logCommandEvent).toHaveBeenCalledTimes(4);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`${commands[0].command} started at ${formatDate(startDate0, timestampFormat)}`,
commands[0]
);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`${commands[1].command} started at ${formatDate(startDate1, timestampFormat)}`,
commands[1]
);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`${commands[1].command} stopped at ${formatDate(endDate1, timestampFormat)} after ${command1DurationTextMs}`,
commands[1]
);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`${commands[0].command} stopped at ${formatDate(endDate0, timestampFormat)} after ${command0DurationTextMs}`,
commands[0]
);
});

it('does not log timings summary if there was an error', () => {
controller.handle(commands);

commands[0].close.next(command0ExitInfo);
commands[1].error.next();

expect(logger.logTable).toHaveBeenCalledTimes(0);

});

it('logs the sorted timings summary when all processes close successfully', () => {
jest.spyOn(controller, 'printExitInfoTimingTable');
controller.handle(commands);

commands[0].close.next(command0ExitInfo);
commands[1].close.next(command1ExitInfo);

expect(logger.logTable).toHaveBeenCalledTimes(1);

// un-sorted ie by finish order
expect(controller.printExitInfoTimingTable).toHaveBeenCalledWith([
command0ExitInfo,
command1ExitInfo
]);

// sorted by duration
expect(logger.logTable).toHaveBeenCalledWith([
exitInfoToTimingInfo(command1ExitInfo),
exitInfoToTimingInfo(command0ExitInfo)
]);

});
32 changes: 18 additions & 14 deletions src/flow-control/restart-process.js
Original file line number Diff line number Diff line change
@@ -2,19 +2,21 @@ const Rx = require('rxjs');
const { defaultIfEmpty, delay, filter, mapTo, skip, take, takeWhile } = require('rxjs/operators');

const defaults = require('../defaults');
const BaseHandler = require('./base-handler');

module.exports = class RestartProcess {
module.exports = class RestartProcess extends BaseHandler {
constructor({ delay, tries, logger, scheduler }) {
super({ logger });

this.delay = +delay || defaults.restartDelay;
this.tries = +tries || defaults.restartTries;
this.tries = this.tries < 0 ? Infinity : this.tries;
this.logger = logger;
this.scheduler = scheduler;
}

handle(commands) {
if (this.tries === 0) {
return commands;
return { commands };
}

commands.map(command => command.close.pipe(
@@ -36,17 +38,19 @@ module.exports = class RestartProcess {
}
}));

return commands.map(command => {
const closeStream = command.close.pipe(filter(({ exitCode }, emission) => {
// We let all success codes pass, and failures only after restarting won't happen again
return exitCode === 0 || emission >= this.tries;
}));
return {
commands: commands.map(command => {
const closeStream = command.close.pipe(filter(({ exitCode }, emission) => {
// We let all success codes pass, and failures only after restarting won't happen again
return exitCode === 0 || emission >= this.tries;
}));

return new Proxy(command, {
get(target, prop) {
return prop === 'close' ? closeStream : target[prop];
}
});
});
return new Proxy(command, {
get(target, prop) {
return prop === 'close' ? closeStream : target[prop];
}
});
})
};
}
};
18 changes: 13 additions & 5 deletions src/flow-control/restart-process.spec.js
Original file line number Diff line number Diff line change
@@ -69,7 +69,15 @@ it('restarts processes up to tries', () => {
expect(commands[0].start).toHaveBeenCalledTimes(2);
});

it.todo('restart processes forever, if tries is negative');
it('restart processes forever, if tries is negative', () => {
controller = new RestartProcess({
logger,
scheduler,
delay: 100,
tries: -1
});
expect(controller.tries).toBe(Infinity);
});

it('restarts processes until they succeed', () => {
controller.handle(commands);
@@ -91,17 +99,17 @@ it('restarts processes until they succeed', () => {
describe('returned commands', () => {
it('are the same if 0 tries are to be attempted', () => {
controller = new RestartProcess({ logger, scheduler });
expect(controller.handle(commands)).toBe(commands);
expect(controller.handle(commands)).toMatchObject({ commands });
});

it('are not the same, but with same length if 1+ tries are to be attempted', () => {
const newCommands = controller.handle(commands);
const { commands: newCommands } = controller.handle(commands);
expect(newCommands).not.toBe(commands);
expect(newCommands).toHaveLength(commands.length);
});

it('skip close events followed by restarts', () => {
const newCommands = controller.handle(commands);
const { commands: newCommands } = controller.handle(commands);

const callback = jest.fn();
newCommands[0].close.subscribe(callback);
@@ -120,7 +128,7 @@ describe('returned commands', () => {
});

it('keep non-close streams from original commands', () => {
const newCommands = controller.handle(commands);
const { commands: newCommands } = controller.handle(commands);
newCommands.forEach((newCommand, i) => {
expect(newCommand.close).not.toBe(commands[i].close);
expect(newCommand.error).toBe(commands[i].error);
67 changes: 66 additions & 1 deletion src/logger.js
Original file line number Diff line number Diff line change
@@ -5,7 +5,11 @@ const formatDate = require('date-fns/format');
const defaults = require('./defaults');

module.exports = class Logger {
constructor({ outputStream, prefixFormat, prefixLength, raw, timestampFormat }) {
constructor({ hide, outputStream, prefixFormat, prefixLength, raw, timestampFormat }) {
// To avoid empty strings from hiding the output of commands that don't have a name,
// keep in the list of commands to hide only strings with some length.
// This might happen through the CLI when no `--hide` argument is specified, for example.
this.hide = _.castArray(hide).filter(name => name || name === 0).map(String);
this.raw = raw;
this.outputStream = outputStream;
this.prefixFormat = prefixFormat;
@@ -76,6 +80,10 @@ module.exports = class Logger {
}

logCommandText(text, command) {
if (this.hide.includes(String(command.index)) || this.hide.includes(command.name)) {
return;
}

const prefix = this.colorText(command, this.getPrefix(command));
return this.log(prefix + (prefix ? ' ' : ''), text);
}
@@ -88,6 +96,63 @@ module.exports = class Logger {
this.log(chalk.reset('-->') + ' ', chalk.reset(text) + '\n');
}

logTable(tableContents) {
// For now, can only print array tables with some content.
if (this.raw || !Array.isArray(tableContents) || !tableContents.length) {
return;
}

let nextColIndex = 0;
const headers = {};
const contentRows = tableContents.map(row => {
const rowContents = [];
Object.keys(row).forEach((col) => {
if (!headers[col]) {
headers[col] = {
index: nextColIndex++,
//
length: col.length,
};
}

const colIndex = headers[col].index;
const formattedValue = String(row[col] == null ? '' : row[col]);
// Update the column length in case this rows value is longer than the previous length for the column.
headers[col].length = Math.max(formattedValue.length, headers[col].length);
rowContents[colIndex] = formattedValue;
return rowContents;
});
return rowContents;
});

const headersFormatted = Object
.keys(headers)
.map(header => header.padEnd(headers[header].length, ' '));

if (!headersFormatted.length) {
// No columns exist.
return;
}

const borderRowFormatted = headersFormatted.map(header => '─'.padEnd(header.length, '─'));

this.logGlobalEvent(`┌─${borderRowFormatted.join('─┬─')}─┐`);
this.logGlobalEvent(`│ ${headersFormatted.join(' │ ')} │`);
this.logGlobalEvent(`├─${borderRowFormatted.join('─┼─')}─┤`);

contentRows.forEach(contentRow => {
const contentRowFormatted = headersFormatted.map((header, colIndex) => {
// If the table was expanded after this row was processed, it won't have this column.
// Use an empty string in this case.
const col = contentRow[colIndex] || '';
return col.padEnd(header.length, ' ');
});
this.logGlobalEvent(`│ ${contentRowFormatted.join(' │ ')} │`);
});

this.logGlobalEvent(`└─${borderRowFormatted.join('─┴─')}─┘`);
}

log(prefix, text) {
if (this.raw) {
return this.outputStream.write(text);
123 changes: 123 additions & 0 deletions src/logger.spec.js
Original file line number Diff line number Diff line change
@@ -176,6 +176,20 @@ describe('#logCommandText()', () => {

expect(logger.log).toHaveBeenCalledWith(chalk.hex(prefixColor)('[1]') + ' ', 'foo');
});

it('does nothing if command is hidden by name', () => {
const logger = createLogger({ hide: ['abc'] });
logger.logCommandText('foo', { name: 'abc' });

expect(logger.log).not.toHaveBeenCalled();
});

it('does nothing if command is hidden by index', () => {
const logger = createLogger({ hide: [3] });
logger.logCommandText('foo', { index: 3 });

expect(logger.log).not.toHaveBeenCalled();
});
});

describe('#logCommandEvent()', () => {
@@ -186,10 +200,119 @@ describe('#logCommandEvent()', () => {
expect(logger.log).not.toHaveBeenCalled();
});

it('does nothing if command is hidden by name', () => {
const logger = createLogger({ hide: ['abc'] });
logger.logCommandEvent('foo', { name: 'abc' });

expect(logger.log).not.toHaveBeenCalled();
});

it('does nothing if command is hidden by index', () => {
const logger = createLogger({ hide: [3] });
logger.logCommandEvent('foo', { index: 3 });

expect(logger.log).not.toHaveBeenCalled();
});

it('logs text in gray dim', () => {
const logger = createLogger();
logger.logCommandEvent('foo', { index: 1 });

expect(logger.log).toHaveBeenCalledWith(chalk.reset('[1]') + ' ', chalk.reset('foo') + '\n');
});
});

describe('#logTable()', () => {
it('does not log anything in raw mode', () => {
const logger = createLogger({ raw: true });
logger.logTable([{ foo: 1, bar: 2 }]);

expect(logger.log).not.toHaveBeenCalled();
});

it('does not log anything if value is not an array', () => {
const logger = createLogger();
logger.logTable({});
logger.logTable(null);
logger.logTable(0);
logger.logTable('');

expect(logger.log).not.toHaveBeenCalled();
});

it('does not log anything if array is empy', () => {
const logger = createLogger();
logger.logTable([]);

expect(logger.log).not.toHaveBeenCalled();
});

it('does not log anything if array items have no properties', () => {
const logger = createLogger();
logger.logTable([{}]);

expect(logger.log).not.toHaveBeenCalled();
});

it('logs a header for each item\'s properties', () => {
const logger = createLogger();
logger.logTable([{ foo: 1, bar: 2 }]);

expect(logger.log).toHaveBeenCalledWith(
chalk.reset('-->') + ' ',
chalk.reset('│ foo │ bar │') + '\n',
);
});

it('logs padded headers according to longest column\'s value', () => {
const logger = createLogger();
logger.logTable([{ a: 'foo', b: 'barbaz' }]);

expect(logger.log).toHaveBeenCalledWith(
chalk.reset('-->') + ' ',
chalk.reset('│ a │ b │') + '\n',
);
});

it('logs each items\'s values', () => {
const logger = createLogger();
logger.logTable([{ foo: 123 }, { foo: 456 }]);

expect(logger.log).toHaveBeenCalledWith(
chalk.reset('-->') + ' ',
chalk.reset('│ 123 │') + '\n',
);
expect(logger.log).toHaveBeenCalledWith(
chalk.reset('-->') + ' ',
chalk.reset('│ 456 │') + '\n',
);
});

it('logs each items\'s values padded according to longest column\'s value', () => {
const logger = createLogger();
logger.logTable([{ foo: 1 }, { foo: 123 }]);

expect(logger.log).toHaveBeenCalledWith(
chalk.reset('-->') + ' ',
chalk.reset('│ 1 │') + '\n',
);
});

it('logs items with different properties in each', () => {
const logger = createLogger();
logger.logTable([{ foo: 1 }, { bar: 2 }]);

expect(logger.log).toHaveBeenCalledWith(
chalk.reset('-->') + ' ',
chalk.reset('│ foo │ bar │') + '\n',
);
expect(logger.log).toHaveBeenCalledWith(
chalk.reset('-->') + ' ',
chalk.reset('│ 1 │ │') + '\n',
);
expect(logger.log).toHaveBeenCalledWith(
chalk.reset('-->') + ' ',
chalk.reset('│ │ 2 │') + '\n',
);
});
});