From d795536974ce8b26e681ed7b5e59625b599d116a Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 17 Nov 2025 09:27:52 -0800 Subject: [PATCH] Separate 'test' command definitions from implementations --- .../Commands/Test/MTP/MSBuildUtility.cs | 4 +- .../Commands/Test/TestCommandDefinition.cs | 381 ++++++++++++++++++ .../dotnet/Commands/Test/TestCommandParser.cs | 357 +--------------- .../Commands/Test/VSTest/TestCommand.cs | 10 +- .../Extensions/OptionForwardingExtensions.cs | 2 +- src/Cli/dotnet/Telemetry/TelemetryFilter.cs | 4 +- .../Test/TestCommandParserTests.cs | 14 +- 7 files changed, 411 insertions(+), 361 deletions(-) create mode 100644 src/Cli/dotnet/Commands/Test/TestCommandDefinition.cs diff --git a/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs b/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs index 839aa7b9b5a3..06e78ce4718a 100644 --- a/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs +++ b/src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs @@ -104,7 +104,7 @@ public static BuildOptions GetBuildOptions(ParseResult parseResult) pathOptions, parseResult.GetValue(CommonOptions.NoRestoreOption), parseResult.GetValue(MicrosoftTestingPlatformOptions.NoBuildOption), - parseResult.HasOption(TestCommandParser.VerbosityOption) ? parseResult.GetValue(TestCommandParser.VerbosityOption) : null, + parseResult.HasOption(TestCommandDefinition.VerbosityOption) ? parseResult.GetValue(TestCommandDefinition.VerbosityOption) : null, parseResult.GetValue(MicrosoftTestingPlatformOptions.NoLaunchProfileOption), parseResult.GetValue(MicrosoftTestingPlatformOptions.NoLaunchProfileArgumentsOption), otherArgs, @@ -124,7 +124,7 @@ private static bool BuildOrRestoreProjectOrSolution(string filePath, BuildOption msbuildArgs.Add($"-verbosity:quiet"); } - var parsedMSBuildArgs = MSBuildArgs.AnalyzeMSBuildArguments(msbuildArgs, CommonOptions.PropertiesOption, CommonOptions.RestorePropertiesOption, TestCommandParser.MTPTargetOption, TestCommandParser.VerbosityOption, CommonOptions.NoLogoOption()); + var parsedMSBuildArgs = MSBuildArgs.AnalyzeMSBuildArguments(msbuildArgs, CommonOptions.PropertiesOption, CommonOptions.RestorePropertiesOption, TestCommandDefinition.MTPTargetOption, TestCommandDefinition.VerbosityOption, CommonOptions.NoLogoOption()); int result = new RestoringCommand(parsedMSBuildArgs, buildOptions.HasNoRestore).Execute(); diff --git a/src/Cli/dotnet/Commands/Test/TestCommandDefinition.cs b/src/Cli/dotnet/Commands/Test/TestCommandDefinition.cs new file mode 100644 index 000000000000..50c2ab027534 --- /dev/null +++ b/src/Cli/dotnet/Commands/Test/TestCommandDefinition.cs @@ -0,0 +1,381 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.DotNet.Cli.CommandLine; +using Microsoft.DotNet.Cli.Extensions; +using Command = System.CommandLine.Command; + +namespace Microsoft.DotNet.Cli.Commands.Test; + +internal static class TestCommandDefinition +{ + public enum TestRunner + { + VSTest, + MicrosoftTestingPlatform + } + + private sealed class GlobalJsonModel + { + [JsonPropertyName("test")] + public GlobalJsonTestNode Test { get; set; } = null!; + } + + private sealed class GlobalJsonTestNode + { + [JsonPropertyName("runner")] + public string RunnerName { get; set; } = null!; + } + + public const string Name = "test"; + public static readonly string DocsLink = "https://aka.ms/dotnet-test"; + + public static readonly Option SettingsOption = new Option("--settings", "-s") + { + Description = CliCommandStrings.CmdSettingsDescription, + HelpName = CliCommandStrings.CmdSettingsFile + }.ForwardAsSingle(o => $"-property:VSTestSetting={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}"); + + public static readonly Option ListTestsOption = new Option("--list-tests", "-t") + { + Description = CliCommandStrings.CmdListTestsDescription, + Arity = ArgumentArity.Zero + }.ForwardAs("-property:VSTestListTests=true"); + + public static readonly Option FilterOption = new Option("--filter") + { + Description = CliCommandStrings.CmdTestCaseFilterDescription, + HelpName = CliCommandStrings.CmdTestCaseFilterExpression + }.ForwardAsSingle(o => $"-property:VSTestTestCaseFilter={SurroundWithDoubleQuotes(o!)}"); + + public static readonly Option> AdapterOption = new Option>("--test-adapter-path") + { + Description = CliCommandStrings.CmdTestAdapterPathDescription, + HelpName = CliCommandStrings.CmdTestAdapterPath + }.ForwardAsSingle(o => $"-property:VSTestTestAdapterPath={SurroundWithDoubleQuotes(string.Join(";", o!.Select(CommandDirectoryContext.GetFullPath)))}") + .AllowSingleArgPerToken(); + + public static readonly Option> LoggerOption = new Option>("--logger", "-l") + { + Description = CliCommandStrings.CmdLoggerDescription, + HelpName = CliCommandStrings.CmdLoggerOption + }.ForwardAsSingle(o => + { + var loggersString = string.Join(";", GetSemiColonEscapedArgs(o!)); + + return $"-property:VSTestLogger={SurroundWithDoubleQuotes(loggersString)}"; + }) + .AllowSingleArgPerToken(); + + public static readonly Option OutputOption = new Option("--output", "-o") + { + Description = CliCommandStrings.CmdOutputDescription, + HelpName = CliCommandStrings.TestCmdOutputDir + } + .ForwardAsOutputPath("OutputPath", true); + + public static readonly Option DiagOption = new Option("--diag", "-d") + { + Description = CliCommandStrings.CmdPathTologFileDescription, + HelpName = CliCommandStrings.CmdPathToLogFile + } + .ForwardAsSingle(o => $"-property:VSTestDiag={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}"); + + public static readonly Option NoBuildOption = new Option("--no-build") + { + Description = CliCommandStrings.CmdNoBuildDescription, + Arity = ArgumentArity.Zero + }.ForwardAs("-property:VSTestNoBuild=true"); + + public static readonly Option ResultsOption = new Option("--results-directory") + { + Description = CliCommandStrings.CmdResultsDirectoryDescription, + HelpName = CliCommandStrings.CmdPathToResultsDirectory + }.ForwardAsSingle(o => $"-property:VSTestResultsDirectory={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}"); + + public static readonly Option> CollectOption = new Option>("--collect") + { + Description = CliCommandStrings.cmdCollectDescription, + HelpName = CliCommandStrings.cmdCollectFriendlyName + }.ForwardAsSingle(o => $"-property:VSTestCollect=\"{string.Join(";", GetSemiColonEscapedArgs(o!))}\"") + .AllowSingleArgPerToken(); + + public static readonly Option BlameOption = new Option("--blame") + { + Description = CliCommandStrings.CmdBlameDescription, + Arity = ArgumentArity.Zero + }.ForwardIfEnabled("-property:VSTestBlame=true"); + + public static readonly Option BlameCrashOption = new Option("--blame-crash") + { + Description = CliCommandStrings.CmdBlameCrashDescription, + Arity = ArgumentArity.Zero + }.ForwardIfEnabled("-property:VSTestBlameCrash=true"); + + public static readonly Option BlameCrashDumpOption = CreateBlameCrashDumpOption(); + + private static Option CreateBlameCrashDumpOption() + { + Option result = new Option("--blame-crash-dump-type") + { + Description = CliCommandStrings.CmdBlameCrashDumpTypeDescription, + HelpName = CliCommandStrings.CrashDumpTypeArgumentName, + } + .ForwardAsMany(o => ["-property:VSTestBlameCrash=true", $"-property:VSTestBlameCrashDumpType={o}"]); + result.AcceptOnlyFromAmong(["full", "mini"]); + return result; + } + + public static readonly Option BlameCrashAlwaysOption = new Option("--blame-crash-collect-always") + { + Description = CliCommandStrings.CmdBlameCrashCollectAlwaysDescription, + Arity = ArgumentArity.Zero + }.ForwardIfEnabled(["-property:VSTestBlameCrash=true", "-property:VSTestBlameCrashCollectAlways=true"]); + + public static readonly Option BlameHangOption = new Option("--blame-hang") + { + Description = CliCommandStrings.CmdBlameHangDescription, + Arity = ArgumentArity.Zero + }.ForwardAs("-property:VSTestBlameHang=true"); + + public static readonly Option BlameHangDumpOption = CreateBlameHangDumpOption(); + + private static Option CreateBlameHangDumpOption() + { + Option result = new Option("--blame-hang-dump-type") + { + Description = CliCommandStrings.CmdBlameHangDumpTypeDescription, + HelpName = CliCommandStrings.HangDumpTypeArgumentName + } + .ForwardAsMany(o => ["-property:VSTestBlameHang=true", $"-property:VSTestBlameHangDumpType={o}"]); + result.AcceptOnlyFromAmong(["full", "mini", "none"]); + return result; + } + + public static readonly Option BlameHangTimeoutOption = new Option("--blame-hang-timeout") + { + Description = CliCommandStrings.CmdBlameHangTimeoutDescription, + HelpName = CliCommandStrings.HangTimeoutArgumentName + }.ForwardAsMany(o => ["-property:VSTestBlameHang=true", $"-property:VSTestBlameHangTimeout={o}"]); + + public static readonly Option NoLogoOption = CommonOptions.NoLogoOption(forwardAs: "--property:VSTestNoLogo=true", description: CliCommandStrings.TestCmdNoLogo); + + public static readonly Option NoRestoreOption = CommonOptions.NoRestoreOption; + + public static readonly Option FrameworkOption = CommonOptions.FrameworkOption(CliCommandStrings.TestFrameworkOptionDescription); + + public static readonly Option ConfigurationOption = CommonOptions.ConfigurationOption(CliCommandStrings.TestConfigurationOptionDescription); + + public static readonly Option VerbosityOption = CommonOptions.VerbosityOption(); + public static readonly Option VsTestTargetOption = CommonOptions.RequiredMSBuildTargetOption("VSTest"); + public static readonly Option MTPTargetOption = CommonOptions.RequiredMSBuildTargetOption(CliConstants.MTPTarget); + + public static TestRunner GetTestRunner() + { + string? globalJsonPath = GetGlobalJsonPath(Environment.CurrentDirectory); + if (!File.Exists(globalJsonPath)) + { + return TestRunner.VSTest; + } + + string jsonText = File.ReadAllText(globalJsonPath); + + // This code path is hit exactly once during the whole life of the dotnet process. + // So, no concern about caching JsonSerializerOptions. + var globalJson = JsonSerializer.Deserialize(jsonText, new JsonSerializerOptions() + { + AllowDuplicateProperties = false, + AllowTrailingCommas = false, + ReadCommentHandling = JsonCommentHandling.Skip, + }); + + var name = globalJson?.Test?.RunnerName; + + if (name is null || name.Equals(CliConstants.VSTest, StringComparison.OrdinalIgnoreCase)) + { + return TestRunner.VSTest; + } + + if (name.Equals(CliConstants.MicrosoftTestingPlatform, StringComparison.OrdinalIgnoreCase)) + { + return TestRunner.MicrosoftTestingPlatform; + } + + throw new InvalidOperationException(string.Format(CliCommandStrings.CmdUnsupportedTestRunnerDescription, name)); + } + + private static string? GetGlobalJsonPath(string? startDir) + { + string? directory = startDir; + while (directory != null) + { + string globalJsonPath = Path.Combine(directory, "global.json"); + if (File.Exists(globalJsonPath)) + { + return globalJsonPath; + } + + directory = Path.GetDirectoryName(directory); + } + return null; + } + + public static Command Create() + { + var command = new Command(Name); + + switch (GetTestRunner()) + { + case TestRunner.VSTest: + ConfigureVSTestCommand(command); + break; + + case TestRunner.MicrosoftTestingPlatform: + ConfigureTestingPlatformCommand(command); + break; + + default: + throw new InvalidOperationException(); + }; + + return command; + } + + public static void ConfigureTestingPlatformCommand(Command command) + { + command.Description = CliCommandStrings.DotnetTestCommandMTPDescription; + command.Options.Add(MicrosoftTestingPlatformOptions.ProjectOrSolutionOption); + command.Options.Add(MicrosoftTestingPlatformOptions.SolutionOption); + command.Options.Add(MicrosoftTestingPlatformOptions.TestModulesFilterOption); + command.Options.Add(MicrosoftTestingPlatformOptions.TestModulesRootDirectoryOption); + command.Options.Add(MicrosoftTestingPlatformOptions.ResultsDirectoryOption); + command.Options.Add(MicrosoftTestingPlatformOptions.ConfigFileOption); + command.Options.Add(MicrosoftTestingPlatformOptions.DiagnosticOutputDirectoryOption); + command.Options.Add(MicrosoftTestingPlatformOptions.MaxParallelTestModulesOption); + command.Options.Add(MicrosoftTestingPlatformOptions.MinimumExpectedTestsOption); + command.Options.Add(CommonOptions.ArchitectureOption); + command.Options.Add(CommonOptions.EnvOption); + command.Options.Add(CommonOptions.PropertiesOption); + command.Options.Add(MicrosoftTestingPlatformOptions.ConfigurationOption); + command.Options.Add(MicrosoftTestingPlatformOptions.FrameworkOption); + command.Options.Add(CommonOptions.OperatingSystemOption); + command.Options.Add(CommonOptions.RuntimeOption(CliCommandStrings.TestRuntimeOptionDescription)); + command.Options.Add(VerbosityOption); + command.Options.Add(CommonOptions.NoRestoreOption); + command.Options.Add(MicrosoftTestingPlatformOptions.NoBuildOption); + command.Options.Add(MicrosoftTestingPlatformOptions.NoAnsiOption); + command.Options.Add(MicrosoftTestingPlatformOptions.NoProgressOption); + command.Options.Add(MicrosoftTestingPlatformOptions.OutputOption); + command.Options.Add(MicrosoftTestingPlatformOptions.ListTestsOption); + command.Options.Add(MicrosoftTestingPlatformOptions.NoLaunchProfileOption); + command.Options.Add(MicrosoftTestingPlatformOptions.NoLaunchProfileArgumentsOption); + command.Options.Add(MTPTargetOption); + } + + public static void ConfigureVSTestCommand(Command command) + { + command.Description = CliCommandStrings.DotnetTestCommandVSTestDescription; + command.TreatUnmatchedTokensAsErrors = false; + command.DocsLink = DocsLink; + + // We are on purpose not capturing the solution, project or directory here. We want to pass it to the + // MSBuild command so we are letting it flow. + + command.Options.Add(SettingsOption); + command.Options.Add(ListTestsOption); + command.Options.Add(CommonOptions.TestEnvOption); + command.Options.Add(FilterOption); + command.Options.Add(AdapterOption); + command.Options.Add(LoggerOption); + command.Options.Add(OutputOption); + command.Options.Add(CommonOptions.ArtifactsPathOption); + command.Options.Add(DiagOption); + command.Options.Add(NoBuildOption); + command.Options.Add(ResultsOption); + command.Options.Add(CollectOption); + command.Options.Add(BlameOption); + command.Options.Add(BlameCrashOption); + command.Options.Add(BlameCrashDumpOption); + command.Options.Add(BlameCrashAlwaysOption); + command.Options.Add(BlameHangOption); + command.Options.Add(BlameHangDumpOption); + command.Options.Add(BlameHangTimeoutOption); + command.Options.Add(NoLogoOption); + command.Options.Add(ConfigurationOption); + command.Options.Add(FrameworkOption); + command.Options.Add(CommonOptions.RuntimeOption(CliCommandStrings.TestRuntimeOptionDescription)); + command.Options.Add(NoRestoreOption); + command.Options.Add(CommonOptions.InteractiveMsBuildForwardOption); + command.Options.Add(VerbosityOption); + command.Options.Add(CommonOptions.ArchitectureOption); + command.Options.Add(CommonOptions.OperatingSystemOption); + command.Options.Add(CommonOptions.PropertiesOption); + command.Options.Add(CommonOptions.DisableBuildServersOption); + command.Options.Add(VsTestTargetOption); + } + + private static string GetSemiColonEscapedstring(string arg) + { + if (arg.IndexOf(";") != -1) + { + return arg.Replace(";", "%3b"); + } + + return arg; + } + + private static string[] GetSemiColonEscapedArgs(IEnumerable args) + { + int counter = 0; + string[] array = new string[args.Count()]; + + foreach (string arg in args) + { + array[counter++] = GetSemiColonEscapedstring(arg); + } + + return array; + } + + /// + /// Adding double quotes around the property helps MSBuild arguments parser and avoid incorrect splits on ',' or ';'. + /// + internal /* for testing purposes */ static string SurroundWithDoubleQuotes(string input) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + // If already escaped by double quotes then return original string. + if (input.StartsWith("\"", StringComparison.Ordinal) + && input.EndsWith("\"", StringComparison.Ordinal)) + { + return input; + } + + // We want to count the number of trailing backslashes to ensure + // we will have an even number before adding the final double quote. + // Otherwise the last \" will be interpreted as escaping the double + // quote rather than a backslash and a double quote. + var trailingBackslashesCount = 0; + for (int i = input.Length - 1; i >= 0; i--) + { + if (input[i] == '\\') + { + trailingBackslashesCount++; + } + else + { + break; + } + } + + return trailingBackslashesCount % 2 == 0 + ? string.Concat("\"", input, "\"") + : string.Concat("\"", input, "\\\""); + } +} diff --git a/src/Cli/dotnet/Commands/Test/TestCommandParser.cs b/src/Cli/dotnet/Commands/Test/TestCommandParser.cs index 57be7086f011..2ee6a66fc750 100644 --- a/src/Cli/dotnet/Commands/Test/TestCommandParser.cs +++ b/src/Cli/dotnet/Commands/Test/TestCommandParser.cs @@ -1,373 +1,42 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.CommandLine; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.DotNet.Cli.CommandLine; -using Microsoft.DotNet.Cli.Extensions; using Command = System.CommandLine.Command; namespace Microsoft.DotNet.Cli.Commands.Test; internal static class TestCommandParser { - private sealed class GlobalJsonModel - { - [JsonPropertyName("test")] - public GlobalJsonTestNode Test { get; set; } = null!; - } - - private sealed class GlobalJsonTestNode - { - [JsonPropertyName("runner")] - public string RunnerName { get; set; } = null!; - } - - public static readonly string DocsLink = "https://aka.ms/dotnet-test"; - - public static readonly Option SettingsOption = new Option("--settings", "-s") - { - Description = CliCommandStrings.CmdSettingsDescription, - HelpName = CliCommandStrings.CmdSettingsFile - }.ForwardAsSingle(o => $"-property:VSTestSetting={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}"); - - public static readonly Option ListTestsOption = new Option("--list-tests", "-t") - { - Description = CliCommandStrings.CmdListTestsDescription, - Arity = ArgumentArity.Zero - }.ForwardAs("-property:VSTestListTests=true"); - - public static readonly Option FilterOption = new Option("--filter") - { - Description = CliCommandStrings.CmdTestCaseFilterDescription, - HelpName = CliCommandStrings.CmdTestCaseFilterExpression - }.ForwardAsSingle(o => $"-property:VSTestTestCaseFilter={SurroundWithDoubleQuotes(o!)}"); - - public static readonly Option> AdapterOption = new Option>("--test-adapter-path") - { - Description = CliCommandStrings.CmdTestAdapterPathDescription, - HelpName = CliCommandStrings.CmdTestAdapterPath - }.ForwardAsSingle(o => $"-property:VSTestTestAdapterPath={SurroundWithDoubleQuotes(string.Join(";", o!.Select(CommandDirectoryContext.GetFullPath)))}") - .AllowSingleArgPerToken(); - - public static readonly Option> LoggerOption = new Option>("--logger", "-l") - { - Description = CliCommandStrings.CmdLoggerDescription, - HelpName = CliCommandStrings.CmdLoggerOption - }.ForwardAsSingle(o => - { - var loggersString = string.Join(";", GetSemiColonEscapedArgs(o!)); - - return $"-property:VSTestLogger={SurroundWithDoubleQuotes(loggersString)}"; - }) - .AllowSingleArgPerToken(); - - public static readonly Option OutputOption = new Option("--output", "-o") - { - Description = CliCommandStrings.CmdOutputDescription, - HelpName = CliCommandStrings.TestCmdOutputDir - } - .ForwardAsOutputPath("OutputPath", true); - - public static readonly Option DiagOption = new Option("--diag", "-d") - { - Description = CliCommandStrings.CmdPathTologFileDescription, - HelpName = CliCommandStrings.CmdPathToLogFile - } - .ForwardAsSingle(o => $"-property:VSTestDiag={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}"); - - public static readonly Option NoBuildOption = new Option("--no-build") - { - Description = CliCommandStrings.CmdNoBuildDescription, - Arity = ArgumentArity.Zero - }.ForwardAs("-property:VSTestNoBuild=true"); - - public static readonly Option ResultsOption = new Option("--results-directory") - { - Description = CliCommandStrings.CmdResultsDirectoryDescription, - HelpName = CliCommandStrings.CmdPathToResultsDirectory - }.ForwardAsSingle(o => $"-property:VSTestResultsDirectory={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}"); - - public static readonly Option> CollectOption = new Option>("--collect") - { - Description = CliCommandStrings.cmdCollectDescription, - HelpName = CliCommandStrings.cmdCollectFriendlyName - }.ForwardAsSingle(o => $"-property:VSTestCollect=\"{string.Join(";", GetSemiColonEscapedArgs(o!))}\"") - .AllowSingleArgPerToken(); - - public static readonly Option BlameOption = new Option("--blame") - { - Description = CliCommandStrings.CmdBlameDescription, - Arity = ArgumentArity.Zero - }.ForwardIfEnabled("-property:VSTestBlame=true"); - - public static readonly Option BlameCrashOption = new Option("--blame-crash") - { - Description = CliCommandStrings.CmdBlameCrashDescription, - Arity = ArgumentArity.Zero - }.ForwardIfEnabled("-property:VSTestBlameCrash=true"); - - public static readonly Option BlameCrashDumpOption = CreateBlameCrashDumpOption(); - - private static Option CreateBlameCrashDumpOption() - { - Option result = new Option("--blame-crash-dump-type") - { - Description = CliCommandStrings.CmdBlameCrashDumpTypeDescription, - HelpName = CliCommandStrings.CrashDumpTypeArgumentName, - } - .ForwardAsMany(o => ["-property:VSTestBlameCrash=true", $"-property:VSTestBlameCrashDumpType={o}"]); - result.AcceptOnlyFromAmong(["full", "mini"]); - return result; - } - - public static readonly Option BlameCrashAlwaysOption = new Option("--blame-crash-collect-always") - { - Description = CliCommandStrings.CmdBlameCrashCollectAlwaysDescription, - Arity = ArgumentArity.Zero - }.ForwardIfEnabled(["-property:VSTestBlameCrash=true", "-property:VSTestBlameCrashCollectAlways=true"]); - - public static readonly Option BlameHangOption = new Option("--blame-hang") - { - Description = CliCommandStrings.CmdBlameHangDescription, - Arity = ArgumentArity.Zero - }.ForwardAs("-property:VSTestBlameHang=true"); - - public static readonly Option BlameHangDumpOption = CreateBlameHangDumpOption(); - - private static Option CreateBlameHangDumpOption() - { - Option result = new Option("--blame-hang-dump-type") - { - Description = CliCommandStrings.CmdBlameHangDumpTypeDescription, - HelpName = CliCommandStrings.HangDumpTypeArgumentName - } - .ForwardAsMany(o => ["-property:VSTestBlameHang=true", $"-property:VSTestBlameHangDumpType={o}"]); - result.AcceptOnlyFromAmong(["full", "mini", "none"]); - return result; - } - - public static readonly Option BlameHangTimeoutOption = new Option("--blame-hang-timeout") - { - Description = CliCommandStrings.CmdBlameHangTimeoutDescription, - HelpName = CliCommandStrings.HangTimeoutArgumentName - }.ForwardAsMany(o => ["-property:VSTestBlameHang=true", $"-property:VSTestBlameHangTimeout={o}"]); - - public static readonly Option NoLogoOption = CommonOptions.NoLogoOption(forwardAs: "--property:VSTestNoLogo=true", description: CliCommandStrings.TestCmdNoLogo); - - public static readonly Option NoRestoreOption = CommonOptions.NoRestoreOption; - - public static readonly Option FrameworkOption = CommonOptions.FrameworkOption(CliCommandStrings.TestFrameworkOptionDescription); - - public static readonly Option ConfigurationOption = CommonOptions.ConfigurationOption(CliCommandStrings.TestConfigurationOptionDescription); - - public static readonly Option VerbosityOption = CommonOptions.VerbosityOption(); - public static readonly Option VsTestTargetOption = CommonOptions.RequiredMSBuildTargetOption("VSTest"); - public static readonly Option MTPTargetOption = CommonOptions.RequiredMSBuildTargetOption(CliConstants.MTPTarget); - - - private static readonly Command Command = ConstructCommand(); + private static readonly Command Command = CreateCommand(); public static Command GetCommand() { return Command; } - private static string GetTestRunnerName() + private static Command CreateCommand() { - string? globalJsonPath = GetGlobalJsonPath(Environment.CurrentDirectory); - if (!File.Exists(globalJsonPath)) + return TestCommandDefinition.GetTestRunner() switch { - return CliConstants.VSTest; - } - - string jsonText = File.ReadAllText(globalJsonPath); - - // This code path is hit exactly once during the whole life of the dotnet process. - // So, no concern about caching JsonSerializerOptions. - var globalJson = JsonSerializer.Deserialize(jsonText, new JsonSerializerOptions() - { - AllowDuplicateProperties = false, - AllowTrailingCommas = false, - ReadCommentHandling = JsonCommentHandling.Skip, - }); - - return globalJson?.Test?.RunnerName ?? CliConstants.VSTest; - } - - private static string? GetGlobalJsonPath(string? startDir) - { - string? directory = startDir; - while (directory != null) - { - string globalJsonPath = Path.Combine(directory, "global.json"); - if (File.Exists(globalJsonPath)) - { - return globalJsonPath; - } - - directory = Path.GetDirectoryName(directory); - } - return null; - } - - private static Command ConstructCommand() - { - string testRunnerName = GetTestRunnerName(); - - if (testRunnerName.Equals(CliConstants.VSTest, StringComparison.OrdinalIgnoreCase)) - { - return GetVSTestCliCommand(); - } - else if (testRunnerName.Equals(CliConstants.MicrosoftTestingPlatform, StringComparison.OrdinalIgnoreCase)) - { - return GetTestingPlatformCliCommand(); - } - - throw new InvalidOperationException(string.Format(CliCommandStrings.CmdUnsupportedTestRunnerDescription, testRunnerName)); + TestCommandDefinition.TestRunner.VSTest => CreateVSTestCommand(), + TestCommandDefinition.TestRunner.MicrosoftTestingPlatform => CreateTestingPlatformCommand(), + _ => throw new NotSupportedException(), + }; } - private static Command GetTestingPlatformCliCommand() + private static Command CreateTestingPlatformCommand() { - var command = new MicrosoftTestingPlatformTestCommand("test", CliCommandStrings.DotnetTestCommandMTPDescription); + var command = new MicrosoftTestingPlatformTestCommand(TestCommandDefinition.Name); + TestCommandDefinition.ConfigureTestingPlatformCommand(command); command.SetAction(parseResult => command.Run(parseResult)); - command.Options.Add(MicrosoftTestingPlatformOptions.ProjectOrSolutionOption); - command.Options.Add(MicrosoftTestingPlatformOptions.SolutionOption); - command.Options.Add(MicrosoftTestingPlatformOptions.TestModulesFilterOption); - command.Options.Add(MicrosoftTestingPlatformOptions.TestModulesRootDirectoryOption); - command.Options.Add(MicrosoftTestingPlatformOptions.ResultsDirectoryOption); - command.Options.Add(MicrosoftTestingPlatformOptions.ConfigFileOption); - command.Options.Add(MicrosoftTestingPlatformOptions.DiagnosticOutputDirectoryOption); - command.Options.Add(MicrosoftTestingPlatformOptions.MaxParallelTestModulesOption); - command.Options.Add(MicrosoftTestingPlatformOptions.MinimumExpectedTestsOption); - command.Options.Add(CommonOptions.ArchitectureOption); - command.Options.Add(CommonOptions.EnvOption); - command.Options.Add(CommonOptions.PropertiesOption); - command.Options.Add(MicrosoftTestingPlatformOptions.ConfigurationOption); - command.Options.Add(MicrosoftTestingPlatformOptions.FrameworkOption); - command.Options.Add(CommonOptions.OperatingSystemOption); - command.Options.Add(CommonOptions.RuntimeOption(CliCommandStrings.TestRuntimeOptionDescription)); - command.Options.Add(VerbosityOption); - command.Options.Add(CommonOptions.NoRestoreOption); - command.Options.Add(MicrosoftTestingPlatformOptions.NoBuildOption); - command.Options.Add(MicrosoftTestingPlatformOptions.NoAnsiOption); - command.Options.Add(MicrosoftTestingPlatformOptions.NoProgressOption); - command.Options.Add(MicrosoftTestingPlatformOptions.OutputOption); - command.Options.Add(MicrosoftTestingPlatformOptions.ListTestsOption); - command.Options.Add(MicrosoftTestingPlatformOptions.NoLaunchProfileOption); - command.Options.Add(MicrosoftTestingPlatformOptions.NoLaunchProfileArgumentsOption); - command.Options.Add(MTPTargetOption); - return command; } - private static Command GetVSTestCliCommand() + private static Command CreateVSTestCommand() { - Command command = new("test", CliCommandStrings.DotnetTestCommandVSTestDescription) - { - TreatUnmatchedTokensAsErrors = false, - DocsLink = DocsLink - }; - - // We are on purpose not capturing the solution, project or directory here. We want to pass it to the - // MSBuild command so we are letting it flow. - - command.Options.Add(SettingsOption); - command.Options.Add(ListTestsOption); - command.Options.Add(CommonOptions.TestEnvOption); - command.Options.Add(FilterOption); - command.Options.Add(AdapterOption); - command.Options.Add(LoggerOption); - command.Options.Add(OutputOption); - command.Options.Add(CommonOptions.ArtifactsPathOption); - command.Options.Add(DiagOption); - command.Options.Add(NoBuildOption); - command.Options.Add(ResultsOption); - command.Options.Add(CollectOption); - command.Options.Add(BlameOption); - command.Options.Add(BlameCrashOption); - command.Options.Add(BlameCrashDumpOption); - command.Options.Add(BlameCrashAlwaysOption); - command.Options.Add(BlameHangOption); - command.Options.Add(BlameHangDumpOption); - command.Options.Add(BlameHangTimeoutOption); - command.Options.Add(NoLogoOption); - command.Options.Add(ConfigurationOption); - command.Options.Add(FrameworkOption); - command.Options.Add(CommonOptions.RuntimeOption(CliCommandStrings.TestRuntimeOptionDescription)); - command.Options.Add(NoRestoreOption); - command.Options.Add(CommonOptions.InteractiveMsBuildForwardOption); - command.Options.Add(VerbosityOption); - command.Options.Add(CommonOptions.ArchitectureOption); - command.Options.Add(CommonOptions.OperatingSystemOption); - command.Options.Add(CommonOptions.PropertiesOption); - command.Options.Add(CommonOptions.DisableBuildServersOption); - command.Options.Add(VsTestTargetOption); + var command = new Command(TestCommandDefinition.Name); + TestCommandDefinition.ConfigureVSTestCommand(command); command.SetAction(TestCommand.Run); - return command; } - - private static string GetSemiColonEscapedstring(string arg) - { - if (arg.IndexOf(";") != -1) - { - return arg.Replace(";", "%3b"); - } - - return arg; - } - - private static string[] GetSemiColonEscapedArgs(IEnumerable args) - { - int counter = 0; - string[] array = new string[args.Count()]; - - foreach (string arg in args) - { - array[counter++] = GetSemiColonEscapedstring(arg); - } - - return array; - } - - /// - /// Adding double quotes around the property helps MSBuild arguments parser and avoid incorrect splits on ',' or ';'. - /// - internal /* for testing purposes */ static string SurroundWithDoubleQuotes(string input) - { - if (input is null) - { - throw new ArgumentNullException(nameof(input)); - } - - // If already escaped by double quotes then return original string. - if (input.StartsWith("\"", StringComparison.Ordinal) - && input.EndsWith("\"", StringComparison.Ordinal)) - { - return input; - } - - // We want to count the number of trailing backslashes to ensure - // we will have an even number before adding the final double quote. - // Otherwise the last \" will be interpreted as escaping the double - // quote rather than a backslash and a double quote. - var trailingBackslashesCount = 0; - for (int i = input.Length - 1; i >= 0; i--) - { - if (input[i] == '\\') - { - trailingBackslashesCount++; - } - else - { - break; - } - } - - return trailingBackslashesCount % 2 == 0 - ? string.Concat("\"", input, "\"") - : string.Concat("\"", input, "\\\""); - } } diff --git a/src/Cli/dotnet/Commands/Test/VSTest/TestCommand.cs b/src/Cli/dotnet/Commands/Test/VSTest/TestCommand.cs index 5fc54e4e1630..32bc8cf23172 100644 --- a/src/Cli/dotnet/Commands/Test/VSTest/TestCommand.cs +++ b/src/Cli/dotnet/Commands/Test/VSTest/TestCommand.cs @@ -233,14 +233,14 @@ private static TestCommand FromParseResult(ParseResult result, string[] settings msbuildArgs.Add($"-property:VSTestSessionCorrelationId={testSessionCorrelationId}"); } - bool noRestore = result.GetValue(TestCommandParser.NoRestoreOption) || result.GetValue(TestCommandParser.NoBuildOption); + bool noRestore = result.GetValue(TestCommandDefinition.NoRestoreOption) || result.GetValue(TestCommandDefinition.NoBuildOption); var parsedMSBuildArgs = MSBuildArgs.AnalyzeMSBuildArguments( msbuildArgs, CommonOptions.PropertiesOption, CommonOptions.RestorePropertiesOption, - TestCommandParser.VsTestTargetOption, - TestCommandParser.VerbosityOption, + TestCommandDefinition.VsTestTargetOption, + TestCommandDefinition.VerbosityOption, CommonOptions.NoLogoOption()) .CloneWithNoLogo(true); @@ -288,9 +288,9 @@ internal static int RunArtifactPostProcessingIfNeeded(string testSessionCorrelat var artifactsPostProcessArgs = new List { "--artifactsProcessingMode-postprocess", $"--testSessionCorrelationId:{testSessionCorrelationId}" }; - if (parseResult.GetResult(TestCommandParser.DiagOption) is not null) + if (parseResult.GetResult(TestCommandDefinition.DiagOption) is not null) { - artifactsPostProcessArgs.Add($"--diag:{parseResult.GetValue(TestCommandParser.DiagOption)}"); + artifactsPostProcessArgs.Add($"--diag:{parseResult.GetValue(TestCommandDefinition.DiagOption)}"); } try diff --git a/src/Cli/dotnet/Extensions/OptionForwardingExtensions.cs b/src/Cli/dotnet/Extensions/OptionForwardingExtensions.cs index 83eabb7e7675..6d191c02f8f3 100644 --- a/src/Cli/dotnet/Extensions/OptionForwardingExtensions.cs +++ b/src/Cli/dotnet/Extensions/OptionForwardingExtensions.cs @@ -30,7 +30,7 @@ public static Option ForwardAsOutputPath(this Option option, str { // Not sure if this is necessary, but this is what "dotnet test" previously did and so we are // preserving the behavior here after refactoring - argVal = TestCommandParser.SurroundWithDoubleQuotes(argVal); + argVal = TestCommandDefinition.SurroundWithDoubleQuotes(argVal); } return [ $"--property:{outputPropertyName}={argVal}", diff --git a/src/Cli/dotnet/Telemetry/TelemetryFilter.cs b/src/Cli/dotnet/Telemetry/TelemetryFilter.cs index f410cd70c2fa..730daae1ed57 100644 --- a/src/Cli/dotnet/Telemetry/TelemetryFilter.cs +++ b/src/Cli/dotnet/Telemetry/TelemetryFilter.cs @@ -118,8 +118,8 @@ PublishCommandParser.ConfigurationOption ] ( topLevelCommandName: ["run", "clean", "test"], optionsToLog: [ RunCommandParser.FrameworkOption, CleanCommandParser.FrameworkOption, - TestCommandParser.FrameworkOption, RunCommandParser.ConfigurationOption, CleanCommandParser.ConfigurationOption, - TestCommandParser.ConfigurationOption ] + TestCommandDefinition.FrameworkOption, RunCommandParser.ConfigurationOption, CleanCommandParser.ConfigurationOption, + TestCommandDefinition.ConfigurationOption ] ), new TopLevelCommandNameAndOptionToLog ( diff --git a/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs b/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs index 1c780c37bc14..c6339a66369f 100644 --- a/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs +++ b/test/dotnet.Tests/CommandTests/Test/TestCommandParserTests.cs @@ -7,13 +7,13 @@ namespace Microsoft.DotNet.Cli.Test.Tests { - public class TestCommandParserTests + public class TestCommandDefinitionTests { [Fact] public void SurroundWithDoubleQuotesWithNullThrows() { Assert.Throws(() => - TestCommandParser.SurroundWithDoubleQuotes(null!)); + TestCommandDefinition.SurroundWithDoubleQuotes(null!)); } [Theory] @@ -23,7 +23,7 @@ public void SurroundWithDoubleQuotesWithNullThrows() public void SurroundWithDoubleQuotesWhenAlreadySurroundedDoesNothing(string input) { var escapedInput = "\"" + input + "\""; - var result = TestCommandParser.SurroundWithDoubleQuotes(escapedInput); + var result = TestCommandDefinition.SurroundWithDoubleQuotes(escapedInput); result.Should().Be(escapedInput); } @@ -35,7 +35,7 @@ public void SurroundWithDoubleQuotesWhenAlreadySurroundedDoesNothing(string inpu [InlineData("a\"")] public void SurroundWithDoubleQuotesWhenNotSurroundedSurrounds(string input) { - var result = TestCommandParser.SurroundWithDoubleQuotes(input); + var result = TestCommandDefinition.SurroundWithDoubleQuotes(input); result.Should().Be("\"" + input + "\""); } @@ -46,7 +46,7 @@ public void SurroundWithDoubleQuotesWhenNotSurroundedSurrounds(string input) [InlineData("/\\/\\/\\\\")] public void SurroundWithDoubleQuotesHandlesCorrectlyEvenCountOfTrailingBackslashes(string input) { - var result = TestCommandParser.SurroundWithDoubleQuotes(input); + var result = TestCommandDefinition.SurroundWithDoubleQuotes(input); result.Should().Be("\"" + input + "\""); } @@ -57,14 +57,14 @@ public void SurroundWithDoubleQuotesHandlesCorrectlyEvenCountOfTrailingBackslash [InlineData("/\\/\\/\\")] public void SurroundWithDoubleQuotesHandlesCorrectlyOddCountOfTrailingBackslashes(string input) { - var result = TestCommandParser.SurroundWithDoubleQuotes(input); + var result = TestCommandDefinition.SurroundWithDoubleQuotes(input); result.Should().Be("\"" + input + "\\\""); } [Fact] public void VSTestCommandIncludesPropertiesOption() { - var command = TestCommandParser.GetCommand(); + var command = TestCommandDefinition.Create(); // Verify that the command includes a property option that supports the /p alias var propertyOption = command.Options.FirstOrDefault(o =>