Skip to content

Commit 0630416

Browse files
authored
Separate 'test' command definitions from implementations (#51781)
1 parent aa29c25 commit 0630416

File tree

7 files changed

+411
-361
lines changed

7 files changed

+411
-361
lines changed

src/Cli/dotnet/Commands/Test/MTP/MSBuildUtility.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ public static BuildOptions GetBuildOptions(ParseResult parseResult)
133133
pathOptions,
134134
parseResult.GetValue(CommonOptions.NoRestoreOption),
135135
parseResult.GetValue(MicrosoftTestingPlatformOptions.NoBuildOption),
136-
parseResult.HasOption(TestCommandParser.VerbosityOption) ? parseResult.GetValue(TestCommandParser.VerbosityOption) : null,
136+
parseResult.HasOption(TestCommandDefinition.VerbosityOption) ? parseResult.GetValue(TestCommandDefinition.VerbosityOption) : null,
137137
parseResult.GetValue(MicrosoftTestingPlatformOptions.NoLaunchProfileOption),
138138
parseResult.GetValue(MicrosoftTestingPlatformOptions.NoLaunchProfileArgumentsOption),
139139
otherArgs,
@@ -153,7 +153,7 @@ private static bool BuildOrRestoreProjectOrSolution(string filePath, BuildOption
153153
msbuildArgs.Add($"-verbosity:quiet");
154154
}
155155

156-
var parsedMSBuildArgs = MSBuildArgs.AnalyzeMSBuildArguments(msbuildArgs, CommonOptions.PropertiesOption, CommonOptions.RestorePropertiesOption, TestCommandParser.MTPTargetOption, TestCommandParser.VerbosityOption, CommonOptions.NoLogoOption());
156+
var parsedMSBuildArgs = MSBuildArgs.AnalyzeMSBuildArguments(msbuildArgs, CommonOptions.PropertiesOption, CommonOptions.RestorePropertiesOption, TestCommandDefinition.MTPTargetOption, TestCommandDefinition.VerbosityOption, CommonOptions.NoLogoOption());
157157

158158
int result = new RestoringCommand(parsedMSBuildArgs, buildOptions.HasNoRestore).Execute();
159159

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
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

Comments
 (0)