From 082717f3f1a4117262a44044b108f79d395282f0 Mon Sep 17 00:00:00 2001 From: John Gee Date: Tue, 1 Jun 2021 19:04:14 +1200 Subject: [PATCH] Add showHelpAfterError (#1534) * First cut at displayHelpWithError * Rename method * Rename * Add typings * Simplify unknownCommand message to match others * Add chain test * Add JSDoc typing for TypeScript --checkJS * Inherit behaviour to subcommands * Add tests * Add README * Tweak wording * Add test for invalid argument showing help --- Readme.md | 18 ++++ lib/command.js | 29 ++++-- tests/command.chain.test.js | 6 ++ tests/command.exitOverride.test.js | 2 +- tests/command.helpOption.test.js | 2 +- tests/command.showHelpAfterError.test.js | 122 +++++++++++++++++++++++ tests/command.unknownCommand.test.js | 16 --- typings/index.d.ts | 5 + typings/index.test-d.ts | 5 + 9 files changed, 180 insertions(+), 25 deletions(-) create mode 100644 tests/command.showHelpAfterError.test.js diff --git a/Readme.md b/Readme.md index 248bcf2d0..43e104879 100644 --- a/Readme.md +++ b/Readme.md @@ -30,6 +30,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md) - [Life cycle hooks](#life-cycle-hooks) - [Automated help](#automated-help) - [Custom help](#custom-help) + - [Display help after errors](#display-help-after-errors) - [Display help from code](#display-help-from-code) - [.usage and .name](#usage-and-name) - [.helpOption(flags, description)](#helpoptionflags-description) @@ -668,6 +669,23 @@ The second parameter can be a string, or a function returning a string. The func - error: a boolean for whether the help is being displayed due to a usage error - command: the Command which is displaying the help +### Display help after errors + +The default behaviour for usage errors is to just display a short error message. +You can change the behaviour to show the full help or a custom help message after an error. + +```js +program.showHelpAfterError(); +// or +program.showHelpAfterError('(add --help for additional information)'); +``` + +```sh +$ pizza --unknown +error: unknown option '--unknown' +(add --help for additional information) +``` + ### Display help from code `.help()`: display help information and exit immediately. You can optionally pass `{ error: true }` to display on stderr and exit with an error status. diff --git a/lib/command.js b/lib/command.js index 175213ae1..23c655f01 100644 --- a/lib/command.js +++ b/lib/command.js @@ -42,6 +42,8 @@ class Command extends EventEmitter { this._enablePositionalOptions = false; this._passThroughOptions = false; this._lifeCycleHooks = {}; // a hash of arrays + /** @type {boolean | string} */ + this._showHelpAfterError = false; // see .configureOutput() for docs this._outputConfiguration = { @@ -125,6 +127,7 @@ class Command extends EventEmitter { cmd._combineFlagAndOptionalValue = this._combineFlagAndOptionalValue; cmd._allowExcessArguments = this._allowExcessArguments; cmd._enablePositionalOptions = this._enablePositionalOptions; + cmd._showHelpAfterError = this._showHelpAfterError; cmd._executableFile = opts.executableFile || null; // Custom name for executable file, set missing to null to match constructor if (args) cmd.arguments(args); @@ -201,6 +204,18 @@ class Command extends EventEmitter { return this; } + /** + * Display the help or a custom message after an error occurs. + * + * @param {boolean|string} [displayHelp] + * @return {Command} `this` command for chaining + */ + showHelpAfterError(displayHelp = true) { + if (typeof displayHelp !== 'string') displayHelp = !!displayHelp; + this._showHelpAfterError = displayHelp; + return this; + } + /** * Add a prepared subcommand. * @@ -1370,6 +1385,12 @@ Expecting one of '${allowedValues.join("', '")}'`); */ _displayError(exitCode, code, message) { this._outputConfiguration.outputError(`${message}\n`, this._outputConfiguration.writeErr); + if (typeof this._showHelpAfterError === 'string') { + this._outputConfiguration.writeErr(`${this._showHelpAfterError}\n`); + } else if (this._showHelpAfterError) { + this._outputConfiguration.writeErr('\n'); + this.outputHelp({ error: true }); + } this._exit(exitCode, code, message); } @@ -1446,13 +1467,7 @@ Expecting one of '${allowedValues.join("', '")}'`); */ unknownCommand() { - const partCommands = [this.name()]; - for (let parentCmd = this.parent; parentCmd; parentCmd = parentCmd.parent) { - partCommands.unshift(parentCmd.name()); - } - const fullCommand = partCommands.join(' '); - const message = `error: unknown command '${this.args[0]}'.` + - (this._hasHelpOption ? ` See '${fullCommand} ${this._helpLongFlag}'.` : ''); + const message = `error: unknown command '${this.args[0]}'`; this._displayError(1, 'commander.unknownCommand', message); }; diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js index 43f463e15..a450afdcd 100644 --- a/tests/command.chain.test.js +++ b/tests/command.chain.test.js @@ -177,4 +177,10 @@ describe('Command methods that should return this for chaining', () => { const result = program.setOptionValue(); expect(result).toBe(program); }); + + test('when call .showHelpAfterError() then returns this', () => { + const program = new Command(); + const result = program.showHelpAfterError(); + expect(result).toBe(program); + }); }); diff --git a/tests/command.exitOverride.test.js b/tests/command.exitOverride.test.js index dd6a5b1d9..bdd7fe7c9 100644 --- a/tests/command.exitOverride.test.js +++ b/tests/command.exitOverride.test.js @@ -62,7 +62,7 @@ describe('.exitOverride and error details', () => { } expect(stderrSpy).toHaveBeenCalled(); - expectCommanderError(caughtErr, 1, 'commander.unknownCommand', "error: unknown command 'oops'. See 'prog --help'."); + expectCommanderError(caughtErr, 1, 'commander.unknownCommand', "error: unknown command 'oops'"); }); // Same error as above, but with custom handler. diff --git a/tests/command.helpOption.test.js b/tests/command.helpOption.test.js index cbd1d94ae..00e068c94 100644 --- a/tests/command.helpOption.test.js +++ b/tests/command.helpOption.test.js @@ -128,6 +128,6 @@ describe('helpOption', () => { .command('foo'); expect(() => { program.parse(['UNKNOWN'], { from: 'user' }); - }).toThrow("error: unknown command 'UNKNOWN'."); + }).toThrow("error: unknown command 'UNKNOWN'"); }); }); diff --git a/tests/command.showHelpAfterError.test.js b/tests/command.showHelpAfterError.test.js new file mode 100644 index 000000000..98ad04426 --- /dev/null +++ b/tests/command.showHelpAfterError.test.js @@ -0,0 +1,122 @@ +const commander = require('../'); + +describe('showHelpAfterError with message', () => { + const customHelpMessage = 'See --help'; + + function makeProgram() { + const writeMock = jest.fn(); + const program = new commander.Command(); + program + .exitOverride() + .showHelpAfterError(customHelpMessage) + .configureOutput({ writeErr: writeMock }); + + return { program, writeMock }; + } + + test('when missing command-argument then shows help', () => { + const { program, writeMock } = makeProgram(); + program.argument(''); + let caughtErr; + try { + program.parse([], { from: 'user' }); + } catch (err) { + caughtErr = err; + } + expect(caughtErr.code).toBe('commander.missingArgument'); + expect(writeMock).toHaveBeenLastCalledWith(`${customHelpMessage}\n`); + }); + + test('when missing option-argument then shows help', () => { + const { program, writeMock } = makeProgram(); + program.option('--output '); + let caughtErr; + try { + program.parse(['--output'], { from: 'user' }); + } catch (err) { + caughtErr = err; + } + expect(caughtErr.code).toBe('commander.optionMissingArgument'); + expect(writeMock).toHaveBeenLastCalledWith(`${customHelpMessage}\n`); + }); + + test('when missing mandatory option then shows help', () => { + const { program, writeMock } = makeProgram(); + program.requiredOption('--password '); + let caughtErr; + try { + program.parse([], { from: 'user' }); + } catch (err) { + caughtErr = err; + } + expect(caughtErr.code).toBe('commander.missingMandatoryOptionValue'); + expect(writeMock).toHaveBeenLastCalledWith(`${customHelpMessage}\n`); + }); + + test('when unknown option then shows help', () => { + const { program, writeMock } = makeProgram(); + let caughtErr; + try { + program.parse(['--unknown-option'], { from: 'user' }); + } catch (err) { + caughtErr = err; + } + expect(caughtErr.code).toBe('commander.unknownOption'); + expect(writeMock).toHaveBeenLastCalledWith(`${customHelpMessage}\n`); + }); + + test('when too many command-arguments then shows help', () => { + const { program, writeMock } = makeProgram(); + program + .allowExcessArguments(false); + let caughtErr; + try { + program.parse(['surprise'], { from: 'user' }); + } catch (err) { + caughtErr = err; + } + expect(caughtErr.code).toBe('commander.excessArguments'); + expect(writeMock).toHaveBeenLastCalledWith(`${customHelpMessage}\n`); + }); + + test('when unknown command then shows help', () => { + const { program, writeMock } = makeProgram(); + program.command('sub1'); + let caughtErr; + try { + program.parse(['sub2'], { from: 'user' }); + } catch (err) { + caughtErr = err; + } + expect(caughtErr.code).toBe('commander.unknownCommand'); + expect(writeMock).toHaveBeenLastCalledWith(`${customHelpMessage}\n`); + }); + + test('when invalid option choice then shows help', () => { + const { program, writeMock } = makeProgram(); + program.addOption(new commander.Option('--color').choices(['red', 'blue'])); + let caughtErr; + try { + program.parse(['--color', 'pink'], { from: 'user' }); + } catch (err) { + caughtErr = err; + } + expect(caughtErr.code).toBe('commander.invalidArgument'); + expect(writeMock).toHaveBeenLastCalledWith(`${customHelpMessage}\n`); + }); +}); + +test('when showHelpAfterError() and error and then shows full help', () => { + const writeMock = jest.fn(); + const program = new commander.Command(); + program + .exitOverride() + .showHelpAfterError() + .configureOutput({ writeErr: writeMock }); + + try { + program.parse(['--unknown-option'], { from: 'user' }); + } catch (err) { + } + expect(writeMock).toHaveBeenLastCalledWith(program.helpInformation()); +}); diff --git a/tests/command.unknownCommand.test.js b/tests/command.unknownCommand.test.js index 937bc54e0..03603c5ea 100644 --- a/tests/command.unknownCommand.test.js +++ b/tests/command.unknownCommand.test.js @@ -78,20 +78,4 @@ describe('unknownCommand', () => { } expect(caughtErr.code).toBe('commander.unknownCommand'); }); - - test('when unknown subcommand then help suggestion includes command path', () => { - const program = new commander.Command(); - program - .exitOverride() - .command('sub') - .command('subsub'); - let caughtErr; - try { - program.parse('node test.js sub unknown'.split(' ')); - } catch (err) { - caughtErr = err; - } - expect(caughtErr.code).toBe('commander.unknownCommand'); - expect(writeErrorSpy.mock.calls[0][0]).toMatch('test sub'); - }); }); diff --git a/typings/index.d.ts b/typings/index.d.ts index bb5a5451e..8c5a0d026 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -388,6 +388,11 @@ export class Command { /** Get configuration */ configureOutput(): OutputConfiguration; + /** + * Display the help or a custom message after an error occurs. + */ + showHelpAfterError(displayHelp?: boolean | string): this; + /** * Register callback `fn` for the command. * diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 53f79b165..2e6d9494e 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -283,6 +283,11 @@ expectType(program.configureHelp({ })); expectType(program.configureHelp()); +// showHelpAfterError +expectType(program.showHelpAfterError()); +expectType(program.showHelpAfterError(true)); +expectType(program.showHelpAfterError('See --help')); + // configureOutput expectType(program.configureOutput({ })); expectType(program.configureOutput());