diff --git a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.UnknownAndBootstrapValidation.cs b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.UnknownAndBootstrapValidation.cs index f85933dd6c..51a6f9e460 100644 --- a/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.UnknownAndBootstrapValidation.cs +++ b/src/Platform/Microsoft.Testing.Platform/CommandLine/CommandLineOptionsValidator.UnknownAndBootstrapValidation.cs @@ -10,6 +10,105 @@ namespace Microsoft.Testing.Platform.CommandLine; internal static partial class CommandLineOptionsValidator { + // Keep in sync with public command-line providers that are automatically registered by the listed packages. + // Packages without public options, options requiring explicit framework registration, and hidden internal options + // are intentionally omitted. + private static readonly Dictionary KnownExtensionOptionsByPackage = new(StringComparer.OrdinalIgnoreCase) + { + ["Microsoft.Testing.Extensions.AzureDevOpsReport"] = + [ + "publish-azdo-run-name", + "publish-azdo-test-results", + "report-azdo", + "report-azdo-annotations", + "report-azdo-demote-known-flaky", + "report-azdo-flaky-history", + "report-azdo-groups", + "report-azdo-quarantine-file", + "report-azdo-severity", + "report-azdo-slow-test-history", + "report-azdo-slow-test-history-min-sample", + "report-azdo-slow-test-history-multiplier", + "report-azdo-stackframe-filter", + "report-azdo-summary", + "report-azdo-upload-artifact-exclude", + "report-azdo-upload-artifact-include", + "report-azdo-upload-artifact-name", + "report-azdo-upload-artifacts", + ], + ["Microsoft.Testing.Extensions.CodeCoverage"] = + [ + "coverage", + "coverage-output", + "coverage-output-format", + "coverage-settings", + ], + ["Microsoft.Testing.Extensions.CrashDump"] = + [ + "crash-report", + "crash-report-if-supported", + "crash-sequence", + "crashdump", + "crashdump-filename", + "crashdump-type", + ], + ["Microsoft.Testing.Extensions.CtrfReport"] = + [ + "report-ctrf", + "report-ctrf-filename", + ], + ["Microsoft.Testing.Extensions.GitHubActionsReport"] = + [ + "report-gh", + "report-gh-annotations", + "report-gh-failure-details", + "report-gh-groups", + "report-gh-slow-test-notices", + "report-gh-slow-test-threshold", + "report-gh-step-summary", + "report-gh-step-summary-sections", + ], + ["Microsoft.Testing.Extensions.HangDump"] = + [ + "hangdump", + "hangdump-filename", + "hangdump-timeout", + "hangdump-type", + "hangdump-type-if-supported", + ], + ["Microsoft.Testing.Extensions.HtmlReport"] = + [ + "report-html", + "report-html-filename", + ], + ["Microsoft.Testing.Extensions.JUnitReport"] = + [ + "report-junit", + "report-junit-filename", + ], + ["Microsoft.Testing.Extensions.Retry"] = + [ + "retry-failed-tests", + "retry-failed-tests-delay", + "retry-failed-tests-max-percentage", + "retry-failed-tests-max-tests", + ], + ["Microsoft.Testing.Extensions.TrxReport"] = + [ + "report-trx", + "report-trx-filename", + ], + ["Microsoft.Testing.Extensions.VideoRecorder"] = + [ + "capture-video", + "capture-video-args", + "capture-video-chapters", + "capture-video-granularity", + "capture-video-max-duration", + "capture-video-source", + ], + }; + private static ValidationResult ValidateNoUnknownOptions( CommandLineParseResult parseResult, IReadOnlyList? jsonCommandLineOptions, @@ -21,11 +120,17 @@ private static ValidationResult ValidateNoUnknownOptions( // parsing is already case-sensitive but a case-insensitive lookup is a strict superset and // does not change CLI behavior. var validOptionNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var visibleOptionNames = new HashSet(StringComparer.OrdinalIgnoreCase); + bool includeKnownExtensionOptions = !parseResult.HasTool; foreach (KeyValuePair> provider in extensionOptionsByProvider) { foreach (CommandLineOption option in provider.Value) { validOptionNames.Add(option.Name); + if (!option.IsHidden) + { + visibleOptionNames.Add(option.Name); + } } } @@ -34,6 +139,10 @@ private static ValidationResult ValidateNoUnknownOptions( foreach (CommandLineOption option in provider.Value) { validOptionNames.Add(option.Name); + if (!option.IsHidden) + { + visibleOptionNames.Add(option.Name); + } } } @@ -43,7 +152,7 @@ private static ValidationResult ValidateNoUnknownOptions( if (!validOptionNames.Contains(optionRecord.Name)) { stringBuilder ??= new(); - stringBuilder.AppendLine(string.Format(CultureInfo.InvariantCulture, PlatformResources.CommandLineUnknownOption, optionRecord.Name)); + AppendUnknownOptionError(stringBuilder, optionRecord.Name, validOptionNames, visibleOptionNames, includeKnownExtensionOptions); } } @@ -58,7 +167,9 @@ private static ValidationResult ValidateNoUnknownOptions( if (!validOptionNames.Contains(entry.OptionName)) { stringBuilder ??= new(); - string innerError = string.Format(CultureInfo.InvariantCulture, PlatformResources.CommandLineUnknownOption, entry.OptionName); + StringBuilder innerErrorBuilder = new(); + AppendUnknownOptionError(innerErrorBuilder, entry.OptionName, validOptionNames, visibleOptionNames, includeKnownExtensionOptions); + string innerError = innerErrorBuilder.ToTrimmedString(); stringBuilder.AppendLine(string.Format(CultureInfo.InvariantCulture, PlatformResources.JsonCommandLineOptionsValidationErrorPrefix, innerError)); } } @@ -74,6 +185,136 @@ private static ValidationResult ValidateNoUnknownOptions( : ValidationResult.Valid(); } + private static void AppendUnknownOptionError( + StringBuilder stringBuilder, + string unknownOptionName, + HashSet validOptionNames, + HashSet visibleOptionNames, + bool includeKnownExtensionOptions) + { + stringBuilder.AppendLine(string.Format(CultureInfo.InvariantCulture, PlatformResources.CommandLineUnknownOption, unknownOptionName)); + + if (includeKnownExtensionOptions + && GetKnownExtensionPackage(unknownOptionName) is { } packageName) + { + AppendMissingExtensionSuggestion(stringBuilder, unknownOptionName, packageName); + return; + } + + IEnumerable candidateOptionNames = includeKnownExtensionOptions + ? visibleOptionNames.Concat(KnownExtensionOptionsByPackage.Values.SelectMany(static optionNames => optionNames)) + : visibleOptionNames; + string? suggestedOptionName = FindSuggestedOption( + unknownOptionName, + candidateOptionNames); + if (suggestedOptionName is null) + { + return; + } + + stringBuilder.AppendLine(string.Format(CultureInfo.InvariantCulture, PlatformResources.CommandLineOptionSuggestion, suggestedOptionName)); + + if (includeKnownExtensionOptions + && GetKnownExtensionPackage(suggestedOptionName) is { } suggestedPackageName + && !validOptionNames.Contains(suggestedOptionName)) + { + AppendMissingExtensionSuggestion(stringBuilder, suggestedOptionName, suggestedPackageName); + } + } + + private static void AppendMissingExtensionSuggestion(StringBuilder stringBuilder, string optionName, string packageName) + => stringBuilder.AppendLine(string.Format( + CultureInfo.InvariantCulture, + PlatformResources.CommandLineOptionRequiresExtension, + optionName, + packageName)); + + private static string? GetKnownExtensionPackage(string optionName) + { + foreach (KeyValuePair extensionOptions in KnownExtensionOptionsByPackage) + { + if (extensionOptions.Value.Contains(optionName, StringComparer.OrdinalIgnoreCase)) + { + return extensionOptions.Key; + } + } + + return null; + } + + private static string? FindSuggestedOption(string unknownOptionName, IEnumerable candidateOptionNames) + { + int maximumDistance = unknownOptionName.Length switch + { + <= 4 => 1, + <= 12 => 2, + _ => 3, + }; + + string? bestCandidate = null; + int bestDistance = maximumDistance + 1; + bool hasAmbiguousBestCandidate = false; + + foreach (string candidateOptionName in candidateOptionNames.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (Math.Abs(candidateOptionName.Length - unknownOptionName.Length) > maximumDistance) + { + continue; + } + + int distance = CalculateEditDistance(unknownOptionName, candidateOptionName); + if (distance < bestDistance) + { + bestCandidate = candidateOptionName; + bestDistance = distance; + hasAmbiguousBestCandidate = false; + } + else if (distance == bestDistance) + { + hasAmbiguousBestCandidate = true; + } + } + + return bestDistance <= maximumDistance && !hasAmbiguousBestCandidate + ? bestCandidate + : null; + } + + private static int CalculateEditDistance(string source, string target) + { + int[,] distances = new int[source.Length + 1, target.Length + 1]; + for (int i = 0; i <= source.Length; i++) + { + distances[i, 0] = i; + } + + for (int j = 0; j <= target.Length; j++) + { + distances[0, j] = j; + } + + for (int i = 1; i <= source.Length; i++) + { + for (int j = 1; j <= target.Length; j++) + { + int substitutionCost = char.ToUpperInvariant(source[i - 1]) == char.ToUpperInvariant(target[j - 1]) ? 0 : 1; + distances[i, j] = Math.Min( + Math.Min(distances[i - 1, j] + 1, distances[i, j - 1] + 1), + distances[i - 1, j - 1] + substitutionCost); + + if (i > 1 + && j > 1 + && char.ToUpperInvariant(source[i - 1]) == char.ToUpperInvariant(target[j - 2]) + && char.ToUpperInvariant(source[i - 2]) == char.ToUpperInvariant(target[j - 1])) + { + distances[i, j] = Math.Min(distances[i, j], distances[i - 2, j - 2] + 1); + } + } + } + + return distances[source.Length, target.Length]; + } + private static ValidationResult ValidateNoBootstrapOnlyOptionsInJson( IReadOnlyList? jsonCommandLineOptions) { diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx index a4f84b0d67..e11c16163e 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx +++ b/src/Platform/Microsoft.Testing.Platform/Resources/PlatformResources.resx @@ -467,10 +467,18 @@ Unknown option '--{0}' {0} is the unknown option name without the leading dashes. + + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + Run '--help' to see the options registered by this test application. If the option belongs to an extension, ensure its package is referenced and the extension is registered. {Locked="'--help'"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + Invalid command line arguments: diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf index ba68da0ca6..49ff70ad26 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf @@ -222,6 +222,16 @@ Možnost --{0} od zprostředkovatele {1} (UID: {2}) používá vyhrazenou předponu --internal. {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. Rozhraní ICommandLineOptions ještě není sestavené. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf index 7b6c6a0071..8f7ca414a8 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf @@ -222,6 +222,16 @@ Die Option "--{0}" vom Anbieter "{1}" (UID: {2}) verwendet das reservierte Präfix "--internal". {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions wurde noch nicht erstellt. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf index 3aefad80cf..d06e30e3fa 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf @@ -222,6 +222,16 @@ La opción “--{0}” del proveedor “{1}” (UID: {2}) usa el prefijo reservado “--internal” {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions aún no se ha compilado. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf index 5a2f48c3e7..068fa5dddd 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf @@ -222,6 +222,16 @@ L’option « --{0} » du fournisseur « {1} » (UID : {2}) utilise le préfixe réservé « --internal » {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions n’a pas encore été généré. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf index b1267606a9..1da226298d 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf @@ -222,6 +222,16 @@ L'opzione '--{0}' del provider '{1}' (UID: {2}) usa il prefisso riservato '--internal' {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions non è stato ancora compilato. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf index 3cd6efed06..ae24f83ddf 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf @@ -222,6 +222,16 @@ プロバイダー '{1}' のオプション '--{0}' (UID:{2}) は予約済みプレフィックス '--internal' を 使用しています {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions はまだ構築されていません。 diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf index 061924bf0b..736b4ebdfa 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf @@ -222,6 +222,16 @@ 공급자 '{1}'(UID: {2})의 옵션 '-- {0}'이 예약된 접두사 '--internal'을 사용하고 있습니다. {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions가 아직 빌드되지 않았습니다. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf index c1395fa9fd..d01deb9c38 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf @@ -222,6 +222,16 @@ Opcja „--{0}” od dostawcy „{1}” (UID: {2}) używa zastrzeżonego prefiksu „--internal” {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. Obiekt ICommandLineOptions nie został jeszcze skompilowany. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf index b2fc6ea470..dadd1bfcd2 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf @@ -222,6 +222,16 @@ A opção '--{0}' do provedor '{1}' (UID: {2}) está usando o prefixo reservado '--internal' {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. O ICommandLineOptions ainda não foi criado. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf index 5faedd71ee..78aaf8c9b9 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf @@ -222,6 +222,16 @@ Параметр "--{0}" от поставщика "{1}" (UID: {2}) использует зарезервированный префикс "--internal" {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. Параметр ICommandLineOptions еще не создан. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf index 3b79922318..73b8e7a330 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf @@ -222,6 +222,16 @@ '{1}' sağlayıcısındaki (UID: {2}) `--{0}` seçeneği ayrılmış '--internal' önekini kullanıyor {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions henüz derlenmedi. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf index 633d554b0c..f85d1f5021 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf @@ -222,6 +222,16 @@ 来自提供程序“{1}” (UID: {2}) 的选项“--{0}”正在使用保留前缀“--internal” {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions 尚未生成。 diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf index 50790821e8..3112912762 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf @@ -222,6 +222,16 @@ 提供者 '{1}' (UID: {2}) 中的選項 '--{0}' 使用保留的前置詞 '--internal' {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. 尚未建置 ICommandLineOptions。 diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs index 0d45ed9bb6..0030e94dfb 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/CommandLine/CommandLineHandlerTests.cs @@ -357,6 +357,30 @@ public async Task ParseAndValidateAsync_ToolOptionDoesNotConflictWithNormalExten Assert.IsTrue(result.IsValid); } + [TestMethod] + [DataRow("--report-trx")] + [DataRow("--report-txr")] + public async Task ParseAndValidateAsync_KnownExtensionOptionInToolMode_DoesNotSuggestPackageOrOption(string option) + { + CommandLineParseResult parseResult = CommandLineParser.Parse(["tool", option], new SystemEnvironment()); + ICommandLineOptionsProvider[] providers = + [ + new ExtensionCommandLineProviderMockValidConfiguration("report-trx"), + new ToolCommandLineProviderMock("input"), + ]; + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + parseResult, + _systemCommandLineOptionsProviders, + providers, + Mock.Of()); + + Assert.IsFalse(result.IsValid); + Assert.Contains($"Unknown option '{option}'", result.ErrorMessage); + Assert.DoesNotContain("Did you mean", result.ErrorMessage); + Assert.DoesNotContain("Microsoft.Testing.Extensions.TrxReport", result.ErrorMessage); + } + [TestMethod] public async Task ParseAndValidateAsync_UnknownJsonOptionForSelectedTool_ReturnsInvalid() { @@ -535,6 +559,200 @@ Unknown option '--x' """, result.ErrorMessage); } + [TestMethod] + public async Task ParseAndValidateAsync_UnknownOptionWithCloseRegisteredOption_SuggestsOption() + { + CommandLineParseResult parseResult = CommandLineParser.Parse(["--halp"], new SystemEnvironment()); + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + parseResult, + _systemCommandLineOptionsProviders, + _extensionCommandLineOptionsProviders, + Mock.Of()); + + Assert.IsFalse(result.IsValid); + Assert.AreEqual( + """ + Unknown option '--halp' + Did you mean '--help'? + Run '--help' to see the options registered by this test application. If the option belongs to an extension, ensure its package is referenced and the extension is registered. + Command line: --halp + """, + result.ErrorMessage); + } + + [TestMethod] + [DataRow("abcdefgh", "abczefgx", true)] + [DataRow("abcdefgh", "abczefxy", false)] + [DataRow("abcdefghijklmn", "abczefghijklxy", true)] + [DataRow("abcdefghijklmn", "abczefghijkwxy", false)] + public async Task ParseAndValidateAsync_UnknownOption_RespectsSuggestionDistanceThresholds( + string registeredOptionName, + string unknownOptionName, + bool shouldSuggest) + { + CommandLineParseResult parseResult = CommandLineParser.Parse([$"--{unknownOptionName}"], new SystemEnvironment()); + ICommandLineOptionsProvider[] extensionCommandLineOptionsProviders = + [ + new ExtensionCommandLineProviderMockValidConfiguration(registeredOptionName), + ]; + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + parseResult, + _systemCommandLineOptionsProviders, + extensionCommandLineOptionsProviders, + Mock.Of()); + + Assert.IsFalse(result.IsValid); + Assert.Contains($"Unknown option '--{unknownOptionName}'", result.ErrorMessage); + if (shouldSuggest) + { + Assert.Contains($"Did you mean '--{registeredOptionName}'?", result.ErrorMessage); + } + else + { + Assert.DoesNotContain("Did you mean", result.ErrorMessage); + } + } + + [TestMethod] + public async Task ParseAndValidateAsync_UnknownOptionWithAmbiguousMatches_DoesNotSuggestOption() + { + CommandLineParseResult parseResult = CommandLineParser.Parse(["--ad"], new SystemEnvironment()); + ICommandLineOptionsProvider[] extensionCommandLineOptionsProviders = + [ + new ExtensionCommandLineProviderMockValidConfiguration("ab"), + new ExtensionCommandLineProviderMockValidConfiguration("ac"), + ]; + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + parseResult, + _systemCommandLineOptionsProviders, + extensionCommandLineOptionsProviders, + Mock.Of()); + + Assert.IsFalse(result.IsValid); + Assert.Contains("Unknown option '--ad'", result.ErrorMessage); + Assert.DoesNotContain("Did you mean", result.ErrorMessage); + } + + [TestMethod] + public async Task ParseAndValidateAsync_UnknownOptionWithCloseHiddenOption_DoesNotSuggestOption() + { + CommandLineParseResult parseResult = CommandLineParser.Parse(["--hiddden"], new SystemEnvironment()); + ICommandLineOptionsProvider[] extensionCommandLineOptionsProviders = + [ + new ExtensionCommandLineProviderMockValidConfiguration("hidden", isHidden: true), + ]; + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + parseResult, + _systemCommandLineOptionsProviders, + extensionCommandLineOptionsProviders, + Mock.Of()); + + Assert.IsFalse(result.IsValid); + Assert.Contains("Unknown option '--hiddden'", result.ErrorMessage); + Assert.DoesNotContain("Did you mean", result.ErrorMessage); + } + + [TestMethod] + [DataRow("--report-azdo", "Microsoft.Testing.Extensions.AzureDevOpsReport")] + [DataRow("--coverage-output-format", "Microsoft.Testing.Extensions.CodeCoverage")] + [DataRow("--crashdump-type", "Microsoft.Testing.Extensions.CrashDump")] + [DataRow("--report-ctrf", "Microsoft.Testing.Extensions.CtrfReport")] + [DataRow("--report-gh", "Microsoft.Testing.Extensions.GitHubActionsReport")] + [DataRow("--hangdump-timeout", "Microsoft.Testing.Extensions.HangDump")] + [DataRow("--report-html", "Microsoft.Testing.Extensions.HtmlReport")] + [DataRow("--report-junit", "Microsoft.Testing.Extensions.JUnitReport")] + [DataRow("--retry-failed-tests", "Microsoft.Testing.Extensions.Retry")] + [DataRow("--report-trx", "Microsoft.Testing.Extensions.TrxReport")] + [DataRow("--capture-video", "Microsoft.Testing.Extensions.VideoRecorder")] + public async Task ParseAndValidateAsync_KnownExtensionOptionWithoutExtension_SuggestsPackage(string option, string packageName) + { + CommandLineParseResult parseResult = CommandLineParser.Parse([option], new SystemEnvironment()); + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + parseResult, + _systemCommandLineOptionsProviders, + _extensionCommandLineOptionsProviders, + Mock.Of()); + + Assert.IsFalse(result.IsValid); + Assert.Contains($"Unknown option '{option}'", result.ErrorMessage); + Assert.Contains($"Option '{option}' is provided by the '{packageName}' extension. Add a package reference to use it.", result.ErrorMessage); + } + + [TestMethod] + [DataRow("--report-adzo", "--report-azdo", "Microsoft.Testing.Extensions.AzureDevOpsReport")] + [DataRow("--coverge", "--coverage", "Microsoft.Testing.Extensions.CodeCoverage")] + [DataRow("--report-htlm", "--report-html", "Microsoft.Testing.Extensions.HtmlReport")] + public async Task ParseAndValidateAsync_MisspelledKnownExtensionOption_SuggestsOptionAndPackage( + string option, + string suggestedOption, + string packageName) + { + CommandLineParseResult parseResult = CommandLineParser.Parse([option], new SystemEnvironment()); + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + parseResult, + _systemCommandLineOptionsProviders, + _extensionCommandLineOptionsProviders, + Mock.Of()); + + Assert.IsFalse(result.IsValid); + Assert.Contains($"Unknown option '{option}'", result.ErrorMessage); + Assert.Contains($"Did you mean '{suggestedOption}'?", result.ErrorMessage); + Assert.Contains($"Option '{suggestedOption}' is provided by the '{packageName}' extension. Add a package reference to use it.", result.ErrorMessage); + } + + [TestMethod] + public async Task ParseAndValidateAsync_MisspelledRegisteredExtensionOption_DoesNotSuggestPackage() + { + CommandLineParseResult parseResult = CommandLineParser.Parse(["--report-adzo"], new SystemEnvironment()); + ICommandLineOptionsProvider[] extensionCommandLineOptionsProviders = + [ + new ExtensionCommandLineProviderMockValidConfiguration("report-azdo"), + ]; + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + parseResult, + _systemCommandLineOptionsProviders, + extensionCommandLineOptionsProviders, + Mock.Of()); + + Assert.IsFalse(result.IsValid); + Assert.AreEqual( + """ + Unknown option '--report-adzo' + Did you mean '--report-azdo'? + Run '--help' to see the options registered by this test application. If the option belongs to an extension, ensure its package is referenced and the extension is registered. + Command line: --report-adzo + """, + result.ErrorMessage); + } + + [TestMethod] + public async Task ParseAndValidateAsync_MisspelledJsonOption_SuggestsRegisteredOption() + { + CommandLineParseResult parseResult = CommandLineParser.Parse([], new SystemEnvironment()); + + ValidationResult result = await CommandLineOptionsValidator.ValidateAsync( + parseResult, + _systemCommandLineOptionsProviders, + _extensionCommandLineOptionsProviders, + Mock.Of(), + [new JsonCommandLineOptionEntry("halp", [], isDisabled: false)]); + + Assert.IsFalse(result.IsValid); + Assert.Contains( + """ + In testconfig.json under 'commandLineOptions': Unknown option '--halp' + Did you mean '--help'? + """, + result.ErrorMessage); + } + [TestMethod] public async Task ParseAndValidateAsync_MultipleUnknownOptions_ReportsAll() { @@ -843,7 +1061,7 @@ public Task ValidateCommandLineOptionsAsync(ICommandLineOption } #pragma warning restore TPEXP - private sealed class ExtensionCommandLineProviderMockValidConfiguration(string optionName) : ICommandLineOptionsProvider + private sealed class ExtensionCommandLineProviderMockValidConfiguration(string optionName, bool isHidden = false) : ICommandLineOptionsProvider { public string Uid => nameof(ExtensionCommandLineProviderMockValidConfiguration); @@ -856,7 +1074,7 @@ private sealed class ExtensionCommandLineProviderMockValidConfiguration(string o public Task IsEnabledAsync() => Task.FromResult(true); public IReadOnlyCollection GetCommandLineOptions() - => [new(optionName, optionName, ArgumentArity.ExactlyOne, isHidden: false)]; + => [new(optionName, optionName, ArgumentArity.ExactlyOne, isHidden)]; public Task ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments) => ValidationResult.ValidTask;