From df711c8bf847255ee7d189f0b3fa77d867761970 Mon Sep 17 00:00:00 2001 From: Marcelo Boveto Shima Date: Sun, 7 Jun 2020 11:31:29 -0300 Subject: [PATCH] Implement lazily created sub commands. --- index.js | 51 ++++++--- .../command.subCommandPromise.action.test.js | 108 ++++++++++++++++++ 2 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 tests/command.subCommandPromise.action.test.js diff --git a/index.js b/index.js index 7134a3865..ae7f5b3bc 100644 --- a/index.js +++ b/index.js @@ -187,6 +187,7 @@ class Command extends EventEmitter { cmd._passCommandToAction = this._passCommandToAction; cmd._executableFile = opts.executableFile || null; // Custom name for executable file, set missing to null to match constructor + cmd._actionHandler = opts.action || null; // A handler for sub commands. this.commands.push(cmd); cmd._parseExpectedArgs(args); cmd.parent = this; @@ -228,8 +229,8 @@ class Command extends EventEmitter { // Fail fast and detect when adding rather than later when parsing. function checkExplicitNames(commandArray) { commandArray.forEach((cmd) => { - if (cmd._executableHandler && !cmd._executableFile) { - throw new Error(`Must specify executableFile for deeply nested executable: ${cmd.name()}`); + if (cmd._executableHandler && !cmd._executableFile && !cmd._actionHandler) { + throw new Error(`Must specify executableFile or action for deeply nested executable: ${cmd.name()}`); } checkExplicitNames(cmd.commands); }); @@ -668,6 +669,11 @@ class Command extends EventEmitter { */ parse(argv, parseOptions) { + this._parseProgram(argv, parseOptions); + return this; + } + + _parseProgram(argv, parseOptions) { if (argv !== undefined && !Array.isArray(argv)) { throw new Error('first parameter to parse must be array or undefined'); } @@ -714,9 +720,7 @@ class Command extends EventEmitter { this._name = this._name || (this._scriptPath && path.basename(this._scriptPath, path.extname(this._scriptPath))); // Let's go! - this._parseCommand([], userArgs); - - return this; + return this.parseCommand([], userArgs); }; /** @@ -736,13 +740,14 @@ class Command extends EventEmitter { * @param {string[]} [argv] * @param {Object} [parseOptions] * @param {string} parseOptions.from - where the args are from: 'node', 'user', 'electron' - * @return {Promise} + * @return {Promise} promise `this` * @api public */ parseAsync(argv, parseOptions) { - this.parse(argv, parseOptions); - return Promise.all(this._actionResults).then(() => this); + return this._parseProgram(argv, parseOptions).then(() => { + return Promise.all(this._actionResults).then(() => this); + }); }; /** @@ -850,9 +855,11 @@ class Command extends EventEmitter { // Store the reference to the child process this.runningCommand = proc; + return Promise.resolve(); }; /** + * @return {Promise} * @api private */ _dispatchSubcommand(commandName, operands, unknown) { @@ -860,35 +867,46 @@ class Command extends EventEmitter { if (!subCommand) this._helpAndError(); if (subCommand._executableHandler) { - this._executeSubCommand(subCommand, operands.concat(unknown)); - } else { - subCommand._parseCommand(operands, unknown); + if (subCommand._actionHandler) { + const actionPromise = subCommand._actionHandler({ command: this, operands, unknown }); + if (actionPromise instanceof Promise) { + return actionPromise; + } + return Promise.resolve(actionPromise); + } + return this._executeSubCommand(subCommand, operands.concat(unknown)); } + return subCommand.parseCommand(operands, unknown); }; /** * Process arguments in context of this command. * - * @api private + * @param {string[]} [operands] + * @param {string[]} [unknown] + * @return {Promise} */ - _parseCommand(operands, unknown) { + parseCommand(operands = [], unknown = []) { const parsed = this.parseOptions(unknown); operands = operands.concat(parsed.operands); unknown = parsed.unknown; this.args = operands.concat(unknown); + this.emit('commandParsed:' + this.name(), this); + + let commandPromise = Promise.resolve(); if (operands && this._findCommand(operands[0])) { - this._dispatchSubcommand(operands[0], operands.slice(1), unknown); + commandPromise = this._dispatchSubcommand(operands[0], operands.slice(1), unknown); } else if (this._lazyHasImplicitHelpCommand() && operands[0] === this._helpCommandName) { if (operands.length === 1) { this.help(); } else { - this._dispatchSubcommand(operands[1], [], [this._helpLongFlag]); + commandPromise = this._dispatchSubcommand(operands[1], [], [this._helpLongFlag]); } } else if (this._defaultCommandName) { outputHelpIfRequested(this, unknown); // Run the help for default command from parent rather than passing to default command - this._dispatchSubcommand(this._defaultCommandName, operands, unknown); + commandPromise = this._dispatchSubcommand(this._defaultCommandName, operands, unknown); } else { if (this.commands.length && this.args.length === 0 && !this._actionHandler && !this._defaultCommandName) { // probably missing subcommand and no handler, user needs help @@ -928,6 +946,7 @@ class Command extends EventEmitter { // fall through for caller to handle after calling .parse() } } + return commandPromise; }; /** diff --git a/tests/command.subCommandPromise.action.test.js b/tests/command.subCommandPromise.action.test.js new file mode 100644 index 000000000..6b68570a9 --- /dev/null +++ b/tests/command.subCommandPromise.action.test.js @@ -0,0 +1,108 @@ +const commander = require('../'); + +// Test some behaviours of .action not covered in more specific tests. + +describe('with action', () => { + test('when .action called then builder action is executed', () => { + const actionMock = jest.fn(); + const program = new commander.Command(); + program.command('run', 'run description', { + action: actionOptions => { + return actionOptions + .command + .command('run') + .action(actionMock) + .parseCommand(actionOptions.operands, actionOptions.unknown); + } + }); + program.parse(['node', 'test', 'run']); + expect(actionMock).toHaveBeenCalled(); + }); + + test('when .action called action is executed', () => { + const actionMock = jest.fn(); + const program = new commander.Command(); + program.command('run', 'run description', { + action: actionOptions => { + return Promise.resolve(actionOptions + .command + .command('run') + .action(actionMock) + .parseCommand(actionOptions.operands, actionOptions.unknown)); + } + }); + return program.parseAsync(['node', 'test', 'run']).then(() => { + expect(actionMock).toHaveBeenCalled(); + }); + }); + + test('arguments is resolved and passed to action', () => { + const actionSpy = jest.fn(); + return new commander.Command() + .command('run ', 'run description', { + action: actionOptions => { + return Promise.resolve(actionOptions + .command + .command('run ') + .action((arg) => { + expect(arg).toEqual('foo'); + actionSpy(); + }) + .parseCommand(actionOptions.operands, actionOptions.unknown)); + } + }) + .parseAsync(['node', 'test', 'run', 'foo']).then(() => { + expect(actionSpy).toHaveBeenCalled(); + }); + }); + + test('options is resolved and passed to action', () => { + const actionSpy = jest.fn(); + return new commander.Command() + .command('run ', 'run description', { + action: actionOptions => { + return Promise.resolve(actionOptions + .command + .command('run') + .requiredOption('--test-option ', 'test value') + .action(command => { + expect(command.testOption).toEqual('bar'); + actionSpy(); + }) + .parseCommand(actionOptions.operands, actionOptions.unknown) + ); + } + }) + .parseAsync(['node', 'test', 'run', '--test-option', 'bar']).then(() => { + expect(actionSpy).toHaveBeenCalled(); + }); + }); + + test('recursive sub command action', () => { + const infoSpy = jest.fn(); + const runSpy = jest.fn(); + const runCommandBuilder = actionOptions => { + return actionOptions.command.command('run ', 'run description', { + action: _actionOptions => { + if (_actionOptions.operands && _actionOptions.operands[0] === 'run') { + runSpy(); + return runCommandBuilder(_actionOptions) + .parseCommand(_actionOptions.operands, _actionOptions.unknown); + } + return Promise.resolve(actionOptions + .command + .command('info') + .action(infoSpy) + .parseCommand(actionOptions.operands, actionOptions.unknown) + ); + } + }); + }; + return runCommandBuilder({ command: new commander.Command() }) + .parseAsync(['node', 'test', 'run', 'run', 'run', 'run', 'info']).then(() => { + expect(infoSpy).toHaveBeenCalled(); + expect(runSpy).toHaveBeenCalledTimes(3); + }) + ; + }); +});