concurrently
Version:
Run commands concurrently
369 lines (368 loc) • 18.2 kB
JavaScript
import { execSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
import { sendCtrlC, spawnWithWrapper } from 'ctrlc-wrapper';
import Rx from 'rxjs';
import { map } from 'rxjs/operators';
import stringArgv from 'string-argv';
import { beforeAll, describe, expect, it } from 'vitest';
import { escapeRegExp } from '../lib/utils.js';
const isWindows = process.platform === 'win32';
const createKillMessage = (prefix, signal) => {
const map = {
SIGTERM: isWindows ? 1 : '(SIGTERM|143)',
// Could theoretically be anything (e.g. 0) if process has SIGINT handler
SIGINT: isWindows ? '(3221225786|0)' : '(SIGINT|130|0)',
};
return new RegExp(`${escapeRegExp(prefix)} exited with code ${map[signal] ?? signal}`);
};
const concurrentlyBin = path.join(__dirname, '..', 'dist', 'bin', 'index.js');
// Build once, then spawn the real CLI without a shell (see open-cli-tools/concurrently#346).
beforeAll(() => {
execSync('pnpm run build', { cwd: path.join(__dirname, '..'), stdio: 'pipe' });
}, 20_000);
/**
* Creates a child process running 'concurrently' with the given args.
* Returns observables for its combined stdout + stderr output, close events, pid, and stdin stream.
*/
const run = (args, ctrlcWrapper) => {
const spawnFn = ctrlcWrapper ? spawnWithWrapper : spawn;
const child = spawnFn('node', [concurrentlyBin, ...stringArgv(args)], {
cwd: __dirname,
env: {
...process.env,
// VS Code extension might allow colors, but this breaks assertions.
// Force colors to be disabled to avoid that.
FORCE_COLOR: '0',
},
});
const stdout = readline.createInterface({
input: child.stdout,
});
const stderr = readline.createInterface({
input: child.stderr,
});
const log = new Rx.Observable((observer) => {
stdout.on('line', (line) => {
observer.next(line);
});
stderr.on('line', (line) => {
observer.next(line);
});
child.on('close', () => {
observer.complete();
});
});
const exit = Rx.firstValueFrom(Rx.fromEvent(child, 'exit').pipe(map((event) => {
const exit = event;
return {
/** The exit code if the child exited on its own. */
code: exit[0],
/** The signal by which the child process was terminated. */
signal: exit[1],
};
})));
const getLogLines = async () => {
const observerSpy = subscribeSpyTo(log);
await observerSpy.onComplete();
observerSpy.unsubscribe();
return observerSpy.getValues();
};
return {
process: child,
stdin: child.stdin,
pid: child.pid,
log,
getLogLines,
exit,
};
};
it('has help command', async () => {
const exit = await run('--help').exit;
expect(exit.code).toBe(0);
});
it('prints help when no arguments are passed', async () => {
const exit = await run('').exit;
expect(exit.code).toBe(0);
});
describe('has version command', () => {
const pkg = fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8');
const { version } = JSON.parse(pkg);
it.each(['--version', '-V', '-v'])('%s', async (arg) => {
const child = run(arg);
const log = await child.getLogLines();
expect(log).toContain(version);
const { code } = await child.exit;
expect(code).toBe(0);
});
});
describe('exiting conditions', () => {
it('is of success by default when running successful commands', async () => {
const exit = await run('"echo foo" "echo bar"').exit;
expect(exit.code).toBe(0);
});
it('strips outer CLI wrapper quotes before running a command', async () => {
const child = run('"echo foo"');
const lines = await child.getLogLines();
const exit = await child.exit;
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(exit.code).toBe(0);
});
it('is of failure by default when one of the command fails', async () => {
const exit = await run('"echo foo" "exit 1"').exit;
expect(exit.code).toBeGreaterThan(0);
});
it('is of success when --success=first and first command to exit succeeds', async () => {
const exit = await run('--success=first "echo foo" "node __fixtures__/sleep.js 0.5 && exit 1"').exit;
expect(exit.code).toBe(0);
});
it('is of failure when --success=first and first command to exit fails', async () => {
const exit = await run('--success=first "exit 1" "node __fixtures__/sleep.js 0.5 && echo foo"').exit;
expect(exit.code).toBeGreaterThan(0);
});
describe('is of success when --success=last and last command to exit succeeds', () => {
it.each(['--success=last', '-s last'])('%s', async (arg) => {
const exit = await run(`${arg} "exit 1" "node __fixtures__/sleep.js 0.5 && echo foo"`)
.exit;
expect(exit.code).toBe(0);
});
});
it('is of failure when --success=last and last command to exit fails', async () => {
const exit = await run('--success=last "echo foo" "node __fixtures__/sleep.js 0.5 && exit 1"').exit;
expect(exit.code).toBeGreaterThan(0);
});
it('is of success when a SIGINT is sent', async () => {
// Windows doesn't support sending signals like on POSIX platforms.
// However, in a console, processes can be interrupted with CTRL+C (like a SIGINT).
// This is what we simulate here with the help of a wrapper application.
const child = run('"node __fixtures__/read-echo.js"', isWindows);
// Wait for command to have started before sending SIGINT
child.log.subscribe((line) => {
if (/READING/.test(line)) {
if (isWindows) {
// Instruct the wrapper to send CTRL+C to its child
sendCtrlC(child.process);
}
else {
process.kill(Number(child.pid), 'SIGINT');
}
}
});
const lines = await child.getLogLines();
const exit = await child.exit;
expect(exit.code).toBe(0);
expect(lines).toContainEqual(expect.stringMatching(createKillMessage('[0] node __fixtures__/read-echo.js',
// TODO: Flappy value due to race condition, sometimes killed by concurrently (exit code 1),
// sometimes terminated on its own (exit code 0).
// Related issue: https://github.com/open-cli-tools/concurrently/issues/283
isWindows ? '(3221225786|0|1)' : 'SIGINT')));
});
});
describe('does not log any extra output', () => {
it.each(['--raw', '-r'])('%s', async (arg) => {
const lines = await run(`${arg} "echo foo" "echo bar"`).getLogLines();
expect(lines).toHaveLength(2);
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).toContainEqual(expect.stringContaining('bar'));
});
});
describe('--hide', () => {
it('hides the output of a process by its index', async () => {
const lines = await run('--hide 1 "echo foo" "echo bar"').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).not.toContainEqual(expect.stringContaining('bar'));
});
it('hides the output of a process by its name', async () => {
const lines = await run('-n foo,bar --hide bar "echo foo" "echo bar"').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).not.toContainEqual(expect.stringContaining('bar'));
});
it('hides the output of a process by its index in raw mode', async () => {
const lines = await run('--hide 1 --raw "echo foo" "echo bar"').getLogLines();
expect(lines).toHaveLength(1);
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).not.toContainEqual(expect.stringContaining('bar'));
});
it('hides the output of a process by its name in raw mode', async () => {
const lines = await run('-n foo,bar --hide bar --raw "echo foo" "echo bar"').getLogLines();
expect(lines).toHaveLength(1);
expect(lines).toContainEqual(expect.stringContaining('foo'));
expect(lines).not.toContainEqual(expect.stringContaining('bar'));
});
});
describe('--group', () => {
it('groups output per process', async () => {
const lines = await run('--group "echo foo && node __fixtures__/sleep.js 1 && echo bar" "echo baz"').getLogLines();
expect(lines.slice(0, 4)).toEqual([
expect.stringContaining('foo'),
expect.stringContaining('bar'),
expect.any(String),
expect.stringContaining('baz'),
]);
});
});
describe('--names', () => {
describe('prefixes with names', () => {
it.each(['--names', '-n'])('%s', async (arg) => {
const lines = await run(`${arg} foo,bar "echo foo" "echo bar"`).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[foo] foo'));
expect(lines).toContainEqual(expect.stringContaining('[bar] bar'));
});
});
});
describe('specifies custom prefix', () => {
it.each(['--prefix', '-p'])('%s', async (arg) => {
const lines = await run(`${arg} command "echo foo" "echo bar"`).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[echo foo] foo'));
expect(lines).toContainEqual(expect.stringContaining('[echo bar] bar'));
});
});
describe('specifies custom prefix length', () => {
it.each(['--prefix command --prefix-length 5', '-p command -l 5'])('%s', async (arg) => {
const lines = await run(`${arg} "echo foo" "echo bar"`).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[ec..o] foo'));
expect(lines).toContainEqual(expect.stringContaining('[ec..r] bar'));
});
});
describe('--pad-prefix', () => {
it('pads prefixes with spaces', async () => {
const lines = await run('--pad-prefix -n foo,barbaz "echo foo" "echo bar"').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[foo ]'));
expect(lines).toContainEqual(expect.stringContaining('[barbaz]'));
});
});
describe('--restart-tries', () => {
it('changes how many times a command will restart', async () => {
const lines = await run('--restart-tries 1 "exit 1"').getLogLines();
expect(lines).toEqual([
expect.stringContaining('[0] exit 1 exited with code 1'),
expect.stringContaining('[0] exit 1 restarted'),
expect.stringContaining('[0] exit 1 exited with code 1'),
]);
});
});
describe('--kill-others', () => {
describe('kills on success', () => {
it.each(['--kill-others', '-k'])('%s', async (arg) => {
const lines = await run(`${arg} "node __fixtures__/sleep.js 10" "exit 0"`).getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[1] exit 0 exited with code 0'));
expect(lines).toContainEqual(expect.stringContaining('Sending SIGTERM to other processes'));
expect(lines).toContainEqual(expect.stringMatching(createKillMessage('[0] node __fixtures__/sleep.js 10', 'SIGTERM')));
});
});
it('kills on failure', async () => {
const lines = await run('--kill-others "node __fixtures__/sleep.js 10" "exit 1"').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[1] exit 1 exited with code 1'));
expect(lines).toContainEqual(expect.stringContaining('Sending SIGTERM to other processes'));
expect(lines).toContainEqual(expect.stringMatching(createKillMessage('[0] node __fixtures__/sleep.js 10', 'SIGTERM')));
});
});
describe('--kill-others-on-fail', () => {
it('does not kill on success', async () => {
const lines = await run('--kill-others-on-fail "node __fixtures__/sleep.js 0.5" "exit 0"').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[1] exit 0 exited with code 0'));
expect(lines).toContainEqual(expect.stringContaining('[0] node __fixtures__/sleep.js 0.5 exited with code 0'));
});
it('kills on failure', async () => {
const lines = await run('--kill-others-on-fail "node __fixtures__/sleep.js 10" "exit 1"').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[1] exit 1 exited with code 1'));
expect(lines).toContainEqual(expect.stringContaining('Sending SIGTERM to other processes'));
expect(lines).toContainEqual(expect.stringMatching(createKillMessage('[0] node __fixtures__/sleep.js 10', 'SIGTERM')));
});
});
describe('--handle-input', () => {
describe('forwards input to first process by default', () => {
it.each(['--handle-input', '-i'])('%s', async (arg) => {
const child = run(`${arg} "node __fixtures__/read-echo.js"`);
child.log.subscribe((line) => {
if (/READING/.test(line)) {
child.stdin.write('stop\n');
}
});
const lines = await child.getLogLines();
const exit = await child.exit;
expect(exit.code).toBe(0);
expect(lines).toContainEqual(expect.stringContaining('[0] stop'));
expect(lines).toContainEqual(expect.stringContaining('[0] node __fixtures__/read-echo.js exited with code 0'));
});
});
it('forwards input to process --default-input-target', async () => {
const child = run('-ki --default-input-target 1 "node __fixtures__/read-echo.js" "node __fixtures__/read-echo.js"');
child.log.subscribe((line) => {
if (/\[1\] READING/.test(line)) {
child.stdin.write('stop\n');
}
});
const lines = await child.getLogLines();
const exit = await child.exit;
expect(exit.code).toBeGreaterThan(0);
expect(lines).toContainEqual(expect.stringContaining('[1] stop'));
expect(lines).toContainEqual(expect.stringMatching(createKillMessage('[0] node __fixtures__/read-echo.js', 'SIGTERM')));
});
it('forwards input to specified process', async () => {
const child = run('-ki "node __fixtures__/read-echo.js" "node __fixtures__/read-echo.js"');
child.log.subscribe((line) => {
if (/\[1\] READING/.test(line)) {
child.stdin.write('1:stop\n');
}
});
const lines = await child.getLogLines();
const exit = await child.exit;
expect(exit.code).toBeGreaterThan(0);
expect(lines).toContainEqual(expect.stringContaining('[1] stop'));
expect(lines).toContainEqual(expect.stringMatching(createKillMessage('[0] node __fixtures__/read-echo.js', 'SIGTERM')));
});
});
describe('--teardown', () => {
it('runs teardown commands when input commands exit', async () => {
const lines = await run('--teardown "echo bye" "echo hey"').getLogLines();
expect(lines).toEqual([
expect.stringContaining('[0] hey'),
expect.stringContaining('[0] echo hey exited with code 0'),
expect.stringContaining('--> Running teardown command "echo bye"'),
expect.stringContaining('bye'),
expect.stringContaining('--> Teardown command "echo bye" exited with code 0'),
]);
});
it('runs multiple teardown commands', async () => {
const lines = await run('--teardown "echo bye" --teardown "echo bye2" "echo hey"').getLogLines();
expect(lines).toContain('bye');
expect(lines).toContain('bye2');
});
});
describe('--timings', () => {
const defaultTimestampFormatRegex = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}/;
const tableTopBorderRegex = /^--> ┌[─┬]+┐$/;
const tableHeaderRowRegex = /^--> │ name +│ duration +│ exit code +│ killed +│ command +│$/;
const tableBottomBorderRegex = /^--> └[─┴]+┘$/;
const timingsTests = {
'shows timings on success': ['node __fixtures__/sleep.js 0.5', 'exit 0'],
'shows timings on failure': ['node __fixtures__/sleep.js 0.75', 'exit 1'],
};
it.each(Object.entries(timingsTests))('%s', async (_, commands) => {
const lines = await run(`--timings ${commands.map((command) => `"${command}"`).join(' ')}`).getLogLines();
// Expect output to contain process start / stop messages for each command
commands.forEach((command, index) => {
const escapedCommand = escapeRegExp(command);
expect(lines).toContainEqual(expect.stringMatching(new RegExp(`^\\[${index}] ${escapedCommand} started at ${defaultTimestampFormatRegex.source}$`)));
expect(lines).toContainEqual(expect.stringMatching(new RegExp(`^\\[${index}] ${escapedCommand} stopped at ${defaultTimestampFormatRegex.source} after (\\d|,)+ms$`)));
});
// Expect output to contain timings table
expect(lines).toContainEqual(expect.stringMatching(tableTopBorderRegex));
expect(lines).toContainEqual(expect.stringMatching(tableHeaderRowRegex));
expect(lines).toContainEqual(expect.stringMatching(tableBottomBorderRegex));
});
});
describe('--passthrough-arguments', () => {
it('argument placeholders are properly replaced when passthrough-arguments is enabled', async () => {
const lines = await run('--passthrough-arguments "echo {1}" -- echo').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[0] echo echo exited with code 0'));
});
it('argument placeholders are not replaced when passthrough-arguments is disabled', async () => {
const lines = await run('"echo {1}" -- echo').getLogLines();
expect(lines).toContainEqual(expect.stringContaining('[0] echo {1} exited with code 0'));
expect(lines).toContainEqual(expect.stringContaining('[1] echo exited with code 0'));
});
});