Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Teardown commands #500

Merged
merged 4 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading