concurrently 6.4.0 → 6.5.0

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
@@ -203,6 +203,10 @@ Examples:
203
203
  $ concurrently --names "HTTP,WATCH" -c "bgBlue.bold,bgMagenta.bold"
204
204
  "http-server" "npm run watch"
205
205
 
206
+ - Configuring via environment variables with CONCURRENTLY_ prefix
207
+
208
+ $ CONCURRENTLY_RAW=true CONCURRENTLY_KILL_OTHERS=true concurrently "echo hello" "echo world"
209
+
206
210
  - Send input to default
207
211
 
208
212
  $ concurrently --handle-input "nodemon" "npm run watch-js"
@@ -222,9 +226,9 @@ Examples:
222
226
 
223
227
  $ concurrently npm:watch-node npm:watch-js npm:watch-css
224
228
 
225
- - Shortened NPM run command with wildcard
229
+ - Shortened NPM run command with wildcard (make sure to wrap it in quotes!)
226
230
 
227
- $ concurrently npm:watch-*
231
+ $ concurrently "npm:watch-*"
228
232
 
229
233
  For more details, visit https://github.com/open-cli-tools/concurrently
230
234
  ```
@@ -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': {
@@ -62,6 +64,11 @@ const args = yargs
62
64
  default: defaults.hide,
63
65
  type: 'string'
64
66
  },
67
+ 'timings': {
68
+ describe: 'Show timing information for all processes',
69
+ type: 'boolean',
70
+ default: defaults.timings
71
+ },
65
72
 
66
73
  // Kill others
67
74
  'k': {
@@ -142,7 +149,7 @@ const args = yargs
142
149
  'Can be either the index or the name of the process.'
143
150
  }
144
151
  })
145
- .group(['m', 'n', 'name-separator', 'raw', 's', 'no-color', 'hide'], 'General')
152
+ .group(['m', 'n', 'name-separator', 'raw', 's', 'no-color', 'hide', 'timings'], 'General')
146
153
  .group(['p', 'c', 'l', 't'], 'Prefix styling')
147
154
  .group(['i', 'default-input-target'], 'Input handling')
148
155
  .group(['k', 'kill-others-on-fail'], 'Killing other processes')
@@ -172,6 +179,7 @@ concurrently(args._.map((command, index) => ({
172
179
  restartTries: args.restartTries,
173
180
  successCondition: args.success,
174
181
  timestampFormat: args.timestampFormat,
182
+ timings: args.timings
175
183
  }).then(
176
184
  () => process.exit(0),
177
185
  () => process.exit(1)
@@ -346,7 +346,6 @@ describe('--handle-input', () => {
346
346
  }, done);
347
347
  });
348
348
 
349
-
350
349
  it('forwards input to process --default-input-target', done => {
351
350
  const lines = [];
352
351
  const child = run('-ki --default-input-target 1 "node fixtures/read-echo.js" "node fixtures/read-echo.js"');
@@ -383,3 +382,47 @@ describe('--handle-input', () => {
383
382
  }, done);
384
383
  });
385
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,6 +8,7 @@ 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({
@@ -43,9 +44,14 @@ module.exports = exports = (commands, options = {}) => {
43
44
  new KillOthers({
44
45
  logger,
45
46
  conditions: options.killOthers
47
+ }),
48
+ new LogTimings({
49
+ logger: options.timings ? logger: null,
50
+ timestampFormat: options.timestampFormat,
46
51
  })
47
52
  ],
48
- prefixColors: options.prefixColors || []
53
+ prefixColors: options.prefixColors || [],
54
+ timings: options.timings
49
55
  });
50
56
  };
51
57
 
@@ -60,3 +66,4 @@ exports.LogError = LogError;
60
66
  exports.LogExit = LogExit;
61
67
  exports.LogOutput = LogOutput;
62
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.4.0",
3
+ "version": "6.5.0",
4
4
  "description": "Run commands concurrently",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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'
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) => {
@@ -50,6 +51,7 @@ module.exports = (commands, options) => {
50
51
  prefixColor: lastColor,
51
52
  killProcess: options.kill,
52
53
  spawn: options.spawn,
54
+ timings: options.timings,
53
55
  }, command)
54
56
  );
55
57
  })
@@ -73,7 +75,9 @@ module.exports = (commands, options) => {
73
75
  maybeRunMore(commandsLeft);
74
76
  }
75
77
 
76
- return new CompletionListener({ successCondition: options.successCondition })
78
+ return new CompletionListener({
79
+ successCondition: options.successCondition,
80
+ })
77
81
  .listen(commands)
78
82
  .finally(() => {
79
83
  handleResult.onFinishCallbacks.forEach((onFinish) => onFinish());
@@ -86,6 +90,7 @@ function mapToCommandInfo(command) {
86
90
  name: command.name || '',
87
91
  env: command.env || {},
88
92
  cwd: command.cwd || '',
93
+
89
94
  }, command.prefixColor ? {
90
95
  prefixColor: command.prefixColor,
91
96
  } : {});
package/src/defaults.js CHANGED
@@ -29,5 +29,7 @@ module.exports = {
29
29
  // Refer to https://date-fns.org/v2.0.1/docs/format
30
30
  timestampFormat: 'yyyy-MM-dd HH:mm:ss.SSS',
31
31
  // Current working dir passed as option to spawn command. Default: process.cwd()
32
- cwd: undefined
32
+ cwd: undefined,
33
+ // Whether to show timing information for processes in console output
34
+ timings: false,
33
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
@@ -96,6 +96,63 @@ module.exports = class Logger {
96
96
  this.log(chalk.reset('-->') + ' ', chalk.reset(text) + '\n');
97
97
  }
98
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
+
99
156
  log(prefix, text) {
100
157
  if (this.raw) {
101
158
  return this.outputStream.write(text);
@@ -221,3 +221,98 @@ describe('#logCommandEvent()', () => {
221
221
  expect(logger.log).toHaveBeenCalledWith(chalk.reset('[1]') + ' ', chalk.reset('foo') + '\n');
222
222
  });
223
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
+ });