concurrently
Version:
Run commands concurrently
321 lines (320 loc) • 13.3 kB
JavaScript
import os from 'node:os';
import { Writable } from 'node:stream';
import { beforeEach, expect, it, vi } from 'vitest';
import { createMockInstance } from './__fixtures__/create-mock-instance.js';
import { createFakeProcess, FakeCommand } from './__fixtures__/fake-command.js';
import { concurrently } from './concurrently.js';
import { Logger } from './logger.js';
let spawn;
let kill;
let onFinishHooks;
let controllers;
let processes;
const create = (commands, options = {}) => concurrently(commands, Object.assign(options, { controllers, spawn, kill }));
beforeEach(() => {
vi.resetAllMocks();
processes = [];
spawn = vi.fn(() => {
const process = createFakeProcess(processes.length);
processes.push(process);
return process;
});
kill = vi.fn();
onFinishHooks = [vi.fn(), vi.fn()];
controllers = [
{ handle: vi.fn((commands) => ({ commands, onFinish: onFinishHooks[0] })) },
{ handle: vi.fn((commands) => ({ commands, onFinish: onFinishHooks[1] })) },
];
});
it('fails if commands is not an array', () => {
const bomb = () => create('foo');
expect(bomb).toThrow();
});
it('fails if no commands were provided', () => {
const bomb = () => create([]);
expect(bomb).toThrow();
});
it('spawns all commands', () => {
create(['echo', 'kill']);
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('kill', expect.objectContaining({}));
});
it('log output is passed to output stream if logger is specified in options', () => {
const logger = new Logger({ hide: [] });
const outputStream = createMockInstance(Writable);
create(['foo'], { logger, outputStream });
logger.log('foo', 'bar');
expect(outputStream.write).toHaveBeenCalledTimes(2);
expect(outputStream.write).toHaveBeenCalledWith('foo');
expect(outputStream.write).toHaveBeenCalledWith('bar');
});
it('log output is not passed to output stream after it has errored', () => {
const logger = new Logger({ hide: [] });
const outputStream = new Writable();
vi.spyOn(outputStream, 'write');
create(['foo'], { logger, outputStream });
outputStream.emit('error', new Error('test'));
logger.log('foo', 'bar');
expect(outputStream.write).not.toHaveBeenCalled();
});
it('spawns commands up to configured limit at once', () => {
create(['foo', 'bar', 'baz', 'qux'], { maxProcesses: 2 });
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('foo', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('bar', expect.objectContaining({}));
// Test out of order completion picking up new processes in-order
processes[1].emit('close', 1, null);
expect(spawn).toHaveBeenCalledTimes(3);
expect(spawn).toHaveBeenCalledWith('baz', expect.objectContaining({}));
processes[0].emit('close', null, 'SIGINT');
expect(spawn).toHaveBeenCalledTimes(4);
expect(spawn).toHaveBeenCalledWith('qux', expect.objectContaining({}));
// Shouldn't attempt to spawn anything else.
processes[2].emit('close', 1, null);
expect(spawn).toHaveBeenCalledTimes(4);
});
it('spawns commands up to percent based limit at once', () => {
// Mock architecture with 4 cores
const cpusSpy = vi.spyOn(os, 'cpus');
cpusSpy.mockReturnValue(Array.from({ length: 4 }).fill({
model: 'Intel',
speed: 0,
times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 },
}));
create(['foo', 'bar', 'baz', 'qux'], { maxProcesses: '50%' });
// Max parallel processes should be 2 (50% of 4 cores)
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('foo', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('bar', expect.objectContaining({}));
// Close first process and expect third to be spawned
processes[0].emit('close', 1, null);
expect(spawn).toHaveBeenCalledTimes(3);
expect(spawn).toHaveBeenCalledWith('baz', expect.objectContaining({}));
// Close second process and expect fourth to be spawned
processes[1].emit('close', 1, null);
expect(spawn).toHaveBeenCalledTimes(4);
expect(spawn).toHaveBeenCalledWith('qux', expect.objectContaining({}));
});
it('does not spawn further commands on abort signal aborted', () => {
const abortController = new AbortController();
create(['foo', 'bar'], { maxProcesses: 1, abortSignal: abortController.signal });
expect(spawn).toHaveBeenCalledTimes(1);
abortController.abort();
processes[0].emit('close', 0, null);
expect(spawn).toHaveBeenCalledTimes(1);
});
it('preserves quotes in well-formed shell commands in the library API', () => {
create(['"/usr/local/bin/mytool" --flag "some value"']);
controllers.forEach((controller) => {
expect(controller.handle).toHaveBeenCalledWith([
expect.objectContaining({
command: '"/usr/local/bin/mytool" --flag "some value"',
index: 0,
}),
]);
});
});
it('passes commands with multiple quote sets through unchanged in the library API', () => {
create(['"/usr/local/bin/mytool" --flag "some value" --other "last arg"']);
controllers.forEach((controller) => {
expect(controller.handle).toHaveBeenCalledWith([
expect.objectContaining({
command: '"/usr/local/bin/mytool" --flag "some value" --other "last arg"',
index: 0,
}),
]);
});
});
it('runs commands with a name or prefix color', () => {
create([{ command: 'echo', prefixColor: 'red', name: 'foo' }, 'kill']);
controllers.forEach((controller) => {
expect(controller.handle).toHaveBeenCalledWith([
expect.objectContaining({ command: 'echo', index: 0, name: 'foo', prefixColor: 'red' }),
expect.objectContaining({ command: 'kill', index: 1, name: '', prefixColor: '' }),
]);
});
});
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 = new FakeCommand('banana', 'banana');
controllers[0].handle.mockReturnValue({ commands: [fakeCommand] });
create(['echo']);
expect(controllers[0].handle).toHaveBeenCalledWith([
expect.objectContaining({ command: 'echo', index: 0 }),
]);
expect(controllers[1].handle).toHaveBeenCalledWith([fakeCommand]);
expect(fakeCommand.start).toHaveBeenCalledTimes(1);
});
it('merges extra env vars into each command', () => {
create([
{ command: 'echo', env: { foo: 'bar' } },
{ command: 'echo', env: { foo: 'baz' } },
'kill',
]);
expect(spawn).toHaveBeenCalledTimes(3);
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
env: expect.objectContaining({ foo: 'bar' }),
}));
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
env: expect.objectContaining({ foo: 'baz' }),
}));
expect(spawn).toHaveBeenCalledWith('kill', expect.objectContaining({
env: expect.not.objectContaining({ foo: expect.anything() }),
}));
});
it('uses cwd from options for each command', () => {
create([
{ command: 'echo', env: { foo: 'bar' } },
{ command: 'echo', env: { foo: 'baz' } },
'kill',
], {
cwd: 'foobar',
});
expect(spawn).toHaveBeenCalledTimes(3);
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
env: expect.objectContaining({ foo: 'bar' }),
cwd: 'foobar',
}));
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
env: expect.objectContaining({ foo: 'baz' }),
cwd: 'foobar',
}));
expect(spawn).toHaveBeenCalledWith('kill', expect.objectContaining({
env: expect.not.objectContaining({ foo: expect.anything() }),
cwd: 'foobar',
}));
});
it('uses overridden cwd option for each command if specified', () => {
create([
{ command: 'echo', env: { foo: 'bar' }, cwd: 'baz' },
{ command: 'echo', env: { foo: 'baz' } },
], {
cwd: 'foobar',
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
env: expect.objectContaining({ foo: 'bar' }),
cwd: 'baz',
}));
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
env: expect.objectContaining({ foo: 'baz' }),
cwd: 'foobar',
}));
});
it('uses raw from options for each command', () => {
create([{ command: 'echo' }, 'kill'], {
raw: true,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
stdio: ['inherit', 'inherit', 'inherit'],
}));
expect(spawn).toHaveBeenCalledWith('kill', expect.objectContaining({
stdio: ['inherit', 'inherit', 'inherit'],
}));
});
it('uses overridden raw option for each command if specified', () => {
create([{ command: 'echo', raw: false }, { command: 'echo' }], {
raw: true,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
stdio: ['pipe', 'pipe', 'pipe'],
}));
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
stdio: ['inherit', 'inherit', 'inherit'],
}));
});
it('uses hide from options for each command', () => {
create([{ command: 'echo' }, 'kill'], {
hide: [1],
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
stdio: ['pipe', 'pipe', 'pipe'],
}));
expect(spawn).toHaveBeenCalledWith('kill', expect.objectContaining({
stdio: ['pipe', 'ignore', 'ignore'],
}));
});
it('hides output for commands even if raw option is on', () => {
create([{ command: 'echo' }, 'kill'], {
hide: [1],
raw: true,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({
stdio: ['inherit', 'inherit', 'inherit'],
}));
expect(spawn).toHaveBeenCalledWith('kill', expect.objectContaining({
stdio: ['pipe', 'ignore', 'ignore'],
}));
});
it('argument placeholders are properly replaced when additional arguments are passed', () => {
create([
{ command: 'echo {1}' },
{ command: 'echo {@}' },
{ command: 'echo {*}' },
{ command: 'echo \\{@}' },
], {
additionalArguments: ['foo', 'bar'],
});
expect(spawn).toHaveBeenCalledTimes(4);
expect(spawn).toHaveBeenCalledWith('echo foo', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('echo foo bar', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith("echo 'foo bar'", expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('echo {@}', expect.objectContaining({}));
});
it('argument placeholders are not replaced when additional arguments are not defined', () => {
create([
{ command: 'echo {1}' },
{ command: 'echo {@}' },
{ command: 'echo {*}' },
{ command: 'echo \\{@}' },
]);
expect(spawn).toHaveBeenCalledTimes(4);
expect(spawn).toHaveBeenCalledWith('echo {1}', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('echo {@}', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('echo {*}', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('echo {@}', expect.objectContaining({}));
});
it('runs onFinish hook after all commands run', async () => {
const promise = create(['foo', 'bar'], { maxProcesses: 1 });
expect(spawn).toHaveBeenCalledTimes(1);
expect(onFinishHooks[0]).not.toHaveBeenCalled();
expect(onFinishHooks[1]).not.toHaveBeenCalled();
processes[0].emit('close', 0, null);
expect(spawn).toHaveBeenCalledTimes(2);
expect(onFinishHooks[0]).not.toHaveBeenCalled();
expect(onFinishHooks[1]).not.toHaveBeenCalled();
processes[1].emit('close', 0, null);
await promise.result;
expect(onFinishHooks[0]).toHaveBeenCalled();
expect(onFinishHooks[1]).toHaveBeenCalled();
});
// This test should time out if broken
it('waits for onFinish hooks to complete before resolving', async () => {
onFinishHooks[0].mockResolvedValue(undefined);
const { result } = create(['foo', 'bar']);
processes[0].emit('close', 0, null);
processes[1].emit('close', 0, null);
await expect(result).resolves.toBeDefined();
});
it('rejects if onFinish hooks reject', async () => {
onFinishHooks[0].mockRejectedValue('error');
const { result } = create(['foo', 'bar']);
processes[0].emit('close', 0, null);
processes[1].emit('close', 0, null);
await expect(result).rejects.toBe('error');
});