diff --git a/bin/concurrently.spec.ts b/bin/concurrently.spec.ts index 6b944bd5..6cca66e7 100644 --- a/bin/concurrently.spec.ts +++ b/bin/concurrently.spec.ts @@ -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) => { diff --git a/bin/concurrently.ts b/bin/concurrently.ts index cb13f8d6..e9017022 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import _ from 'lodash'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; @@ -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] ') .help('h') .alias('h', 'help') @@ -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': { @@ -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') @@ -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) => ({ @@ -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), diff --git a/src/concurrently.spec.ts b/src/concurrently.spec.ts index 8407be56..2f64b8c6 100644 --- a/src/concurrently.spec.ts +++ b/src/concurrently.spec.ts @@ -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[]; let processes: ChildProcess[]; const create = (commands: ConcurrentlyCommandInput[], options: Partial = {}) => @@ -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'); +}); diff --git a/src/concurrently.ts b/src/concurrently.ts index 9b5ca497..8c2bfc15 100644 --- a/src/concurrently.ts +++ b/src/concurrently.ts @@ -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, diff --git a/src/flow-control/flow-controller.ts b/src/flow-control/flow-controller.ts index 5a47c70e..ff1f0f3d 100644 --- a/src/flow-control/flow-controller.ts +++ b/src/flow-control/flow-controller.ts @@ -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 }; } diff --git a/src/flow-control/teardown.spec.ts b/src/flow-control/teardown.spec.ts new file mode 100644 index 00000000..155a3f0f --- /dev/null +++ b/src/flow-control/teardown.spec.ts @@ -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()); + }); +}); diff --git a/src/flow-control/teardown.ts b/src/flow-control/teardown.ts new file mode 100644 index 00000000..33b2f7ad --- /dev/null +++ b/src/flow-control/teardown.ts @@ -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 } { + 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 }; + } +} diff --git a/src/index.ts b/src/index.ts index 75edd2d4..ccf1d517 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 & { @@ -91,6 +92,12 @@ export type ConcurrentlyOptions = Omit