Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
module.exports = class ExpandNpmShortcut {
parse(commandInfo) {
const [, npmCmd, cmdName, args] = commandInfo.command.match(/^(npm|yarn|pnpm):(\S+)(.*)/) || [];
if (!cmdName) {
return commandInfo;
}
return Object.assign({}, commandInfo, {
name: commandInfo.name || cmdName,
command: `${npmCmd} run ${cmdName}${args}`
});
}
};

View File

@@ -0,0 +1,36 @@
const ExpandNpmShortcut = require('./expand-npm-shortcut');
const parser = new ExpandNpmShortcut();
it('returns same command if no npm: prefix is present', () => {
const commandInfo = {
name: 'echo',
command: 'echo foo'
};
expect(parser.parse(commandInfo)).toBe(commandInfo);
});
for (const npmCmd of ['npm', 'yarn', 'pnpm']) {
describe(`with ${npmCmd}: prefix`, () => {
it(`expands to "${npmCmd} run <script> <args>"`, () => {
const commandInfo = {
name: 'echo',
command: `${npmCmd}:foo -- bar`
};
expect(parser.parse(commandInfo)).toEqual({
name: 'echo',
command: `${npmCmd} run foo -- bar`
});
});
it('sets name to script name if none', () => {
const commandInfo = {
command: `${npmCmd}:foo -- bar`
};
expect(parser.parse(commandInfo)).toEqual({
name: 'foo',
command: `${npmCmd} run foo -- bar`
});
});
});
}

View File

@@ -0,0 +1,43 @@
const _ = require('lodash');
const fs = require('fs');
module.exports = class ExpandNpmWildcard {
static readPackage() {
try {
const json = fs.readFileSync('package.json', { encoding: 'utf-8' });
return JSON.parse(json);
} catch (e) {
return {};
}
}
constructor(readPackage = ExpandNpmWildcard.readPackage) {
this.readPackage = readPackage;
}
parse(commandInfo) {
const [, npmCmd, cmdName, args] = commandInfo.command.match(/(npm|yarn|pnpm) run (\S+)([^&]*)/) || [];
const wildcardPosition = (cmdName || '').indexOf('*');
// If the regex didn't match an npm script, or it has no wildcard,
// then we have nothing to do here
if (!cmdName || wildcardPosition === -1) {
return commandInfo;
}
if (!this.scripts) {
this.scripts = Object.keys(this.readPackage().scripts || {});
}
const preWildcard = _.escapeRegExp(cmdName.substr(0, wildcardPosition));
const postWildcard = _.escapeRegExp(cmdName.substr(wildcardPosition + 1));
const wildcardRegex = new RegExp(`^${preWildcard}(.*?)${postWildcard}$`);
return this.scripts
.filter(script => wildcardRegex.test(script))
.map(script => Object.assign({}, commandInfo, {
command: `${npmCmd} run ${script}${args}`,
name: script
}));
}
};

View File

@@ -0,0 +1,58 @@
const ExpandNpmWildcard = require('./expand-npm-wildcard');
let parser, readPkg;
beforeEach(() => {
readPkg = jest.fn();
parser = new ExpandNpmWildcard(readPkg);
});
it('returns same command if not an npm run command', () => {
const commandInfo = {
command: 'npm test'
};
expect(readPkg).not.toHaveBeenCalled();
expect(parser.parse(commandInfo)).toBe(commandInfo);
});
it('returns same command if no wildcard present', () => {
const commandInfo = {
command: 'npm run foo bar'
};
expect(readPkg).not.toHaveBeenCalled();
expect(parser.parse(commandInfo)).toBe(commandInfo);
});
it('expands to nothing if no scripts exist in package.json', () => {
readPkg.mockReturnValue({});
expect(parser.parse({ command: 'npm run foo-*-baz qux' })).toEqual([]);
});
for (const npmCmd of ['npm', 'yarn', 'pnpm']) {
describe(`with an ${npmCmd}: prefix`, () => {
it('expands to all scripts matching pattern', () => {
readPkg.mockReturnValue({
scripts: {
'foo-bar-baz': '',
'foo--baz': '',
}
});
expect(parser.parse({ command: `${npmCmd} run foo-*-baz qux` })).toEqual([
{ name: 'foo-bar-baz', command: `${npmCmd} run foo-bar-baz qux` },
{ name: 'foo--baz', command: `${npmCmd} run foo--baz qux` },
]);
});
it('caches scripts upon calls', () => {
readPkg.mockReturnValue({});
parser.parse({ command: `${npmCmd} run foo-*-baz qux` });
parser.parse({ command: `${npmCmd} run foo-*-baz qux` });
expect(readPkg).toHaveBeenCalledTimes(1);
});
});
}

View File

@@ -0,0 +1,12 @@
module.exports = class StripQuotes {
parse(commandInfo) {
let { command } = commandInfo;
// Removes the quotes surrounding a command.
if (/^"(.+?)"$/.test(command) || /^'(.+?)'$/.test(command)) {
command = command.substr(1, command.length - 2);
}
return Object.assign({}, commandInfo, { command });
}
};

View File

@@ -0,0 +1,20 @@
const StripQuotes = require('./strip-quotes');
const parser = new StripQuotes();
it('returns command as is if no single/double quote at the beginning', () => {
expect(parser.parse({ command: 'echo foo' })).toEqual({ command: 'echo foo' });
});
it('strips single quotes', () => {
expect(parser.parse({ command: '\'echo foo\'' })).toEqual({ command: 'echo foo' });
});
it('strips double quotes', () => {
expect(parser.parse({ command: '"echo foo"' })).toEqual({ command: 'echo foo' });
});
it('does not remove quotes if they are impaired', () => {
expect(parser.parse({ command: '"echo foo' })).toEqual({ command: '"echo foo' });
expect(parser.parse({ command: 'echo foo\'' })).toEqual({ command: 'echo foo\'' });
expect(parser.parse({ command: '"echo foo\'' })).toEqual({ command: '"echo foo\'' });
});

View File

@@ -0,0 +1,63 @@
const Rx = require('rxjs');
module.exports = class Command {
get killable() {
return !!this.process;
}
constructor({ index, name, command, prefixColor, env, killProcess, spawn, spawnOpts }) {
this.index = index;
this.name = name;
this.command = command;
this.prefixColor = prefixColor;
this.env = env;
this.killProcess = killProcess;
this.spawn = spawn;
this.spawnOpts = spawnOpts;
this.killed = false;
this.error = new Rx.Subject();
this.close = new Rx.Subject();
this.stdout = new Rx.Subject();
this.stderr = new Rx.Subject();
}
start() {
const child = this.spawn(this.command, this.spawnOpts);
this.process = child;
this.pid = child.pid;
Rx.fromEvent(child, 'error').subscribe(event => {
this.process = undefined;
this.error.next(event);
});
Rx.fromEvent(child, 'close').subscribe(([exitCode, signal]) => {
this.process = undefined;
this.close.next({
command: {
command: this.command,
name: this.name,
prefixColor: this.prefixColor,
env: this.env,
},
index: this.index,
exitCode: exitCode === null ? signal : exitCode,
killed: this.killed,
});
});
child.stdout && pipeTo(Rx.fromEvent(child.stdout, 'data'), this.stdout);
child.stderr && pipeTo(Rx.fromEvent(child.stderr, 'data'), this.stderr);
this.stdin = child.stdin;
}
kill(code) {
if (this.killable) {
this.killed = true;
this.killProcess(this.pid, code);
}
}
};
function pipeTo(stream, subject) {
stream.subscribe(event => subject.next(event));
}

View File

@@ -0,0 +1,183 @@
const EventEmitter = require('events');
const Command = require('./command');
const createProcess = () => {
const process = new EventEmitter();
process.pid = 1;
return process;
};
const createProcessWithIO = () => {
const process = createProcess();
return Object.assign(process, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
stdin: new EventEmitter()
});
};
describe('#start()', () => {
it('spawns process with given command and options', () => {
const spawn = jest.fn().mockReturnValue(createProcess());
const command = new Command({
spawn,
spawnOpts: { bla: true },
command: 'echo foo',
});
command.start();
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith(command.command, { bla: true });
});
it('sets stdin, process and PID', () => {
const process = createProcessWithIO();
const command = new Command({ spawn: () => process });
command.start();
expect(command.process).toBe(process);
expect(command.pid).toBe(process.pid);
expect(command.stdin).toBe(process.stdin);
});
it('shares errors to the error stream', done => {
const process = createProcess();
const command = new Command({ spawn: () => process });
command.error.subscribe(data => {
expect(data).toBe('foo');
expect(command.process).toBeUndefined();
done();
});
command.start();
process.emit('error', 'foo');
});
it('shares closes to the close stream with exit code', done => {
const process = createProcess();
const command = new Command({ spawn: () => process });
command.close.subscribe(data => {
expect(data.exitCode).toBe(0);
expect(data.killed).toBe(false);
expect(command.process).toBeUndefined();
done();
});
command.start();
process.emit('close', 0, null);
});
it('shares closes to the close stream with signal', done => {
const process = createProcess();
const command = new Command({ spawn: () => process });
command.close.subscribe(data => {
expect(data.exitCode).toBe('SIGKILL');
expect(data.killed).toBe(false);
done();
});
command.start();
process.emit('close', null, 'SIGKILL');
});
it('shares closes to the close stream with command info and index', done => {
const process = createProcess();
const commandInfo = {
command: 'cmd',
name: 'name',
prefixColor: 'green',
env: { VAR: 'yes' },
};
const command = new Command(
Object.assign({
index: 1,
spawn: () => process
}, commandInfo)
);
command.close.subscribe(data => {
expect(data.command).toEqual(commandInfo);
expect(data.killed).toBe(false);
expect(data.index).toBe(1);
done();
});
command.start();
process.emit('close', 0, null);
});
it('shares stdout to the stdout stream', done => {
const process = createProcessWithIO();
const command = new Command({ spawn: () => process });
command.stdout.subscribe(data => {
expect(data.toString()).toBe('hello');
done();
});
command.start();
process.stdout.emit('data', Buffer.from('hello'));
});
it('shares stderr to the stdout stream', done => {
const process = createProcessWithIO();
const command = new Command({ spawn: () => process });
command.stderr.subscribe(data => {
expect(data.toString()).toBe('dang');
done();
});
command.start();
process.stderr.emit('data', Buffer.from('dang'));
});
});
describe('#kill()', () => {
let process, killProcess, command;
beforeEach(() => {
process = createProcess();
killProcess = jest.fn();
command = new Command({ spawn: () => process, killProcess });
});
it('kills process', () => {
command.start();
command.kill();
expect(killProcess).toHaveBeenCalledTimes(1);
expect(killProcess).toHaveBeenCalledWith(command.pid, undefined);
});
it('kills process with some signal', () => {
command.start();
command.kill('SIGKILL');
expect(killProcess).toHaveBeenCalledTimes(1);
expect(killProcess).toHaveBeenCalledWith(command.pid, 'SIGKILL');
});
it('does not try to kill inexistent process', () => {
command.start();
process.emit('error');
command.kill();
expect(killProcess).not.toHaveBeenCalled();
});
it('marks the command as killed', done => {
command.start();
command.close.subscribe(data => {
expect(data.exitCode).toBe(1);
expect(data.killed).toBe(true);
done();
});
command.kill();
process.emit('close', 1, null);
});
});

View File

@@ -0,0 +1,39 @@
const Rx = require('rxjs');
const { bufferCount, switchMap, take } = require('rxjs/operators');
module.exports = class CompletionListener {
constructor({ successCondition, scheduler }) {
this.successCondition = successCondition;
this.scheduler = scheduler;
}
isSuccess(exitCodes) {
switch (this.successCondition) {
/* eslint-disable indent */
case 'first':
return exitCodes[0] === 0;
case 'last':
return exitCodes[exitCodes.length - 1] === 0;
default:
return exitCodes.every(exitCode => exitCode === 0);
/* eslint-enable indent */
}
}
listen(commands) {
const closeStreams = commands.map(command => command.close);
return Rx.merge(...closeStreams)
.pipe(
bufferCount(closeStreams.length),
switchMap(exitInfos =>
this.isSuccess(exitInfos.map(({ exitCode }) => exitCode))
? Rx.of(exitInfos, this.scheduler)
: Rx.throwError(exitInfos, this.scheduler)
),
take(1)
)
.toPromise();
}
};

View File

@@ -0,0 +1,88 @@
const { TestScheduler } = require('rxjs/testing');
const createFakeCommand = require('./flow-control/fixtures/fake-command');
const CompletionListener = require('./completion-listener');
let commands, scheduler;
beforeEach(() => {
commands = [createFakeCommand('foo'), createFakeCommand('bar')];
scheduler = new TestScheduler();
});
const createController = successCondition =>
new CompletionListener({
successCondition,
scheduler
});
describe('with default success condition set', () => {
it('succeeds if all processes exited with code 0', () => {
const result = createController().listen(commands);
commands[0].close.next({ exitCode: 0 });
commands[1].close.next({ exitCode: 0 });
scheduler.flush();
return expect(result).resolves.toEqual([{ exitCode: 0 }, { exitCode: 0 }]);
});
it('fails if one of the processes exited with non-0 code', () => {
const result = createController().listen(commands);
commands[0].close.next({ exitCode: 0 });
commands[1].close.next({ exitCode: 1 });
scheduler.flush();
expect(result).rejects.toEqual([{ exitCode: 0 }, { exitCode: 1 }]);
});
});
describe('with success condition set to first', () => {
it('succeeds if first process to exit has code 0', () => {
const result = createController('first').listen(commands);
commands[1].close.next({ exitCode: 0 });
commands[0].close.next({ exitCode: 1 });
scheduler.flush();
return expect(result).resolves.toEqual([{ exitCode: 0 }, { exitCode: 1 }]);
});
it('fails if first process to exit has non-0 code', () => {
const result = createController('first').listen(commands);
commands[1].close.next({ exitCode: 1 });
commands[0].close.next({ exitCode: 0 });
scheduler.flush();
return expect(result).rejects.toEqual([{ exitCode: 1 }, { exitCode: 0 }]);
});
});
describe('with success condition set to last', () => {
it('succeeds if last process to exit has code 0', () => {
const result = createController('last').listen(commands);
commands[1].close.next({ exitCode: 1 });
commands[0].close.next({ exitCode: 0 });
scheduler.flush();
return expect(result).resolves.toEqual([{ exitCode: 1 }, { exitCode: 0 }]);
});
it('fails if last process to exit has non-0 code', () => {
const result = createController('last').listen(commands);
commands[1].close.next({ exitCode: 0 });
commands[0].close.next({ exitCode: 1 });
scheduler.flush();
return expect(result).rejects.toEqual([{ exitCode: 0 }, { exitCode: 1 }]);
});
});

View File

@@ -0,0 +1,111 @@
const assert = require('assert');
const _ = require('lodash');
const spawn = require('spawn-command');
const treeKill = require('tree-kill');
const StripQuotes = require('./command-parser/strip-quotes');
const ExpandNpmShortcut = require('./command-parser/expand-npm-shortcut');
const ExpandNpmWildcard = require('./command-parser/expand-npm-wildcard');
const CompletionListener = require('./completion-listener');
const getSpawnOpts = require('./get-spawn-opts');
const Command = require('./command');
const defaults = {
spawn,
kill: treeKill,
raw: false,
controllers: [],
cwd: undefined,
};
module.exports = (commands, options) => {
assert.ok(Array.isArray(commands), '[concurrently] commands should be an array');
assert.notStrictEqual(commands.length, 0, '[concurrently] no commands provided');
options = _.defaults(options, defaults);
const commandParsers = [
new StripQuotes(),
new ExpandNpmShortcut(),
new ExpandNpmWildcard()
];
let lastColor = '';
commands = _(commands)
.map(mapToCommandInfo)
.flatMap(command => parseCommand(command, commandParsers))
.map((command, index) => {
// Use documented behaviour of repeating last color when specifying more commands than colors
lastColor = options.prefixColors && options.prefixColors[index] || lastColor;
return new Command(
Object.assign({
index,
spawnOpts: getSpawnOpts({
raw: options.raw,
env: command.env,
cwd: command.cwd || options.cwd,
}),
prefixColor: lastColor,
killProcess: options.kill,
spawn: options.spawn,
}, command)
);
})
.value();
const handleResult = options.controllers.reduce(
({ commands: prevCommands, onFinishCallbacks }, controller) => {
const { commands, onFinish } = controller.handle(prevCommands);
return {
commands,
onFinishCallbacks: _.concat(onFinishCallbacks, onFinish ? [onFinish] : [])
};
},
{ commands, onFinishCallbacks: [] }
);
commands = handleResult.commands;
const commandsLeft = commands.slice();
const maxProcesses = Math.max(1, Number(options.maxProcesses) || commandsLeft.length);
for (let i = 0; i < maxProcesses; i++) {
maybeRunMore(commandsLeft);
}
return new CompletionListener({ successCondition: options.successCondition })
.listen(commands)
.finally(() => {
handleResult.onFinishCallbacks.forEach((onFinish) => onFinish());
});
};
function mapToCommandInfo(command) {
return Object.assign({
command: command.command || command,
name: command.name || '',
env: command.env || {},
cwd: command.cwd || '',
}, command.prefixColor ? {
prefixColor: command.prefixColor,
} : {});
}
function parseCommand(command, parsers) {
return parsers.reduce(
(commands, parser) => _.flatMap(commands, command => parser.parse(command)),
_.castArray(command)
);
}
function maybeRunMore(commandsLeft) {
const command = commandsLeft.shift();
if (!command) {
return;
}
command.start();
command.close.subscribe(() => {
maybeRunMore(commandsLeft);
});
}

View File

@@ -0,0 +1,199 @@
const EventEmitter = require('events');
const createFakeCommand = require('./flow-control/fixtures/fake-command');
const FakeHandler = require('./flow-control/fixtures/fake-handler');
const concurrently = require('./concurrently');
let spawn, kill, controllers, processes = [];
const create = (commands, options = {}) => concurrently(
commands,
Object.assign(options, { controllers, spawn, kill })
);
beforeEach(() => {
processes = [];
spawn = jest.fn(() => {
const process = new EventEmitter();
processes.push(process);
process.pid = processes.length;
return process;
});
kill = jest.fn();
controllers = [new FakeHandler(), new FakeHandler()];
});
it('fails if commands is not an array', () => {
const bomb = () => create('foo');
expect(bomb).toThrowError();
});
it('fails if no commands were provided', () => {
const bomb = () => create([]);
expect(bomb).toThrowError();
});
it('spawns all commands', () => {
create(['echo', 'kill']);
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenCalledWith('echo', expect.objectContaining({}));
expect(spawn).toHaveBeenCalledWith('kill', expect.objectContaining({}));
});
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('runs controllers with the commands', () => {
create(['echo', '"echo wrapped"']);
controllers.forEach(controller => {
expect(controller.handle).toHaveBeenCalledWith([
expect.objectContaining({ command: 'echo', index: 0 }),
expect.objectContaining({ command: 'echo wrapped', index: 1 }),
]);
});
});
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 = createFakeCommand('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('runs onFinish hook after all commands run', async () => {
const promise = create(['foo', 'bar'], { maxProcesses: 1 });
expect(spawn).toHaveBeenCalledTimes(1);
expect(controllers[0].onFinish).not.toHaveBeenCalled();
expect(controllers[1].onFinish).not.toHaveBeenCalled();
processes[0].emit('close', 0, null);
expect(spawn).toHaveBeenCalledTimes(2);
expect(controllers[0].onFinish).not.toHaveBeenCalled();
expect(controllers[1].onFinish).not.toHaveBeenCalled();
processes[1].emit('close', 0, null);
await promise;
expect(controllers[0].onFinish).toHaveBeenCalled();
expect(controllers[1].onFinish).toHaveBeenCalled();
});

View File

@@ -0,0 +1,33 @@
/**
* This file is meant to be a shared place for default configs.
* It's read by the flow controllers, the executable, etc.
*
* Refer to tests for the meaning of the different possible values.
*/
module.exports = {
defaultInputTarget: 0,
// Whether process.stdin should be forwarded to child processes
handleInput: false,
// How many processes to run at once
maxProcesses: 0,
// Indices and names of commands whose output to be not logged
hide: '',
nameSeparator: ',',
// Which prefix style to use when logging processes output.
prefix: '',
// Refer to https://www.npmjs.com/package/chalk
prefixColors: 'reset',
// How many bytes we'll show on the command prefix
prefixLength: 10,
raw: false,
// Number of attempts of restarting a process, if it exits with non-0 code
restartTries: 0,
// How many milliseconds concurrently should wait before restarting a process.
restartDelay: 0,
// Condition of success for concurrently itself.
success: 'all',
// Refer to https://date-fns.org/v2.0.1/docs/format
timestampFormat: 'yyyy-MM-dd HH:mm:ss.SSS',
// Current working dir passed as option to spawn command. Default: process.cwd()
cwd: undefined
};

View File

@@ -0,0 +1,16 @@
module.exports = class BaseHandler {
constructor(options = {}) {
const { logger } = options;
this.logger = logger;
}
handle(commands) {
return {
commands,
// an optional callback to call when all commands have finished
// (either successful or not)
onFinish: null,
};
}
};

View File

@@ -0,0 +1,50 @@
const Rx = require('rxjs');
const { map } = require('rxjs/operators');
const defaults = require('../defaults');
const BaseHandler = require('./base-handler');
module.exports = class InputHandler extends BaseHandler {
constructor({ defaultInputTarget, inputStream, pauseInputStreamOnFinish, logger }) {
super({ logger });
this.defaultInputTarget = defaultInputTarget || defaults.defaultInputTarget;
this.inputStream = inputStream;
this.pauseInputStreamOnFinish = pauseInputStreamOnFinish !== false;
}
handle(commands) {
if (!this.inputStream) {
return { commands };
}
Rx.fromEvent(this.inputStream, 'data')
.pipe(map(data => data.toString()))
.subscribe(data => {
let [targetId, input] = data.split(/:(.+)/);
targetId = input ? targetId : this.defaultInputTarget;
input = input || data;
const command = commands.find(command => (
command.name === targetId ||
command.index.toString() === targetId.toString()
));
if (command && command.stdin) {
command.stdin.write(input);
} else {
this.logger.logGlobalEvent(`Unable to find command ${targetId}, or it has no stdin open\n`);
}
});
return {
commands,
onFinish: () => {
if (this.pauseInputStreamOnFinish) {
// https://github.com/kimmobrunfeldt/concurrently/issues/252
this.inputStream.pause();
}
},
};
}
};

View File

@@ -0,0 +1,113 @@
const stream = require('stream');
const { createMockInstance } = require('jest-create-mock-instance');
const Logger = require('../logger');
const createFakeCommand = require('./fixtures/fake-command');
const InputHandler = require('./input-handler');
let commands, controller, inputStream, logger;
beforeEach(() => {
commands = [
createFakeCommand('foo', 'echo foo', 0),
createFakeCommand('bar', 'echo bar', 1),
];
inputStream = new stream.PassThrough();
logger = createMockInstance(Logger);
controller = new InputHandler({
defaultInputTarget: 0,
inputStream,
logger
});
});
it('returns same commands', () => {
expect(controller.handle(commands)).toMatchObject({ commands });
controller = new InputHandler({ logger });
expect(controller.handle(commands)).toMatchObject({ commands });
});
it('forwards input stream to default target ID', () => {
controller.handle(commands);
inputStream.write('something');
expect(commands[0].stdin.write).toHaveBeenCalledTimes(1);
expect(commands[0].stdin.write).toHaveBeenCalledWith('something');
expect(commands[1].stdin.write).not.toHaveBeenCalled();
});
it('forwards input stream to target index specified in input', () => {
controller.handle(commands);
inputStream.write('1:something');
expect(commands[0].stdin.write).not.toHaveBeenCalled();
expect(commands[1].stdin.write).toHaveBeenCalledTimes(1);
expect(commands[1].stdin.write).toHaveBeenCalledWith('something');
});
it('forwards input stream to target index specified in input when input contains colon', () => {
controller.handle(commands);
inputStream.emit('data', Buffer.from('1::something'));
inputStream.emit('data', Buffer.from('1:some:thing'));
expect(commands[0].stdin.write).not.toHaveBeenCalled();
expect(commands[1].stdin.write).toHaveBeenCalledTimes(2);
expect(commands[1].stdin.write).toHaveBeenCalledWith(':something');
expect(commands[1].stdin.write).toHaveBeenCalledWith('some:thing');
});
it('forwards input stream to target name specified in input', () => {
controller.handle(commands);
inputStream.write('bar:something');
expect(commands[0].stdin.write).not.toHaveBeenCalled();
expect(commands[1].stdin.write).toHaveBeenCalledTimes(1);
expect(commands[1].stdin.write).toHaveBeenCalledWith('something');
});
it('logs error if command has no stdin open', () => {
commands[0].stdin = null;
controller.handle(commands);
inputStream.write('something');
expect(commands[1].stdin.write).not.toHaveBeenCalled();
expect(logger.logGlobalEvent).toHaveBeenCalledWith('Unable to find command 0, or it has no stdin open\n');
});
it('logs error if command is not found', () => {
controller.handle(commands);
inputStream.write('foobar:something');
expect(commands[0].stdin.write).not.toHaveBeenCalled();
expect(commands[1].stdin.write).not.toHaveBeenCalled();
expect(logger.logGlobalEvent).toHaveBeenCalledWith('Unable to find command foobar, or it has no stdin open\n');
});
it('pauses input stream when finished', () => {
expect(inputStream.readableFlowing).toBeNull();
const { onFinish } = controller.handle(commands);
expect(inputStream.readableFlowing).toBe(true);
onFinish();
expect(inputStream.readableFlowing).toBe(false);
});
it('does not pause input stream when pauseInputStreamOnFinish is set to false', () => {
controller = new InputHandler({ inputStream, pauseInputStreamOnFinish: false });
expect(inputStream.readableFlowing).toBeNull();
const { onFinish } = controller.handle(commands);
expect(inputStream.readableFlowing).toBe(true);
onFinish();
expect(inputStream.readableFlowing).toBe(true);
});

View File

@@ -0,0 +1,35 @@
const { map } = require('rxjs/operators');
const BaseHandler = require('./base-handler');
module.exports = class KillOnSignal extends BaseHandler {
constructor({ process }) {
super();
this.process = process;
}
handle(commands) {
let caughtSignal;
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach(signal => {
this.process.on(signal, () => {
caughtSignal = signal;
commands.forEach(command => command.kill(signal));
});
});
return {
commands: commands.map(command => {
const closeStream = command.close.pipe(map(exitInfo => {
const exitCode = caughtSignal === 'SIGINT' ? 0 : exitInfo.exitCode;
return Object.assign({}, exitInfo, { exitCode });
}));
return new Proxy(command, {
get(target, prop) {
return prop === 'close' ? closeStream : target[prop];
}
});
})
};
}
};

View File

@@ -0,0 +1,79 @@
const EventEmitter = require('events');
const createFakeCommand = require('./fixtures/fake-command');
const KillOnSignal = require('./kill-on-signal');
let commands, controller, process;
beforeEach(() => {
process = new EventEmitter();
commands = [
createFakeCommand(),
createFakeCommand(),
];
controller = new KillOnSignal({ process });
});
it('returns commands that keep non-close streams from original commands', () => {
const { commands: newCommands } = controller.handle(commands);
newCommands.forEach((newCommand, i) => {
expect(newCommand.close).not.toBe(commands[i].close);
expect(newCommand.error).toBe(commands[i].error);
expect(newCommand.stdout).toBe(commands[i].stdout);
expect(newCommand.stderr).toBe(commands[i].stderr);
});
});
it('returns commands that map SIGINT to exit code 0', () => {
const { commands: newCommands } = controller.handle(commands);
expect(newCommands).not.toBe(commands);
expect(newCommands).toHaveLength(commands.length);
const callback = jest.fn();
newCommands[0].close.subscribe(callback);
process.emit('SIGINT');
// A fake command's .kill() call won't trigger a close event automatically...
commands[0].close.next({ exitCode: 1 });
expect(callback).not.toHaveBeenCalledWith({ exitCode: 'SIGINT' });
expect(callback).toHaveBeenCalledWith({ exitCode: 0 });
});
it('returns commands that keep non-SIGINT exit codes', () => {
const { commands: newCommands } = controller.handle(commands);
expect(newCommands).not.toBe(commands);
expect(newCommands).toHaveLength(commands.length);
const callback = jest.fn();
newCommands[0].close.subscribe(callback);
commands[0].close.next({ exitCode: 1 });
expect(callback).toHaveBeenCalledWith({ exitCode: 1 });
});
it('kills all commands on SIGINT', () => {
controller.handle(commands);
process.emit('SIGINT');
expect(process.listenerCount('SIGINT')).toBe(1);
expect(commands[0].kill).toHaveBeenCalledWith('SIGINT');
expect(commands[1].kill).toHaveBeenCalledWith('SIGINT');
});
it('kills all commands on SIGTERM', () => {
controller.handle(commands);
process.emit('SIGTERM');
expect(process.listenerCount('SIGTERM')).toBe(1);
expect(commands[0].kill).toHaveBeenCalledWith('SIGTERM');
expect(commands[1].kill).toHaveBeenCalledWith('SIGTERM');
});
it('kills all commands on SIGHUP', () => {
controller.handle(commands);
process.emit('SIGHUP');
expect(process.listenerCount('SIGHUP')).toBe(1);
expect(commands[0].kill).toHaveBeenCalledWith('SIGHUP');
expect(commands[1].kill).toHaveBeenCalledWith('SIGHUP');
});

View File

@@ -0,0 +1,38 @@
const _ = require('lodash');
const { filter, map } = require('rxjs/operators');
const BaseHandler = require('./base-handler');
module.exports = class KillOthers extends BaseHandler {
constructor({ logger, conditions }) {
super({ logger });
this.conditions = _.castArray(conditions);
}
handle(commands) {
const conditions = this.conditions.filter(condition => (
condition === 'failure' ||
condition === 'success'
));
if (!conditions.length) {
return { commands };
}
const closeStates = commands.map(command => command.close.pipe(
map(({ exitCode }) => exitCode === 0 ? 'success' : 'failure'),
filter(state => conditions.includes(state))
));
closeStates.forEach(closeState => closeState.subscribe(() => {
const killableCommands = commands.filter(command => command.killable);
if (killableCommands.length) {
this.logger.logGlobalEvent('Sending SIGTERM to other processes..');
killableCommands.forEach(command => command.kill());
}
}));
return { commands };
}
};

View File

@@ -0,0 +1,66 @@
const { createMockInstance } = require('jest-create-mock-instance');
const Logger = require('../logger');
const createFakeCommand = require('./fixtures/fake-command');
const KillOthers = require('./kill-others');
let commands, logger;
beforeEach(() => {
commands = [
createFakeCommand(),
createFakeCommand()
];
logger = createMockInstance(Logger);
});
const createWithConditions = conditions => new KillOthers({
logger,
conditions
});
it('returns same commands', () => {
expect(createWithConditions(['foo']).handle(commands)).toMatchObject({ commands });
expect(createWithConditions(['failure']).handle(commands)).toMatchObject({ commands });
});
it('does not kill others if condition does not match', () => {
createWithConditions(['failure']).handle(commands);
commands[1].killable = true;
commands[0].close.next({ exitCode: 0 });
expect(logger.logGlobalEvent).not.toHaveBeenCalled();
expect(commands[0].kill).not.toHaveBeenCalled();
expect(commands[1].kill).not.toHaveBeenCalled();
});
it('kills other killable processes on success', () => {
createWithConditions(['success']).handle(commands);
commands[1].killable = true;
commands[0].close.next({ exitCode: 0 });
expect(logger.logGlobalEvent).toHaveBeenCalledTimes(1);
expect(logger.logGlobalEvent).toHaveBeenCalledWith('Sending SIGTERM to other processes..');
expect(commands[0].kill).not.toHaveBeenCalled();
expect(commands[1].kill).toHaveBeenCalled();
});
it('kills other killable processes on failure', () => {
createWithConditions(['failure']).handle(commands);
commands[1].killable = true;
commands[0].close.next({ exitCode: 1 });
expect(logger.logGlobalEvent).toHaveBeenCalledTimes(1);
expect(logger.logGlobalEvent).toHaveBeenCalledWith('Sending SIGTERM to other processes..');
expect(commands[0].kill).not.toHaveBeenCalled();
expect(commands[1].kill).toHaveBeenCalled();
});
it('does not try to kill processes already dead', () => {
createWithConditions(['failure']).handle(commands);
commands[0].close.next({ exitCode: 1 });
expect(logger.logGlobalEvent).not.toHaveBeenCalled();
expect(commands[0].kill).not.toHaveBeenCalled();
expect(commands[1].kill).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,18 @@
const { of } = require('rxjs');
const BaseHandler = require('./base-handler');
module.exports = class LogExit extends BaseHandler {
handle(commands) {
commands.forEach(command => command.error.subscribe(event => {
this.logger.logCommandEvent(
`Error occurred when executing command: ${command.command}`,
command
);
this.logger.logCommandEvent(event.stack || event, command);
}));
return { commands };
}
};

View File

@@ -0,0 +1,40 @@
const { createMockInstance } = require('jest-create-mock-instance');
const Logger = require('../logger');
const LogError = require('./log-error');
const createFakeCommand = require('./fixtures/fake-command');
let controller, logger, commands;
beforeEach(() => {
commands = [
createFakeCommand(),
createFakeCommand(),
];
logger = createMockInstance(Logger);
controller = new LogError({ logger });
});
it('returns same commands', () => {
expect(controller.handle(commands)).toMatchObject({ commands });
});
it('logs the error event of each command', () => {
controller.handle(commands);
commands[0].error.next('error from command 0');
const error = new Error('some error message');
commands[1].error.next(error);
expect(logger.logCommandEvent).toHaveBeenCalledTimes(4);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`Error occurred when executing command: ${commands[0].command}`,
commands[0]
);
expect(logger.logCommandEvent).toHaveBeenCalledWith('error from command 0', commands[0]);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`Error occurred when executing command: ${commands[1].command}`,
commands[1]
);
expect(logger.logCommandEvent).toHaveBeenCalledWith(error.stack, commands[1]);
});

View File

@@ -0,0 +1,11 @@
const BaseHandler = require('./base-handler');
module.exports = class LogExit extends BaseHandler {
handle(commands) {
commands.forEach(command => command.close.subscribe(({ exitCode }) => {
this.logger.logCommandEvent(`${command.command} exited with code ${exitCode}`, command);
}));
return { commands };
}
};

View File

@@ -0,0 +1,36 @@
const { createMockInstance } = require('jest-create-mock-instance');
const Logger = require('../logger');
const LogExit = require('./log-exit');
const createFakeCommand = require('./fixtures/fake-command');
let controller, logger, commands;
beforeEach(() => {
commands = [
createFakeCommand(),
createFakeCommand(),
];
logger = createMockInstance(Logger);
controller = new LogExit({ logger });
});
it('returns same commands', () => {
expect(controller.handle(commands)).toMatchObject({ commands });
});
it('logs the close event of each command', () => {
controller.handle(commands);
commands[0].close.next({ exitCode: 0 });
commands[1].close.next({ exitCode: 'SIGTERM' });
expect(logger.logCommandEvent).toHaveBeenCalledTimes(2);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`${commands[0].command} exited with code 0`,
commands[0]
);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`${commands[1].command} exited with code SIGTERM`,
commands[1]
);
});

View File

@@ -0,0 +1,12 @@
const BaseHandler = require('./base-handler');
module.exports = class LogOutput extends BaseHandler {
handle(commands) {
commands.forEach(command => {
command.stdout.subscribe(text => this.logger.logCommandText(text.toString(), command));
command.stderr.subscribe(text => this.logger.logCommandText(text.toString(), command));
});
return { commands };
}
};

View File

@@ -0,0 +1,41 @@
const { createMockInstance } = require('jest-create-mock-instance');
const Logger = require('../logger');
const LogOutput = require('./log-output');
const createFakeCommand = require('./fixtures/fake-command');
let controller, logger, commands;
beforeEach(() => {
commands = [
createFakeCommand(),
createFakeCommand(),
];
logger = createMockInstance(Logger);
controller = new LogOutput({ logger });
});
it('returns same commands', () => {
expect(controller.handle(commands)).toMatchObject({ commands });
});
it('logs the stdout of each command', () => {
controller.handle(commands);
commands[0].stdout.next(Buffer.from('foo'));
commands[1].stdout.next(Buffer.from('bar'));
expect(logger.logCommandText).toHaveBeenCalledTimes(2);
expect(logger.logCommandText).toHaveBeenCalledWith('foo', commands[0]);
expect(logger.logCommandText).toHaveBeenCalledWith('bar', commands[1]);
});
it('logs the stderr of each command', () => {
controller.handle(commands);
commands[0].stderr.next(Buffer.from('foo'));
commands[1].stderr.next(Buffer.from('bar'));
expect(logger.logCommandText).toHaveBeenCalledTimes(2);
expect(logger.logCommandText).toHaveBeenCalledWith('foo', commands[0]);
expect(logger.logCommandText).toHaveBeenCalledWith('bar', commands[1]);
});

View File

@@ -0,0 +1,56 @@
const Rx = require('rxjs');
const { defaultIfEmpty, delay, filter, mapTo, skip, take, takeWhile } = require('rxjs/operators');
const defaults = require('../defaults');
const BaseHandler = require('./base-handler');
module.exports = class RestartProcess extends BaseHandler {
constructor({ delay, tries, logger, scheduler }) {
super({ logger });
this.delay = +delay || defaults.restartDelay;
this.tries = +tries || defaults.restartTries;
this.tries = this.tries < 0 ? Infinity : this.tries;
this.scheduler = scheduler;
}
handle(commands) {
if (this.tries === 0) {
return { commands };
}
commands.map(command => command.close.pipe(
take(this.tries),
takeWhile(({ exitCode }) => exitCode !== 0)
)).map((failure, index) => Rx.merge(
// Delay the emission (so that the restarts happen on time),
// explicitly telling the subscriber that a restart is needed
failure.pipe(delay(this.delay, this.scheduler), mapTo(true)),
// Skip the first N emissions (as these would be duplicates of the above),
// meaning it will be empty because of success, or failed all N times,
// and no more restarts should be attempted.
failure.pipe(skip(this.tries), defaultIfEmpty(false))
).subscribe(restart => {
const command = commands[index];
if (restart) {
this.logger.logCommandEvent(`${command.command} restarted`, command);
command.start();
}
}));
return {
commands: commands.map(command => {
const closeStream = command.close.pipe(filter(({ exitCode }, emission) => {
// We let all success codes pass, and failures only after restarting won't happen again
return exitCode === 0 || emission >= this.tries;
}));
return new Proxy(command, {
get(target, prop) {
return prop === 'close' ? closeStream : target[prop];
}
});
})
};
}
};

View File

@@ -0,0 +1,131 @@
const { createMockInstance } = require('jest-create-mock-instance');
const { TestScheduler } = require('rxjs/testing');
const Logger = require('../logger');
const createFakeCommand = require('./fixtures/fake-command');
const RestartProcess = require('./restart-process');
let commands, controller, logger, scheduler;
beforeEach(() => {
commands = [
createFakeCommand(),
createFakeCommand()
];
logger = createMockInstance(Logger);
scheduler = new TestScheduler();
controller = new RestartProcess({
logger,
scheduler,
delay: 100,
tries: 2
});
});
it('does not restart processes that complete with success', () => {
controller.handle(commands);
commands[0].close.next({ exitCode: 0 });
commands[1].close.next({ exitCode: 0 });
scheduler.flush();
expect(commands[0].start).toHaveBeenCalledTimes(0);
expect(commands[1].start).toHaveBeenCalledTimes(0);
});
it('restarts processes that fail after delay has passed', () => {
controller.handle(commands);
commands[0].close.next({ exitCode: 1 });
commands[1].close.next({ exitCode: 0 });
scheduler.flush();
expect(logger.logCommandEvent).toHaveBeenCalledTimes(1);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`${commands[0].command} restarted`,
commands[0]
);
expect(commands[0].start).toHaveBeenCalledTimes(1);
expect(commands[1].start).not.toHaveBeenCalled();
});
it('restarts processes up to tries', () => {
controller.handle(commands);
commands[0].close.next({ exitCode: 1 });
commands[0].close.next({ exitCode: 'SIGTERM' });
commands[0].close.next({ exitCode: 'SIGTERM' });
commands[1].close.next({ exitCode: 0 });
scheduler.flush();
expect(logger.logCommandEvent).toHaveBeenCalledTimes(2);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`${commands[0].command} restarted`,
commands[0]
);
expect(commands[0].start).toHaveBeenCalledTimes(2);
});
it.todo('restart processes forever, if tries is negative');
it('restarts processes until they succeed', () => {
controller.handle(commands);
commands[0].close.next({ exitCode: 1 });
commands[0].close.next({ exitCode: 0 });
commands[1].close.next({ exitCode: 0 });
scheduler.flush();
expect(logger.logCommandEvent).toHaveBeenCalledTimes(1);
expect(logger.logCommandEvent).toHaveBeenCalledWith(
`${commands[0].command} restarted`,
commands[0]
);
expect(commands[0].start).toHaveBeenCalledTimes(1);
});
describe('returned commands', () => {
it('are the same if 0 tries are to be attempted', () => {
controller = new RestartProcess({ logger, scheduler });
expect(controller.handle(commands)).toMatchObject({ commands });
});
it('are not the same, but with same length if 1+ tries are to be attempted', () => {
const { commands: newCommands } = controller.handle(commands);
expect(newCommands).not.toBe(commands);
expect(newCommands).toHaveLength(commands.length);
});
it('skip close events followed by restarts', () => {
const { commands: newCommands } = controller.handle(commands);
const callback = jest.fn();
newCommands[0].close.subscribe(callback);
newCommands[1].close.subscribe(callback);
commands[0].close.next({ exitCode: 1 });
commands[0].close.next({ exitCode: 1 });
commands[0].close.next({ exitCode: 1 });
commands[1].close.next({ exitCode: 1 });
commands[1].close.next({ exitCode: 0 });
scheduler.flush();
// 1 failure from commands[0], 1 success from commands[1]
expect(callback).toHaveBeenCalledTimes(2);
});
it('keep non-close streams from original commands', () => {
const { commands: newCommands } = controller.handle(commands);
newCommands.forEach((newCommand, i) => {
expect(newCommand.close).not.toBe(commands[i].close);
expect(newCommand.error).toBe(commands[i].error);
expect(newCommand.stdout).toBe(commands[i].stdout);
expect(newCommand.stderr).toBe(commands[i].stderr);
});
});
});

View File

@@ -0,0 +1,16 @@
const supportsColor = require('supports-color');
module.exports = ({
colorSupport = supportsColor.stdout,
cwd,
process = global.process,
raw = false,
env = {},
}) => Object.assign(
{
cwd: cwd || process.cwd(),
},
raw && { stdio: 'inherit' },
/^win/.test(process.platform) && { detached: false },
{ env: Object.assign(colorSupport ? { FORCE_COLOR: colorSupport.level } : {}, process.env, env) }
);

View File

@@ -0,0 +1,30 @@
const getSpawnOpts = require('./get-spawn-opts');
it('sets detached mode to false for Windows platform', () => {
expect(getSpawnOpts({ process: { platform: 'win32', cwd: jest.fn() } }).detached).toBe(false);
});
it('sets stdio to inherit when raw', () => {
expect(getSpawnOpts({ raw: true }).stdio).toBe('inherit');
});
it('merges FORCE_COLOR into env vars if color supported', () => {
const process = { env: { foo: 'bar' }, cwd: jest.fn() };
expect(getSpawnOpts({ process, colorSupport: false }).env).toEqual(process.env);
expect(getSpawnOpts({ process, colorSupport: { level: 1 } }).env).toEqual({
FORCE_COLOR: 1,
foo: 'bar'
});
});
it('sets default cwd to process.cwd()', () => {
const process = { cwd: jest.fn().mockReturnValue('process-cwd') };
expect(getSpawnOpts({
process,
}).cwd).toBe('process-cwd');
});
it('overrides default cwd', () => {
const cwd = 'foobar';
expect(getSpawnOpts({ cwd }).cwd).toBe(cwd);
});

View File

@@ -0,0 +1,123 @@
const chalk = require('chalk');
const _ = require('lodash');
const formatDate = require('date-fns/format');
const defaults = require('./defaults');
module.exports = class Logger {
constructor({ hide, outputStream, prefixFormat, prefixLength, raw, timestampFormat }) {
// To avoid empty strings from hiding the output of commands that don't have a name,
// keep in the list of commands to hide only strings with some length.
// This might happen through the CLI when no `--hide` argument is specified, for example.
this.hide = _.castArray(hide).filter(name => name || name === 0).map(String);
this.raw = raw;
this.outputStream = outputStream;
this.prefixFormat = prefixFormat;
this.prefixLength = prefixLength || defaults.prefixLength;
this.timestampFormat = timestampFormat || defaults.timestampFormat;
}
shortenText(text) {
if (!text || text.length <= this.prefixLength) {
return text;
}
const ellipsis = '..';
const prefixLength = this.prefixLength - ellipsis.length;
const endLength = Math.floor(prefixLength / 2);
const beginningLength = prefixLength - endLength;
const beginnning = text.substring(0, beginningLength);
const end = text.substring(text.length - endLength, text.length);
return beginnning + ellipsis + end;
}
getPrefixesFor(command) {
return {
none: '',
pid: command.pid,
index: command.index,
name: command.name,
command: this.shortenText(command.command),
time: formatDate(Date.now(), this.timestampFormat)
};
}
getPrefix(command) {
const prefix = this.prefixFormat || (command.name ? 'name' : 'index');
if (prefix === 'none') {
return '';
}
const prefixes = this.getPrefixesFor(command);
if (Object.keys(prefixes).includes(prefix)) {
return `[${prefixes[prefix]}]`;
}
return _.reduce(prefixes, (prev, val, key) => {
const keyRegex = new RegExp(_.escapeRegExp(`{${key}}`), 'g');
return prev.replace(keyRegex, val);
}, prefix);
}
colorText(command, text) {
let color;
if (command.prefixColor && command.prefixColor.startsWith('#')) {
color = chalk.hex(command.prefixColor);
} else {
const defaultColor = _.get(chalk, defaults.prefixColors, chalk.reset);
color = _.get(chalk, command.prefixColor, defaultColor);
}
return color(text);
}
logCommandEvent(text, command) {
if (this.raw) {
return;
}
this.logCommandText(chalk.reset(text) + '\n', command);
}
logCommandText(text, command) {
if (this.hide.includes(String(command.index)) || this.hide.includes(command.name)) {
return;
}
const prefix = this.colorText(command, this.getPrefix(command));
return this.log(prefix + (prefix ? ' ' : ''), text);
}
logGlobalEvent(text) {
if (this.raw) {
return;
}
this.log(chalk.reset('-->') + ' ', chalk.reset(text) + '\n');
}
log(prefix, text) {
if (this.raw) {
return this.outputStream.write(text);
}
// #70 - replace some ANSI code that would impact clearing lines
text = text.replace(/\u2026/g, '...');
const lines = text.split('\n').map((line, index, lines) => {
// First line will write prefix only if we finished the last write with a LF.
// Last line won't write prefix because it should be empty.
if (index === 0 || index === lines.length - 1) {
return line;
}
return prefix + line;
});
if (!this.lastChar || this.lastChar === '\n') {
this.outputStream.write(prefix);
}
this.lastChar = text[text.length - 1];
this.outputStream.write(lines.join('\n'));
}
};

View File

@@ -0,0 +1,223 @@
const { Writable } = require('stream');
const chalk = require('chalk');
const { createMockInstance } = require('jest-create-mock-instance');
const Logger = require('./logger');
let outputStream;
beforeEach(() => {
outputStream = createMockInstance(Writable);
// Force chalk to use colours, otherwise tests may pass when they were supposed to be failing.
chalk.level = 3;
});
const createLogger = options => {
const logger = new Logger(Object.assign({ outputStream }, options));
jest.spyOn(logger, 'log');
return logger;
};
describe('#log()', () => {
it('writes prefix + text to the output stream', () => {
const logger = new Logger({ outputStream });
logger.log('foo', 'bar');
expect(outputStream.write).toHaveBeenCalledTimes(2);
expect(outputStream.write).toHaveBeenCalledWith('foo');
expect(outputStream.write).toHaveBeenCalledWith('bar');
});
it('writes multiple lines of text with prefix on each', () => {
const logger = new Logger({ outputStream });
logger.log('foo', 'bar\nbaz\n');
expect(outputStream.write).toHaveBeenCalledTimes(2);
expect(outputStream.write).toHaveBeenCalledWith('foo');
expect(outputStream.write).toHaveBeenCalledWith('bar\nfoobaz\n');
});
it('does not prepend prefix if last call did not finish with a LF', () => {
const logger = new Logger({ outputStream });
logger.log('foo', 'bar');
outputStream.write.mockClear();
logger.log('foo', 'baz');
expect(outputStream.write).toHaveBeenCalledTimes(1);
expect(outputStream.write).toHaveBeenCalledWith('baz');
});
it('does not prepend prefix or handle text if logger is in raw mode', () => {
const logger = new Logger({ outputStream, raw: true });
logger.log('foo', 'bar\nbaz\n');
expect(outputStream.write).toHaveBeenCalledTimes(1);
expect(outputStream.write).toHaveBeenCalledWith('bar\nbaz\n');
});
});
describe('#logGlobalEvent()', () => {
it('does nothing if in raw mode', () => {
const logger = createLogger({ raw: true });
logger.logGlobalEvent('foo');
expect(logger.log).not.toHaveBeenCalled();
});
it('logs in gray dim style with arrow prefix', () => {
const logger = createLogger();
logger.logGlobalEvent('foo');
expect(logger.log).toHaveBeenCalledWith(
chalk.reset('-->') + ' ',
chalk.reset('foo') + '\n'
);
});
});
describe('#logCommandText()', () => {
it('logs with name if no prefixFormat is set', () => {
const logger = createLogger();
logger.logCommandText('foo', { name: 'bla' });
expect(logger.log).toHaveBeenCalledWith(chalk.reset('[bla]') + ' ', 'foo');
});
it('logs with index if no prefixFormat is set, and command has no name', () => {
const logger = createLogger();
logger.logCommandText('foo', { index: 2 });
expect(logger.log).toHaveBeenCalledWith(chalk.reset('[2]') + ' ', 'foo');
});
it('logs with prefixFormat set to pid', () => {
const logger = createLogger({ prefixFormat: 'pid' });
logger.logCommandText('foo', {
pid: 123,
info: {}
});
expect(logger.log).toHaveBeenCalledWith(chalk.reset('[123]') + ' ', 'foo');
});
it('logs with prefixFormat set to name', () => {
const logger = createLogger({ prefixFormat: 'name' });
logger.logCommandText('foo', { name: 'bar' });
expect(logger.log).toHaveBeenCalledWith(chalk.reset('[bar]') + ' ', 'foo');
});
it('logs with prefixFormat set to index', () => {
const logger = createLogger({ prefixFormat: 'index' });
logger.logCommandText('foo', { index: 3 });
expect(logger.log).toHaveBeenCalledWith(chalk.reset('[3]') + ' ', 'foo');
});
it('logs with prefixFormat set to time (with timestampFormat)', () => {
const logger = createLogger({ prefixFormat: 'time', timestampFormat: 'yyyy' });
logger.logCommandText('foo', {});
const year = new Date().getFullYear();
expect(logger.log).toHaveBeenCalledWith(chalk.reset(`[${year}]`) + ' ', 'foo');
});
it('logs with templated prefixFormat', () => {
const logger = createLogger({ prefixFormat: '{index}-{name}' });
logger.logCommandText('foo', { index: 0, name: 'bar' });
expect(logger.log).toHaveBeenCalledWith(chalk.reset('0-bar') + ' ', 'foo');
});
it('does not strip spaces from beginning or end of prefixFormat', () => {
const logger = createLogger({ prefixFormat: ' {index}-{name} ' });
logger.logCommandText('foo', { index: 0, name: 'bar' });
expect(logger.log).toHaveBeenCalledWith(chalk.reset(' 0-bar ') + ' ', 'foo');
});
it('logs with no prefix', () => {
const logger = createLogger({ prefixFormat: 'none' });
logger.logCommandText('foo', { command: 'echo foo' });
expect(logger.log).toHaveBeenCalledWith(chalk.reset(''), 'foo');
});
it('logs prefix using command line itself', () => {
const logger = createLogger({ prefixFormat: 'command' });
logger.logCommandText('foo', { command: 'echo foo' });
expect(logger.log).toHaveBeenCalledWith(chalk.reset('[echo foo]') + ' ', 'foo');
});
it('logs prefix using command line itself, capped at prefixLength bytes', () => {
const logger = createLogger({ prefixFormat: 'command', prefixLength: 6 });
logger.logCommandText('foo', { command: 'echo foo' });
expect(logger.log).toHaveBeenCalledWith(chalk.reset('[ec..oo]') + ' ', 'foo');
});
it('logs prefix using prefixColor from command', () => {
const logger = createLogger();
logger.logCommandText('foo', { prefixColor: 'blue', index: 1 });
expect(logger.log).toHaveBeenCalledWith(chalk.blue('[1]') + ' ', 'foo');
});
it('logs prefix in gray dim if prefixColor from command does not exist', () => {
const logger = createLogger();
logger.logCommandText('foo', { prefixColor: 'blue.fake', index: 1 });
expect(logger.log).toHaveBeenCalledWith(chalk.reset('[1]') + ' ', 'foo');
});
it('logs prefix using prefixColor from command if prefixColor is a hex value', () => {
const logger = createLogger();
const prefixColor = '#32bd8a';
logger.logCommandText('foo', {prefixColor, index: 1});
expect(logger.log).toHaveBeenCalledWith(chalk.hex(prefixColor)('[1]') + ' ', 'foo');
});
it('does nothing if command is hidden by name', () => {
const logger = createLogger({ hide: ['abc'] });
logger.logCommandText('foo', { name: 'abc' });
expect(logger.log).not.toHaveBeenCalled();
});
it('does nothing if command is hidden by index', () => {
const logger = createLogger({ hide: [3] });
logger.logCommandText('foo', { index: 3 });
expect(logger.log).not.toHaveBeenCalled();
});
});
describe('#logCommandEvent()', () => {
it('does nothing if in raw mode', () => {
const logger = createLogger({ raw: true });
logger.logCommandEvent('foo');
expect(logger.log).not.toHaveBeenCalled();
});
it('does nothing if command is hidden by name', () => {
const logger = createLogger({ hide: ['abc'] });
logger.logCommandEvent('foo', { name: 'abc' });
expect(logger.log).not.toHaveBeenCalled();
});
it('does nothing if command is hidden by index', () => {
const logger = createLogger({ hide: [3] });
logger.logCommandEvent('foo', { index: 3 });
expect(logger.log).not.toHaveBeenCalled();
});
it('logs text in gray dim', () => {
const logger = createLogger();
logger.logCommandEvent('foo', { index: 1 });
expect(logger.log).toHaveBeenCalledWith(chalk.reset('[1]') + ' ', chalk.reset('foo') + '\n');
});
});