diff --git a/lib/argument.js b/lib/argument.js index c16430250..14afa1304 100644 --- a/lib/argument.js +++ b/lib/argument.js @@ -16,6 +16,7 @@ class Argument { this.description = description || ''; this.variadic = false; this.parseArg = undefined; + this.chained = false; this.defaultValue = undefined; this.defaultValueDescription = undefined; this.argChoices = undefined; @@ -89,6 +90,17 @@ class Argument { return this; } + /** + * When set to true, next call to the function provided via .argParser() will be chained to its return value if it is thenable. + * + * @param {boolean} [chained] + * @return {Argument} + */ + chainArgParserCalls(chained = true) { + this.chained = !!chained; + return this; + } + /** * Only allow argument value to be one of choices. * diff --git a/lib/command.js b/lib/command.js index 590a271dd..83aa46e02 100644 --- a/lib/command.js +++ b/lib/command.js @@ -38,6 +38,7 @@ class Command extends EventEmitter { this._name = name || ''; this._optionValues = {}; this._optionValueSources = {}; // default, env, cli etc + this._overwrittenOptionValues = {}; this._storeOptionsAsProperties = false; this._actionHandler = null; this._executableHandler = false; @@ -57,6 +58,13 @@ class Command extends EventEmitter { this._showHelpAfterError = false; this._showSuggestionAfterError = true; + /** @type {boolean | undefined} */ + this._asyncParsing = undefined; + /** @type {boolean | undefined} */ + this._awaitHook = undefined; + /** @type {{ preSubcommand: number, postArguments: number } | null} */ + this._awaitHookIndices = null; + // see .configureOutput() for docs this._outputConfiguration = { writeOut: (str) => process.stdout.write(str), @@ -399,7 +407,7 @@ class Command extends EventEmitter { */ hook(event, listener) { - const allowedValues = ['preSubcommand', 'preAction', 'postAction']; + const allowedValues = ['preSubcommand', 'postArguments', 'preAction', 'postAction']; if (!allowedValues.includes(event)) { throw new Error(`Unexpected value for event passed to hook : '${event}'. Expecting one of '${allowedValues.join("', '")}'`); @@ -412,6 +420,149 @@ Expecting one of '${allowedValues.join("', '")}'`); return this; } + /** + * @return {Promise[]} + * @api private + */ + + _getOptionResavePromises() { + const promises = []; + + Object.entries(this.opts()).forEach(([key, value]) => { + if (thenable(value)) { + promises.push((async() => { + this.setOptionValueWithSource( + key, await value, this.getOptionValueSource(key) + ); + })()); + } + }); + + Object.entries(this._overwrittenOptionValues).forEach(([key, values]) => { + values.forEach(value => { + if (thenable(value)) { + promises.push((async() => { + await value; + })()); + } + }); + }); + + return promises; + } + + /** + * @api private + */ + + _shouldAwait() { + let shouldAwait; + let cmd = this; + // Only await if the nearest ancestor with explicitly set _awaitHook value has it set to true, + // or if there is no such ancestor and .parseAsync() was called on the top-level command. + do { + shouldAwait = cmd._awaitHook ?? cmd._asyncParsing; + cmd = cmd.parent; + } while (shouldAwait === undefined); + return shouldAwait; + } + + /** + * @api private + */ + + _awaitHookPreSubcommand() { + this.hook('preSubcommand', () => { + if (this._shouldAwait()) { + const toAwait = this._getOptionResavePromises(); + if (toAwait.length) return Promise.all(toAwait); + } + }); + } + + /** + * @api private + */ + + _awaitHookPostArguments() { + this.hook('postArguments', (thisCommand, leafCommand) => { + if (( + // Implicit hook only at top level + this._awaitHook || this._asyncParsing !== undefined + ) && leafCommand._shouldAwait()) { + const toAwait = leafCommand._getOptionResavePromises(); + + leafCommand.processedArgs.forEach((value, i) => { + if (thenable(value)) { + toAwait.push((async() => { + leafCommand.processedArgs[i] = await value; + })()); + } + }); + + if (toAwait.length) return Promise.all(toAwait); + } + }); + } + + /** + * @api private + */ + + _addAwaitHook() { + this._awaitHookPreSubcommand(); + this._awaitHookPostArguments(); + + const events = ['preSubcommand', 'postArguments']; + this._awaitHookIndices = events.reduce((obj, event) => { + obj[event] = this._lifeCycleHooks[event].length - 1; + return obj; + }, {}); + } + + /** + * @api private + */ + + _removeAwaitHook() { + const events = ['preSubcommand', 'postArguments']; + events.forEach((event) => { + this._lifeCycleHooks[event].splice(this._awaitHookIndices[event], 1); + }); + this._awaitHookIndices = null; + } + + /** + * Add hook to await argument and option values. + * Useful for asynchronous custom processing (parseArg) of arguments and option-arguments. + * + * Hook behaviour depends on the value of `enabled`: + * - If the value is `false`, do not await anything. + * - If the value is `true`, await for this command before dispatching subcommand; and for action command before calling action handlers if the "should await" condition applies (see below). + * - If the value is `undefined`, await for this command before dispatching subcommand if the "should await" condition applies (see below); and await for action command in the same manner as if the value were `true`, but only if this command is the top level command (i.e. has no parent). + * + * The "should await" condition for a command is as follows: + * - either the method was called with an `enabled` value of `true` on the nearest command ancestor for which the method was called with an `enabled` value other than `undefined`; + * - or there is no such ancestor and `parseAsync()` was called on the top-level command. + * + * @param {boolean | undefined} [enabled] + * @return {Command} `this` command for chaining + */ + + awaitHook(enabled) { + this._awaitHook = enabled === undefined ? undefined : !!enabled; + + if (this._awaitHookIndices) { + this._removeAwaitHook(); + } + + if (this._awaitHook !== false) { + this._addAwaitHook(); + } + + return this; + } + /** * Register callback to use as replacement for calling process.exit. * @@ -526,6 +677,14 @@ Expecting one of '${allowedValues.join("', '")}'`); // handler for cli and env supplied values const handleOptionValue = (val, invalidValueMessage, valueSource) => { + const handleError = (err) => { + if (err?.code === 'commander.invalidArgument') { + const message = `${invalidValueMessage} ${err.message}`; + this.error(message, { exitCode: err.exitCode, code: err.code }); + } + throw err; + }; + // val is null for optional option used without an optional-argument. // val is undefined for boolean and negated option. if (val == null && option.presetArg !== undefined) { @@ -534,16 +693,17 @@ Expecting one of '${allowedValues.join("', '")}'`); // custom processing const oldValue = this.getOptionValue(name); + if (val !== null && option.parseArg) { - try { - val = option.parseArg(val, oldValue); - } catch (err) { - if (err.code === 'commander.invalidArgument') { - const message = `${invalidValueMessage} ${err.message}`; - this.error(message, { exitCode: err.exitCode, code: err.code }); - } - throw err; + const optionValues = this._storeOptionsAsProperties + ? this + : this._optionValues; + const overwrite = name in optionValues; + if (overwrite) { + this._overwrittenOptionValues[name] ??= []; + this._overwrittenOptionValues[name].push(this.getOptionValue(name)); } + val = this._parseArg(option, val, oldValue, handleError); } else if (val !== null && option.variadic) { val = option._concatValue(val, oldValue); } @@ -905,10 +1065,16 @@ Expecting one of '${allowedValues.join("', '")}'`); */ parse(argv, parseOptions) { - const userArgs = this._prepareUserArgs(argv, parseOptions); - this._parseCommand([], userArgs); + this._asyncParsing = false; - return this; + try { + const userArgs = this._prepareUserArgs(argv, parseOptions); + this._parseCommand([], userArgs); + + return this; + } finally { + this._asyncParsing = undefined; + } } /** @@ -931,10 +1097,23 @@ Expecting one of '${allowedValues.join("', '")}'`); */ async parseAsync(argv, parseOptions) { - const userArgs = this._prepareUserArgs(argv, parseOptions); - await this._parseCommand([], userArgs); + this._asyncParsing = true; + const shouldAddAwaitHook = ( + // Only true if awaitHook() has never been called + this._awaitHook === undefined && this._awaitHookIndices === null + ); - return this; + try { + if (shouldAddAwaitHook) this._addAwaitHook(); + + const userArgs = this._prepareUserArgs(argv, parseOptions); + await this._parseCommand([], userArgs); + + return this; + } finally { + this._asyncParsing = undefined; + if (shouldAddAwaitHook) this._removeAwaitHook(); + } } /** @@ -1126,26 +1305,78 @@ Expecting one of '${allowedValues.join("', '")}'`); } } + /** + * @param {Argument|Option} target + * @param {*} value + * @param {Function} handleError + * @api private + */ + + _catchChainError(target, value, handleError) { + if (target.chained && thenable(value)) { + return value.then(x => x, handleError); + } + + return value; + } + + /** + * Internal implementation shared by ._processArguments() and option and optionEnv event listeners. + * + * @param {Argument|Option} target + * @param {*} value + * @param {*} previous + * @param {Function} handleError + * @return {*} + * @api private + */ + + _parseArg(target, value, previous, handleError) { + let parsedValue; + + if (target.chained && thenable(previous)) { + // chain thenables + parsedValue = previous.then( + (result) => { + result = target.parseArg(value, result); + // call with result and not parsedValue to not catch handleError exception repeatedly + result = this._catchChainError(target, result, handleError); + return result; + } + ); + } else { + try { + parsedValue = target.parseArg(value, previous); + parsedValue = this._catchChainError(target, parsedValue, handleError); + } catch (err) { + handleError(err); + } + } + + return parsedValue; + } + /** * Process this.args using this._args and save as this.processedArgs! * + * @return {Promise|undefined} * @api private */ _processArguments() { const myParseArg = (argument, value, previous) => { + const handleError = (err) => { + if (err?.code === 'commander.invalidArgument') { + const message = `error: command-argument value '${value}' is invalid for argument '${argument.name()}'. ${err.message}`; + this.error(message, { exitCode: err.exitCode, code: err.code }); + } + throw err; + }; + // Extra processing for nice error message on parsing failure. let parsedValue = value; if (value !== null && argument.parseArg) { - try { - parsedValue = argument.parseArg(value, previous); - } catch (err) { - if (err.code === 'commander.invalidArgument') { - const message = `error: command-argument value '${value}' is invalid for argument '${argument.name()}'. ${err.message}`; - this.error(message, { exitCode: err.exitCode, code: err.code }); - } - throw err; - } + parsedValue = this._parseArg(argument, value, previous, handleError); } return parsedValue; }; @@ -1176,6 +1407,8 @@ Expecting one of '${allowedValues.join("', '")}'`); processedArgs[index] = value; }); this.processedArgs = processedArgs; + + return this._chainOrCallHooks(undefined, 'postArguments'); } /** @@ -1188,8 +1421,7 @@ Expecting one of '${allowedValues.join("', '")}'`); */ _chainOrCall(promise, fn) { - // thenable - if (promise && promise.then && typeof promise.then === 'function') { + if (thenable(promise)) { // already have a promise, chain callback return promise.then(() => fn()); } @@ -1293,9 +1525,7 @@ Expecting one of '${allowedValues.join("', '")}'`); const commandEvent = `command:${this.name()}`; if (this._actionHandler) { checkForUnknownOptions(); - this._processArguments(); - - let actionResult; + let actionResult = this._processArguments(); actionResult = this._chainOrCallHooks(actionResult, 'preAction'); actionResult = this._chainOrCall(actionResult, () => this._actionHandler(this.processedArgs)); if (this.parent) { @@ -1308,9 +1538,11 @@ Expecting one of '${allowedValues.join("', '")}'`); } if (this.parent && this.parent.listenerCount(commandEvent)) { checkForUnknownOptions(); - this._processArguments(); + const result = this._processArguments(); this.parent.emit(commandEvent, operands, unknown); // legacy - } else if (operands.length) { + return result; + } + if (operands.length) { if (this._findCommand('*')) { // legacy default command return this._dispatchSubcommand('*', operands, unknown); } @@ -1321,7 +1553,7 @@ Expecting one of '${allowedValues.join("', '")}'`); this.unknownCommand(); } else { checkForUnknownOptions(); - this._processArguments(); + return this._processArguments(); } } else if (this.commands.length) { checkForUnknownOptions(); @@ -1329,7 +1561,7 @@ Expecting one of '${allowedValues.join("', '")}'`); this.help({ error: true }); } else { checkForUnknownOptions(); - this._processArguments(); + return this._processArguments(); // fall through for caller to handle after calling .parse() } } @@ -2193,4 +2425,14 @@ function getCommandAndParents(startCommand) { return result; } +/** + * @param {*} value + * @returns {boolean} + * @api private + */ + +function thenable(value) { + return typeof value?.then === 'function'; +} + exports.Command = Command; diff --git a/lib/option.js b/lib/option.js index d61fc5f2f..2f7dd61dd 100644 --- a/lib/option.js +++ b/lib/option.js @@ -31,6 +31,7 @@ class Option { this.presetArg = undefined; this.envVar = undefined; this.parseArg = undefined; + this.chained = false; this.hidden = false; this.argChoices = undefined; this.conflictsWith = []; @@ -135,6 +136,17 @@ class Option { return this; } + /** + * When set to true, next call to the function provided via .argParser() will be chained to its return value if it is thenable. + * + * @param {boolean} [chained] + * @return {Argument} + */ + chainArgParserCalls(chained = true) { + this.chained = !!chained; + return this; + } + /** * Whether the option is mandatory and must have a value after parsing. * diff --git a/package-lock.json b/package-lock.json index c32c80e62..da2deae60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "commander", - "version": "10.0.1", + "version": "11.0.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.2.4", diff --git a/tests/argument.chain.test.js b/tests/argument.chain.test.js index dbc7947c2..b8057f4ac 100644 --- a/tests/argument.chain.test.js +++ b/tests/argument.chain.test.js @@ -13,6 +13,12 @@ describe('Argument methods that should return this for chaining', () => { expect(result).toBe(argument); }); + test('when call .chainArgParserCalls() then returns this', () => { + const argument = new Argument(''); + const result = argument.chainArgParserCalls(); + expect(result).toBe(argument); + }); + test('when call .choices() then returns this', () => { const argument = new Argument(''); const result = argument.choices(['a']); diff --git a/tests/argument.custom-processing.test.js b/tests/argument.custom-processing.test.js index ef3d16822..6163d4b8c 100644 --- a/tests/argument.custom-processing.test.js +++ b/tests/argument.custom-processing.test.js @@ -205,3 +205,31 @@ test('when custom processing for argument throws plain error then not CommanderE expect(caughtErr).toBeInstanceOf(Error); expect(caughtErr).not.toBeInstanceOf(commander.CommanderError); }); + +test('when custom with .chainArgParserCalls() then parsed to chain', async() => { + const args = ['1', '2']; + const resolvedValue = '12'; + const coercion = async(value, previousValue) => ( + previousValue === undefined ? value : previousValue + value + ); + const awaited = coercion(args[0], undefined); + const mockCoercion = jest.fn().mockImplementation(coercion); + + const argument = new commander.Argument('', 'desc') + .argParser(mockCoercion) + .chainArgParserCalls(); + + let actionValue; + + const program = new commander.Command(); + program + .addArgument(argument) + .action((value) => { + actionValue = value; + }); + + program.parse(args, { from: 'user' }); + expect(program.processedArgs[0]).toEqual(awaited); + expect(actionValue).toEqual(awaited); + await expect(actionValue).resolves.toEqual(resolvedValue); +}); diff --git a/tests/command.argumentVariations.test.js b/tests/command.argumentVariations.test.js index a8a781625..684cb082a 100644 --- a/tests/command.argumentVariations.test.js +++ b/tests/command.argumentVariations.test.js @@ -9,7 +9,8 @@ test.each(getSingleArgCases(''))('when add "" using %s t _name: 'explicit-required', required: true, variadic: false, - description: '' + description: '', + chained: false }; expect(argument).toEqual(expectedShape); }); @@ -20,7 +21,8 @@ test.each(getSingleArgCases('implicit-required'))('when add "arg" using %s then _name: 'implicit-required', required: true, variadic: false, - description: '' + description: '', + chained: false }; expect(argument).toEqual(expectedShape); }); @@ -31,7 +33,8 @@ test.each(getSingleArgCases('[optional]'))('when add "[arg]" using %s then argum _name: 'optional', required: false, variadic: false, - description: '' + description: '', + chained: false }; expect(argument).toEqual(expectedShape); }); @@ -42,7 +45,8 @@ test.each(getSingleArgCases(''))('when add "" usin _name: 'explicit-required', required: true, variadic: true, - description: '' + description: '', + chained: false }; expect(argument).toEqual(expectedShape); }); @@ -53,7 +57,8 @@ test.each(getSingleArgCases('implicit-required...'))('when add "arg..." using %s _name: 'implicit-required', required: true, variadic: true, - description: '' + description: '', + chained: false }; expect(argument).toEqual(expectedShape); }); @@ -64,7 +69,8 @@ test.each(getSingleArgCases('[optional...]'))('when add "[arg...]" using %s then _name: 'optional', required: false, variadic: true, - description: '' + description: '', + chained: false }; expect(argument).toEqual(expectedShape); }); diff --git a/tests/command.awaitHook.test.js b/tests/command.awaitHook.test.js new file mode 100644 index 000000000..e6951e7ef --- /dev/null +++ b/tests/command.awaitHook.test.js @@ -0,0 +1,267 @@ +/* eslint 'jest/expect-expect': [ + 'warn', + { + assertFunctionNames: ['expect', 'testWithArguments', 'testWithOptions'] + } +] */ + +const commander = require('../'); + +const makeThenable = (function() { + const cache = new Map(); + return (value) => { + if (cache.has(value)) { + return cache.get(value); + } + const thenable = { + then: (fn) => makeThenable(fn(value)) + }; + cache.set(value, thenable); + return thenable; + }; +})(); + +const chainedAwaited = async(coercion, args) => ( + args.reduce(async(promise, v) => { + const { thenable } = await promise; + return { thenable: makeThenable(coercion(v, await thenable)) }; + }, { thenable: makeThenable(undefined) }) +); + +describe('awaitHook with arguments', () => { + async function testWithArguments(program, args, resolvedValues, awaited) { + let actionValues; + program + .action((...args) => { + actionValues = args.slice(0, resolvedValues.length); + }); + + const result = program.parseAsync(args, { from: 'user' }); + awaited.forEach((value, i) => { + expect(program.processedArgs[i]).toBe(value); + }); + await result; + expect(program.processedArgs).toEqual(resolvedValues); + expect(actionValues).toEqual(resolvedValues); + } + + test('when awaitHook and arguments with custom processing then .processedArgs and action arguments resolved from callback', async() => { + const args = ['1', '2']; + const resolvedValues = [3, 4]; + const awaited = [ + makeThenable(resolvedValues[0]), + resolvedValues[1] + ]; + const mockCoercions = awaited.map( + value => jest.fn().mockImplementation(() => value) + ); + + const program = new commander.Command(); + program + .argument('', 'desc', mockCoercions[0]) + .argument('[arg]', 'desc', mockCoercions[1]); + + await testWithArguments(program, args, resolvedValues, awaited); + }); + + test('when awaitHook and arguments not specified with default values then .processedArgs and action arguments resolved from default values', async() => { + const args = []; + const resolvedValues = [1, 2]; + const awaited = [ + makeThenable(resolvedValues[0]), + resolvedValues[1] + ]; + + const program = new commander.Command(); + program + .argument('[arg]', 'desc', awaited[0]) + .argument('[arg]', 'desc', awaited[1]); + + await testWithArguments(program, args, resolvedValues, awaited); + }); + + test('when awaitHook and variadic argument with chained asynchronous custom processing then .processedArgs and action arguments resolved from chain', async() => { + const args = ['1', '2']; + const resolvedValues = ['12']; + const coercion = (value, previousValue) => { + const coerced = previousValue === undefined + ? value + : previousValue + value; + return makeThenable(coerced); + }; + const awaited = [(await chainedAwaited(coercion, args)).thenable]; + const mockCoercion = jest.fn().mockImplementation(coercion); + + const argument = new commander.Argument('', 'desc') + .argParser(mockCoercion) + .chainArgParserCalls(); + + const program = new commander.Command(); + program + .addArgument(argument); + + await testWithArguments(program, args, resolvedValues, awaited); + }); +}); + +describe('awaitHook with options', () => { + async function testWithOptions(program, args, resolvedValues, awaited) { + program + .action(() => {}); + + const result = program.parseAsync(args, { from: 'user' }); + Object.entries(awaited).forEach(([key, value]) => { + expect(program.opts()[key]).toBe(value); + }); + await result; + expect(program.opts()).toEqual(resolvedValues); + } + + test('when awaitHook and options with custom processing then .opts() resolved from callback', async() => { + const args = ['-a', '1', '-b', '2']; + const resolvedValues = { a: 3, b: 4 }; + const awaited = { + a: makeThenable(resolvedValues.a), + b: resolvedValues.b + }; + const mockCoercions = Object.entries(awaited).reduce((acc, [key, value]) => { + acc[key] = jest.fn().mockImplementation(() => value); + return acc; + }, {}); + + const program = new commander.Command(); + program + .option('-a ', 'desc', mockCoercions.a) + .option('-b [arg]', 'desc', mockCoercions.b); + + await testWithOptions(program, args, resolvedValues, awaited); + expect(program.getOptionValueSource('a')).toEqual('cli'); + expect(program.getOptionValueSource('b')).toEqual('cli'); + }); + + test('when awaitHook and options not specified with default values then .opts() resolved from default values', async() => { + const args = []; + const resolvedValues = { a: 1, b: 2 }; + const awaited = { + a: makeThenable(resolvedValues.a), + b: resolvedValues.b + }; + + const program = new commander.Command(); + program + .option('-a ', 'desc', awaited.a) + .option('-b [arg]', 'desc', awaited.b); + + await testWithOptions(program, args, resolvedValues, awaited); + expect(program.getOptionValueSource('a')).toEqual('default'); + expect(program.getOptionValueSource('b')).toEqual('default'); + }); + + test('when awaitHook and implied option values then .opts() resolved from implied values', async() => { + const args = ['-c']; + const resolvedValues = { a: 1, b: 2, c: true }; + const awaited = { + a: makeThenable(resolvedValues.a), + b: resolvedValues.b, + c: true + }; + + const option = new commander.Option('-c').implies(awaited); + const program = new commander.Command(); + program + .option('-a ') + .option('-b [arg]') + .addOption(option); + + await testWithOptions(program, args, resolvedValues, awaited); + expect(program.getOptionValueSource('a')).toEqual('implied'); + expect(program.getOptionValueSource('b')).toEqual('implied'); + }); + + test('when awaitHook and non-variadic repeated option with chained asynchronous custom processing then .opts() resolved from chain', async() => { + const args = ['-a', '1', '-a', '2']; + const resolvedValues = { a: '12' }; + const coercion = (value, previousValue) => { + const coerced = previousValue === undefined + ? value + : previousValue + value; + return makeThenable(coerced); + }; + const awaited = { + a: (await chainedAwaited( + coercion, args.filter((_, i) => i % 2)) + ).thenable + }; + const mockCoercion = jest.fn().mockImplementation(coercion); + + const option = new commander.Option('-a [arg]', 'desc') + .argParser(mockCoercion) + .chainArgParserCalls(); + + const program = new commander.Command(); + program + .addOption(option); + + await testWithOptions(program, args, resolvedValues, awaited); + expect(program.getOptionValueSource('a')).toEqual('cli'); + }); + + test('when awaitHook and variadic option with chained asynchronous custom processing then .opts() resolved from chain', async() => { + const args = ['-a', '1', '2']; + const resolvedValues = { a: '12' }; + const coercion = (value, previousValue) => { + const coerced = previousValue === undefined + ? value + : previousValue + value; + return makeThenable(coerced); + }; + const awaited = { + a: (await chainedAwaited( + coercion, args.slice(1)) + ).thenable + }; + const mockCoercion = jest.fn().mockImplementation(coercion); + + const option = new commander.Option('-a ', 'desc') + .argParser(mockCoercion) + .chainArgParserCalls(); + + const program = new commander.Command(); + program + .addOption(option); + + await testWithOptions(program, args, resolvedValues, awaited); + expect(program.getOptionValueSource('a')).toEqual('cli'); + }); + + test('when awaitHook with subcommand and options with custom processing then global .opts() resolved from callback before local option-argument parser is called', async() => { + const arr = []; + + const coercion = (value) => { + return new Promise((resolve) => { + setImmediate(() => { + arr.push(value); + resolve(value); + }); + }); + }; + const mockCoercion = jest.fn().mockImplementation(coercion); + + const syncCoercion = (value) => { + arr.push(value); + return value; + }; + const mockSyncCoercion = jest.fn().mockImplementation(syncCoercion); + + const program = new commander.Command(); + program + .option('-a ', 'desc', mockCoercion) + .command('subcommand') + .option('-b ', 'desc', mockSyncCoercion) + .action(() => {}); + + const args = ['-a', '1', 'subcommand', '-b', '2']; + await program.parseAsync(args, { from: 'user' }); + expect(arr).toEqual(['1', '2']); + }); +}); diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js index 3f907c869..68c6c70ca 100644 --- a/tests/command.chain.test.js +++ b/tests/command.chain.test.js @@ -172,6 +172,12 @@ describe('Command methods that should return this for chaining', () => { expect(result).toBe(program); }); + test('when call .awaitHook() then returns this', () => { + const program = new Command(); + const result = program.awaitHook(); + expect(result).toBe(program); + }); + test('when call .setOptionValue() then returns this', () => { const program = new Command(); const result = program.setOptionValue('foo', 'bar'); diff --git a/tests/command.parse.test.js b/tests/command.parse.test.js index b9903725f..5f5457627 100644 --- a/tests/command.parse.test.js +++ b/tests/command.parse.test.js @@ -87,6 +87,100 @@ describe('return type', () => { const result = await program.parseAsync(['node', 'test']); expect(result).toBe(program); }); + + const makeMockCoercion = (errors, promises, condition) => ( + jest.fn().mockImplementation( + (value) => { + if (condition?.()) { + return value; + } + + const error = new Error(); + errors.push(error); + const promise = Promise.reject(error); + promises.push(promise); + return promise; + } + ) + ); + + test('when await .parseAsync and asynchronous custom processing for arguments fails then rejects', async() => { + const promises = []; + const errors = []; + const mockCoercion = makeMockCoercion(errors, promises); + + const program = new commander.Command(); + program + .argument('[arg]', 'desc', mockCoercion) + .argument('[arg]', 'desc', mockCoercion) + .awaitHook() + .action(() => { }); + + const result = program.parseAsync(['1', '2'], { from: 'user' }); + + let caught; + try { + await result; + } catch (value) { + caught = value; + } + + expect(errors).toContain(caught); + expect(mockCoercion).toHaveBeenCalledTimes(2); + }); + + test('when await .parseAsync and asynchronous custom processing for options fails then rejects', async() => { + const promises = []; + const errors = []; + const mockCoercion = makeMockCoercion(errors, promises); + + const program = new commander.Command(); + program + .option('-a [arg]', 'desc', mockCoercion) + .option('-b [arg]', 'desc', mockCoercion) + .awaitHook() + .action(() => { }); + + const result = program.parseAsync(['-a', '1', '-b', '2'], { from: 'user' }); + + let caught; + try { + await result; + } catch (value) { + caught = value; + } + + expect(errors).toContain(caught); + expect(mockCoercion).toHaveBeenCalledTimes(2); + }); + + test('when await .parseAsync and asynchronous custom processing fails for overwritten non-variadic option then rejects', async() => { + const promises = []; + const errors = []; + const mockCoercion = makeMockCoercion( + errors, promises, () => promises.length + ); + + const program = new commander.Command(); + program + .option('-a [arg]', 'desc', mockCoercion) + .awaitHook() + .action(() => { }); + + const result = program.parseAsync( + ['-a', '1', '-a', '2', '-a', '3'], { from: 'user' } + ); + + let caught; + try { + await result; + } catch (value) { + caught = value; + } + + expect(errors[0]).toBe(caught); + expect(mockCoercion).toHaveBeenCalledTimes(3); + }); }); // Easy mistake to make when writing unit tests diff --git a/tests/option.chain.test.js b/tests/option.chain.test.js index 3ed59688d..6d23bce7b 100644 --- a/tests/option.chain.test.js +++ b/tests/option.chain.test.js @@ -13,6 +13,12 @@ describe('Option methods that should return this for chaining', () => { expect(result).toBe(option); }); + test('when call .chainArgParserCalls() then returns this', () => { + const option = new Option('-e,--example '); + const result = option.chainArgParserCalls(); + expect(result).toBe(option); + }); + test('when call .makeOptionMandatory() then returns this', () => { const option = new Option('-e,--example '); const result = option.makeOptionMandatory(); diff --git a/tests/options.custom-processing.test.js b/tests/options.custom-processing.test.js index aa1022fbb..886a6009c 100644 --- a/tests/options.custom-processing.test.js +++ b/tests/options.custom-processing.test.js @@ -139,3 +139,49 @@ test('when commaSeparatedList x,y,z then value is [x, y, z]', () => { program.parse(['node', 'test', '--list', 'x,y,z']); expect(program.opts().list).toEqual(['x', 'y', 'z']); }); + +test('when custom non-variadic repeated with .chainArgParserCalls() then parsed to chain', async() => { + const args = ['-a', '1', '-a', '2']; + const resolvedValue = '12'; + const coercion = async(value, previousValue) => ( + previousValue === undefined ? value : previousValue + value + ); + const awaited = coercion(args[1], undefined); + const mockCoercion = jest.fn().mockImplementation(coercion); + + const option = new commander.Option('-a ', 'desc') + .argParser(mockCoercion) + .chainArgParserCalls(); + + const program = new commander.Command(); + program + .addOption(option) + .action(() => {}); + + program.parse(args, { from: 'user' }); + expect(program.opts()).toEqual({ a: awaited }); + await expect(program.opts().a).resolves.toEqual(resolvedValue); +}); + +test('when custom variadic with .chainArgParserCalls() then parsed to chain', async() => { + const args = ['-a', '1', '2']; + const resolvedValue = '12'; + const coercion = async(value, previousValue) => ( + previousValue === undefined ? value : previousValue + value + ); + const awaited = coercion(args[1], undefined); + const mockCoercion = jest.fn().mockImplementation(coercion); + + const option = new commander.Option('-a ', 'desc') + .argParser(mockCoercion) + .chainArgParserCalls(); + + const program = new commander.Command(); + program + .addOption(option) + .action(() => {}); + + program.parse(args, { from: 'user' }); + expect(program.opts()).toEqual({ a: awaited }); + await expect(program.opts().a).resolves.toEqual(resolvedValue); +}); diff --git a/typings/index.d.ts b/typings/index.d.ts index 695c3bd25..b2a62209f 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -65,6 +65,11 @@ export class Argument { */ argParser(fn: (value: string, previous: T) => T): this; + /** + * When set to true, next call to the function provided via .argParser() will be chained to its return value if it is thenable. + */ + chainArgParserCalls(chained?: boolean): this; + /** * Only allow argument value to be one of choices. */ @@ -159,6 +164,11 @@ export class Option { */ argParser(fn: (value: string, previous: T) => T): this; + /** + * When set to true, next call to the function provided via .argParser() will be chained to its return value if it is thenable. + */ + chainArgParserCalls(chained?: boolean): this; + /** * Whether the option is mandatory and must have a value after parsing. */ @@ -271,7 +281,7 @@ export interface OutputConfiguration { } export type AddHelpTextPosition = 'beforeAll' | 'before' | 'after' | 'afterAll'; -export type HookEvent = 'preSubcommand' | 'preAction' | 'postAction'; +export type HookEvent = 'preSubcommand' | 'postArguments' | 'preAction' | 'postAction'; export type OptionValueSource = 'default' | 'config' | 'env' | 'cli' | 'implied'; export type OptionValues = Record; @@ -418,6 +428,21 @@ export class Command { */ hook(event: HookEvent, listener: (thisCommand: Command, actionCommand: Command) => void | Promise): this; + /** + * Add hook to await argument and option values. + * Useful for asynchronous custom processing (parseArg) of arguments and option-arguments. + * + * Hook behaviour depends on the value of `enabled`: + * - If the value is `false`, do not await anything. + * - If the value is `true`, await for this command before dispatching subcommand; and for action command before calling action handlers if the "should await" condition applies (see below). + * - If the value is `undefined`, await for this command before dispatching subcommand if the "should await" condition applies (see below); and await for action command in the same manner as if the value were `true`, but only if this command is the top level command (i.e. has no parent). + * + * The "should await" condition for a command is as follows: + * - either the method was called with an `enabled` value of `true` on the nearest command ancestor for which the method was called with an `enabled` value other than `undefined`; + * - or there is no such ancestor and `parseAsync()` was called on the top-level command. + */ + awaitHook(enabled?: boolean | undefined): this; + /** * Register callback to use as replacement for calling process.exit. */ diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 734036fad..e29a84bfa 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -83,20 +83,33 @@ expectType(program.error('Goodbye', { exitCode: 2 })); expectType(program.error('Goodbye', { code: 'my.error', exitCode: 2 })); // hook +expectType(program.hook('preSubcommand', () => {})); +expectType(program.hook('preSubcommand', (thisCommand, subcommand) => { + // implicit parameter types + expectType(thisCommand); + expectType(subcommand); +})); +expectType(program.hook('postArguments', () => {})); +expectType(program.hook('postArguments', async() => {})); +expectType(program.hook('postArguments', (thisCommand, leafCommand) => { + // implicit parameter types + expectType(thisCommand); + expectType(leafCommand); +})); expectType(program.hook('preAction', () => {})); -expectType(program.hook('postAction', () => {})); expectType(program.hook('preAction', async() => {})); expectType(program.hook('preAction', (thisCommand, actionCommand) => { // implicit parameter types expectType(thisCommand); expectType(actionCommand); })); -expectType(program.hook('preSubcommand', () => {})); -expectType(program.hook('preSubcommand', (thisCommand, subcommand) => { - // implicit parameter types - expectType(thisCommand); - expectType(subcommand); -})); +expectType(program.hook('postAction', () => {})); + +// awaitHook +expectType(program.awaitHook()); +expectType(program.awaitHook(true)); +expectType(program.awaitHook(false)); +expectType(program.awaitHook(undefined)); // action expectType(program.action(() => {})); @@ -418,6 +431,11 @@ expectType(baseOption.fullDescription()); expectType(baseOption.argParser((value: string) => parseInt(value))); expectType(baseOption.argParser((value: string, previous: string[]) => { return previous.concat(value); })); +// chainArgParserCalls +expectType(baseOption.chainArgParserCalls()); +expectType(baseOption.chainArgParserCalls(true)); +expectType(baseOption.chainArgParserCalls(false)); + // makeOptionMandatory expectType(baseOption.makeOptionMandatory()); expectType(baseOption.makeOptionMandatory(true)); @@ -466,6 +484,11 @@ expectType(baseArgument.default(60, 'one minute')); expectType(baseArgument.argParser((value: string) => parseInt(value))); expectType(baseArgument.argParser((value: string, previous: string[]) => { return previous.concat(value); })); +// chainArgParserCalls +expectType(baseArgument.chainArgParserCalls()); +expectType(baseArgument.chainArgParserCalls(true)); +expectType(baseArgument.chainArgParserCalls(false)); + // choices expectType(baseArgument.choices(['a', 'b'])); expectType(baseArgument.choices(['a', 'b'] as const));