-
Notifications
You must be signed in to change notification settings - Fork 0
Use custom argument parsers for the test args/run settings #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,15 @@ | ||
| // Copyright (c) .NET Foundation and contributors. All rights reserved. | ||
| // Licensed under the MIT license. See LICENSE file in the project root for full license information. | ||
|
|
||
| #nullable enable | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.CommandLine; | ||
| using System.CommandLine.Parsing; | ||
| using System.IO; | ||
| using System.Linq; | ||
| using System.Text; | ||
| using Microsoft.DotNet.Cli; | ||
| using Microsoft.DotNet.Cli.Utils; | ||
|
|
||
|
|
@@ -17,7 +20,7 @@ public class TestCommand : RestoringCommand | |
| public TestCommand( | ||
| IEnumerable<string> msbuildArgs, | ||
| bool noRestore, | ||
| string msbuildPath = null) | ||
| string? msbuildPath = null) | ||
| : base(msbuildArgs, noRestore, msbuildPath) | ||
| { | ||
| } | ||
|
|
@@ -32,23 +35,19 @@ public static int Run(ParseResult parseResult) | |
| // from the VSTest side. | ||
| string testSessionCorrelationId = $"{Environment.ProcessId}_{Guid.NewGuid()}"; | ||
|
|
||
| string[] args = parseResult.GetArguments(); | ||
| var args = parseResult.GetValueForArgument(TestCommandParser.ForwardedArgs) ?? Array.Empty<string>(); | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this logic is a lot more understandable - let me know what you think
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Really better. |
||
| var settings = parseResult.GetValueForArgument(TestCommandParser.InlineRunSettings) ?? Array.Empty<(string key, string value)>(); | ||
|
|
||
| if (VSTestTrace.TraceEnabled) | ||
| { | ||
| string commandLineParameters = ""; | ||
| if (args?.Length > 0) | ||
| if (args.Length > 0) | ||
| { | ||
| commandLineParameters = args.Aggregate((a, b) => $"{a} | {b}"); | ||
| } | ||
| VSTestTrace.SafeWriteTrace(() => $"Argument list: '{commandLineParameters}'"); | ||
| } | ||
|
|
||
| // settings parameters are after -- (including --), these should not be considered by the parser | ||
| string[] settings = args.SkipWhile(a => a != "--").ToArray(); | ||
| // all parameters before -- | ||
| args = args.TakeWhile(a => a != "--").ToArray(); | ||
|
|
||
| // Fix for https://github.com/Microsoft/vstest/issues/1453 | ||
| // Run dll/exe directly using the VSTestForwardingApp | ||
| if (ContainsBuiltTestSources(args)) | ||
|
|
@@ -59,11 +58,11 @@ public static int Run(ParseResult parseResult) | |
| return ForwardToMsbuild(parseResult, settings, testSessionCorrelationId); | ||
| } | ||
|
|
||
| private static int ForwardToMsbuild(ParseResult parseResult, string[] settings, string testSessionCorrelationId) | ||
| private static int ForwardToMsbuild(ParseResult parseResult, (string key, string value)[] settings, string testSessionCorrelationId) | ||
| { | ||
| // Workaround for https://github.com/Microsoft/vstest/issues/1503 | ||
| const string NodeWindowEnvironmentName = "MSBUILDENSURESTDOUTFORTASKPROCESSES"; | ||
| string previousNodeWindowSetting = Environment.GetEnvironmentVariable(NodeWindowEnvironmentName); | ||
| string? previousNodeWindowSetting = Environment.GetEnvironmentVariable(NodeWindowEnvironmentName); | ||
| try | ||
| { | ||
| Environment.SetEnvironmentVariable(NodeWindowEnvironmentName, "1"); | ||
|
|
@@ -80,7 +79,10 @@ private static int ForwardToMsbuild(ParseResult parseResult, string[] settings, | |
| } | ||
| } | ||
|
|
||
| private static int ForwardToVSTestConsole(ParseResult parseResult, string[] args, string[] settings, string testSessionCorrelationId) | ||
| private static string Escape(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\""); | ||
| private static string FormatRunSetting((string key, string value) kv) => $"{Escape(kv.key)}={Escape(kv.value)}"; | ||
|
|
||
| private static int ForwardToVSTestConsole(ParseResult parseResult, string[] args, (string key, string value)[] settings, string testSessionCorrelationId) | ||
| { | ||
| List<string> convertedArgs = new VSTestArgumentConverter().Convert(args, out List<string> ignoredArgs); | ||
| if (ignoredArgs.Any()) | ||
|
|
@@ -90,7 +92,7 @@ private static int ForwardToVSTestConsole(ParseResult parseResult, string[] args | |
|
|
||
| // merge the args settings, we don't need to escape | ||
| // one more time, there is no extra hop via msbuild | ||
| convertedArgs.AddRange(settings); | ||
| convertedArgs.AddRange(settings.Select(FormatRunSetting)); | ||
|
|
||
| if (!FeatureFlag.Instance.IsSet(FeatureFlag.DISABLE_ARTIFACTS_POSTPROCESSING)) | ||
| { | ||
|
|
@@ -107,7 +109,7 @@ private static int ForwardToVSTestConsole(ParseResult parseResult, string[] args | |
| return exitCode; | ||
| } | ||
|
|
||
| private static TestCommand FromParseResult(ParseResult result, string[] settings, string testSessionCorrelationId, string msbuildPath = null) | ||
| private static TestCommand FromParseResult(ParseResult result, (string key, string value)[] settings, string testSessionCorrelationId, string? msbuildPath = null) | ||
| { | ||
| result.ShowHelpOrErrorIfAppropriate(); | ||
|
|
||
|
|
@@ -122,27 +124,24 @@ private static TestCommand FromParseResult(ParseResult result, string[] settings | |
|
|
||
| if (settings.Any()) | ||
| { | ||
| //workaround for correct -- logic | ||
| var commandArgument = result.GetValueForArgument(TestCommandParser.SlnOrProjectArgument); | ||
| if(!string.IsNullOrWhiteSpace(commandArgument) && !settings.Contains(commandArgument)) | ||
|
|
||
| var builder = new StringBuilder(); | ||
| foreach (var kv in settings) | ||
| { | ||
| msbuildArgs.Add(result.GetValueForArgument(TestCommandParser.SlnOrProjectArgument)); | ||
| builder.Append(FormatRunSetting(kv)); | ||
| builder.Append(';'); | ||
| } | ||
|
|
||
| // skip '--' and escape every \ to be \\ and every " to be \" to survive the next hop | ||
| string[] escaped = settings.Skip(1).Select(s => s.Replace("\\", "\\\\").Replace("\"", "\\\"")).ToArray(); | ||
|
|
||
| string runSettingsArg = string.Join(";", escaped); | ||
| msbuildArgs.Add($"-property:VSTestCLIRunSettings=\"{runSettingsArg}\""); | ||
| msbuildArgs.Add($"-property:VSTestCLIRunSettings=\"{builder.ToString()}\""); | ||
| } | ||
| else | ||
| { | ||
| var argument = result.GetValueForArgument(TestCommandParser.SlnOrProjectArgument); | ||
| if(!string.IsNullOrWhiteSpace(argument)) | ||
| if (!string.IsNullOrWhiteSpace(argument)) | ||
| msbuildArgs.Add(argument); | ||
| } | ||
|
|
||
| string verbosityArg = result.ForwardedOptionValues<IReadOnlyCollection<string>>(TestCommandParser.GetCommand(), "verbosity")?.SingleOrDefault() ?? null; | ||
| string? verbosityArg = result.ForwardedOptionValues<IReadOnlyCollection<string>>(TestCommandParser.GetCommand(), "verbosity")?.SingleOrDefault() ?? null; | ||
| if (verbosityArg != null) | ||
| { | ||
| string[] verbosity = verbosityArg.Split(':', 2); | ||
|
|
@@ -249,7 +248,7 @@ private static void SetEnvironmentVariablesFromParameters(TestCommand testComman | |
| return; | ||
| } | ||
|
|
||
| foreach (string env in parseResult.GetValueForOption(option)) | ||
| foreach (string env in parseResult.GetValueForOption(option)!) | ||
| { | ||
| string name = env; | ||
| string value = string.Empty; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| using System.CommandLine; | ||
| using System.CommandLine.Invocation; | ||
| using System.CommandLine.Parsing; | ||
| using System.IO; | ||
| using System.Linq; | ||
| using Microsoft.DotNet.Tools; | ||
| using Microsoft.DotNet.Tools.Test; | ||
|
|
@@ -17,12 +18,132 @@ internal static class TestCommandParser | |
| { | ||
| public static readonly string DocsLink = "https://aka.ms/dotnet-test"; | ||
|
|
||
| public static readonly Argument<string> SlnOrProjectArgument = new Argument<string>(CommonLocalizableStrings.SolutionOrProjectArgumentName) | ||
| /// <summary> | ||
| /// Parser delegate that only accepts a token that is a | ||
| /// * project or solution file | ||
| /// * directory that contains a project or solution | ||
| /// * .dll or .exe file | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// S.CL usage note - OnlyTake(0) signals that this token should be returned to the | ||
| /// token stream for the next argument. In this way we prevent initial tokens that | ||
| /// are not relevant from being captured in this argument, since the syntax is a bit | ||
| /// ambiguous. | ||
| /// </remarks> | ||
| private static ParseArgument<string> IsProjectOrSln = | ||
| (ctx) => | ||
| { | ||
| bool HasProjectOrSolution(DirectoryInfo dir) => | ||
| dir.EnumerateFiles("*.*proj").Any() || dir.EnumerateFiles(".sln").Any(); | ||
|
|
||
| if (ctx.Tokens.Count == 0) | ||
| { | ||
| ctx.OnlyTake(0); | ||
| return null; | ||
| } | ||
| else | ||
| { | ||
| var tokenValue = ctx.Tokens[0].Value; | ||
| var ext = System.IO.Path.GetExtension(tokenValue); | ||
| if (ext.EndsWith("proj") || ext.EndsWith(".sln") || ext.EndsWith(".dll") || ext.EndsWith(".exe")) | ||
| { | ||
| ctx.OnlyTake(1); | ||
| return tokenValue; | ||
| } | ||
| else | ||
| { | ||
| var path = System.IO.Path.GetFullPath(tokenValue); | ||
| var dir = new System.IO.DirectoryInfo(path); | ||
| if (dir.Exists && HasProjectOrSolution(dir)) | ||
| { | ||
| ctx.OnlyTake(1); | ||
| return tokenValue; | ||
| } | ||
|
|
||
| ctx.OnlyTake(0); | ||
| return null; | ||
Tunduk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
| }; | ||
|
|
||
| public static readonly Argument<string> SlnOrProjectArgument = new Argument<string>(CommonLocalizableStrings.SolutionOrProjectArgumentName, parse: IsProjectOrSln) | ||
| { | ||
| Description = CommonLocalizableStrings.SolutionOrProjectArgumentDescription, | ||
| Arity = ArgumentArity.ZeroOrOne | ||
| }; | ||
|
|
||
| public static (bool success, string key, string value) TryParseRunSetting (string token) { | ||
| var parts = token.Split('='); | ||
| if (parts.Length == 2) { | ||
| return (true, parts[0], parts[1]); | ||
| } | ||
| return (false, null, null); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// A parser that takes from the start of the token stream until the the first argument that could be a RunSetting | ||
| /// </summary> | ||
| private static ParseArgument<string[]> ParseUntilFirstPotentialRunSetting = ctx => | ||
| { | ||
| if (ctx.Tokens.Count == 0) | ||
| { | ||
| ctx.OnlyTake(0); | ||
| return Array.Empty<string>(); | ||
| } | ||
| var tokens = new List<string>(); | ||
| foreach (var token in ctx.Tokens) { | ||
| var (success, key, value) = TryParseRunSetting(token.Value); | ||
| if (success) { | ||
| break; | ||
| } | ||
| else | ||
| { | ||
| tokens.Add(token.Value); | ||
| } | ||
| } | ||
| ctx.OnlyTake(tokens.Count); | ||
| return tokens.ToArray(); | ||
| }; | ||
|
|
||
| public static readonly Argument<string[]> ForwardedArgs = new(LocalizableStrings.AdditionalArguments, parse: ParseUntilFirstPotentialRunSetting, description: LocalizableStrings.AdditionalArgumentsDescription) | ||
| { | ||
| Arity = ArgumentArity.ZeroOrMore | ||
| }; | ||
|
|
||
| private static ParseArgument<(string key, string value)[]> ParseRunSettings = ctx => | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it ok that we don't check double dash? Seems like we can pass
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we can actually test this now - I should add some tests where we take certain suspicious command lines, run them through the parser, and then do assertions on the parsed arguments. Do you have any specific command lines you'd recommend testing against?
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried to run Unfortunately I don't use
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My bad. System.CommandLine was updated.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I reproduced problem. Something odd happens when we pass 3 arguments to command and call command with 1 token. I'll create an issue for System.CommandLine team tomorrow
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @baronfel just FYI dotnet/command-line-api#1779 |
||
| { | ||
| if (ctx.Tokens.Count == 0) | ||
| { | ||
| ctx.OnlyTake(0); | ||
| return Array.Empty<(string key, string value)>(); | ||
| } | ||
| var settings = new List<(string key, string value)>(ctx.Tokens.Count); | ||
| var consumed = 0; | ||
| foreach (var token in ctx.Tokens) | ||
| { | ||
| var parts = token.Value.Split('=', 2); | ||
| if (parts.Length == 2) | ||
| { | ||
| consumed += 1; | ||
| settings.Add((parts[0], parts[1])); | ||
| } | ||
| else | ||
| { | ||
| //$"Argument '{token.Value}' could not be parsed as a RunSetting. Use a key/value pair separated with an equals character, like 'foo=bar'"; | ||
| ctx.ErrorMessage = String.Format(LocalizableStrings.CouldNotParseRunSetting, token.Value); | ||
| ctx.OnlyTake(consumed); | ||
| return null; | ||
| } | ||
| } | ||
| ctx.OnlyTake(consumed); | ||
| return settings.ToArray(); | ||
| }; | ||
|
|
||
| public static readonly Argument<(string key, string value)[]> InlineRunSettings = new(LocalizableStrings.RunSettings, parse: ParseRunSettings, description: LocalizableStrings.RunSettingsDescription) | ||
| { | ||
| Arity = ArgumentArity.ZeroOrMore | ||
| }; | ||
|
|
||
| public static readonly Option<string> SettingsOption = new ForwardedOption<string>(new string[] { "-s", "--settings" }, LocalizableStrings.CmdSettingsDescription) | ||
| { | ||
| ArgumentHelpName = LocalizableStrings.CmdSettingsFile | ||
|
|
@@ -159,6 +280,8 @@ private static Command ConstructCommand() | |
| command.AddOption(CommonOptions.VerbosityOption); | ||
| command.AddOption(CommonOptions.ArchitectureOption); | ||
| command.AddOption(CommonOptions.OperatingSystemOption); | ||
| command.AddArgument(ForwardedArgs); | ||
| command.AddArgument(InlineRunSettings); | ||
|
|
||
| command.SetHandler(TestCommand.Run); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we need
testto be part of the new error reporting as well if we want the nice parser errors to be shown to the user. now that we do explicit forwarding of args to commands like MSbuild, we could potentially remove this in favor of the standardUseParseErrorReporting()middleware, but I didn't think that was necessary for your---removal PR at this time. It can come next :)