From 8b95f57bb2a49b098c6bf23cea88c6f900a34f89 Mon Sep 17 00:00:00 2001 From: "Benjamin E. Coe" Date: Sat, 13 Mar 2021 18:46:58 -0800 Subject: [PATCH] feat(async): add support for async check and coerce (#1872) BREAKING CHANGE: yargs now returns a promise if async or check are asynchronous. refactor(coerce)!: coerce is now applied before validation. refactor(check): check callback is no longer passed an aliases object. --- docs/api.md | 15 +- lib/cjs.ts | 2 - lib/command.ts | 38 ++--- lib/middleware.ts | 73 ++++++-- lib/utils/maybe-async-result.ts | 32 ++++ lib/validation.ts | 42 +---- lib/yargs-factory.ts | 160 ++++++++++++++--- package.json | 4 +- test/command.cjs | 57 +++++++ test/middleware.cjs | 292 +++++++++++++++++++++++++++++--- test/usage.cjs | 4 +- test/yargs.cjs | 12 +- 12 files changed, 584 insertions(+), 147 deletions(-) create mode 100644 lib/utils/maybe-async-result.ts diff --git a/docs/api.md b/docs/api.md index 83300445f..2440dd318 100644 --- a/docs/api.md +++ b/docs/api.md @@ -115,7 +115,7 @@ If `key` is an array, interpret all the elements as booleans. Check that certain conditions are met in the provided arguments. -`fn` is called with two arguments, the parsed `argv` hash and an array of options and their aliases. +`fn` is called with the parsed `argv` hash. If `fn` throws or returns a non-truthy value, Yargs will show the thrown error and usage information. Yargs will then exit, unless @@ -175,7 +175,7 @@ var argv = require('yargs/yargs')(process.argv.slice(2)) .coerce(key, fn) ---------------- -Provide a synchronous function to coerce or transform the value(s) given on the +Provide a function to coerce or transform the value(s) given on the command line for `key`. The coercion function should accept one argument, representing the parsed value from @@ -184,8 +184,7 @@ return a new value or throw an error. The returned value will be used as the val `key` (or one of its aliases) in `argv`. If the function throws, the error will be treated as a validation -failure, delegating to either a custom [`.fail()`](#fail) handler or printing -the error message in the console. +failure, delegating to either a custom [`.fail()`](#fail) handler or printing the error message in the console. Coercion will be applied to a value after all other modifications, such as [`.normalize()`](#normalize). @@ -195,7 +194,7 @@ _Examples:_ ```js var argv = require('yargs/yargs')(process.argv.slice(2)) .coerce('file', function (arg) { - return require('fs').readFileSync(arg, 'utf8') + return await require('fs').promises.readFile(arg, 'utf8') }) .argv ``` @@ -212,8 +211,7 @@ var argv = require('yargs/yargs')(process.argv.slice(2)) .argv ``` -You can also map the same function to several keys at one time. Just pass an -array of keys as the first argument to `.coerce()`: +You can also map the same function to several keys at one time. Just pass an array of keys as the first argument to `.coerce()`: ```js var path = require('path') @@ -222,8 +220,7 @@ var argv = require('yargs/yargs')(process.argv.slice(2)) .argv ``` -If you are using dot-notion or arrays, .e.g., `user.email` and `user.password`, -coercion will be applied to the final object that has been parsed: +If you are using dot-notion or arrays, .e.g., `user.email` and `user.password`, coercion will be applied to the final object that has been parsed: ```js // --user.name Batman --user.password 123 diff --git a/lib/cjs.ts b/lib/cjs.ts index 3940c29e7..64b57f8ba 100644 --- a/lib/cjs.ts +++ b/lib/cjs.ts @@ -5,7 +5,6 @@ import {applyExtends} from './utils/apply-extends'; import {argsert} from './argsert.js'; import {isPromise} from './utils/is-promise.js'; import {objFilter} from './utils/obj-filter.js'; -import {globalMiddlewareFactory} from './middleware.js'; import {parseCommand} from './parse-command.js'; import * as processArgv from './utils/process-argv.js'; import {YargsWithShim, rebase} from './yargs-factory.js'; @@ -35,7 +34,6 @@ export default { cjsPlatformShim, Yargs, argsert, - globalMiddlewareFactory, isPromise, objFilter, parseCommand, diff --git a/lib/command.ts b/lib/command.ts index 8cf3c2db0..5434c1149 100644 --- a/lib/command.ts +++ b/lib/command.ts @@ -8,6 +8,7 @@ import {isPromise} from './utils/is-promise.js'; import { applyMiddleware, commandMiddlewareFactory, + GlobalMiddleware, Middleware, } from './middleware.js'; import {parseCommand, Positional} from './parse-command.js'; @@ -23,6 +24,7 @@ import { Arguments, DetailedArguments, } from './yargs-factory.js'; +import {maybeAsyncResult} from './utils/maybe-async-result.js'; import whichModule from './utils/which-module.js'; const DEFAULT_MARKER = /(^\*)|(^\$0)/; @@ -35,7 +37,7 @@ export function command( yargs: YargsInstance, usage: UsageInstance, validation: ValidationInstance, - globalMiddleware: Middleware[] = [], + globalMiddleware: GlobalMiddleware, shim: PlatformShim ) { const self: CommandInstance = {} as CommandInstance; @@ -307,6 +309,7 @@ export function command( if (helpOnly) return innerArgv; const middlewares = globalMiddleware + .getMiddleware() .slice(0) .concat(commandHandler.middlewares); innerArgv = applyMiddleware(innerArgv, yargs, middlewares, true); @@ -320,16 +323,10 @@ export function command( (yargs.parsed as DetailedArguments).error, !command ); - if (isPromise(innerArgv)) { - // If the middlware returned a promise, resolve the middleware - // before applying the validation: - innerArgv = innerArgv.then(argv => { - validation(argv); - return argv; - }); - } else { - validation(innerArgv); - } + innerArgv = maybeAsyncResult(innerArgv, result => { + validation(result); + return result; + }); } if (commandHandler.handler && !yargs._hasOutput()) { @@ -342,18 +339,14 @@ export function command( yargs._postProcess(innerArgv, populateDoubleDash, false, false); innerArgv = applyMiddleware(innerArgv, yargs, middlewares, false); - if (isPromise(innerArgv)) { - const innerArgvRef = innerArgv; - innerArgv = innerArgv - .then(argv => commandHandler.handler(argv)) - .then(() => innerArgvRef); - } else { - const handlerResult = commandHandler.handler(innerArgv); + innerArgv = maybeAsyncResult(innerArgv, result => { + const handlerResult = commandHandler.handler(result as Arguments); if (isPromise(handlerResult)) { - const innerArgvRef = innerArgv; - innerArgv = handlerResult.then(() => innerArgvRef); + return handlerResult.then(() => result); + } else { + return result; } - } + }); yargs.getUsageInstance().cacheHelpMessage(); if (isPromise(innerArgv) && !yargs._hasParseCallback()) { @@ -499,8 +492,9 @@ export function command( if (!unparsed.length) return; const config: Configuration = Object.assign({}, options.configuration, { - 'populate--': true, + 'populate--': false, }); + const parsed = shim.Parser.detailed( unparsed, Object.assign({}, options, { diff --git a/lib/middleware.ts b/lib/middleware.ts index 7028d055b..35d35463d 100644 --- a/lib/middleware.ts +++ b/lib/middleware.ts @@ -2,17 +2,21 @@ import {argsert} from './argsert.js'; import {isPromise} from './utils/is-promise.js'; import {YargsInstance, Arguments} from './yargs-factory.js'; -export function globalMiddlewareFactory( - globalMiddleware: Middleware[], - context: T -) { - return function ( +export class GlobalMiddleware { + globalMiddleware: Middleware[] = []; + yargs: YargsInstance; + frozens: Array = []; + constructor(yargs: YargsInstance) { + this.yargs = yargs; + } + addMiddleware( callback: MiddlewareCallback | MiddlewareCallback[], - applyBeforeValidation = false - ) { + applyBeforeValidation: boolean, + global = true + ): YargsInstance { argsert( - ' [boolean]', - [callback, applyBeforeValidation], + ' [boolean] [boolean]', + [callback, applyBeforeValidation, global], arguments.length ); if (Array.isArray(callback)) { @@ -20,17 +24,50 @@ export function globalMiddlewareFactory( if (typeof callback[i] !== 'function') { throw Error('middleware must be a function'); } - (callback[ - i - ] as Middleware).applyBeforeValidation = applyBeforeValidation; + const m = callback[i] as Middleware; + m.applyBeforeValidation = applyBeforeValidation; + m.global = global; } - Array.prototype.push.apply(globalMiddleware, callback as Middleware[]); + Array.prototype.push.apply( + this.globalMiddleware, + callback as Middleware[] + ); } else if (typeof callback === 'function') { - (callback as Middleware).applyBeforeValidation = applyBeforeValidation; - globalMiddleware.push(callback as Middleware); + const m = callback as Middleware; + m.applyBeforeValidation = applyBeforeValidation; + m.global = global; + this.globalMiddleware.push(callback as Middleware); } - return context; - }; + return this.yargs; + } + // For "coerce" middleware, only one middleware instance can be registered + // per option: + addCoerceMiddleware( + callback: MiddlewareCallback, + option: string + ): YargsInstance { + const aliases = this.yargs.getAliases(); + this.globalMiddleware = this.globalMiddleware.filter(m => { + const toCheck = [...(aliases[option] ? aliases[option] : []), option]; + if (!m.option) return true; + else return !toCheck.includes(m.option); + }); + (callback as Middleware).option = option; + return this.addMiddleware(callback, true, true); + } + getMiddleware() { + return this.globalMiddleware; + } + freeze() { + this.frozens.push([...this.globalMiddleware]); + } + unfreeze() { + const frozen = this.frozens.pop(); + if (frozen !== undefined) this.globalMiddleware = frozen; + } + reset() { + this.globalMiddleware = this.globalMiddleware.filter(m => m.global); + } } export function commandMiddlewareFactory( @@ -85,4 +122,6 @@ export interface MiddlewareCallback { export interface Middleware extends MiddlewareCallback { applyBeforeValidation: boolean; + global: boolean; + option?: string; } diff --git a/lib/utils/maybe-async-result.ts b/lib/utils/maybe-async-result.ts new file mode 100644 index 000000000..1e288cb11 --- /dev/null +++ b/lib/utils/maybe-async-result.ts @@ -0,0 +1,32 @@ +// maybeAsyncResult() allows the same error/completion handler to be +// applied to a value regardless of whether it is a concrete value or an +// eventual value. +// +// As of yargs@v17, if no asynchronous steps are run, .e.g, a +// check() script that resolves a promise, yargs will return a concrete +// value. If any asynchronous steps are introduced, yargs resolves a promise. +import {isPromise} from './is-promise.js'; +export function maybeAsyncResult( + getResult: (() => T | Promise) | T | Promise, + resultHandler: (result: T) => T | Promise, + errorHandler: (err: Error) => T = (err: Error) => { + throw err; + } +): T | Promise { + try { + const result = isFunction(getResult) ? getResult() : getResult; + if (isPromise(result)) { + return result.then((result: T) => { + return resultHandler(result); + }); + } else { + return resultHandler(result); + } + } catch (err) { + return errorHandler(err); + } +} + +function isFunction(arg: (() => any) | any): arg is () => any { + return typeof arg === 'function'; +} diff --git a/lib/validation.ts b/lib/validation.ts index 371374d46..871d4784a 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -14,7 +14,7 @@ import {DetailedArguments} from './typings/yargs-parser-types.js'; const specialKeys = ['$0', '--', '_']; // validation-type-stuff, missing params, -// bad implications, custom checks. +// bad implications: export function validation( yargs: YargsInstance, usage: UsageInstance, @@ -275,34 +275,6 @@ export function validation( usage.fail(msg); }; - // custom checks, added using the `check` option on yargs. - let checks: CustomCheck[] = []; - self.check = function check(f, global) { - checks.push({ - func: f, - global, - }); - }; - - self.customChecks = function customChecks(argv, aliases) { - for (let i = 0, f; (f = checks[i]) !== undefined; i++) { - const func = f.func; - let result = null; - try { - result = func(argv, aliases); - } catch (err) { - usage.fail(err.message ? err.message : err, err); - continue; - } - - if (!result) { - usage.fail(__('Argument check failed: %s', func.toString())); - } else if (typeof result === 'string' || result instanceof Error) { - usage.fail(result.toString(), result); - } - } - }; - // check implications, argument foo implies => argument bar. let implied: Dictionary = {}; self.implies = function implies(key, value) { @@ -441,7 +413,6 @@ export function validation( self.reset = function reset(localLookup) { implied = objFilter(implied, k => !localLookup[k]); conflicting = objFilter(conflicting, k => !localLookup[k]); - checks = checks.filter(c => c.global); return self; }; @@ -449,14 +420,13 @@ export function validation( self.freeze = function freeze() { frozens.push({ implied, - checks, conflicting, }); }; self.unfreeze = function unfreeze() { const frozen = frozens.pop(); assertNotStrictEqual(frozen, undefined, shim); - ({implied, checks, conflicting} = frozen); + ({implied, conflicting} = frozen); }; return self; @@ -464,13 +434,11 @@ export function validation( /** Instance of the validation module. */ export interface ValidationInstance { - check(f: CustomCheck['func'], global: boolean): void; conflicting(argv: Arguments): void; conflicts( key: string | Dictionary, value?: string | string[] ): void; - customChecks(argv: Arguments, aliases: DetailedArguments['aliases']): void; freeze(): void; getConflicting(): Dictionary<(string | undefined)[]>; getImplied(): Dictionary; @@ -503,14 +471,8 @@ export interface ValidationInstance { unknownCommands(argv: Arguments): boolean; } -interface CustomCheck { - func: (argv: Arguments, aliases: DetailedArguments['aliases']) => any; - global: boolean; -} - interface FrozenValidationInstance { implied: Dictionary; - checks: CustomCheck[]; conflicting: Dictionary<(string | undefined)[]>; } diff --git a/lib/yargs-factory.ts b/lib/yargs-factory.ts index f41c9c8b3..7a9185928 100644 --- a/lib/yargs-factory.ts +++ b/lib/yargs-factory.ts @@ -53,11 +53,12 @@ import {objFilter} from './utils/obj-filter.js'; import {applyExtends} from './utils/apply-extends.js'; import { applyMiddleware, - globalMiddlewareFactory, + GlobalMiddleware, MiddlewareCallback, Middleware, } from './middleware.js'; import {isPromise} from './utils/is-promise.js'; +import {maybeAsyncResult} from './utils/maybe-async-result.js'; import setBlocking from './utils/set-blocking.js'; let shim: PlatformShim; @@ -75,16 +76,14 @@ function Yargs( let command: CommandInstance; let completion: CompletionInstance | null = null; let groups: Dictionary = {}; - const globalMiddleware: Middleware[] = []; let output = ''; const preservedGroups: Dictionary = {}; + const globalMiddleware = new GlobalMiddleware(self); let usage: UsageInstance; let validation: ValidationInstance; const y18n = shim.y18n; - self.middleware = globalMiddlewareFactory(globalMiddleware, self); - self.scriptName = function (scriptName) { self.customScriptName = true; self.$0 = scriptName; @@ -223,7 +222,6 @@ function Yargs( 'choices', 'demandedOptions', 'demandedCommands', - 'coerce', 'deprecatedOptions', ]; @@ -248,6 +246,7 @@ function Yargs( ? command.reset() : Command(self, usage, validation, globalMiddleware, shim); if (!completion) completion = Completion(self, usage, command, shim); + globalMiddleware.reset(); completionCommand = null; output = ''; @@ -281,6 +280,7 @@ function Yargs( usage.freeze(); validation.freeze(); command.freeze(); + globalMiddleware.freeze(); } function unfreeze() { const frozen = frozens.pop(); @@ -306,6 +306,7 @@ function Yargs( usage.unfreeze(); validation.unfreeze(); command.unfreeze(); + globalMiddleware.unfreeze(); } self.boolean = function (keys) { @@ -491,7 +492,56 @@ function Yargs( [keys, value], arguments.length ); - populateParserHintSingleValueDictionary(self.coerce, 'coerce', keys, value); + if (Array.isArray(keys)) { + if (!value) { + throw new YError('coerce callback must be provided'); + } + for (const key of keys) { + self.coerce(key, value); + } + return self; + } else if (typeof keys === 'object') { + for (const key of Object.keys(keys)) { + self.coerce(key, keys[key]); + } + return self; + } + if (!value) { + throw new YError('coerce callback must be provided'); + } + // This noop tells yargs-parser about the existence of the option + // represented by "keys", so that it can apply camel case expansion + // if needed: + self.alias(keys, keys); + globalMiddleware.addCoerceMiddleware( + ( + argv: Arguments, + yargs: YargsInstance + ): Partial | Promise> => { + let aliases: Dictionary; + return maybeAsyncResult< + Partial | Promise> | any + >( + () => { + aliases = yargs.getAliases(); + return value(argv[keys]); + }, + (result: any): Partial => { + argv[keys] = result; + if (aliases[keys]) { + for (const alias of aliases[keys]) { + argv[alias] = result; + } + } + return argv; + }, + (err: Error): Partial | Promise> => { + throw new YError(err.message); + } + ); + }, + keys + ); return self; }; @@ -758,6 +808,10 @@ function Yargs( return self; }; + self.getAliases = () => { + return self.parsed ? self.parsed.aliases : {}; + }; + self.getDemandedOptions = () => { argsert([], 0); return options.demandedOptions; @@ -847,12 +901,51 @@ function Yargs( return self; }; - self.check = function (f, _global) { - argsert(' [boolean]', [f, _global], arguments.length); - validation.check(f, _global !== false); + self.check = function (f, global) { + argsert(' [boolean]', [f, global], arguments.length); + self.middleware( + ( + argv: Arguments, + _yargs: YargsInstance + ): Partial | Promise> => { + return maybeAsyncResult< + Partial | Promise> | any + >( + () => { + return f(argv); + }, + (result: any): Partial | Promise> => { + if (!result) { + usage.fail(y18n.__('Argument check failed: %s', f.toString())); + } else if (typeof result === 'string' || result instanceof Error) { + usage.fail(result.toString(), result); + } + return argv; + }, + (err: Error): Partial | Promise> => { + usage.fail(err.message ? err.message : err.toString(), err); + return argv; + } + ); + }, + false, + global + ); return self; }; + self.middleware = ( + callback: MiddlewareCallback | MiddlewareCallback[], + applyBeforeValidation?: boolean, + global = true + ) => { + return globalMiddleware.addMiddleware( + callback, + !!applyBeforeValidation, + global + ); + }; + self.global = function global(globals, global) { argsert(' [boolean]', [globals, global], arguments.length); globals = ([] as string[]).concat(globals); @@ -1689,9 +1782,24 @@ function Yargs( if (!requestCompletions) { const validation = self._runValidation(aliases, {}, parsed.error); if (!calledFromCommand) { - argvPromise = applyMiddleware(argv, self, globalMiddleware, true); + argvPromise = applyMiddleware( + argv, + self, + globalMiddleware.getMiddleware(), + true + ); } argvPromise = validateAsync(validation, argvPromise ?? argv); + if (isPromise(argvPromise) && !calledFromCommand) { + argvPromise = argvPromise.then(() => { + return applyMiddleware( + argv, + self, + globalMiddleware.getMiddleware(), + false + ); + }); + } } } } catch (err) { @@ -1712,17 +1820,10 @@ function Yargs( validation: (argv: Arguments) => void, argv: Arguments | Promise ): Arguments | Promise { - if (isPromise(argv)) { - // If the middlware returned a promise, resolve the middleware - // before applying the validation: - argv = argv.then(argv => { - validation(argv); - return argv; - }); - } else { - validation(argv); - } - return argv; + return maybeAsyncResult(argv, result => { + validation(result); + return result; + }); } // Applies a couple post processing steps that are easier to perform @@ -1745,7 +1846,12 @@ function Yargs( argv = self._parsePositionalNumbers(argv); } if (runGlobalMiddleware) { - argv = applyMiddleware(argv, self, globalMiddleware, false); + argv = applyMiddleware( + argv, + self, + globalMiddleware.getMiddleware(), + false + ); } return argv; }; @@ -1812,7 +1918,6 @@ function Yargs( } else if (strictOptions) { validation.unknownArguments(argv, aliases, {}, false, false); } - validation.customChecks(argv, aliases); validation.limitedChoices(argv); validation.implications(argv); validation.conflicting(argv); @@ -1900,10 +2005,7 @@ export interface YargsInstance { }; array(keys: string | string[]): YargsInstance; boolean(keys: string | string[]): YargsInstance; - check( - f: (argv: Arguments, aliases: Dictionary) => any, - _global?: boolean - ): YargsInstance; + check(f: (argv: Arguments) => any, global?: boolean): YargsInstance; choices: { (keys: string | string[], choices: string | string[]): YargsInstance; (keyChoices: Dictionary): YargsInstance; @@ -2003,6 +2105,7 @@ export interface YargsInstance { ): Promise | any; getHelp(): Promise; getContext(): Context; + getAliases(): Dictionary; getDemandedCommands(): Options['demandedCommands']; getDemandedOptions(): Options['demandedOptions']; getDeprecatedOptions(): Options['deprecatedOptions']; @@ -2030,7 +2133,8 @@ export interface YargsInstance { }; middleware( callback: MiddlewareCallback | MiddlewareCallback[], - applyBeforeValidation?: boolean + applyBeforeValidation?: boolean, + global?: boolean ): YargsInstance; nargs: { (keys: string | string[], nargs: number): YargsInstance; diff --git a/package.json b/package.json index ceeaca2d2..60c83ecea 100644 --- a/package.json +++ b/package.json @@ -79,8 +79,8 @@ "fix": "gts fix && npm run fix:js", "fix:js": "eslint . --ext cjs --ext mjs --ext js --fix", "posttest": "npm run check", - "test": "c8 mocha ./test/*.cjs --require ./test/before.cjs --timeout=12000 --check-leaks", - "test:esm": "c8 mocha ./test/esm/*.mjs --check-leaks", + "test": "c8 mocha --enable-source-maps ./test/*.cjs --require ./test/before.cjs --timeout=12000 --check-leaks", + "test:esm": "c8 mocha --enable-source-maps ./test/esm/*.mjs --check-leaks", "coverage": "c8 report --check-coverage", "prepare": "npm run compile", "pretest": "npm run compile -- -p tsconfig.test.json && cross-env NODE_ENV=test npm run build:cjs", diff --git a/test/command.cjs b/test/command.cjs index 5a3c01269..59f05ada4 100644 --- a/test/command.cjs +++ b/test/command.cjs @@ -1,6 +1,7 @@ /* global describe, it, beforeEach */ /* eslint-disable no-unused-vars */ 'use strict'; +const assert = require('assert'); const yargs = require('../index.cjs'); const expect = require('chai').expect; const checkOutput = require('./helpers/utils.cjs').checkOutput; @@ -1250,6 +1251,29 @@ describe('Command', () => { checkCalled.should.equal(false); }); + it('allows each builder to specify own middleware', () => { + let checkCalled1 = 0; + let checkCalled2 = 0; + const y = yargs() + .command('command ', 'a command', () => { + yargs.check(argv => { + checkCalled1++; + return true; + }); + }) + .command('command2 ', 'a second command', yargs => { + yargs.check(argv => { + checkCalled2++; + return true; + }); + }); + y.parse('command blerg --foo'); + y.parse('command2 blerg --foo'); + y.parse('command blerg --foo'); + checkCalled1.should.equal(2); + checkCalled2.should.equal(1); + }); + it('applies demandOption globally', done => { yargs('command blerg --foo') .command('command ', 'a command') @@ -1885,4 +1909,37 @@ describe('Command', () => { yargs().command(cmds).parse('a c 10 5', context); context.output.value.should.equal(15); }); + + it('allows async exception in handler to be caught', async () => { + await assert.rejects( + yargs(['mw']) + .fail(false) + .command( + 'mw', + 'adds func to middleware', + () => {}, + async () => { + throw Error('not cool'); + } + ) + .parse(), + /not cool/ + ); + }); + + it('reports error if error occurs parsing positional', () => { + try { + yargs(['cmd', 'neat']) + .fail(false) + .command('cmd ', 'run a foo command', yargs => { + yargs.option('foo', { + nargs: 3, + }); + }) + .parse(); + throw Error('unreachable'); + } catch (err) { + err.message.should.match(/Not enough arguments/); + } + }); }); diff --git a/test/middleware.cjs b/test/middleware.cjs index ed1074c81..625be7d3d 100644 --- a/test/middleware.cjs +++ b/test/middleware.cjs @@ -2,8 +2,8 @@ /* global describe, it, beforeEach, afterEach */ /* eslint-disable no-unused-vars */ +const assert = require('assert'); const {expect} = require('chai'); -const {globalMiddlewareFactory} = require('../build/index.cjs'); let yargs; require('chai').should(); @@ -12,6 +12,12 @@ function clearRequireCache() { delete require.cache[require.resolve('../build/index.cjs')]; } +async function wait() { + return Promise(resolve => { + setTimeout(resolve, 10); + }); +} + describe('middleware', () => { beforeEach(() => { yargs = require('../index.cjs'); @@ -20,30 +26,6 @@ describe('middleware', () => { clearRequireCache(); }); - it('should add a list of callbacks to global middleware', () => { - const globalMiddleware = []; - - globalMiddlewareFactory(globalMiddleware)([() => {}, () => {}]); - - globalMiddleware.should.have.lengthOf(2); - }); - - it('should throw exception if middleware is not a function', () => { - const globalMiddleware = []; - - expect(() => { - globalMiddlewareFactory(globalMiddleware)(['callback1', 'callback2']); - }).to.throw('middleware must be a function'); - }); - - it('should add a single callback to global middleware', () => { - const globalMiddleware = []; - - globalMiddlewareFactory(globalMiddleware)(() => {}); - - globalMiddleware.should.have.lengthOf(1); - }); - it('runs the middleware before reaching the handler', done => { yargs(['mw']) .middleware(argv => { @@ -623,4 +605,264 @@ describe('middleware', () => { .parse(); argv.foo.should.equal(198); }); + + it('throws error if middleware not function', () => { + let err; + assert.throws(() => { + yargs('snuh --foo 99').middleware(['hello']).parse(); + }, /middleware must be a function/); + }); + + describe('async check', () => { + describe('success', () => { + it('returns promise if check is async', async () => { + const argvPromise = yargs('--foo 100') + .middleware(argv => { + argv.foo *= 2; + }, true) + .check(async argv => { + wait(); + if (argv.foo < 200) return false; + else return true; + }) + .parse(); + (!!argvPromise.then).should.equal(true); + const argv = await argvPromise; + argv.foo.should.equal(200); + }); + it('returns promise if check and middleware is async', async () => { + const argvPromise = yargs('--foo 100') + .middleware(async argv => { + wait(); + argv.foo *= 2; + }, true) + .check(async argv => { + wait(); + if (argv.foo < 200) return false; + else return true; + }) + .parse(); + (!!argvPromise.then).should.equal(true); + const argv = await argvPromise; + argv.foo.should.equal(200); + }); + it('allows async check to be used with command', async () => { + let output = ''; + const argv = await yargs('cmd --foo 300') + .command( + 'cmd', + 'a command', + yargs => { + yargs.check(async argv => { + wait(); + output += 'first'; + if (argv.foo < 200) return false; + else return true; + }); + }, + async argv => { + wait(); + output += 'second'; + } + ) + .parse(); + argv._.should.include('cmd'); + argv.foo.should.equal(300); + output.should.equal('firstsecond'); + }); + it('allows async check to be used with command and middleware', async () => { + let output = ''; + const argv = await yargs('cmd --foo 100') + .command( + 'cmd', + 'a command', + yargs => { + yargs.check(async argv => { + wait(); + output += 'second'; + if (argv.foo < 200) return false; + else return true; + }); + }, + async argv => { + wait(); + output += 'fourth'; + }, + [ + async argv => { + wait(); + output += 'third'; + argv.foo *= 2; + }, + ] + ) + .middleware(async argv => { + wait(); + output += 'first'; + argv.foo *= 2; + }, true) + .parse(); + argv._.should.include('cmd'); + argv.foo.should.equal(400); + output.should.equal('firstsecondthirdfourth'); + }); + }); + describe('failure', () => { + it('allows failed check to be caught', async () => { + await assert.rejects( + yargs('--f 33') + .alias('foo', 'f') + .fail(false) + .check(async argv => { + wait(); + return argv.foo > 50; + }) + .parse(), + /Argument check failed/ + ); + }); + it('allows error to be caught before calling command', async () => { + let output = ''; + await assert.rejects( + yargs('cmd --foo 100') + .fail(false) + .command( + 'cmd', + 'a command', + yargs => { + yargs.check(async argv => { + wait(); + output += 'first'; + if (argv.foo < 200) return false; + else return true; + }); + }, + async argv => { + wait(); + output += 'second'; + } + ) + .parse(), + /Argument check failed/ + ); + output.should.equal('first'); + }); + it('allows error to be caught before calling command and middleware', async () => { + let output = ''; + await assert.rejects( + yargs('cmd --foo 10') + .fail(false) + .command( + 'cmd', + 'a command', + yargs => { + yargs.check(async argv => { + wait(); + output += 'second'; + if (argv.foo < 200) return false; + else return true; + }); + }, + async argv => { + wait(); + output += 'fourth'; + }, + [ + async argv => { + wait(); + output += 'third'; + argv.foo *= 2; + }, + ] + ) + .middleware(async argv => { + wait(); + output += 'first'; + argv.foo *= 2; + }, true) + .parse(), + /Argument check failed/ + ); + output.should.equal('firstsecond'); + }); + }); + it('applies alliases prior to calling check', async () => { + const argv = await yargs('--f 99') + .alias('foo', 'f') + .check(async argv => { + wait(); + return argv.foo > 50; + }) + .parse(); + argv.foo.should.equal(99); + }); + }); + + describe('async coerce', () => { + it('allows two commands to register different coerce methods', async () => { + const y = yargs() + .command('command1', 'first command', yargs => { + yargs.coerce('foo', async arg => { + wait(); + return new Date(arg); + }); + }) + .command('command2', 'second command', yargs => { + yargs.coerce('foo', async arg => { + wait(); + return new Number(arg); + }); + }) + .coerce('foo', async _arg => { + return 'hello'; + }); + const r1 = await y.parse('command1 --foo 2020-10-10'); + expect(r1.foo).to.be.an.instanceof(Date); + const r2 = await y.parse('command2 --foo 302'); + r2.foo.should.equal(302); + }); + it('coerce is applied to argument and aliases', async () => { + let callCount = 0; + const argvPromise = yargs() + .alias('f', 'foo') + .coerce('foo', async arg => { + wait(); + callCount++; + return new Date(arg.toString()); + }) + .parse('-f 2014'); + (!!argvPromise.then).should.equal(true); + const argv = await argvPromise; + callCount.should.equal(1); + expect(argv.foo).to.be.an.instanceof(Date); + expect(argv.f).to.be.an.instanceof(Date); + }); + it('applies coerce before validation', async () => { + const argv = await yargs() + .option('foo', { + choices: [10, 20, 30], + }) + .coerce('foo', async arg => { + wait(); + return (arg *= 2); + }) + .parse('--foo 5'); + argv.foo.should.equal(10); + }); + it('allows error to be caught', async () => { + await assert.rejects( + yargs() + .fail(false) + .option('foo', { + choices: [10, 20, 30], + }) + .coerce('foo', async arg => { + wait(); + return (arg *= 2); + }) + .parse('--foo 2'), + /Choices: 10, 20, 30/ + ); + }); + }); }); diff --git a/test/usage.cjs b/test/usage.cjs index f09b88c41..d914bb7c1 100644 --- a/test/usage.cjs +++ b/test/usage.cjs @@ -4175,6 +4175,7 @@ describe('usage tests', () => { describe('help message caching', () => { it('should display proper usage when an async handler fails', done => { const y = yargs() + .scriptName('mocha') .command('cmd', 'test command', {}, () => { return new Promise((resolve, reject) => setTimeout(reject, 10)); }) @@ -4207,8 +4208,9 @@ describe('usage tests', () => { it('should not display a cached help message for the next parsing', done => { const y = yargs() + .scriptName('mocha') .command('cmd', 'test command', {}, () => { - return new Promise((resolve, reject) => setTimeout(resolve, 10)); + return new Promise((resolve, _reject) => setTimeout(resolve, 10)); }) .demandCommand(1, 'You need at least one command before moving on') .exitProcess(false); diff --git a/test/yargs.cjs b/test/yargs.cjs index e279ec425..d8e016280 100644 --- a/test/yargs.cjs +++ b/test/yargs.cjs @@ -2,6 +2,7 @@ /* global context, describe, it, beforeEach, afterEach */ /* eslint-disable no-unused-vars */ +const assert = require('assert'); const expect = require('chai').expect; const fs = require('fs'); const path = require('path'); @@ -303,7 +304,6 @@ describe('yargs dsl tests', () => { narg: {}, defaultDescription: {}, choices: {}, - coerce: {}, skipValidation: [], count: [], normalize: [], @@ -1985,6 +1985,7 @@ describe('yargs dsl tests', () => { '--file', path.join(__dirname, 'fixtures', 'package.json'), ]) + .alias('file', 'f') .coerce('file', arg => JSON.parse(fs.readFileSync(arg, 'utf8'))) .parse(); expect(argv.file).to.have.property('version').and.equal('9.9.9'); @@ -2138,6 +2139,15 @@ describe('yargs dsl tests', () => { expect(msg).to.equal('ball'); expect(err).to.not.equal(undefined); }); + + it('throws error if coerce callback is missing', () => { + assert.throws(() => { + yargs().coerce(['a', 'b']); + }, /coerce callback must be provided/); + assert.throws(() => { + yargs().coerce('c'); + }, /coerce callback must be provided/); + }); }); describe('stop parsing', () => {