|
| 1 | +// Licensed to the .NET Foundation under one or more agreements. |
| 2 | +// The .NET Foundation licenses this file to you under the MIT license. |
| 3 | + |
| 4 | +using System.CommandLine; |
| 5 | +using System.Text.Json; |
| 6 | +using System.Text.Json.Serialization; |
| 7 | +using Microsoft.DotNet.Cli.CommandLine; |
| 8 | +using Microsoft.DotNet.Cli.Extensions; |
| 9 | +using Command = System.CommandLine.Command; |
| 10 | + |
| 11 | +namespace Microsoft.DotNet.Cli.Commands.Test; |
| 12 | + |
| 13 | +internal static class TestCommandDefinition |
| 14 | +{ |
| 15 | + public enum TestRunner |
| 16 | + { |
| 17 | + VSTest, |
| 18 | + MicrosoftTestingPlatform |
| 19 | + } |
| 20 | + |
| 21 | + private sealed class GlobalJsonModel |
| 22 | + { |
| 23 | + [JsonPropertyName("test")] |
| 24 | + public GlobalJsonTestNode Test { get; set; } = null!; |
| 25 | + } |
| 26 | + |
| 27 | + private sealed class GlobalJsonTestNode |
| 28 | + { |
| 29 | + [JsonPropertyName("runner")] |
| 30 | + public string RunnerName { get; set; } = null!; |
| 31 | + } |
| 32 | + |
| 33 | + public const string Name = "test"; |
| 34 | + public static readonly string DocsLink = "https://aka.ms/dotnet-test"; |
| 35 | + |
| 36 | + public static readonly Option<string> SettingsOption = new Option<string>("--settings", "-s") |
| 37 | + { |
| 38 | + Description = CliCommandStrings.CmdSettingsDescription, |
| 39 | + HelpName = CliCommandStrings.CmdSettingsFile |
| 40 | + }.ForwardAsSingle(o => $"-property:VSTestSetting={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}"); |
| 41 | + |
| 42 | + public static readonly Option<bool> ListTestsOption = new Option<bool>("--list-tests", "-t") |
| 43 | + { |
| 44 | + Description = CliCommandStrings.CmdListTestsDescription, |
| 45 | + Arity = ArgumentArity.Zero |
| 46 | + }.ForwardAs("-property:VSTestListTests=true"); |
| 47 | + |
| 48 | + public static readonly Option<string> FilterOption = new Option<string>("--filter") |
| 49 | + { |
| 50 | + Description = CliCommandStrings.CmdTestCaseFilterDescription, |
| 51 | + HelpName = CliCommandStrings.CmdTestCaseFilterExpression |
| 52 | + }.ForwardAsSingle(o => $"-property:VSTestTestCaseFilter={SurroundWithDoubleQuotes(o!)}"); |
| 53 | + |
| 54 | + public static readonly Option<IEnumerable<string>> AdapterOption = new Option<IEnumerable<string>>("--test-adapter-path") |
| 55 | + { |
| 56 | + Description = CliCommandStrings.CmdTestAdapterPathDescription, |
| 57 | + HelpName = CliCommandStrings.CmdTestAdapterPath |
| 58 | + }.ForwardAsSingle(o => $"-property:VSTestTestAdapterPath={SurroundWithDoubleQuotes(string.Join(";", o!.Select(CommandDirectoryContext.GetFullPath)))}") |
| 59 | + .AllowSingleArgPerToken(); |
| 60 | + |
| 61 | + public static readonly Option<IEnumerable<string>> LoggerOption = new Option<IEnumerable<string>>("--logger", "-l") |
| 62 | + { |
| 63 | + Description = CliCommandStrings.CmdLoggerDescription, |
| 64 | + HelpName = CliCommandStrings.CmdLoggerOption |
| 65 | + }.ForwardAsSingle(o => |
| 66 | + { |
| 67 | + var loggersString = string.Join(";", GetSemiColonEscapedArgs(o!)); |
| 68 | + |
| 69 | + return $"-property:VSTestLogger={SurroundWithDoubleQuotes(loggersString)}"; |
| 70 | + }) |
| 71 | + .AllowSingleArgPerToken(); |
| 72 | + |
| 73 | + public static readonly Option<string> OutputOption = new Option<string>("--output", "-o") |
| 74 | + { |
| 75 | + Description = CliCommandStrings.CmdOutputDescription, |
| 76 | + HelpName = CliCommandStrings.TestCmdOutputDir |
| 77 | + } |
| 78 | + .ForwardAsOutputPath("OutputPath", true); |
| 79 | + |
| 80 | + public static readonly Option<string> DiagOption = new Option<string>("--diag", "-d") |
| 81 | + { |
| 82 | + Description = CliCommandStrings.CmdPathTologFileDescription, |
| 83 | + HelpName = CliCommandStrings.CmdPathToLogFile |
| 84 | + } |
| 85 | + .ForwardAsSingle(o => $"-property:VSTestDiag={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}"); |
| 86 | + |
| 87 | + public static readonly Option<bool> NoBuildOption = new Option<bool>("--no-build") |
| 88 | + { |
| 89 | + Description = CliCommandStrings.CmdNoBuildDescription, |
| 90 | + Arity = ArgumentArity.Zero |
| 91 | + }.ForwardAs("-property:VSTestNoBuild=true"); |
| 92 | + |
| 93 | + public static readonly Option<string> ResultsOption = new Option<string>("--results-directory") |
| 94 | + { |
| 95 | + Description = CliCommandStrings.CmdResultsDirectoryDescription, |
| 96 | + HelpName = CliCommandStrings.CmdPathToResultsDirectory |
| 97 | + }.ForwardAsSingle(o => $"-property:VSTestResultsDirectory={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}"); |
| 98 | + |
| 99 | + public static readonly Option<IEnumerable<string>> CollectOption = new Option<IEnumerable<string>>("--collect") |
| 100 | + { |
| 101 | + Description = CliCommandStrings.cmdCollectDescription, |
| 102 | + HelpName = CliCommandStrings.cmdCollectFriendlyName |
| 103 | + }.ForwardAsSingle(o => $"-property:VSTestCollect=\"{string.Join(";", GetSemiColonEscapedArgs(o!))}\"") |
| 104 | + .AllowSingleArgPerToken(); |
| 105 | + |
| 106 | + public static readonly Option<bool> BlameOption = new Option<bool>("--blame") |
| 107 | + { |
| 108 | + Description = CliCommandStrings.CmdBlameDescription, |
| 109 | + Arity = ArgumentArity.Zero |
| 110 | + }.ForwardIfEnabled("-property:VSTestBlame=true"); |
| 111 | + |
| 112 | + public static readonly Option<bool> BlameCrashOption = new Option<bool>("--blame-crash") |
| 113 | + { |
| 114 | + Description = CliCommandStrings.CmdBlameCrashDescription, |
| 115 | + Arity = ArgumentArity.Zero |
| 116 | + }.ForwardIfEnabled("-property:VSTestBlameCrash=true"); |
| 117 | + |
| 118 | + public static readonly Option<string> BlameCrashDumpOption = CreateBlameCrashDumpOption(); |
| 119 | + |
| 120 | + private static Option<string> CreateBlameCrashDumpOption() |
| 121 | + { |
| 122 | + Option<string> result = new Option<string>("--blame-crash-dump-type") |
| 123 | + { |
| 124 | + Description = CliCommandStrings.CmdBlameCrashDumpTypeDescription, |
| 125 | + HelpName = CliCommandStrings.CrashDumpTypeArgumentName, |
| 126 | + } |
| 127 | + .ForwardAsMany(o => ["-property:VSTestBlameCrash=true", $"-property:VSTestBlameCrashDumpType={o}"]); |
| 128 | + result.AcceptOnlyFromAmong(["full", "mini"]); |
| 129 | + return result; |
| 130 | + } |
| 131 | + |
| 132 | + public static readonly Option<bool> BlameCrashAlwaysOption = new Option<bool>("--blame-crash-collect-always") |
| 133 | + { |
| 134 | + Description = CliCommandStrings.CmdBlameCrashCollectAlwaysDescription, |
| 135 | + Arity = ArgumentArity.Zero |
| 136 | + }.ForwardIfEnabled(["-property:VSTestBlameCrash=true", "-property:VSTestBlameCrashCollectAlways=true"]); |
| 137 | + |
| 138 | + public static readonly Option<bool> BlameHangOption = new Option<bool>("--blame-hang") |
| 139 | + { |
| 140 | + Description = CliCommandStrings.CmdBlameHangDescription, |
| 141 | + Arity = ArgumentArity.Zero |
| 142 | + }.ForwardAs("-property:VSTestBlameHang=true"); |
| 143 | + |
| 144 | + public static readonly Option<string> BlameHangDumpOption = CreateBlameHangDumpOption(); |
| 145 | + |
| 146 | + private static Option<string> CreateBlameHangDumpOption() |
| 147 | + { |
| 148 | + Option<string> result = new Option<string>("--blame-hang-dump-type") |
| 149 | + { |
| 150 | + Description = CliCommandStrings.CmdBlameHangDumpTypeDescription, |
| 151 | + HelpName = CliCommandStrings.HangDumpTypeArgumentName |
| 152 | + } |
| 153 | + .ForwardAsMany(o => ["-property:VSTestBlameHang=true", $"-property:VSTestBlameHangDumpType={o}"]); |
| 154 | + result.AcceptOnlyFromAmong(["full", "mini", "none"]); |
| 155 | + return result; |
| 156 | + } |
| 157 | + |
| 158 | + public static readonly Option<string> BlameHangTimeoutOption = new Option<string>("--blame-hang-timeout") |
| 159 | + { |
| 160 | + Description = CliCommandStrings.CmdBlameHangTimeoutDescription, |
| 161 | + HelpName = CliCommandStrings.HangTimeoutArgumentName |
| 162 | + }.ForwardAsMany(o => ["-property:VSTestBlameHang=true", $"-property:VSTestBlameHangTimeout={o}"]); |
| 163 | + |
| 164 | + public static readonly Option<bool> NoLogoOption = CommonOptions.NoLogoOption(forwardAs: "--property:VSTestNoLogo=true", description: CliCommandStrings.TestCmdNoLogo); |
| 165 | + |
| 166 | + public static readonly Option<bool> NoRestoreOption = CommonOptions.NoRestoreOption; |
| 167 | + |
| 168 | + public static readonly Option<string> FrameworkOption = CommonOptions.FrameworkOption(CliCommandStrings.TestFrameworkOptionDescription); |
| 169 | + |
| 170 | + public static readonly Option ConfigurationOption = CommonOptions.ConfigurationOption(CliCommandStrings.TestConfigurationOptionDescription); |
| 171 | + |
| 172 | + public static readonly Option<Utils.VerbosityOptions?> VerbosityOption = CommonOptions.VerbosityOption(); |
| 173 | + public static readonly Option<string[]> VsTestTargetOption = CommonOptions.RequiredMSBuildTargetOption("VSTest"); |
| 174 | + public static readonly Option<string[]> MTPTargetOption = CommonOptions.RequiredMSBuildTargetOption(CliConstants.MTPTarget); |
| 175 | + |
| 176 | + public static TestRunner GetTestRunner() |
| 177 | + { |
| 178 | + string? globalJsonPath = GetGlobalJsonPath(Environment.CurrentDirectory); |
| 179 | + if (!File.Exists(globalJsonPath)) |
| 180 | + { |
| 181 | + return TestRunner.VSTest; |
| 182 | + } |
| 183 | + |
| 184 | + string jsonText = File.ReadAllText(globalJsonPath); |
| 185 | + |
| 186 | + // This code path is hit exactly once during the whole life of the dotnet process. |
| 187 | + // So, no concern about caching JsonSerializerOptions. |
| 188 | + var globalJson = JsonSerializer.Deserialize<GlobalJsonModel>(jsonText, new JsonSerializerOptions() |
| 189 | + { |
| 190 | + AllowDuplicateProperties = false, |
| 191 | + AllowTrailingCommas = false, |
| 192 | + ReadCommentHandling = JsonCommentHandling.Skip, |
| 193 | + }); |
| 194 | + |
| 195 | + var name = globalJson?.Test?.RunnerName; |
| 196 | + |
| 197 | + if (name is null || name.Equals(CliConstants.VSTest, StringComparison.OrdinalIgnoreCase)) |
| 198 | + { |
| 199 | + return TestRunner.VSTest; |
| 200 | + } |
| 201 | + |
| 202 | + if (name.Equals(CliConstants.MicrosoftTestingPlatform, StringComparison.OrdinalIgnoreCase)) |
| 203 | + { |
| 204 | + return TestRunner.MicrosoftTestingPlatform; |
| 205 | + } |
| 206 | + |
| 207 | + throw new InvalidOperationException(string.Format(CliCommandStrings.CmdUnsupportedTestRunnerDescription, name)); |
| 208 | + } |
| 209 | + |
| 210 | + private static string? GetGlobalJsonPath(string? startDir) |
| 211 | + { |
| 212 | + string? directory = startDir; |
| 213 | + while (directory != null) |
| 214 | + { |
| 215 | + string globalJsonPath = Path.Combine(directory, "global.json"); |
| 216 | + if (File.Exists(globalJsonPath)) |
| 217 | + { |
| 218 | + return globalJsonPath; |
| 219 | + } |
| 220 | + |
| 221 | + directory = Path.GetDirectoryName(directory); |
| 222 | + } |
| 223 | + return null; |
| 224 | + } |
| 225 | + |
| 226 | + public static Command Create() |
| 227 | + { |
| 228 | + var command = new Command(Name); |
| 229 | + |
| 230 | + switch (GetTestRunner()) |
| 231 | + { |
| 232 | + case TestRunner.VSTest: |
| 233 | + ConfigureVSTestCommand(command); |
| 234 | + break; |
| 235 | + |
| 236 | + case TestRunner.MicrosoftTestingPlatform: |
| 237 | + ConfigureTestingPlatformCommand(command); |
| 238 | + break; |
| 239 | + |
| 240 | + default: |
| 241 | + throw new InvalidOperationException(); |
| 242 | + }; |
| 243 | + |
| 244 | + return command; |
| 245 | + } |
| 246 | + |
| 247 | + public static void ConfigureTestingPlatformCommand(Command command) |
| 248 | + { |
| 249 | + command.Description = CliCommandStrings.DotnetTestCommandMTPDescription; |
| 250 | + command.Options.Add(MicrosoftTestingPlatformOptions.ProjectOrSolutionOption); |
| 251 | + command.Options.Add(MicrosoftTestingPlatformOptions.SolutionOption); |
| 252 | + command.Options.Add(MicrosoftTestingPlatformOptions.TestModulesFilterOption); |
| 253 | + command.Options.Add(MicrosoftTestingPlatformOptions.TestModulesRootDirectoryOption); |
| 254 | + command.Options.Add(MicrosoftTestingPlatformOptions.ResultsDirectoryOption); |
| 255 | + command.Options.Add(MicrosoftTestingPlatformOptions.ConfigFileOption); |
| 256 | + command.Options.Add(MicrosoftTestingPlatformOptions.DiagnosticOutputDirectoryOption); |
| 257 | + command.Options.Add(MicrosoftTestingPlatformOptions.MaxParallelTestModulesOption); |
| 258 | + command.Options.Add(MicrosoftTestingPlatformOptions.MinimumExpectedTestsOption); |
| 259 | + command.Options.Add(CommonOptions.ArchitectureOption); |
| 260 | + command.Options.Add(CommonOptions.EnvOption); |
| 261 | + command.Options.Add(CommonOptions.PropertiesOption); |
| 262 | + command.Options.Add(MicrosoftTestingPlatformOptions.ConfigurationOption); |
| 263 | + command.Options.Add(MicrosoftTestingPlatformOptions.FrameworkOption); |
| 264 | + command.Options.Add(CommonOptions.OperatingSystemOption); |
| 265 | + command.Options.Add(CommonOptions.RuntimeOption(CliCommandStrings.TestRuntimeOptionDescription)); |
| 266 | + command.Options.Add(VerbosityOption); |
| 267 | + command.Options.Add(CommonOptions.NoRestoreOption); |
| 268 | + command.Options.Add(MicrosoftTestingPlatformOptions.NoBuildOption); |
| 269 | + command.Options.Add(MicrosoftTestingPlatformOptions.NoAnsiOption); |
| 270 | + command.Options.Add(MicrosoftTestingPlatformOptions.NoProgressOption); |
| 271 | + command.Options.Add(MicrosoftTestingPlatformOptions.OutputOption); |
| 272 | + command.Options.Add(MicrosoftTestingPlatformOptions.ListTestsOption); |
| 273 | + command.Options.Add(MicrosoftTestingPlatformOptions.NoLaunchProfileOption); |
| 274 | + command.Options.Add(MicrosoftTestingPlatformOptions.NoLaunchProfileArgumentsOption); |
| 275 | + command.Options.Add(MTPTargetOption); |
| 276 | + } |
| 277 | + |
| 278 | + public static void ConfigureVSTestCommand(Command command) |
| 279 | + { |
| 280 | + command.Description = CliCommandStrings.DotnetTestCommandVSTestDescription; |
| 281 | + command.TreatUnmatchedTokensAsErrors = false; |
| 282 | + command.DocsLink = DocsLink; |
| 283 | + |
| 284 | + // We are on purpose not capturing the solution, project or directory here. We want to pass it to the |
| 285 | + // MSBuild command so we are letting it flow. |
| 286 | + |
| 287 | + command.Options.Add(SettingsOption); |
| 288 | + command.Options.Add(ListTestsOption); |
| 289 | + command.Options.Add(CommonOptions.TestEnvOption); |
| 290 | + command.Options.Add(FilterOption); |
| 291 | + command.Options.Add(AdapterOption); |
| 292 | + command.Options.Add(LoggerOption); |
| 293 | + command.Options.Add(OutputOption); |
| 294 | + command.Options.Add(CommonOptions.ArtifactsPathOption); |
| 295 | + command.Options.Add(DiagOption); |
| 296 | + command.Options.Add(NoBuildOption); |
| 297 | + command.Options.Add(ResultsOption); |
| 298 | + command.Options.Add(CollectOption); |
| 299 | + command.Options.Add(BlameOption); |
| 300 | + command.Options.Add(BlameCrashOption); |
| 301 | + command.Options.Add(BlameCrashDumpOption); |
| 302 | + command.Options.Add(BlameCrashAlwaysOption); |
| 303 | + command.Options.Add(BlameHangOption); |
| 304 | + command.Options.Add(BlameHangDumpOption); |
| 305 | + command.Options.Add(BlameHangTimeoutOption); |
| 306 | + command.Options.Add(NoLogoOption); |
| 307 | + command.Options.Add(ConfigurationOption); |
| 308 | + command.Options.Add(FrameworkOption); |
| 309 | + command.Options.Add(CommonOptions.RuntimeOption(CliCommandStrings.TestRuntimeOptionDescription)); |
| 310 | + command.Options.Add(NoRestoreOption); |
| 311 | + command.Options.Add(CommonOptions.InteractiveMsBuildForwardOption); |
| 312 | + command.Options.Add(VerbosityOption); |
| 313 | + command.Options.Add(CommonOptions.ArchitectureOption); |
| 314 | + command.Options.Add(CommonOptions.OperatingSystemOption); |
| 315 | + command.Options.Add(CommonOptions.PropertiesOption); |
| 316 | + command.Options.Add(CommonOptions.DisableBuildServersOption); |
| 317 | + command.Options.Add(VsTestTargetOption); |
| 318 | + } |
| 319 | + |
| 320 | + private static string GetSemiColonEscapedstring(string arg) |
| 321 | + { |
| 322 | + if (arg.IndexOf(";") != -1) |
| 323 | + { |
| 324 | + return arg.Replace(";", "%3b"); |
| 325 | + } |
| 326 | + |
| 327 | + return arg; |
| 328 | + } |
| 329 | + |
| 330 | + private static string[] GetSemiColonEscapedArgs(IEnumerable<string> args) |
| 331 | + { |
| 332 | + int counter = 0; |
| 333 | + string[] array = new string[args.Count()]; |
| 334 | + |
| 335 | + foreach (string arg in args) |
| 336 | + { |
| 337 | + array[counter++] = GetSemiColonEscapedstring(arg); |
| 338 | + } |
| 339 | + |
| 340 | + return array; |
| 341 | + } |
| 342 | + |
| 343 | + /// <summary> |
| 344 | + /// Adding double quotes around the property helps MSBuild arguments parser and avoid incorrect splits on ',' or ';'. |
| 345 | + /// </summary> |
| 346 | + internal /* for testing purposes */ static string SurroundWithDoubleQuotes(string input) |
| 347 | + { |
| 348 | + if (input is null) |
| 349 | + { |
| 350 | + throw new ArgumentNullException(nameof(input)); |
| 351 | + } |
| 352 | + |
| 353 | + // If already escaped by double quotes then return original string. |
| 354 | + if (input.StartsWith("\"", StringComparison.Ordinal) |
| 355 | + && input.EndsWith("\"", StringComparison.Ordinal)) |
| 356 | + { |
| 357 | + return input; |
| 358 | + } |
| 359 | + |
| 360 | + // We want to count the number of trailing backslashes to ensure |
| 361 | + // we will have an even number before adding the final double quote. |
| 362 | + // Otherwise the last \" will be interpreted as escaping the double |
| 363 | + // quote rather than a backslash and a double quote. |
| 364 | + var trailingBackslashesCount = 0; |
| 365 | + for (int i = input.Length - 1; i >= 0; i--) |
| 366 | + { |
| 367 | + if (input[i] == '\\') |
| 368 | + { |
| 369 | + trailingBackslashesCount++; |
| 370 | + } |
| 371 | + else |
| 372 | + { |
| 373 | + break; |
| 374 | + } |
| 375 | + } |
| 376 | + |
| 377 | + return trailingBackslashesCount % 2 == 0 |
| 378 | + ? string.Concat("\"", input, "\"") |
| 379 | + : string.Concat("\"", input, "\\\""); |
| 380 | + } |
| 381 | +} |
0 commit comments