concurrently
Version:
Run commands concurrently
370 lines (369 loc) • 15.6 kB
JavaScript
import { Buffer } from 'node:buffer';
import { EventEmitter } from 'node:events';
import { Readable, Writable } from 'node:stream';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
import Rx from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Command, } from './command.js';
let process;
let sendMessage;
let spawn;
let killProcess;
const IPC_FD = 3;
beforeEach(() => {
sendMessage = vi.fn();
process = new (class extends EventEmitter {
pid = 1;
send = sendMessage;
stdout = new Readable({
read() {
// do nothing
},
});
stderr = new Readable({
read() {
// do nothing
},
});
stdin = new Writable({
write() {
// do nothing
},
});
})();
spawn = vi.fn().mockReturnValue(process);
killProcess = vi.fn();
});
const createCommand = (overrides, spawnOpts = {}) => {
const command = new Command({ index: 0, name: '', command: 'echo foo', ...overrides }, spawnOpts, spawn, killProcess);
let error;
let close;
const timer = subscribeSpyTo(command.timer);
const finished = subscribeSpyTo(new Rx.Observable((observer) => {
// First event in both subjects means command has finished
command.error.subscribe({
next: (value) => {
error = value;
observer.complete();
},
});
command.close.subscribe({
next: (value) => {
close = value;
observer.complete();
},
});
}));
const values = async () => {
await finished.onComplete();
return { error, close, timer: timer.getValues() };
};
return { command, values };
};
it('has stopped state by default', () => {
const { command } = createCommand();
expect(command.state).toBe('stopped');
});
describe('#start()', () => {
it('spawns process with given command and options', () => {
const { command } = createCommand({}, { detached: true });
command.start();
expect(spawn).toHaveBeenCalledExactlyOnceWith(command.command, { detached: true });
});
it('sets stdin, process and PID', () => {
const { command } = createCommand();
command.start();
expect(command.process).toBe(process);
expect(command.pid).toBe(process.pid);
expect(command.stdin).toBe(process.stdin);
});
it('handles process with no stdin', () => {
process.stdin = null;
const { command } = createCommand();
command.start();
expect(command.stdin).toBe(undefined);
});
it('changes state to started', () => {
const { command } = createCommand();
const spy = subscribeSpyTo(command.stateChange);
command.start();
expect(command.state).toBe('started');
expect(spy.getFirstValue()).toBe('started');
});
describe('on errors', () => {
it('changes state to errored', () => {
const { command } = createCommand();
command.start();
const spy = subscribeSpyTo(command.stateChange);
process.emit('error', 'foo');
expect(command.state).toBe('errored');
expect(spy.getFirstValue()).toBe('errored');
});
it('shares to the error stream', async () => {
const { command, values } = createCommand();
command.start();
process.emit('error', 'foo');
const { error } = await values();
expect(error).toBe('foo');
expect(command.process).toBeUndefined();
});
it('shares start and error timing events to the timing stream', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
vi.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
command.start();
process.emit('error', 0, null);
const { timer } = await values();
expect(timer[0]).toEqual({ startDate, endDate: undefined });
expect(timer[1]).toEqual({ startDate, endDate });
});
});
describe('on close', () => {
it('changes state to exited', () => {
const { command } = createCommand();
command.start();
const spy = subscribeSpyTo(command.stateChange);
process.emit('close', 0, null);
expect(command.state).toBe('exited');
expect(spy.getFirstValue()).toBe('exited');
});
it('does not change state if there was an error', () => {
const { command } = createCommand();
command.start();
process.emit('error', 'foo');
const spy = subscribeSpyTo(command.stateChange);
process.emit('close', 0, null);
expect(command.state).toBe('errored');
expect(spy.getValuesLength()).toBe(0);
});
it('shares start and close timing events to the timing stream', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
vi.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
command.start();
process.emit('close', 0, null);
const { timer } = await values();
expect(timer[0]).toEqual({ startDate, endDate: undefined });
expect(timer[1]).toEqual({ startDate, endDate });
});
it('shares to the close stream with exit code', async () => {
const { command, values } = createCommand();
command.start();
process.emit('close', 0, null);
const { close } = await values();
expect(close).toMatchObject({ exitCode: 0, killed: false });
expect(command.process).toBeUndefined();
});
it('shares to the close stream with signal', async () => {
const { command, values } = createCommand();
command.start();
process.emit('close', null, 'SIGKILL');
const { close } = await values();
expect(close).toMatchObject({ exitCode: 'SIGKILL', killed: false });
});
it('shares to the close stream with timing information', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
vi.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
vi.spyOn(globalThis.process, 'hrtime')
.mockReturnValueOnce([0, 0])
.mockReturnValueOnce([1, 1e8]);
command.start();
process.emit('close', null, 'SIGKILL');
const { close } = await values();
expect(close.timings).toStrictEqual({
startDate,
endDate,
durationSeconds: 1.1,
});
});
it('shares to the close stream with command info', async () => {
const commandInfo = {
command: 'cmd',
name: 'name',
prefixColor: 'green',
env: { VAR: 'yes' },
};
const { command, values } = createCommand(commandInfo);
command.start();
process.emit('close', 0, null);
const { close } = await values();
expect(close.command).toEqual(expect.objectContaining(commandInfo));
expect(close.killed).toBe(false);
});
});
it('shares stdout to the stdout stream', async () => {
const { command } = createCommand();
const stdout = Rx.firstValueFrom(command.stdout);
command.start();
process.stdout?.emit('data', Buffer.from('hello'));
expect((await stdout).toString()).toBe('hello');
});
it('shares stderr to the stdout stream', async () => {
const { command } = createCommand();
const stderr = Rx.firstValueFrom(command.stderr);
command.start();
process.stderr?.emit('data', Buffer.from('dang'));
expect((await stderr).toString()).toBe('dang');
});
describe('on incoming messages', () => {
it('does not share to the incoming messages stream, if IPC is disabled', () => {
const { command } = createCommand();
const spy = subscribeSpyTo(command.messages.incoming);
command.start();
process.emit('message', {});
expect(spy.getValuesLength()).toBe(0);
});
it('shares to the incoming messages stream, if IPC is enabled', () => {
const { command } = createCommand({ ipc: IPC_FD });
const spy = subscribeSpyTo(command.messages.incoming);
command.start();
const message1 = {};
process.emit('message', message1, undefined);
const message2 = {};
const handle = {};
process.emit('message', message2, handle);
expect(spy.getValuesLength()).toBe(2);
expect(spy.getValueAt(0)).toEqual({ message: message1, handle: undefined });
expect(spy.getValueAt(1)).toEqual({ message: message2, handle });
});
});
describe('on outgoing messages', () => {
it('calls onSent with an error if the process does not have IPC enabled', () => {
const { command } = createCommand({ ipc: IPC_FD });
command.start();
Object.assign(process, {
// The TS types don't assume `send` can be undefined,
// despite the Node docs saying so
send: undefined,
});
const onSent = vi.fn();
command.messages.outgoing.next({ message: {}, onSent });
expect(onSent).toHaveBeenCalledWith(expect.any(Error));
});
it('sends the message to the process', () => {
const { command } = createCommand({ ipc: IPC_FD });
command.start();
const message1 = {};
command.messages.outgoing.next({ message: message1, onSent() { } });
const message2 = {};
const handle = {};
command.messages.outgoing.next({ message: message2, handle, onSent() { } });
const message3 = {};
const options = {};
command.messages.outgoing.next({ message: message3, options, onSent() { } });
expect(process.send).toHaveBeenCalledTimes(3);
expect(process.send).toHaveBeenNthCalledWith(1, message1, undefined, undefined, expect.any(Function));
expect(process.send).toHaveBeenNthCalledWith(2, message2, handle, undefined, expect.any(Function));
expect(process.send).toHaveBeenNthCalledWith(3, message3, undefined, options, expect.any(Function));
});
it('sends the message to the process, if it starts late', () => {
const { command } = createCommand({ ipc: IPC_FD });
command.messages.outgoing.next({ message: {}, onSent() { } });
expect(process.send).not.toHaveBeenCalled();
command.start();
expect(process.send).toHaveBeenCalled();
});
it('calls onSent with the result of sending the message', () => {
const { command } = createCommand({ ipc: IPC_FD });
command.start();
const onSent = vi.fn();
command.messages.outgoing.next({ message: {}, onSent });
expect(onSent).not.toHaveBeenCalled();
sendMessage.mock.calls[0][3]();
expect(onSent).toHaveBeenCalledWith(undefined);
const error = new Error('test');
sendMessage.mock.calls[0][3](error);
expect(onSent).toHaveBeenCalledWith(error);
});
});
});
describe('#send()', () => {
it('throws if IPC is not set up', () => {
const { command } = createCommand();
const fn = () => command.send({});
expect(fn).toThrow();
});
it('pushes the message on the outgoing messages stream', () => {
const { command } = createCommand({ ipc: IPC_FD });
const spy = subscribeSpyTo(command.messages.outgoing);
const message1 = { foo: true };
command.send(message1);
const message2 = { bar: 123 };
const handle = {};
command.send(message2, handle);
const message3 = { baz: 'yes' };
const options = {};
command.send(message3, undefined, options);
expect(spy.getValuesLength()).toBe(3);
expect(spy.getValueAt(0)).toMatchObject({
message: message1,
handle: undefined,
options: undefined,
});
expect(spy.getValueAt(1)).toMatchObject({ message: message2, handle, options: undefined });
expect(spy.getValueAt(2)).toMatchObject({ message: message3, handle: undefined, options });
});
it('resolves when onSent callback is called with no arguments', async () => {
const { command } = createCommand({ ipc: IPC_FD });
const spy = subscribeSpyTo(command.messages.outgoing);
const promise = command.send({});
spy.getFirstValue().onSent();
await expect(promise).resolves.toBeUndefined();
});
it('rejects when onSent callback is called with an argument', async () => {
const { command } = createCommand({ ipc: IPC_FD });
const spy = subscribeSpyTo(command.messages.outgoing);
const promise = command.send({});
spy.getFirstValue().onSent('foo');
await expect(promise).rejects.toBe('foo');
});
});
describe('#kill()', () => {
let createdCommand;
beforeEach(() => {
createdCommand = createCommand();
});
it('kills process', () => {
createdCommand.command.start();
createdCommand.command.kill();
expect(killProcess).toHaveBeenCalledExactlyOnceWith(createdCommand.command.pid, undefined);
});
it('kills process with some signal', () => {
createdCommand.command.start();
createdCommand.command.kill('SIGKILL');
expect(killProcess).toHaveBeenCalledExactlyOnceWith(createdCommand.command.pid, 'SIGKILL');
});
it('does not try to kill inexistent process', () => {
createdCommand.command.start();
process.emit('error');
createdCommand.command.kill();
expect(killProcess).not.toHaveBeenCalled();
});
it('marks the command as killed', async () => {
createdCommand.command.start();
createdCommand.command.kill();
process.emit('close', 1, null);
const { close } = await createdCommand.values();
expect(close).toMatchObject({ exitCode: 1, killed: true });
});
});
describe('.canKill()', () => {
it('returns whether command has both PID and process', () => {
const { command } = createCommand();
expect(Command.canKill(command)).toBe(false);
command.pid = 1;
expect(Command.canKill(command)).toBe(false);
command.process = process;
expect(Command.canKill(command)).toBe(true);
});
});