diff --git a/common/changes/@microsoft/rush/ts-command-line-sprintf_2025-07-23-19-30.json b/common/changes/@microsoft/rush/ts-command-line-sprintf_2025-07-23-19-30.json new file mode 100644 index 00000000000..bd7ff97cb34 --- /dev/null +++ b/common/changes/@microsoft/rush/ts-command-line-sprintf_2025-07-23-19-30.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/ts-command-line/ts-command-line-sprintf_2025-07-23-19-06.json b/common/changes/@rushstack/ts-command-line/ts-command-line-sprintf_2025-07-23-19-06.json new file mode 100644 index 00000000000..9997d188108 --- /dev/null +++ b/common/changes/@rushstack/ts-command-line/ts-command-line-sprintf_2025-07-23-19-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/ts-command-line", + "comment": "Escape `%` characters in help text to fix an issue where they were previously interpreted as sprintf-style tokens.", + "type": "patch" + } + ], + "packageName": "@rushstack/ts-command-line" +} \ No newline at end of file diff --git a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap index 11c7b8478a3..3235af39fcf 100644 --- a/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap +++ b/libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap @@ -1191,7 +1191,7 @@ Object { "actionName": "import-strings", "parameters": Array [ Object { - "description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.", + "description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.", "environmentVariable": "RUSH_PARALLELISM", "kind": "String", "longName": "--parallelism", @@ -1345,7 +1345,7 @@ Object { "actionName": "build", "parameters": Array [ Object { - "description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.", + "description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.", "environmentVariable": "RUSH_PARALLELISM", "kind": "String", "longName": "--parallelism", @@ -1502,7 +1502,7 @@ Object { "actionName": "rebuild", "parameters": Array [ Object { - "description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.", + "description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.", "environmentVariable": "RUSH_PARALLELISM", "kind": "String", "longName": "--parallelism", diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 1a38d88537f..3846d9e912b 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -193,7 +193,7 @@ export class PhasedScriptAction extends BaseScriptAction { environmentVariable: EnvironmentVariableNames.RUSH_PARALLELISM, description: 'Specifies the maximum number of concurrent processes to launch during a build.' + - ' The COUNT should be a positive integer, a percentage value (eg. "50%%") or the word "max"' + + ' The COUNT should be a positive integer, a percentage value (eg. "50%") or the word "max"' + ' to specify a count that is equal to the number of CPU cores. If this parameter is omitted,' + ' then the default value depends on the operating system and number of CPU cores.' }) diff --git a/libraries/ts-command-line/src/escapeSprintf.ts b/libraries/ts-command-line/src/escapeSprintf.ts new file mode 100644 index 00000000000..db31524a927 --- /dev/null +++ b/libraries/ts-command-line/src/escapeSprintf.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export function escapeSprintf(input: string): string { + // Escape sprintf-style escape characters + // The primary special character in sprintf format strings is '%' + // which introduces format specifiers like %s, %d, %f, etc. + return input.replace(/%/g, '%%'); +} diff --git a/libraries/ts-command-line/src/providers/CommandLineAction.ts b/libraries/ts-command-line/src/providers/CommandLineAction.ts index a5fd4221151..350ae50c6a3 100644 --- a/libraries/ts-command-line/src/providers/CommandLineAction.ts +++ b/libraries/ts-command-line/src/providers/CommandLineAction.ts @@ -5,6 +5,7 @@ import type * as argparse from 'argparse'; import { CommandLineParameterProvider } from './CommandLineParameterProvider'; import { CommandLineParserExitError } from './CommandLineParserExitError'; +import { escapeSprintf } from '../escapeSprintf'; /** * Options for the CommandLineAction constructor. @@ -83,8 +84,8 @@ export abstract class CommandLineAction extends CommandLineParameterProvider { */ public _buildParser(actionsSubParser: argparse.SubParser): void { this._argumentParser = actionsSubParser.addParser(this.actionName, { - help: this.summary, - description: this.documentation + help: escapeSprintf(this.summary), + description: escapeSprintf(this.documentation) }); // Monkey-patch the error handling for the action parser diff --git a/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts b/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts index a60ead0a7db..9ff64b48a25 100644 --- a/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts +++ b/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts @@ -39,6 +39,7 @@ import { CommandLineStringListParameter } from '../parameters/CommandLineStringL import { CommandLineRemainder } from '../parameters/CommandLineRemainder'; import { SCOPING_PARAMETER_GROUP } from '../Constants'; import { CommandLineParserExitError } from './CommandLineParserExitError'; +import { escapeSprintf } from '../escapeSprintf'; /** * The result containing the parsed parameter long name and scope. Returned when calling @@ -858,7 +859,7 @@ export abstract class CommandLineParameterProvider { // NOTE: Our "environmentVariable" feature takes precedence over argparse's "defaultValue", // so we have to reimplement that feature. const argparseOptions: argparse.ArgumentOptions = { - help: finalDescription, + help: escapeSprintf(finalDescription), dest: parserKey, metavar: (parameter as CommandLineParameterWithArgument).argumentName, required, diff --git a/libraries/ts-command-line/src/providers/CommandLineParser.ts b/libraries/ts-command-line/src/providers/CommandLineParser.ts index 961f0709a41..7855cd5a301 100644 --- a/libraries/ts-command-line/src/providers/CommandLineParser.ts +++ b/libraries/ts-command-line/src/providers/CommandLineParser.ts @@ -14,6 +14,7 @@ import { import { CommandLineParserExitError, CustomArgumentParser } from './CommandLineParserExitError'; import { TabCompleteAction } from './TabCompletionAction'; import { TypeUuid, uuidAlreadyReportedError } from '../TypeUuidLite'; +import { escapeSprintf } from '../escapeSprintf'; /** * Options for the {@link CommandLineParser} constructor. @@ -75,13 +76,16 @@ export abstract class CommandLineParser extends CommandLineParameterProvider { this._actions = []; this._actionsByName = new Map(); + const { toolFilename, toolDescription, toolEpilog } = options; + this._argumentParser = new CustomArgumentParser({ addHelp: true, - prog: this._options.toolFilename, - description: this._options.toolDescription, + prog: toolFilename, + description: escapeSprintf(toolDescription), epilog: Colorize.bold( - this._options.toolEpilog ?? - `For detailed help about a specific command, use: ${this._options.toolFilename} -h` + escapeSprintf( + toolEpilog ?? `For detailed help about a specific command, use: ${toolFilename} -h` + ) ) }); } diff --git a/libraries/ts-command-line/src/test/ActionlessParser.test.ts b/libraries/ts-command-line/src/test/ActionlessParser.test.ts index 53b63a6c3a7..034376ab5d9 100644 --- a/libraries/ts-command-line/src/test/ActionlessParser.test.ts +++ b/libraries/ts-command-line/src/test/ActionlessParser.test.ts @@ -3,6 +3,7 @@ import { CommandLineParser } from '../providers/CommandLineParser'; import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; class TestCommandLine extends CommandLineParser { public flag: CommandLineFlagParameter; @@ -27,6 +28,11 @@ class TestCommandLine extends CommandLineParser { } describe(`Actionless ${CommandLineParser.name}`, () => { + it('renders help text', () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + ensureHelpTextMatchesSnapshot(commandLineParser); + }); + it('parses an empty arg list', async () => { const commandLineParser: TestCommandLine = new TestCommandLine(); diff --git a/libraries/ts-command-line/src/test/AliasedCommandLineAction.test.ts b/libraries/ts-command-line/src/test/AliasedCommandLineAction.test.ts index 71c975ba15b..1534f638a9e 100644 --- a/libraries/ts-command-line/src/test/AliasedCommandLineAction.test.ts +++ b/libraries/ts-command-line/src/test/AliasedCommandLineAction.test.ts @@ -8,6 +8,7 @@ import type { CommandLineParameterProvider } from '../providers/CommandLineParam import { AliasCommandLineAction } from '../providers/AliasCommandLineAction'; import { CommandLineAction } from '../providers/CommandLineAction'; import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; class TestAliasAction extends AliasCommandLineAction { public done: boolean = false; @@ -104,6 +105,11 @@ class TestCommandLine extends CommandLineParser { } describe(AliasCommandLineAction.name, () => { + it('renders help text', () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + ensureHelpTextMatchesSnapshot(commandLineParser); + }); + it('executes the aliased action', async () => { const commandLineParser: TestCommandLine = new TestCommandLine(); const targetAction: TestAction = commandLineParser.getAction('action') as TestAction; diff --git a/libraries/ts-command-line/src/test/AmbiguousCommandLineParser.test.ts b/libraries/ts-command-line/src/test/AmbiguousCommandLineParser.test.ts index 1e25d0d67cc..a53fc714c75 100644 --- a/libraries/ts-command-line/src/test/AmbiguousCommandLineParser.test.ts +++ b/libraries/ts-command-line/src/test/AmbiguousCommandLineParser.test.ts @@ -9,6 +9,7 @@ import type { CommandLineStringParameter } from '../parameters/CommandLineString import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter'; import type { CommandLineParameterProvider } from '../providers/CommandLineParameterProvider'; import { SCOPING_PARAMETER_GROUP } from '../Constants'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; class GenericCommandLine extends CommandLineParser { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -250,6 +251,17 @@ class AbbreviationScopedAction extends ScopedCommandLineAction { } describe(`Ambiguous ${CommandLineParser.name}`, () => { + it('renders help text', () => { + const commandLineParser: GenericCommandLine = new GenericCommandLine( + AmbiguousAction, + AbbreviationAction, + AliasAction, + AmbiguousScopedAction, + AbbreviationScopedAction + ); + ensureHelpTextMatchesSnapshot(commandLineParser); + }); + it('fails to execute when an ambiguous short name is provided', async () => { const commandLineParser: GenericCommandLine = new GenericCommandLine(AmbiguousAction); diff --git a/libraries/ts-command-line/src/test/CommandLineParameter.test.ts b/libraries/ts-command-line/src/test/CommandLineParameter.test.ts index 0c4210b4999..d8668b9c37a 100644 --- a/libraries/ts-command-line/src/test/CommandLineParameter.test.ts +++ b/libraries/ts-command-line/src/test/CommandLineParameter.test.ts @@ -2,13 +2,13 @@ // See LICENSE in the project root for license information. import * as argparse from 'argparse'; -import { AnsiEscape } from '@rushstack/terminal'; import { DynamicCommandLineParser } from '../providers/DynamicCommandLineParser'; import { DynamicCommandLineAction } from '../providers/DynamicCommandLineAction'; import { CommandLineParameterBase } from '../parameters/BaseClasses'; import type { CommandLineParser } from '../providers/CommandLineParser'; import type { CommandLineAction } from '../providers/CommandLineAction'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; interface IExtendedArgumentParser extends argparse.ArgumentParser { _printMessage: (message: string) => void; @@ -174,18 +174,9 @@ describe(CommandLineParameterBase.name, () => { process.env = existingEnv; }); - it('prints the global help', () => { + it('renders help text', () => { const commandLineParser: CommandLineParser = createParser(); - const helpText: string = AnsiEscape.removeCodes(commandLineParser.renderHelpText()); - expect(helpText).toMatchSnapshot(); - }); - - it('prints the action help', () => { - const commandLineParser: CommandLineParser = createParser(); - const helpText: string = AnsiEscape.removeCodes( - commandLineParser.getAction('do:the-job').renderHelpText() - ); - expect(helpText).toMatchSnapshot(); + ensureHelpTextMatchesSnapshot(commandLineParser); }); it('parses an input with ALL parameters', async () => { diff --git a/libraries/ts-command-line/src/test/CommandLineParser.test.ts b/libraries/ts-command-line/src/test/CommandLineParser.test.ts index 5064ca34b14..4f9aec25aa2 100644 --- a/libraries/ts-command-line/src/test/CommandLineParser.test.ts +++ b/libraries/ts-command-line/src/test/CommandLineParser.test.ts @@ -4,6 +4,7 @@ import { CommandLineAction } from '../providers/CommandLineAction'; import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter'; import { CommandLineParser } from '../providers/CommandLineParser'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; class TestAction extends CommandLineAction { public done: boolean = false; @@ -12,8 +13,8 @@ class TestAction extends CommandLineAction { public constructor() { super({ actionName: 'do:the-job', - summary: 'does the job', - documentation: 'a longer description' + summary: 'does the job with sprintf-style escape characters, 100%', + documentation: 'a longer description with sprintf-style escape characters, 100%' }); this._flag = this.defineFlagParameter({ @@ -32,7 +33,7 @@ class TestCommandLine extends CommandLineParser { public constructor() { super({ toolFilename: 'example', - toolDescription: 'An example project' + toolDescription: 'An example project with sprintf-style escape characters, 100%' }); this.addAction(new TestAction()); @@ -40,6 +41,11 @@ class TestCommandLine extends CommandLineParser { } describe(CommandLineParser.name, () => { + it('renders help text', () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + ensureHelpTextMatchesSnapshot(commandLineParser); + }); + it('executes an action', async () => { const commandLineParser: TestCommandLine = new TestCommandLine(); commandLineParser._registerDefinedParameters({ parentParameterNames: new Set() }); diff --git a/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts b/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts index 5a6a5ce0f3f..e6d08dabe2a 100644 --- a/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts +++ b/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts @@ -1,13 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { AnsiEscape } from '@rushstack/terminal'; - import type { CommandLineAction } from '../providers/CommandLineAction'; import type { CommandLineParser } from '../providers/CommandLineParser'; import { DynamicCommandLineParser } from '../providers/DynamicCommandLineParser'; import { DynamicCommandLineAction } from '../providers/DynamicCommandLineAction'; import { CommandLineRemainder } from '../parameters/CommandLineRemainder'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; function createParser(): DynamicCommandLineParser { const commandLineParser: DynamicCommandLineParser = new DynamicCommandLineParser({ @@ -43,16 +42,9 @@ function createParser(): DynamicCommandLineParser { } describe(CommandLineRemainder.name, () => { - it('prints the global help', () => { - const commandLineParser: CommandLineParser = createParser(); - const helpText: string = AnsiEscape.removeCodes(commandLineParser.renderHelpText()); - expect(helpText).toMatchSnapshot(); - }); - - it('prints the action help', () => { + it('renders help text', () => { const commandLineParser: CommandLineParser = createParser(); - const helpText: string = AnsiEscape.removeCodes(commandLineParser.getAction('run').renderHelpText()); - expect(helpText).toMatchSnapshot(); + ensureHelpTextMatchesSnapshot(commandLineParser); }); it('parses an action input with remainder', async () => { diff --git a/libraries/ts-command-line/src/test/ConflictingCommandLineParser.test.ts b/libraries/ts-command-line/src/test/ConflictingCommandLineParser.test.ts index 8545bc68631..fc93f22478d 100644 --- a/libraries/ts-command-line/src/test/ConflictingCommandLineParser.test.ts +++ b/libraries/ts-command-line/src/test/ConflictingCommandLineParser.test.ts @@ -5,6 +5,7 @@ import { CommandLineAction } from '../providers/CommandLineAction'; import type { CommandLineStringParameter } from '../parameters/CommandLineStringParameter'; import { CommandLineParser } from '../providers/CommandLineParser'; import type { IScopedLongNameParseResult } from '../providers/CommandLineParameterProvider'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; class GenericCommandLine extends CommandLineParser { public constructor(action: new () => CommandLineAction) { @@ -124,6 +125,8 @@ describe(`Conflicting ${CommandLineParser.name}`, () => { it('executes an action', async () => { const commandLineParser: GenericCommandLine = new GenericCommandLine(TestAction); + ensureHelpTextMatchesSnapshot(commandLineParser); + await commandLineParser.executeAsync([ 'do:the-job', '--scope1:arg', @@ -147,6 +150,8 @@ describe(`Conflicting ${CommandLineParser.name}`, () => { it('parses the scope out of a long name correctly', async () => { const commandLineParser: GenericCommandLine = new GenericCommandLine(TestAction); + ensureHelpTextMatchesSnapshot(commandLineParser); + let result: IScopedLongNameParseResult = commandLineParser.parseScopedLongName('--scope1:arg'); expect(result.scope).toEqual('scope1'); expect(result.longName).toEqual('--arg'); diff --git a/libraries/ts-command-line/src/test/DynamicCommandLineParser.test.ts b/libraries/ts-command-line/src/test/DynamicCommandLineParser.test.ts index c3abff49619..795b9ed2068 100644 --- a/libraries/ts-command-line/src/test/DynamicCommandLineParser.test.ts +++ b/libraries/ts-command-line/src/test/DynamicCommandLineParser.test.ts @@ -4,6 +4,7 @@ import { DynamicCommandLineParser } from '../providers/DynamicCommandLineParser'; import { DynamicCommandLineAction } from '../providers/DynamicCommandLineAction'; import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; describe(DynamicCommandLineParser.name, () => { it('parses an action', async () => { @@ -23,6 +24,8 @@ describe(DynamicCommandLineParser.name, () => { description: 'The flag' }); + ensureHelpTextMatchesSnapshot(commandLineParser); + await commandLineParser.executeAsync(['do:the-job', '--flag']); expect(commandLineParser.selectedAction).toEqual(action); diff --git a/libraries/ts-command-line/src/test/EndToEndTest.test.ts b/libraries/ts-command-line/src/test/EndToEndTest.test.ts index a42c3ccd9be..43d31c30777 100644 --- a/libraries/ts-command-line/src/test/EndToEndTest.test.ts +++ b/libraries/ts-command-line/src/test/EndToEndTest.test.ts @@ -2,8 +2,8 @@ // See LICENSE in the project root for license information. import type { ChildProcess } from 'node:child_process'; -import { AnsiEscape } from '@rushstack/terminal'; import { Executable } from '@rushstack/node-core-library'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; const TEST_CLI_PATH: string = `${__dirname}/test-cli/start`; @@ -36,13 +36,7 @@ describe('end-to-end test', () => { const parser = new WidgetCommandLine(); - const globalHelpText: string = AnsiEscape.formatForTests(parser.renderHelpText()); - expect(globalHelpText).toMatchSnapshot('global help'); - - for (const action of parser.actions) { - const actionHelpText: string = AnsiEscape.formatForTests(action.renderHelpText()); - expect(actionHelpText).toMatchSnapshot(action.actionName); - } + ensureHelpTextMatchesSnapshot(parser); }); describe('execution tests', () => { diff --git a/libraries/ts-command-line/src/test/ScopedCommandLineAction.test.ts b/libraries/ts-command-line/src/test/ScopedCommandLineAction.test.ts index e33c3f71d8f..a535c1e77cc 100644 --- a/libraries/ts-command-line/src/test/ScopedCommandLineAction.test.ts +++ b/libraries/ts-command-line/src/test/ScopedCommandLineAction.test.ts @@ -8,6 +8,7 @@ import type { CommandLineStringParameter } from '../parameters/CommandLineString import { CommandLineParser } from '../providers/CommandLineParser'; import type { CommandLineParameterProvider } from '../providers/CommandLineParameterProvider'; import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; class TestScopedAction extends ScopedCommandLineAction { public done: boolean = false; @@ -67,6 +68,11 @@ class TestCommandLine extends CommandLineParser { } describe(CommandLineParser.name, () => { + it('renders help text', () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + ensureHelpTextMatchesSnapshot(commandLineParser); + }); + it('throws on unknown scoped arg', async () => { const commandLineParser: TestCommandLine = new TestCommandLine(); const args: string[] = ['scoped-action', '--scope', 'foo', '--', '--scoped-bar', 'baz']; diff --git a/libraries/ts-command-line/src/test/TabCompleteAction.test.ts b/libraries/ts-command-line/src/test/TabCompleteAction.test.ts index 4b2e6bd722f..f70e5d0c815 100644 --- a/libraries/ts-command-line/src/test/TabCompleteAction.test.ts +++ b/libraries/ts-command-line/src/test/TabCompleteAction.test.ts @@ -4,6 +4,7 @@ import { DynamicCommandLineParser } from '../providers/DynamicCommandLineParser'; import { DynamicCommandLineAction } from '../providers/DynamicCommandLineAction'; import { TabCompleteAction } from '../providers/TabCompletionAction'; +import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; async function arrayFromAsyncIteratorAsync(iterator: AsyncIterable): Promise { const ret: string[] = []; @@ -204,6 +205,10 @@ const commandLineParser: DynamicCommandLineParser = getCommandLineParser(); const tc: TabCompleteAction = new TabCompleteAction(commandLineParser.actions, commandLineParser.parameters); describe(TabCompleteAction.name, () => { + it('renders help text', () => { + ensureHelpTextMatchesSnapshot(commandLineParser); + }); + it(`gets completion(s) for rush `, async () => { const commandLine: string = 'rush '; const actual: string[] = await arrayFromAsyncIteratorAsync( diff --git a/libraries/ts-command-line/src/test/__snapshots__/ActionlessParser.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/ActionlessParser.test.ts.snap new file mode 100644 index 00000000000..89ee4b8b082 --- /dev/null +++ b/libraries/ts-command-line/src/test/__snapshots__/ActionlessParser.test.ts.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Actionless CommandLineParser renders help text: global help 1`] = ` +"usage: example [-h] [--flag] + +An example project + +Optional arguments: + -h, --help Show this help message and exit. + --flag The flag + +[bold]For detailed help about a specific command, use: example -h[normal] +" +`; diff --git a/libraries/ts-command-line/src/test/__snapshots__/AliasedCommandLineAction.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/AliasedCommandLineAction.test.ts.snap index 4c2abb0de1b..45b417c3c12 100644 --- a/libraries/ts-command-line/src/test/__snapshots__/AliasedCommandLineAction.test.ts.snap +++ b/libraries/ts-command-line/src/test/__snapshots__/AliasedCommandLineAction.test.ts.snap @@ -51,3 +51,51 @@ Object { "--verbose": "true", } `; + +exports[`AliasCommandLineAction renders help text: action 1`] = ` +"usage: example action [-h] [--flag] + +a longer description + +Optional arguments: + -h, --help Show this help message and exit. + --flag The flag +" +`; + +exports[`AliasCommandLineAction renders help text: global help 1`] = ` +"usage: example [-h] ... + +An example project + +Positional arguments: + + action does the action + scoped-action + does the scoped action + +Optional arguments: + -h, --help Show this help message and exit. + +[bold]For detailed help about a specific command, use: example -h[normal] +" +`; + +exports[`AliasCommandLineAction renders help text: scoped-action 1`] = ` +"usage: example scoped-action [-h] [--verbose] [--scope SCOPE] ... + +a longer description + +Positional arguments: + \\"...\\" Scoped parameters. Must be prefixed with \\"--\\", ex. \\"-- + --scopedParameter foo --scopedFlag\\". For more information on + available scoped parameters, use \\"-- --help\\". + +Optional arguments: + -h, --help Show this help message and exit. + --verbose A flag parameter. + +Optional scoping arguments: + --scope SCOPE The scope +" +`; diff --git a/libraries/ts-command-line/src/test/__snapshots__/AmbiguousCommandLineParser.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/AmbiguousCommandLineParser.test.ts.snap index 643a1e8175f..37c140b9f80 100644 --- a/libraries/ts-command-line/src/test/__snapshots__/AmbiguousCommandLineParser.test.ts.snap +++ b/libraries/ts-command-line/src/test/__snapshots__/AmbiguousCommandLineParser.test.ts.snap @@ -39,6 +39,42 @@ exports[`Ambiguous CommandLineParser fails to execute when an ambiguous short na " `; +exports[`Ambiguous CommandLineParser renders help text: do:the-job 1`] = ` +"usage: example do:the-job [-h] [--short1 ARG] [--short2 ARG] + [--scope1:arg ARG] [--scope2:arg ARG] + [--non-conflicting-arg ARG] + + +a longer description + +Optional arguments: + -h, --help Show this help message and exit. + --short1 ARG The argument + --short2 ARG The argument + --scope1:arg ARG The argument + --scope2:arg ARG The argument + --non-conflicting-arg ARG, --scope:non-conflicting-arg ARG + The argument +" +`; + +exports[`Ambiguous CommandLineParser renders help text: global help 1`] = ` +"usage: example [-h] ... + +An example project + +Positional arguments: + + do:the-job + does the job + +Optional arguments: + -h, --help Show this help message and exit. + +[bold]For detailed help about a specific command, use: example -h[normal] +" +`; + exports[`Ambiguous aliased CommandLineParser can execute the non-ambiguous scoped long names 1`] = ` "usage: example do:the-job [-h] [--short1 ARG] [--short2 ARG] [--scope1:arg ARG] [--scope2:arg ARG] diff --git a/libraries/ts-command-line/src/test/__snapshots__/CommandLineParameter.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/CommandLineParameter.test.ts.snap index db60ce85a32..46d1b2cee61 100644 --- a/libraries/ts-command-line/src/test/__snapshots__/CommandLineParameter.test.ts.snap +++ b/libraries/ts-command-line/src/test/__snapshots__/CommandLineParameter.test.ts.snap @@ -532,7 +532,38 @@ Array [ ] `; -exports[`CommandLineParameterBase prints the action help 1`] = ` +exports[`CommandLineParameterBase prints the same usage if a required parameter backed by an env variable is not provided as when a different required parameter is missing: Usage 1`] = ` +"usage: example do:the-job [-h] [-c {one,two,three,default}] + [--choice-with-default {one,two,three,default}] + [-C {red,green,blue}] [-f] [-i NUMBER] + [--integer-with-default NUMBER] --integer-required + NUMBER --env-integer-required NUMBER [-I LIST_ITEM] + [-s TEXT] [--string-with-default TEXT] + [--string-with-undocumented-synonym TEXT] + [-l LIST_ITEM] + +" +`; + +exports[`CommandLineParameterBase raises an error if a required parameter backed by an env variable is not provided: Error 1`] = ` +[Error: example do:the-job: error: Argument "--env-integer-required" is required +] +`; + +exports[`CommandLineParameterBase raises an error if a required parameter backed by an env variable is not provided: Usage 1`] = ` +"usage: example do:the-job [-h] [-c {one,two,three,default}] + [--choice-with-default {one,two,three,default}] + [-C {red,green,blue}] [-f] [-i NUMBER] + [--integer-with-default NUMBER] --integer-required + NUMBER --env-integer-required NUMBER [-I LIST_ITEM] + [-s TEXT] [--string-with-default TEXT] + [--string-with-undocumented-synonym TEXT] + [-l LIST_ITEM] + +" +`; + +exports[`CommandLineParameterBase renders help text: do:the-job 1`] = ` "usage: example do:the-job [-h] [-c {one,two,three,default}] [--choice-with-default {one,two,three,default}] [-C {red,green,blue}] [-f] [-i NUMBER] @@ -597,7 +628,7 @@ Optional arguments: " `; -exports[`CommandLineParameterBase prints the global help 1`] = ` +exports[`CommandLineParameterBase renders help text: global help 1`] = ` "usage: example [-h] [-g] ... An example project @@ -610,37 +641,6 @@ Optional arguments: -h, --help Show this help message and exit. -g, --global-flag A flag that affects all actions -For detailed help about a specific command, use: example -h -" -`; - -exports[`CommandLineParameterBase prints the same usage if a required parameter backed by an env variable is not provided as when a different required parameter is missing: Usage 1`] = ` -"usage: example do:the-job [-h] [-c {one,two,three,default}] - [--choice-with-default {one,two,three,default}] - [-C {red,green,blue}] [-f] [-i NUMBER] - [--integer-with-default NUMBER] --integer-required - NUMBER --env-integer-required NUMBER [-I LIST_ITEM] - [-s TEXT] [--string-with-default TEXT] - [--string-with-undocumented-synonym TEXT] - [-l LIST_ITEM] - -" -`; - -exports[`CommandLineParameterBase raises an error if a required parameter backed by an env variable is not provided: Error 1`] = ` -[Error: example do:the-job: error: Argument "--env-integer-required" is required -] -`; - -exports[`CommandLineParameterBase raises an error if a required parameter backed by an env variable is not provided: Usage 1`] = ` -"usage: example do:the-job [-h] [-c {one,two,three,default}] - [--choice-with-default {one,two,three,default}] - [-C {red,green,blue}] [-f] [-i NUMBER] - [--integer-with-default NUMBER] --integer-required - NUMBER --env-integer-required NUMBER [-I LIST_ITEM] - [-s TEXT] [--string-with-default TEXT] - [--string-with-undocumented-synonym TEXT] - [-l LIST_ITEM] - +[bold]For detailed help about a specific command, use: example -h[normal] " `; diff --git a/libraries/ts-command-line/src/test/__snapshots__/CommandLineParser.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/CommandLineParser.test.ts.snap new file mode 100644 index 00000000000..efee07961d0 --- /dev/null +++ b/libraries/ts-command-line/src/test/__snapshots__/CommandLineParser.test.ts.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CommandLineParser renders help text: do:the-job 1`] = ` +"usage: example do:the-job [-h] [--flag] + +a longer description with sprintf-style escape characters, 100% + +Optional arguments: + -h, --help Show this help message and exit. + --flag The flag +" +`; + +exports[`CommandLineParser renders help text: global help 1`] = ` +"usage: example [-h] ... + +An example project with sprintf-style escape characters, 100% + +Positional arguments: + + do:the-job + does the job with sprintf-style escape characters, 100% + +Optional arguments: + -h, --help Show this help message and exit. + +[bold]For detailed help about a specific command, use: example -h[normal] +" +`; diff --git a/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap index 1250aa3605e..9205cbc2f2f 100644 --- a/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap +++ b/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap @@ -25,33 +25,33 @@ Array [ ] `; -exports[`CommandLineRemainder prints the action help 1`] = ` -"usage: example run [-h] [--title TEXT] ... +exports[`CommandLineRemainder renders help text: global help 1`] = ` +"usage: example [-h] [--verbose] ... -a longer description +An example project Positional arguments: - \\"...\\" The action remainder + + run does the job Optional arguments: - -h, --help Show this help message and exit. - --title TEXT A string + -h, --help Show this help message and exit. + --verbose A flag that affects all actions + +[bold]For detailed help about a specific command, use: example -h[normal] " `; -exports[`CommandLineRemainder prints the global help 1`] = ` -"usage: example [-h] [--verbose] ... +exports[`CommandLineRemainder renders help text: run 1`] = ` +"usage: example run [-h] [--title TEXT] ... -An example project +a longer description Positional arguments: - - run does the job + \\"...\\" The action remainder Optional arguments: - -h, --help Show this help message and exit. - --verbose A flag that affects all actions - -For detailed help about a specific command, use: example -h + -h, --help Show this help message and exit. + --title TEXT A string " `; diff --git a/libraries/ts-command-line/src/test/__snapshots__/ConflictingCommandLineParser.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/ConflictingCommandLineParser.test.ts.snap index 89514cd42b3..0a3e4c493a6 100644 --- a/libraries/ts-command-line/src/test/__snapshots__/ConflictingCommandLineParser.test.ts.snap +++ b/libraries/ts-command-line/src/test/__snapshots__/ConflictingCommandLineParser.test.ts.snap @@ -23,3 +23,69 @@ Object { "--scope3:non-conflicting-arg": "\\"nonconflictingvalue\\"", } `; + +exports[`Conflicting CommandLineParser executes an action: do:the-job 1`] = ` +"usage: example do:the-job [-h] [--scope1:arg ARG] [--scope2:arg ARG] + [--non-conflicting-arg ARG] + + +a longer description + +Optional arguments: + -h, --help Show this help message and exit. + --scope1:arg ARG The argument + --scope2:arg ARG The argument + --non-conflicting-arg ARG, --scope3:non-conflicting-arg ARG + The argument +" +`; + +exports[`Conflicting CommandLineParser executes an action: global help 1`] = ` +"usage: example [-h] ... + +An example project + +Positional arguments: + + do:the-job + does the job + +Optional arguments: + -h, --help Show this help message and exit. + +[bold]For detailed help about a specific command, use: example -h[normal] +" +`; + +exports[`Conflicting CommandLineParser parses the scope out of a long name correctly: do:the-job 1`] = ` +"usage: example do:the-job [-h] [--scope1:arg ARG] [--scope2:arg ARG] + [--non-conflicting-arg ARG] + + +a longer description + +Optional arguments: + -h, --help Show this help message and exit. + --scope1:arg ARG The argument + --scope2:arg ARG The argument + --non-conflicting-arg ARG, --scope3:non-conflicting-arg ARG + The argument +" +`; + +exports[`Conflicting CommandLineParser parses the scope out of a long name correctly: global help 1`] = ` +"usage: example [-h] ... + +An example project + +Positional arguments: + + do:the-job + does the job + +Optional arguments: + -h, --help Show this help message and exit. + +[bold]For detailed help about a specific command, use: example -h[normal] +" +`; diff --git a/libraries/ts-command-line/src/test/__snapshots__/DynamicCommandLineParser.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/DynamicCommandLineParser.test.ts.snap new file mode 100644 index 00000000000..3794c0f99bc --- /dev/null +++ b/libraries/ts-command-line/src/test/__snapshots__/DynamicCommandLineParser.test.ts.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DynamicCommandLineParser parses an action: do:the-job 1`] = ` +"usage: example do:the-job [-h] [--flag] + +a longer description + +Optional arguments: + -h, --help Show this help message and exit. + --flag The flag +" +`; + +exports[`DynamicCommandLineParser parses an action: global help 1`] = ` +"usage: example [-h] ... + +An example project + +Positional arguments: + + do:the-job + does the job + +Optional arguments: + -h, --help Show this help message and exit. + +[bold]For detailed help about a specific command, use: example -h[normal] +" +`; diff --git a/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap index 0c256de22c8..30828b18fb2 100644 --- a/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap +++ b/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap @@ -56,6 +56,42 @@ Object { } `; +exports[`CommandLineParser renders help text: global help 1`] = ` +"usage: example [-h] ... + +An example project + +Positional arguments: + + scoped-action + does the scoped action + +Optional arguments: + -h, --help Show this help message and exit. + +[bold]For detailed help about a specific command, use: example -h[normal] +" +`; + +exports[`CommandLineParser renders help text: scoped-action 1`] = ` +"usage: example scoped-action [-h] [--verbose] [--scope SCOPE] ... + +a longer description + +Positional arguments: + \\"...\\" Scoped parameters. Must be prefixed with \\"--\\", ex. \\"-- + --scopedParameter foo --scopedFlag\\". For more information on + available scoped parameters, use \\"-- --help\\". + +Optional arguments: + -h, --help Show this help message and exit. + --verbose A flag parameter. + +Optional scoping arguments: + --scope SCOPE The scope +" +`; + exports[`CommandLineParser throws on missing positional arg divider with unknown positional args 1`] = ` "usage: example scoped-action [-h] [--verbose] [--scope SCOPE] ... diff --git a/libraries/ts-command-line/src/test/__snapshots__/TabCompleteAction.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/TabCompleteAction.test.ts.snap new file mode 100644 index 00000000000..37e7b568793 --- /dev/null +++ b/libraries/ts-command-line/src/test/__snapshots__/TabCompleteAction.test.ts.snap @@ -0,0 +1,136 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TabCompleteAction renders help text: add 1`] = ` +"usage: rush add [-h] -p PACKAGE [--exact] [--caret] [--dev] [-m] [-s] [--all] + +Adds a dependency to the package.json and runs rush update. + +Optional arguments: + -h, --help Show this help message and exit. + -p PACKAGE, --package PACKAGE + (Required) The name of the package which should be + added as a dependency. A SemVer version specifier can + be appended after an \\"@\\" sign. WARNING: Symbol + characters are usually interpreted by your shell, so + it's recommended to use quotes. For example, write + \\"rush add --package \\"example@^1.2.3\\"\\" instead of + \\"rush add --package example@^1.2.3\\". + --exact If specified, the SemVer specifier added to the + package.json will be an exact version (e.g. without + tilde or caret). + --caret If specified, the SemVer specifier added to the + package.json will be a prepended with a \\"caret\\" + specifier (\\"^\\"). + --dev If specified, the package will be added to the + \\"devDependencies\\" section of the package.json + -m, --make-consistent + If specified, other packages with this dependency + will have their package.json files updated to use the + same version of the dependency. + -s, --skip-update If specified, the \\"rush update\\" command will not be + run after updating the package.json files. + --all If specified, the dependency will be added to all + projects. +" +`; + +exports[`TabCompleteAction renders help text: build 1`] = ` +"usage: rush build [-h] [-p COUNT] [-t PROJECT1] [-f PROJECT2] + +Build all projects that haven't been built. + +Optional arguments: + -h, --help Show this help message and exit. + -p COUNT, --parallelism COUNT + Specifies the maximum number of concurrent processes + to launch during a build. + -t PROJECT1, --to PROJECT1 + Run command in the specified project and all of its + dependencies. + -f PROJECT2, --from PROJECT2 + Run command in the specified project and all projects + that directly or indirectly depend on the specified + project. +" +`; + +exports[`TabCompleteAction renders help text: change 1`] = ` +"usage: rush change [-h] [-v] [--no-fetch] [-b BRANCH] [--overwrite] + [--email EMAIL] [--bulk] [--message MESSAGE] + [--bump-type {major,minor,patch,none}] + + +Asks a series of questions and then generates a -.json +file. + +Optional arguments: + -h, --help Show this help message and exit. + -v, --verify Verify the change file has been generated and that it + is a valid JSON file + --no-fetch Skips fetching the baseline branch before running + \\"git diff\\" to detect changes. + -b BRANCH, --target-branch BRANCH + If this parameter is specified, compare the checked + out branch with the specified branch. + --overwrite If a changefile already exists, overwrite without + prompting. + --email EMAIL The email address to use in changefiles. If this + parameter is not provided, the email address will be + detected or prompted for in interactive mode. + --bulk If this flag is specified, apply the same change + message and bump type to all changed projects. + --message MESSAGE The message to apply to all changed projects. + --bump-type {major,minor,patch,none} + The bump type to apply to all changed projects. +" +`; + +exports[`TabCompleteAction renders help text: global help 1`] = ` +"usage: rush [-h] [-d] ... + +Rush: a scalable monorepo manager for the web + +Positional arguments: + + add Adds a dependency to the package.json and runs rush update. + build Build all projects that haven't been built. + change Records changes made to projects, indicating how the package + version number should be bumped for the next publish. + install Install package dependencies for all projects in the repo + according to the shrinkwrap file. + +Optional arguments: + -h, --help Show this help message and exit. + -d, --debug Show the full call stack if an error occurs while executing + the tool + +[bold]For detailed help about a specific command, use: rush -h[normal] +" +`; + +exports[`TabCompleteAction renders help text: install 1`] = ` +"usage: rush install [-h] [-p] [--bypass-policy] [--no-link] + [--network-concurrency COUNT] [--debug-package-manager] + [--max-install-attempts NUMBER] + + +Longer description: Install package dependencies for all projects in the repo +according to the shrinkwrap file. + +Optional arguments: + -h, --help Show this help message and exit. + -p, --purge Perform \\"rush purge\\" before starting the installation + --bypass-policy Overrides enforcement of the \\"gitPolicy\\" rules from + rush.json (use honorably!) + --no-link If \\"--no-link\\" is specified, then project symlinks + will NOT be created + --network-concurrency COUNT + If specified, limits the maximum number of concurrent + network requests. + --debug-package-manager + Activates verbose logging for the package manager. + --max-install-attempts NUMBER + Overrides the default maximum number of install + attempts. The default value is 3. +" +`; diff --git a/libraries/ts-command-line/src/test/helpTestUtilities.ts b/libraries/ts-command-line/src/test/helpTestUtilities.ts new file mode 100644 index 00000000000..e2c8aa015d0 --- /dev/null +++ b/libraries/ts-command-line/src/test/helpTestUtilities.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { AnsiEscape } from '@rushstack/terminal'; +import type { CommandLineParser } from '../providers/CommandLineParser'; + +export function ensureHelpTextMatchesSnapshot(parser: CommandLineParser): void { + const globalHelpText: string = AnsiEscape.formatForTests(parser.renderHelpText()); + expect(globalHelpText).toMatchSnapshot('global help'); + + for (const action of parser.actions) { + const actionHelpText: string = AnsiEscape.formatForTests(action.renderHelpText()); + expect(actionHelpText).toMatchSnapshot(action.actionName); + } +}