Skip to content

Commit

Permalink
Teardown commands (#500)
Browse files Browse the repository at this point in the history
  • Loading branch information
gustavohenke authored Aug 28, 2024
1 parent 581b984 commit 950132d
Show file tree
Hide file tree
Showing 8 changed files with 237 additions and 18 deletions.
21 changes: 21 additions & 0 deletions bin/concurrently.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,27 @@ describe('--handle-input', () => {
});
});

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 processStartedMessageRegex = (index: number, command: string) => {
Expand Down
34 changes: 21 additions & 13 deletions bin/concurrently.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import _ from 'lodash';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

Expand All @@ -7,15 +8,13 @@ import { concurrently } from '../src/index';
import { epilogue } from './epilogue';

// Clean-up arguments (yargs expects only the arguments after the program name)
const cleanArgs = hideBin(process.argv);
// Find argument separator (double dash)
const argsSepIdx = cleanArgs.findIndex((arg) => arg === '--');
// Arguments before separator
const argsBeforeSep = argsSepIdx >= 0 ? cleanArgs.slice(0, argsSepIdx) : cleanArgs;
// Arguments after separator
const argsAfterSep = argsSepIdx >= 0 ? cleanArgs.slice(argsSepIdx + 1) : [];

const args = yargs(argsBeforeSep)
const args = yargs(hideBin(process.argv))
.parserConfiguration({
// Avoids options that can be specified multiple times from requiring a `--` to pass commands
'greedy-arrays': false,
// Makes sure that --passthrough-arguments works correctly
'populate--': true,
})
.usage('$0 [options] <command ...>')
.help('h')
.alias('h', 'help')
Expand Down Expand Up @@ -99,6 +98,13 @@ const args = yargs(argsBeforeSep)
type: 'boolean',
default: defaults.passthroughArguments,
},
teardown: {
describe:
'Clean up command(s) to execute before exiting concurrently. Might be specified multiple times.\n' +
"These aren't prefixed and they don't affect concurrently's exit code.",
type: 'string',
array: true,
},

// Kill others
'kill-others': {
Expand Down Expand Up @@ -191,7 +197,7 @@ const args = yargs(argsBeforeSep)
},
})
.group(
['m', 'n', 'name-separator', 's', 'r', 'no-color', 'hide', 'g', 'timings', 'P'],
['m', 'n', 'name-separator', 's', 'r', 'no-color', 'hide', 'g', 'timings', 'P', 'teardown'],
'General',
)
.group(['p', 'c', 'l', 't', 'pad-prefix'], 'Prefix styling')
Expand All @@ -203,8 +209,9 @@ const args = yargs(argsBeforeSep)

// Get names of commands by the specified separator
const names = (args.names || '').split(args.nameSeparator);
// If "passthrough-arguments" is disabled, treat additional arguments as commands
const commands = args.passthroughArguments ? args._ : [...args._, ...argsAfterSep];

const additionalArguments = _.castArray(args['--'] ?? []).map(String);
const commands = args.passthroughArguments ? args._ : args._.concat(additionalArguments);

concurrently(
commands.map((command, index) => ({
Expand Down Expand Up @@ -234,7 +241,8 @@ concurrently(
successCondition: args.success,
timestampFormat: args.timestampFormat,
timings: args.timings,
additionalArguments: args.passthroughArguments ? argsAfterSep : undefined,
teardown: args.teardown,
additionalArguments: args.passthroughArguments ? additionalArguments : undefined,
},
).result.then(
() => process.exit(0),
Expand Down
23 changes: 22 additions & 1 deletion src/concurrently.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Logger } from './logger';

let spawn: SpawnCommand;
let kill: KillProcess;
let onFinishHooks: (() => void)[];
let onFinishHooks: jest.Mock[];
let controllers: jest.Mocked<FlowController>[];
let processes: ChildProcess[];
const create = (commands: ConcurrentlyCommandInput[], options: Partial<ConcurrentlyOptions> = {}) =>
Expand Down Expand Up @@ -396,3 +396,24 @@ it('runs onFinish hook after all commands run', async () => {
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');
});
4 changes: 1 addition & 3 deletions src/concurrently.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,7 @@ export function concurrently(

const result = new CompletionListener({ successCondition: options.successCondition })
.listen(commands, options.abortSignal)
.finally(() => {
handleResult.onFinishCallbacks.forEach((onFinish) => onFinish());
});
.finally(() => Promise.all(handleResult.onFinishCallbacks.map((onFinish) => onFinish())));

return {
result,
Expand Down
2 changes: 1 addition & 1 deletion src/flow-control/flow-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ import { Command } from '../command';
* actually finish.
*/
export interface FlowController {
handle(commands: Command[]): { commands: Command[]; onFinish?: () => void };
handle(commands: Command[]): { commands: Command[]; onFinish?: () => void | Promise<void> };
}
90 changes: 90 additions & 0 deletions src/flow-control/teardown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import createMockInstance from 'jest-create-mock-instance';

import { createFakeProcess, FakeCommand } from '../fixtures/fake-command';
import { Logger } from '../logger';
import { getSpawnOpts } from '../spawn';
import { Teardown } from './teardown';

let spawn: jest.Mock;
let logger: Logger;
const commands = [new FakeCommand()];
const teardown = 'cowsay bye';

beforeEach(() => {
logger = createMockInstance(Logger);
spawn = jest.fn(() => createFakeProcess(1));
});

const create = (teardown: string[]) =>
new Teardown({
spawn,
logger,
commands: teardown,
});

it('returns commands unchanged', () => {
const { commands: actual } = create([]).handle(commands);
expect(actual).toBe(commands);
});

describe('onFinish callback', () => {
it('does not spawn nothing if there are no teardown commands', () => {
create([]).handle(commands).onFinish();
expect(spawn).not.toHaveBeenCalled();
});

it('runs teardown command', () => {
create([teardown]).handle(commands).onFinish();
expect(spawn).toHaveBeenCalledWith(teardown, getSpawnOpts({ stdio: 'raw' }));
});

it('waits for teardown command to close', async () => {
const child = createFakeProcess(1);
spawn.mockReturnValue(child);

const result = create([teardown]).handle(commands).onFinish();
child.emit('close', 1, null);
await expect(result).resolves.toBeUndefined();
});

it('rejects if teardown command errors', async () => {
const child = createFakeProcess(1);
spawn.mockReturnValue(child);

const result = create([teardown]).handle(commands).onFinish();
child.emit('error', 'fail');
await expect(result).rejects.toBeUndefined();
});

it('runs multiple teardown commands in sequence', async () => {
const child1 = createFakeProcess(1);
const child2 = createFakeProcess(2);
spawn.mockReturnValueOnce(child1).mockReturnValueOnce(child2);

const result = create(['foo', 'bar']).handle(commands).onFinish();

expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenLastCalledWith('foo', getSpawnOpts({ stdio: 'raw' }));

child1.emit('close', 1, null);
await new Promise((resolve) => setTimeout(resolve));

expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenLastCalledWith('bar', getSpawnOpts({ stdio: 'raw' }));

child2.emit('close', 0, null);
await expect(result).resolves.toBeUndefined();
});

it('stops running teardown commands on SIGINT', async () => {
const child = createFakeProcess(1);
spawn.mockReturnValue(child);

const result = create(['foo', 'bar']).handle(commands).onFinish();
child.emit('close', null, 'SIGINT');
await result;

expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenLastCalledWith('foo', expect.anything());
});
});
73 changes: 73 additions & 0 deletions src/flow-control/teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as Rx from 'rxjs';

import { Command, SpawnCommand } from '../command';
import { Logger } from '../logger';
import { getSpawnOpts, spawn as baseSpawn } from '../spawn';
import { FlowController } from './flow-controller';

export class Teardown implements FlowController {
private readonly logger: Logger;
private readonly spawn: SpawnCommand;
private readonly teardown: readonly string[];

constructor({
logger,
spawn,
commands,
}: {
logger: Logger;
/**
* Which function to use to spawn commands.
* Defaults to the same used by the rest of concurrently.
*/
spawn?: SpawnCommand;
commands: readonly string[];
}) {
this.logger = logger;
this.spawn = spawn || baseSpawn;
this.teardown = commands;
}

handle(commands: Command[]): { commands: Command[]; onFinish: () => Promise<void> } {
const { logger, teardown, spawn } = this;
const onFinish = async () => {
if (!teardown.length) {
return;
}

for (const command of teardown) {
logger.logGlobalEvent(`Running teardown command "${command}"`);

const child = spawn(command, getSpawnOpts({ stdio: 'raw' }));
const error = Rx.fromEvent(child, 'error');
const close = Rx.fromEvent(child, 'close');

try {
const [exitCode, signal] = await Promise.race([
Rx.firstValueFrom(error).then((event) => {
throw event;
}),
Rx.firstValueFrom(close).then(
(event) => event as [number | null, NodeJS.Signals | null],
),
]);

logger.logGlobalEvent(
`Teardown command "${command}" exited with code ${exitCode ?? signal}`,
);

if (signal === 'SIGINT') {
break;
}
} catch (error) {
const errorText = String(error instanceof Error ? error.stack || error : error);
logger.logGlobalEvent(`Teardown command "${command}" errored:`);
logger.logGlobalEvent(errorText);
return Promise.reject();
}
}
};

return { commands, onFinish };
}
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { LogOutput } from './flow-control/log-output';
import { LogTimings } from './flow-control/log-timings';
import { LoggerPadding } from './flow-control/logger-padding';
import { RestartDelay, RestartProcess } from './flow-control/restart-process';
import { Teardown } from './flow-control/teardown';
import { Logger } from './logger';

export type ConcurrentlyOptions = Omit<BaseConcurrentlyOptions, 'abortSignal' | 'hide'> & {
Expand Down Expand Up @@ -91,6 +92,12 @@ export type ConcurrentlyOptions = Omit<BaseConcurrentlyOptions, 'abortSignal' |
*/
timings?: boolean;

/**
* Clean up command(s) to execute before exiting concurrently.
* These won't be prefixed and don't affect concurrently's exit code.
*/
teardown?: readonly string[];

/**
* List of additional arguments passed that will get replaced in each command.
* If not defined, no argument replacing will happen.
Expand Down Expand Up @@ -155,6 +162,7 @@ export function concurrently(
logger: options.timings ? logger : undefined,
timestampFormat: options.timestampFormat,
}),
new Teardown({ logger, spawn: options.spawn, commands: options.teardown || [] }),
],
prefixColors: options.prefixColors || [],
additionalArguments: options.additionalArguments,
Expand Down

0 comments on commit 950132d

Please sign in to comment.