concurrently 6.2.2 → 6.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,21 +116,25 @@ Help:
116
116
  concurrently [options] <command ...>
117
117
 
118
118
  General
119
- -m, --max-processes How many processes should run at once.
120
- New processes only spawn after all restart tries of a
121
- process. [number]
122
- -n, --names List of custom names to be used in prefix template.
123
- Example names: "main,browser,server" [string]
124
- --name-separator The character to split <names> on. Example usage:
125
- concurrently -n "styles|scripts|server" --name-separator
126
- "|" [default: ","]
127
- -r, --raw Output only raw output of processes, disables prettifying
128
- and concurrently coloring. [boolean]
129
- -s, --success Return exit code of zero or one based on the success or
130
- failure of the "first" child to terminate, the "last
131
- child", or succeed only if "all" child processes succeed.
119
+ -m, --max-processes How many processes should run at once.
120
+ New processes only spawn after all restart tries of a
121
+ process. [number]
122
+ -n, --names List of custom names to be used in prefix template.
123
+ Example names: "main,browser,server" [string]
124
+ --name-separator The character to split <names> on. Example usage:
125
+ concurrently -n "styles|scripts|server" --name-separator
126
+ "|" [default: ","]
127
+ -r, --raw Output only raw output of processes, disables
128
+ prettifying and concurrently coloring. [boolean]
129
+ -s, --success Return exit code of zero or one based on the success or
130
+ failure of the "first" child to terminate, the "last
131
+ child", or succeed only if "all" child processes
132
+ succeed.
132
133
  [choices: "first", "last", "all"] [default: "all"]
133
- --no-color Disables colors from logging [boolean]
134
+ --no-color Disables colors from logging [boolean]
135
+ --hide Comma-separated list of processes to hide the output.
136
+ The processes can be identified by their name or index.
137
+ [string] [default: ""]
134
138
 
135
139
  Prefix styling
136
140
  -p, --prefix Prefix used in logging for each process.
@@ -199,6 +203,10 @@ Examples:
199
203
  $ concurrently --names "HTTP,WATCH" -c "bgBlue.bold,bgMagenta.bold"
200
204
  "http-server" "npm run watch"
201
205
 
206
+ - Configuring via environment variables with CONCURRENTLY_ prefix
207
+
208
+ $ CONCURRENTLY_RAW=true CONCURRENTLY_KILL_OTHERS=true concurrently "echo hello" "echo world"
209
+
202
210
  - Send input to default
203
211
 
204
212
  $ concurrently --handle-input "nodemon" "npm run watch-js"
@@ -218,9 +226,9 @@ Examples:
218
226
 
219
227
  $ concurrently npm:watch-node npm:watch-js npm:watch-css
220
228
 
221
- - Shortened NPM run command with wildcard
229
+ - Shortened NPM run command with wildcard (make sure to wrap it in quotes!)
222
230
 
223
- $ concurrently npm:watch-*
231
+ $ concurrently "npm:watch-*"
224
232
 
225
233
  For more details, visit https://github.com/open-cli-tools/concurrently
226
234
  ```
@@ -250,6 +258,9 @@ concurrently can be used programmatically by using the API documented below:
250
258
  - `prefix`: the prefix type to use when logging processes output.
251
259
  Possible values: `index`, `pid`, `time`, `command`, `name`, `none`, or a template (eg `[{time} process: {pid}]`).
252
260
  Default: the name of the process, or its index if no name is set.
261
+ - `prefixColors`: a list of colors as supported by [chalk](https://www.npmjs.com/package/chalk).
262
+ If concurrently would run more commands than there are colors, the last color is repeated.
263
+ Prefix colors specified per-command take precedence over this list.
253
264
  - `prefixLength`: how many characters to show when prefixing with `command`. Default: `10`
254
265
  - `raw`: whether raw mode should be used, meaning strictly process output will
255
266
  be logged, without any prefixes, colouring or extra stuff.
@@ -11,6 +11,8 @@ const args = yargs
11
11
  .version('v', require('../package.json').version)
12
12
  .alias('v', 'V')
13
13
  .alias('v', 'version')
14
+ // TODO: Add some tests for this.
15
+ .env('CONCURRENTLY')
14
16
  .options({
15
17
  // General
16
18
  'm': {
@@ -55,6 +57,18 @@ const args = yargs
55
57
  describe: 'Disables colors from logging',
56
58
  type: 'boolean'
57
59
  },
60
+ 'hide': {
61
+ describe:
62
+ 'Comma-separated list of processes to hide the output.\n' +
63
+ 'The processes can be identified by their name or index.',
64
+ default: defaults.hide,
65
+ type: 'string'
66
+ },
67
+ 'timings': {
68
+ describe: 'Show timing information for all processes',
69
+ type: 'boolean',
70
+ default: defaults.timings
71
+ },
58
72
 
59
73
  // Kill others
60
74
  'k': {
@@ -135,7 +149,7 @@ const args = yargs
135
149
  'Can be either the index or the name of the process.'
136
150
  }
137
151
  })
138
- .group(['m', 'n', 'name-separator', 'raw', 's', 'no-color'], 'General')
152
+ .group(['m', 'n', 'name-separator', 'raw', 's', 'no-color', 'hide', 'timings'], 'General')
139
153
  .group(['p', 'c', 'l', 't'], 'Prefix styling')
140
154
  .group(['i', 'default-input-target'], 'Input handling')
141
155
  .group(['k', 'kill-others-on-fail'], 'Killing other processes')
@@ -144,19 +158,12 @@ const args = yargs
144
158
  .epilogue(fs.readFileSync(__dirname + '/epilogue.txt', { encoding: 'utf8' }))
145
159
  .argv;
146
160
 
147
- const prefixColors = args.prefixColors.split(',');
148
161
  const names = (args.names || '').split(args.nameSeparator);
149
162
 
150
- let lastColor;
151
- concurrently(args._.map((command, index) => {
152
- // Use documented behaviour of repeating last colour when specifying more commands than colours
153
- lastColor = prefixColors[index] || lastColor;
154
- return {
155
- command,
156
- prefixColor: lastColor,
157
- name: names[index]
158
- };
159
- }), {
163
+ concurrently(args._.map((command, index) => ({
164
+ command,
165
+ name: names[index]
166
+ })), {
160
167
  handleInput: args.handleInput,
161
168
  defaultInputTarget: args.defaultInputTarget,
162
169
  killOthers: args.killOthers
@@ -164,12 +171,15 @@ concurrently(args._.map((command, index) => {
164
171
  : (args.killOthersOnFail ? ['failure'] : []),
165
172
  maxProcesses: args.maxProcesses,
166
173
  raw: args.raw,
174
+ hide: args.hide.split(','),
167
175
  prefix: args.prefix,
176
+ prefixColors: args.prefixColors.split(','),
168
177
  prefixLength: args.prefixLength,
169
178
  restartDelay: args.restartAfter,
170
179
  restartTries: args.restartTries,
171
180
  successCondition: args.success,
172
- timestampFormat: args.timestampFormat
181
+ timestampFormat: args.timestampFormat,
182
+ timings: args.timings
173
183
  }).then(
174
184
  () => process.exit(0),
175
185
  () => process.exit(1)
@@ -163,6 +163,26 @@ describe('--raw', () => {
163
163
  });
164
164
  });
165
165
 
166
+ describe('--hide', () => {
167
+ it('hides the output of a process by its index', done => {
168
+ const child = run('--hide 1 "echo foo" "echo bar"');
169
+ child.log.pipe(buffer(child.close)).subscribe(lines => {
170
+ expect(lines).toContainEqual(expect.stringContaining('foo'));
171
+ expect(lines).not.toContainEqual(expect.stringContaining('bar'));
172
+ done();
173
+ }, done);
174
+ });
175
+
176
+ it('hides the output of a process by its name', done => {
177
+ const child = run('-n foo,bar --hide bar "echo foo" "echo bar"');
178
+ child.log.pipe(buffer(child.close)).subscribe(lines => {
179
+ expect(lines).toContainEqual(expect.stringContaining('foo'));
180
+ expect(lines).not.toContainEqual(expect.stringContaining('bar'));
181
+ done();
182
+ }, done);
183
+ });
184
+ });
185
+
166
186
  describe('--names', () => {
167
187
  it('is aliased to -n', done => {
168
188
  const child = run('-n foo,bar "echo foo" "echo bar"');
@@ -326,7 +346,6 @@ describe('--handle-input', () => {
326
346
  }, done);
327
347
  });
328
348
 
329
-
330
349
  it('forwards input to process --default-input-target', done => {
331
350
  const lines = [];
332
351
  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', () => {
363
382
  }, done);
364
383
  });
365
384
  });
385
+
386
+ describe('--timings', () => {
387
+ const defaultTimestampFormatRegex = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}/;
388
+ const processStartedMessageRegex = (index, command) => {
389
+ return new RegExp( `^\\[${ index }] ${ command } started at ${ defaultTimestampFormatRegex.source }$` );
390
+ };
391
+ const processStoppedMessageRegex = (index, command) => {
392
+ return new RegExp( `^\\[${ index }] ${ command } stopped at ${ defaultTimestampFormatRegex.source } after (\\d|,)+ms$` );
393
+ };
394
+ const expectLinesForProcessStartAndStop = (lines, index, command) => {
395
+ const escapedCommand = _.escapeRegExp(command);
396
+ expect(lines).toContainEqual(expect.stringMatching(processStartedMessageRegex(index, escapedCommand)));
397
+ expect(lines).toContainEqual(expect.stringMatching(processStoppedMessageRegex(index, escapedCommand)));
398
+ };
399
+
400
+ const expectLinesForTimingsTable = (lines) => {
401
+ const tableTopBorderRegex = /┌[─┬]+┐/g;
402
+ expect(lines).toContainEqual(expect.stringMatching(tableTopBorderRegex));
403
+ const tableHeaderRowRegex = /(\W+(name|duration|exit code|killed|command)\W+){5}/g;
404
+ expect(lines).toContainEqual(expect.stringMatching(tableHeaderRowRegex));
405
+ const tableBottomBorderRegex = /└[─┴]+┘/g;
406
+ expect(lines).toContainEqual(expect.stringMatching(tableBottomBorderRegex));
407
+ };
408
+
409
+ it('shows timings on success', done => {
410
+ const child = run('--timings "sleep 0.5" "exit 0"');
411
+ child.log.pipe(buffer(child.close)).subscribe(lines => {
412
+ expectLinesForProcessStartAndStop(lines, 0, 'sleep 0.5');
413
+ expectLinesForProcessStartAndStop(lines, 1, 'exit 0');
414
+ expectLinesForTimingsTable(lines);
415
+ done();
416
+ }, done);
417
+ });
418
+
419
+ it('shows timings on failure', done => {
420
+ const child = run('--timings "sleep 0.75" "exit 1"');
421
+ child.log.pipe(buffer(child.close)).subscribe(lines => {
422
+ expectLinesForProcessStartAndStop(lines, 0, 'sleep 0.75');
423
+ expectLinesForProcessStartAndStop(lines, 1, 'exit 1');
424
+ expectLinesForTimingsTable(lines);
425
+ done();
426
+ }, done);
427
+ });
428
+ });
package/bin/epilogue.txt CHANGED
@@ -16,6 +16,10 @@ Examples:
16
16
 
17
17
  $ $0 --names "HTTP,WATCH" -c "bgBlue.bold,bgMagenta.bold" "http-server" "npm run watch"
18
18
 
19
+ - Configuring via environment variables with CONCURRENTLY_ prefix
20
+
21
+ $ CONCURRENTLY_RAW=true CONCURRENTLY_KILL_OTHERS=true $0 "echo hello" "echo world"
22
+
19
23
  - Send input to default
20
24
 
21
25
  $ $0 --handle-input "nodemon" "npm run watch-js"
@@ -35,8 +39,8 @@ Examples:
35
39
 
36
40
  $ $0 npm:watch-node npm:watch-js npm:watch-css
37
41
 
38
- - Shortened NPM run command with wildcard
42
+ - Shortened NPM run command with wildcard (make sure to wrap it in quotes!)
39
43
 
40
- $ $0 npm:watch-*
44
+ $ $0 "npm:watch-*"
41
45
 
42
46
  For more details, visit https://github.com/open-cli-tools/concurrently
package/index.js CHANGED
@@ -8,9 +8,11 @@ const RestartProcess = require('./src/flow-control/restart-process');
8
8
 
9
9
  const concurrently = require('./src/concurrently');
10
10
  const Logger = require('./src/logger');
11
+ const LogTimings = require( './src/flow-control/log-timings' );
11
12
 
12
13
  module.exports = exports = (commands, options = {}) => {
13
14
  const logger = new Logger({
15
+ hide: options.hide,
14
16
  outputStream: options.outputStream || process.stdout,
15
17
  prefixFormat: options.prefix,
16
18
  prefixLength: options.prefixLength,
@@ -42,8 +44,14 @@ module.exports = exports = (commands, options = {}) => {
42
44
  new KillOthers({
43
45
  logger,
44
46
  conditions: options.killOthers
47
+ }),
48
+ new LogTimings({
49
+ logger: options.timings ? logger: null,
50
+ timestampFormat: options.timestampFormat,
45
51
  })
46
- ]
52
+ ],
53
+ prefixColors: options.prefixColors || [],
54
+ timings: options.timings
47
55
  });
48
56
  };
49
57
 
@@ -58,3 +66,4 @@ exports.LogError = LogError;
58
66
  exports.LogExit = LogExit;
59
67
  exports.LogOutput = LogOutput;
60
68
  exports.RestartProcess = RestartProcess;
69
+ exports.LogTimings = LogTimings;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concurrently",
3
- "version": "6.2.2",
3
+ "version": "6.5.1",
4
4
  "description": "Run commands concurrently",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -32,12 +32,21 @@ module.exports = class ExpandNpmWildcard {
32
32
  const preWildcard = _.escapeRegExp(cmdName.substr(0, wildcardPosition));
33
33
  const postWildcard = _.escapeRegExp(cmdName.substr(wildcardPosition + 1));
34
34
  const wildcardRegex = new RegExp(`^${preWildcard}(.*?)${postWildcard}$`);
35
+ const currentName = commandInfo.name || '';
35
36
 
36
37
  return this.scripts
37
- .filter(script => wildcardRegex.test(script))
38
- .map(script => Object.assign({}, commandInfo, {
39
- command: `${npmCmd} run ${script}${args}`,
40
- name: script
41
- }));
38
+ .map(script => {
39
+ const match = script.match(wildcardRegex);
40
+
41
+ if (match) {
42
+ return Object.assign({}, commandInfo, {
43
+ command: `${npmCmd} run ${script}${args}`,
44
+ // Will use an empty command name if command has no name and the wildcard match is empty,
45
+ // e.g. if `npm:watch-*` matches `npm run watch-`.
46
+ name: currentName + match[1],
47
+ });
48
+ }
49
+ })
50
+ .filter(Boolean);
42
51
  }
43
52
  };
@@ -1,4 +1,5 @@
1
1
  const ExpandNpmWildcard = require('./expand-npm-wildcard');
2
+ const fs = require('fs');
2
3
 
3
4
  let parser, readPkg;
4
5
 
@@ -7,6 +8,34 @@ beforeEach(() => {
7
8
  parser = new ExpandNpmWildcard(readPkg);
8
9
  });
9
10
 
11
+ describe('ExpandNpmWildcard#readPackage', () => {
12
+ it('can read package', () => {
13
+ const expectedPackage = {
14
+ 'name': 'concurrently',
15
+ 'version': '6.4.0',
16
+ };
17
+ jest.spyOn(fs, 'readFileSync').mockImplementation((path, options) => {
18
+ if (path === 'package.json') {
19
+ return JSON.stringify(expectedPackage);
20
+ }
21
+ return null;
22
+ });
23
+
24
+ const actualReadPackage = ExpandNpmWildcard.readPackage();
25
+ expect(actualReadPackage).toEqual(expectedPackage);
26
+ });
27
+
28
+ it('can handle errors reading package', () => {
29
+ jest.spyOn(fs, 'readFileSync').mockImplementation(() => {
30
+ throw new Error('Error reading package');
31
+ });
32
+
33
+ expect(() => ExpandNpmWildcard.readPackage()).not.toThrow();
34
+ expect(ExpandNpmWildcard.readPackage()).toEqual({});
35
+ });
36
+
37
+ });
38
+
10
39
  it('returns same command if not an npm run command', () => {
11
40
  const commandInfo = {
12
41
  command: 'npm test'
@@ -42,8 +71,25 @@ for (const npmCmd of ['npm', 'yarn', 'pnpm']) {
42
71
  });
43
72
 
44
73
  expect(parser.parse({ command: `${npmCmd} run foo-*-baz qux` })).toEqual([
45
- { name: 'foo-bar-baz', command: `${npmCmd} run foo-bar-baz qux` },
46
- { name: 'foo--baz', command: `${npmCmd} run foo--baz qux` },
74
+ { name: 'bar', command: `${npmCmd} run foo-bar-baz qux` },
75
+ { name: '', command: `${npmCmd} run foo--baz qux` },
76
+ ]);
77
+ });
78
+
79
+ it('uses existing command name as prefix to the wildcard match', () => {
80
+ readPkg.mockReturnValue({
81
+ scripts: {
82
+ 'watch-js': '',
83
+ 'watch-css': '',
84
+ }
85
+ });
86
+
87
+ expect(parser.parse({
88
+ name: 'w:',
89
+ command: `${npmCmd} run watch-*`,
90
+ })).toEqual([
91
+ { name: 'w:js', command: `${npmCmd} run watch-js` },
92
+ { name: 'w:css', command: `${npmCmd} run watch-css` },
47
93
  ]);
48
94
  });
49
95
 
package/src/command.js CHANGED
@@ -17,6 +17,7 @@ module.exports = class Command {
17
17
  this.killed = false;
18
18
 
19
19
  this.error = new Rx.Subject();
20
+ this.timer = new Rx.Subject();
20
21
  this.close = new Rx.Subject();
21
22
  this.stdout = new Rx.Subject();
22
23
  this.stderr = new Rx.Subject();
@@ -26,13 +27,21 @@ module.exports = class Command {
26
27
  const child = this.spawn(this.command, this.spawnOpts);
27
28
  this.process = child;
28
29
  this.pid = child.pid;
30
+ const startDate = new Date(Date.now());
31
+ const highResStartTime = process.hrtime();
32
+ this.timer.next({startDate});
29
33
 
30
34
  Rx.fromEvent(child, 'error').subscribe(event => {
31
35
  this.process = undefined;
36
+ const endDate = new Date(Date.now());
37
+ this.timer.next({startDate, endDate});
32
38
  this.error.next(event);
33
39
  });
34
40
  Rx.fromEvent(child, 'close').subscribe(([exitCode, signal]) => {
35
41
  this.process = undefined;
42
+ const endDate = new Date(Date.now());
43
+ this.timer.next({startDate, endDate});
44
+ const [durationSeconds, durationNanoSeconds] = process.hrtime(highResStartTime);
36
45
  this.close.next({
37
46
  command: {
38
47
  command: this.command,
@@ -43,6 +52,11 @@ module.exports = class Command {
43
52
  index: this.index,
44
53
  exitCode: exitCode === null ? signal : exitCode,
45
54
  killed: this.killed,
55
+ timings: {
56
+ startDate,
57
+ endDate,
58
+ durationSeconds: durationSeconds + (durationNanoSeconds / 1e9),
59
+ }
46
60
  });
47
61
  });
48
62
  child.stdout && pipeTo(Rx.fromEvent(child.stdout, 'data'), this.stdout);
@@ -1,4 +1,5 @@
1
1
  const EventEmitter = require('events');
2
+ const process = require('process');
2
3
  const Command = require('./command');
3
4
 
4
5
  const createProcess = () => {
@@ -54,6 +55,72 @@ describe('#start()', () => {
54
55
  process.emit('error', 'foo');
55
56
  });
56
57
 
58
+ it('shares start and close timing events to the timing stream', done => {
59
+ const process = createProcess();
60
+ const command = new Command({ spawn: () => process });
61
+
62
+ const startDate = new Date();
63
+ const endDate = new Date(startDate.getTime() + 1000);
64
+ jest.spyOn(Date, 'now')
65
+ .mockReturnValueOnce(startDate.getTime())
66
+ .mockReturnValueOnce(endDate.getTime());
67
+
68
+ let callCount = 0;
69
+ command.timer.subscribe(({startDate: actualStartDate, endDate: actualEndDate}) => {
70
+ switch (callCount) {
71
+ case 0:
72
+ expect(actualStartDate).toStrictEqual(startDate);
73
+ expect(actualEndDate).toBeUndefined();
74
+ break;
75
+ case 1:
76
+ expect(actualStartDate).toStrictEqual(startDate);
77
+ expect(actualEndDate).toStrictEqual(endDate);
78
+ done();
79
+ break;
80
+ default:
81
+ throw new Error('Unexpected call count');
82
+ }
83
+ callCount++;
84
+ });
85
+
86
+ command.start();
87
+ process.emit('close', 0, null);
88
+
89
+ });
90
+
91
+ it('shares start and error timing events to the timing stream', done => {
92
+ const process = createProcess();
93
+ const command = new Command({ spawn: () => process });
94
+
95
+ const startDate = new Date();
96
+ const endDate = new Date(startDate.getTime() + 1000);
97
+ jest.spyOn(Date, 'now')
98
+ .mockReturnValueOnce(startDate.getTime())
99
+ .mockReturnValueOnce(endDate.getTime());
100
+
101
+ let callCount = 0;
102
+ command.timer.subscribe(({startDate: actualStartDate, endDate: actualEndDate}) => {
103
+ switch (callCount) {
104
+ case 0:
105
+ expect(actualStartDate).toStrictEqual(startDate);
106
+ expect(actualEndDate).toBeUndefined();
107
+ break;
108
+ case 1:
109
+ expect(actualStartDate).toStrictEqual(startDate);
110
+ expect(actualEndDate).toStrictEqual(endDate);
111
+ done();
112
+ break;
113
+ default:
114
+ throw new Error('Unexpected call count');
115
+ }
116
+ callCount++;
117
+ });
118
+
119
+ command.start();
120
+ process.emit('error', 0, null);
121
+
122
+ });
123
+
57
124
  it('shares closes to the close stream with exit code', done => {
58
125
  const process = createProcess();
59
126
  const command = new Command({ spawn: () => process });
@@ -83,6 +150,31 @@ describe('#start()', () => {
83
150
  process.emit('close', null, 'SIGKILL');
84
151
  });
85
152
 
153
+ it('shares closes to the close stream with timing information', done => {
154
+ const process1 = createProcess();
155
+ const command = new Command({ spawn: () => process1 });
156
+
157
+ const startDate = new Date();
158
+ const endDate = new Date(startDate.getTime() + 1000);
159
+ jest.spyOn(Date, 'now')
160
+ .mockReturnValueOnce(startDate.getTime())
161
+ .mockReturnValueOnce(endDate.getTime());
162
+
163
+ jest.spyOn(process, 'hrtime')
164
+ .mockReturnValueOnce([0, 0])
165
+ .mockReturnValueOnce([1, 1e8]);
166
+
167
+ command.close.subscribe(data => {
168
+ expect(data.timings.startDate).toStrictEqual(startDate);
169
+ expect(data.timings.endDate).toStrictEqual(endDate);
170
+ expect(data.timings.durationSeconds).toBe(1.1);
171
+ done();
172
+ });
173
+
174
+ command.start();
175
+ process1.emit('close', null, 'SIGKILL');
176
+ });
177
+
86
178
  it('shares closes to the close stream with command info and index', done => {
87
179
  const process = createProcess();
88
180
  const commandInfo = {
@@ -170,7 +262,7 @@ describe('#kill()', () => {
170
262
 
171
263
  it('marks the command as killed', done => {
172
264
  command.start();
173
-
265
+
174
266
  command.close.subscribe(data => {
175
267
  expect(data.exitCode).toBe(1);
176
268
  expect(data.killed).toBe(true);
@@ -32,7 +32,7 @@ module.exports = class CompletionListener {
32
32
  ? Rx.of(exitInfos, this.scheduler)
33
33
  : Rx.throwError(exitInfos, this.scheduler)
34
34
  ),
35
- take(1)
35
+ take(1),
36
36
  )
37
37
  .toPromise();
38
38
  }
@@ -85,4 +85,5 @@ describe('with success condition set to last', () => {
85
85
 
86
86
  return expect(result).rejects.toEqual([{ exitCode: 0 }, { exitCode: 1 }]);
87
87
  });
88
+
88
89
  });
@@ -18,6 +18,7 @@ const defaults = {
18
18
  raw: false,
19
19
  controllers: [],
20
20
  cwd: undefined,
21
+ timings: false
21
22
  };
22
23
 
23
24
  module.exports = (commands, options) => {
@@ -32,21 +33,28 @@ module.exports = (commands, options) => {
32
33
  new ExpandNpmWildcard()
33
34
  ];
34
35
 
36
+ let lastColor = '';
35
37
  commands = _(commands)
36
38
  .map(mapToCommandInfo)
37
39
  .flatMap(command => parseCommand(command, commandParsers))
38
- .map((command, index) => new Command(
39
- Object.assign({
40
- index,
41
- spawnOpts: getSpawnOpts({
42
- raw: options.raw,
43
- env: command.env,
44
- cwd: command.cwd || options.cwd,
45
- }),
46
- killProcess: options.kill,
47
- spawn: options.spawn,
48
- }, command)
49
- ))
40
+ .map((command, index) => {
41
+ // Use documented behaviour of repeating last color when specifying more commands than colors
42
+ lastColor = options.prefixColors && options.prefixColors[index] || lastColor;
43
+ return new Command(
44
+ Object.assign({
45
+ index,
46
+ spawnOpts: getSpawnOpts({
47
+ raw: options.raw,
48
+ env: command.env,
49
+ cwd: command.cwd || options.cwd,
50
+ }),
51
+ prefixColor: lastColor,
52
+ killProcess: options.kill,
53
+ spawn: options.spawn,
54
+ timings: options.timings,
55
+ }, command)
56
+ );
57
+ })
50
58
  .value();
51
59
 
52
60
  const handleResult = options.controllers.reduce(
@@ -67,7 +75,9 @@ module.exports = (commands, options) => {
67
75
  maybeRunMore(commandsLeft);
68
76
  }
69
77
 
70
- return new CompletionListener({ successCondition: options.successCondition })
78
+ return new CompletionListener({
79
+ successCondition: options.successCondition,
80
+ })
71
81
  .listen(commands)
72
82
  .finally(() => {
73
83
  handleResult.onFinishCallbacks.forEach((onFinish) => onFinish());
@@ -75,13 +85,15 @@ module.exports = (commands, options) => {
75
85
  };
76
86
 
77
87
  function mapToCommandInfo(command) {
78
- return {
88
+ return Object.assign({
79
89
  command: command.command || command,
80
90
  name: command.name || '',
81
- prefixColor: command.prefixColor || '',
82
91
  env: command.env || {},
83
92
  cwd: command.cwd || '',
84
- };
93
+
94
+ }, command.prefixColor ? {
95
+ prefixColor: command.prefixColor,
96
+ } : {});
85
97
  }
86
98
 
87
99
  function parseCommand(command, parsers) {
@@ -84,6 +84,19 @@ it('runs commands with a name or prefix color', () => {
84
84
  });
85
85
  });
86
86
 
87
+ it('runs commands with a list of colors', () => {
88
+ create(['echo', 'kill'], {
89
+ prefixColors: ['red']
90
+ });
91
+
92
+ controllers.forEach(controller => {
93
+ expect(controller.handle).toHaveBeenCalledWith([
94
+ expect.objectContaining({ command: 'echo', prefixColor: 'red' }),
95
+ expect.objectContaining({ command: 'kill', prefixColor: 'red' }),
96
+ ]);
97
+ });
98
+ });
99
+
87
100
  it('passes commands wrapped from a controller to the next one', () => {
88
101
  const fakeCommand = createFakeCommand('banana', 'banana');
89
102
  controllers[0].handle.mockReturnValue({ commands: [fakeCommand] });
package/src/defaults.js CHANGED
@@ -10,6 +10,8 @@ module.exports = {
10
10
  handleInput: false,
11
11
  // How many processes to run at once
12
12
  maxProcesses: 0,
13
+ // Indices and names of commands whose output to be not logged
14
+ hide: '',
13
15
  nameSeparator: ',',
14
16
  // Which prefix style to use when logging processes output.
15
17
  prefix: '',
@@ -27,5 +29,7 @@ module.exports = {
27
29
  // Refer to https://date-fns.org/v2.0.1/docs/format
28
30
  timestampFormat: 'yyyy-MM-dd HH:mm:ss.SSS',
29
31
  // Current working dir passed as option to spawn command. Default: process.cwd()
30
- cwd: undefined
32
+ cwd: undefined,
33
+ // Whether to show timing information for processes in console output
34
+ timings: false,
31
35
  };
@@ -0,0 +1,22 @@
1
+ const stream = require('stream');
2
+ const { createMockInstance } = require('jest-create-mock-instance');
3
+
4
+ const Logger = require('../logger');
5
+ const createFakeCommand = require('./fixtures/fake-command');
6
+ const BaseHandler = require('./base-handler');
7
+
8
+ let commands, controller, inputStream, logger;
9
+
10
+ beforeEach(() => {
11
+ commands = [
12
+ createFakeCommand('foo', 'echo foo', 0),
13
+ createFakeCommand('bar', 'echo bar', 1),
14
+ ];
15
+ inputStream = new stream.PassThrough();
16
+ logger = createMockInstance(Logger);
17
+ controller = new BaseHandler({ logger });
18
+ });
19
+
20
+ it('returns same commands and null onFinish callback by default', () => {
21
+ expect(controller.handle(commands)).toMatchObject({ commands, onFinish: null });
22
+ });
@@ -0,0 +1,64 @@
1
+ const formatDate = require('date-fns/format');
2
+ const Rx = require('rxjs');
3
+ const { bufferCount, take } = require('rxjs/operators');
4
+ const _ = require('lodash');
5
+ const BaseHandler = require('./base-handler');
6
+
7
+ module.exports = class LogTimings extends BaseHandler {
8
+ constructor({ logger, timestampFormat }) {
9
+ super({ logger });
10
+
11
+ this.timestampFormat = timestampFormat;
12
+ }
13
+
14
+ printExitInfoTimingTable(exitInfos) {
15
+ const exitInfoTable = _(exitInfos)
16
+ .sortBy(({ timings }) => timings.durationSeconds)
17
+ .reverse()
18
+ .map(({ command, timings, killed, exitCode }) => {
19
+ const readableDurationMs = (timings.endDate - timings.startDate).toLocaleString();
20
+ return {
21
+ name: command.name,
22
+ duration: `${readableDurationMs}ms`,
23
+ 'exit code': exitCode,
24
+ killed,
25
+ command: command.command,
26
+ };
27
+ })
28
+ .value();
29
+
30
+ this.logger.logGlobalEvent('Timings:');
31
+ this.logger.logTable(exitInfoTable);
32
+ return exitInfos;
33
+ };
34
+
35
+ handle(commands) {
36
+ if (!this.logger) {
37
+ return { commands };
38
+ }
39
+
40
+ // individual process timings
41
+ commands.forEach(command => {
42
+ command.timer.subscribe(({ startDate, endDate }) => {
43
+ if (!endDate) {
44
+ const formattedStartDate = formatDate(startDate, this.timestampFormat);
45
+ this.logger.logCommandEvent(`${command.command} started at ${formattedStartDate}`, command);
46
+ } else {
47
+ const durationMs = endDate.getTime() - startDate.getTime();
48
+ const formattedEndDate = formatDate(endDate, this.timestampFormat);
49
+ this.logger.logCommandEvent(`${command.command} stopped at ${formattedEndDate} after ${durationMs.toLocaleString()}ms`, command);
50
+ }
51
+ });
52
+ });
53
+
54
+ // overall summary timings
55
+ const closeStreams = commands.map(command => command.close);
56
+ this.allProcessesClosed = Rx.merge(...closeStreams).pipe(
57
+ bufferCount(closeStreams.length),
58
+ take(1),
59
+ );
60
+ this.allProcessesClosed.subscribe((exitInfos) => this.printExitInfoTimingTable(exitInfos));
61
+
62
+ return { commands };
63
+ }
64
+ };
@@ -0,0 +1,137 @@
1
+ const { createMockInstance } = require('jest-create-mock-instance');
2
+ const formatDate = require('date-fns/format');
3
+ const Logger = require('../logger');
4
+ const LogTimings = require( './log-timings' );
5
+ const createFakeCommand = require('./fixtures/fake-command');
6
+
7
+ // shown in timing order
8
+ const startDate0 = new Date();
9
+ const startDate1 = new Date(startDate0.getTime() + 1000);
10
+ const endDate1 = new Date(startDate0.getTime() + 3000);
11
+ const endDate0 = new Date(startDate0.getTime() + 5000);
12
+
13
+ const timestampFormat = 'yyyy-MM-dd HH:mm:ss.SSS';
14
+ const getDurationText = (startDate, endDate) => `${(endDate.getTime() - startDate.getTime()).toLocaleString()}ms`;
15
+ const command0DurationTextMs = getDurationText(startDate0, endDate0);
16
+ const command1DurationTextMs = getDurationText(startDate1, endDate1);
17
+
18
+ const exitInfoToTimingInfo = ({ command, timings, killed, exitCode }) => {
19
+ const readableDurationMs = getDurationText(timings.startDate, timings.endDate);
20
+ return {
21
+ name: command.name,
22
+ duration: readableDurationMs,
23
+ 'exit code': exitCode,
24
+ killed,
25
+ command: command.command,
26
+ };
27
+ };
28
+
29
+ let controller, logger, commands, command0ExitInfo, command1ExitInfo;
30
+
31
+ beforeEach(() => {
32
+ commands = [
33
+ createFakeCommand('foo', 'command 1', 0),
34
+ createFakeCommand('bar', 'command 2', 1),
35
+ ];
36
+
37
+ command0ExitInfo = {
38
+ command: commands[0].command,
39
+ timings: {
40
+ startDate: startDate0,
41
+ endDate: endDate0,
42
+ },
43
+ index: commands[0].index,
44
+ killed: false,
45
+ exitCode: 0,
46
+ };
47
+
48
+ command1ExitInfo = {
49
+ command: commands[1].command,
50
+ timings: {
51
+ startDate: startDate1,
52
+ endDate: endDate1,
53
+ },
54
+ index: commands[1].index,
55
+ killed: false,
56
+ exitCode: 0,
57
+ };
58
+
59
+ logger = createMockInstance(Logger);
60
+ controller = new LogTimings({ logger, timestampFormat });
61
+ });
62
+
63
+ it('returns same commands', () => {
64
+ expect(controller.handle(commands)).toMatchObject({ commands });
65
+ });
66
+
67
+ it('does not log timings and doesn\'t throw if no logger is provided', () => {
68
+ controller = new LogTimings({ });
69
+ controller.handle(commands);
70
+
71
+ commands[0].timer.next({ startDate: startDate0 });
72
+ commands[1].timer.next({ startDate: startDate1 });
73
+ commands[1].timer.next({ startDate: startDate1, endDate: endDate1 });
74
+ commands[0].timer.next({ startDate: startDate0, endDate: endDate0 });
75
+
76
+ expect(logger.logCommandEvent).toHaveBeenCalledTimes(0);
77
+ });
78
+
79
+ it('logs the timings at the start and end (ie complete or error) event of each command', () => {
80
+ controller.handle(commands);
81
+
82
+ commands[0].timer.next({ startDate: startDate0 });
83
+ commands[1].timer.next({ startDate: startDate1 });
84
+ commands[1].timer.next({ startDate: startDate1, endDate: endDate1 });
85
+ commands[0].timer.next({ startDate: startDate0, endDate: endDate0 });
86
+
87
+ expect(logger.logCommandEvent).toHaveBeenCalledTimes(4);
88
+ expect(logger.logCommandEvent).toHaveBeenCalledWith(
89
+ `${commands[0].command} started at ${formatDate(startDate0, timestampFormat)}`,
90
+ commands[0]
91
+ );
92
+ expect(logger.logCommandEvent).toHaveBeenCalledWith(
93
+ `${commands[1].command} started at ${formatDate(startDate1, timestampFormat)}`,
94
+ commands[1]
95
+ );
96
+ expect(logger.logCommandEvent).toHaveBeenCalledWith(
97
+ `${commands[1].command} stopped at ${formatDate(endDate1, timestampFormat)} after ${command1DurationTextMs}`,
98
+ commands[1]
99
+ );
100
+ expect(logger.logCommandEvent).toHaveBeenCalledWith(
101
+ `${commands[0].command} stopped at ${formatDate(endDate0, timestampFormat)} after ${command0DurationTextMs}`,
102
+ commands[0]
103
+ );
104
+ });
105
+
106
+ it('does not log timings summary if there was an error', () => {
107
+ controller.handle(commands);
108
+
109
+ commands[0].close.next(command0ExitInfo);
110
+ commands[1].error.next();
111
+
112
+ expect(logger.logTable).toHaveBeenCalledTimes(0);
113
+
114
+ });
115
+
116
+ it('logs the sorted timings summary when all processes close successfully', () => {
117
+ jest.spyOn(controller, 'printExitInfoTimingTable');
118
+ controller.handle(commands);
119
+
120
+ commands[0].close.next(command0ExitInfo);
121
+ commands[1].close.next(command1ExitInfo);
122
+
123
+ expect(logger.logTable).toHaveBeenCalledTimes(1);
124
+
125
+ // un-sorted ie by finish order
126
+ expect(controller.printExitInfoTimingTable).toHaveBeenCalledWith([
127
+ command0ExitInfo,
128
+ command1ExitInfo
129
+ ]);
130
+
131
+ // sorted by duration
132
+ expect(logger.logTable).toHaveBeenCalledWith([
133
+ exitInfoToTimingInfo(command1ExitInfo),
134
+ exitInfoToTimingInfo(command0ExitInfo)
135
+ ]);
136
+
137
+ });
@@ -69,7 +69,15 @@ it('restarts processes up to tries', () => {
69
69
  expect(commands[0].start).toHaveBeenCalledTimes(2);
70
70
  });
71
71
 
72
- it.todo('restart processes forever, if tries is negative');
72
+ it('restart processes forever, if tries is negative', () => {
73
+ controller = new RestartProcess({
74
+ logger,
75
+ scheduler,
76
+ delay: 100,
77
+ tries: -1
78
+ });
79
+ expect(controller.tries).toBe(Infinity);
80
+ });
73
81
 
74
82
  it('restarts processes until they succeed', () => {
75
83
  controller.handle(commands);
package/src/logger.js CHANGED
@@ -5,7 +5,11 @@ const formatDate = require('date-fns/format');
5
5
  const defaults = require('./defaults');
6
6
 
7
7
  module.exports = class Logger {
8
- constructor({ outputStream, prefixFormat, prefixLength, raw, timestampFormat }) {
8
+ constructor({ hide, outputStream, prefixFormat, prefixLength, raw, timestampFormat }) {
9
+ // To avoid empty strings from hiding the output of commands that don't have a name,
10
+ // keep in the list of commands to hide only strings with some length.
11
+ // This might happen through the CLI when no `--hide` argument is specified, for example.
12
+ this.hide = _.castArray(hide).filter(name => name || name === 0).map(String);
9
13
  this.raw = raw;
10
14
  this.outputStream = outputStream;
11
15
  this.prefixFormat = prefixFormat;
@@ -76,6 +80,10 @@ module.exports = class Logger {
76
80
  }
77
81
 
78
82
  logCommandText(text, command) {
83
+ if (this.hide.includes(String(command.index)) || this.hide.includes(command.name)) {
84
+ return;
85
+ }
86
+
79
87
  const prefix = this.colorText(command, this.getPrefix(command));
80
88
  return this.log(prefix + (prefix ? ' ' : ''), text);
81
89
  }
@@ -88,6 +96,63 @@ module.exports = class Logger {
88
96
  this.log(chalk.reset('-->') + ' ', chalk.reset(text) + '\n');
89
97
  }
90
98
 
99
+ logTable(tableContents) {
100
+ // For now, can only print array tables with some content.
101
+ if (this.raw || !Array.isArray(tableContents) || !tableContents.length) {
102
+ return;
103
+ }
104
+
105
+ let nextColIndex = 0;
106
+ const headers = {};
107
+ const contentRows = tableContents.map(row => {
108
+ const rowContents = [];
109
+ Object.keys(row).forEach((col) => {
110
+ if (!headers[col]) {
111
+ headers[col] = {
112
+ index: nextColIndex++,
113
+ //
114
+ length: col.length,
115
+ };
116
+ }
117
+
118
+ const colIndex = headers[col].index;
119
+ const formattedValue = String(row[col] == null ? '' : row[col]);
120
+ // Update the column length in case this rows value is longer than the previous length for the column.
121
+ headers[col].length = Math.max(formattedValue.length, headers[col].length);
122
+ rowContents[colIndex] = formattedValue;
123
+ return rowContents;
124
+ });
125
+ return rowContents;
126
+ });
127
+
128
+ const headersFormatted = Object
129
+ .keys(headers)
130
+ .map(header => header.padEnd(headers[header].length, ' '));
131
+
132
+ if (!headersFormatted.length) {
133
+ // No columns exist.
134
+ return;
135
+ }
136
+
137
+ const borderRowFormatted = headersFormatted.map(header => '─'.padEnd(header.length, '─'));
138
+
139
+ this.logGlobalEvent(`┌─${borderRowFormatted.join('─┬─')}─┐`);
140
+ this.logGlobalEvent(`│ ${headersFormatted.join(' │ ')} │`);
141
+ this.logGlobalEvent(`├─${borderRowFormatted.join('─┼─')}─┤`);
142
+
143
+ contentRows.forEach(contentRow => {
144
+ const contentRowFormatted = headersFormatted.map((header, colIndex) => {
145
+ // If the table was expanded after this row was processed, it won't have this column.
146
+ // Use an empty string in this case.
147
+ const col = contentRow[colIndex] || '';
148
+ return col.padEnd(header.length, ' ');
149
+ });
150
+ this.logGlobalEvent(`│ ${contentRowFormatted.join(' │ ')} │`);
151
+ });
152
+
153
+ this.logGlobalEvent(`└─${borderRowFormatted.join('─┴─')}─┘`);
154
+ }
155
+
91
156
  log(prefix, text) {
92
157
  if (this.raw) {
93
158
  return this.outputStream.write(text);
@@ -176,6 +176,20 @@ describe('#logCommandText()', () => {
176
176
 
177
177
  expect(logger.log).toHaveBeenCalledWith(chalk.hex(prefixColor)('[1]') + ' ', 'foo');
178
178
  });
179
+
180
+ it('does nothing if command is hidden by name', () => {
181
+ const logger = createLogger({ hide: ['abc'] });
182
+ logger.logCommandText('foo', { name: 'abc' });
183
+
184
+ expect(logger.log).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it('does nothing if command is hidden by index', () => {
188
+ const logger = createLogger({ hide: [3] });
189
+ logger.logCommandText('foo', { index: 3 });
190
+
191
+ expect(logger.log).not.toHaveBeenCalled();
192
+ });
179
193
  });
180
194
 
181
195
  describe('#logCommandEvent()', () => {
@@ -186,6 +200,20 @@ describe('#logCommandEvent()', () => {
186
200
  expect(logger.log).not.toHaveBeenCalled();
187
201
  });
188
202
 
203
+ it('does nothing if command is hidden by name', () => {
204
+ const logger = createLogger({ hide: ['abc'] });
205
+ logger.logCommandEvent('foo', { name: 'abc' });
206
+
207
+ expect(logger.log).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it('does nothing if command is hidden by index', () => {
211
+ const logger = createLogger({ hide: [3] });
212
+ logger.logCommandEvent('foo', { index: 3 });
213
+
214
+ expect(logger.log).not.toHaveBeenCalled();
215
+ });
216
+
189
217
  it('logs text in gray dim', () => {
190
218
  const logger = createLogger();
191
219
  logger.logCommandEvent('foo', { index: 1 });
@@ -193,3 +221,98 @@ describe('#logCommandEvent()', () => {
193
221
  expect(logger.log).toHaveBeenCalledWith(chalk.reset('[1]') + ' ', chalk.reset('foo') + '\n');
194
222
  });
195
223
  });
224
+
225
+ describe('#logTable()', () => {
226
+ it('does not log anything in raw mode', () => {
227
+ const logger = createLogger({ raw: true });
228
+ logger.logTable([{ foo: 1, bar: 2 }]);
229
+
230
+ expect(logger.log).not.toHaveBeenCalled();
231
+ });
232
+
233
+ it('does not log anything if value is not an array', () => {
234
+ const logger = createLogger();
235
+ logger.logTable({});
236
+ logger.logTable(null);
237
+ logger.logTable(0);
238
+ logger.logTable('');
239
+
240
+ expect(logger.log).not.toHaveBeenCalled();
241
+ });
242
+
243
+ it('does not log anything if array is empy', () => {
244
+ const logger = createLogger();
245
+ logger.logTable([]);
246
+
247
+ expect(logger.log).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it('does not log anything if array items have no properties', () => {
251
+ const logger = createLogger();
252
+ logger.logTable([{}]);
253
+
254
+ expect(logger.log).not.toHaveBeenCalled();
255
+ });
256
+
257
+ it('logs a header for each item\'s properties', () => {
258
+ const logger = createLogger();
259
+ logger.logTable([{ foo: 1, bar: 2 }]);
260
+
261
+ expect(logger.log).toHaveBeenCalledWith(
262
+ chalk.reset('-->') + ' ',
263
+ chalk.reset('│ foo │ bar │') + '\n',
264
+ );
265
+ });
266
+
267
+ it('logs padded headers according to longest column\'s value', () => {
268
+ const logger = createLogger();
269
+ logger.logTable([{ a: 'foo', b: 'barbaz' }]);
270
+
271
+ expect(logger.log).toHaveBeenCalledWith(
272
+ chalk.reset('-->') + ' ',
273
+ chalk.reset('│ a │ b │') + '\n',
274
+ );
275
+ });
276
+
277
+ it('logs each items\'s values', () => {
278
+ const logger = createLogger();
279
+ logger.logTable([{ foo: 123 }, { foo: 456 }]);
280
+
281
+ expect(logger.log).toHaveBeenCalledWith(
282
+ chalk.reset('-->') + ' ',
283
+ chalk.reset('│ 123 │') + '\n',
284
+ );
285
+ expect(logger.log).toHaveBeenCalledWith(
286
+ chalk.reset('-->') + ' ',
287
+ chalk.reset('│ 456 │') + '\n',
288
+ );
289
+ });
290
+
291
+ it('logs each items\'s values padded according to longest column\'s value', () => {
292
+ const logger = createLogger();
293
+ logger.logTable([{ foo: 1 }, { foo: 123 }]);
294
+
295
+ expect(logger.log).toHaveBeenCalledWith(
296
+ chalk.reset('-->') + ' ',
297
+ chalk.reset('│ 1 │') + '\n',
298
+ );
299
+ });
300
+
301
+ it('logs items with different properties in each', () => {
302
+ const logger = createLogger();
303
+ logger.logTable([{ foo: 1 }, { bar: 2 }]);
304
+
305
+ expect(logger.log).toHaveBeenCalledWith(
306
+ chalk.reset('-->') + ' ',
307
+ chalk.reset('│ foo │ bar │') + '\n',
308
+ );
309
+ expect(logger.log).toHaveBeenCalledWith(
310
+ chalk.reset('-->') + ' ',
311
+ chalk.reset('│ 1 │ │') + '\n',
312
+ );
313
+ expect(logger.log).toHaveBeenCalledWith(
314
+ chalk.reset('-->') + ' ',
315
+ chalk.reset('│ │ 2 │') + '\n',
316
+ );
317
+ });
318
+ });