File: Commands\Test\TestCommandParser.cs
Web Access
Project: ..\..\..\src\Cli\dotnet\dotnet.csproj (dotnet)
// 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.Extensions;
using Microsoft.DotNet.Cli.Utils;
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<string> SettingsOption = new ForwardedOption<string>("--settings", "-s")
    {
        Description = CliCommandStrings.CmdSettingsDescription,
        HelpName = CliCommandStrings.CmdSettingsFile
    }.ForwardAsSingle(o => $"-property:VSTestSetting={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}");
 
    public static readonly Option<bool> ListTestsOption = new ForwardedOption<bool>("--list-tests", "-t")
    {
        Description = CliCommandStrings.CmdListTestsDescription,
        Arity = ArgumentArity.Zero
    }.ForwardAs("-property:VSTestListTests=true");
 
    public static readonly Option<string> FilterOption = new ForwardedOption<string>("--filter")
    {
        Description = CliCommandStrings.CmdTestCaseFilterDescription,
        HelpName = CliCommandStrings.CmdTestCaseFilterExpression
    }.ForwardAsSingle(o => $"-property:VSTestTestCaseFilter={SurroundWithDoubleQuotes(o!)}");
 
    public static readonly Option<IEnumerable<string>> AdapterOption = new ForwardedOption<IEnumerable<string>>("--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<IEnumerable<string>> LoggerOption = new ForwardedOption<IEnumerable<string>>("--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<string> OutputOption = new ForwardedOption<string>("--output", "-o")
    {
        Description = CliCommandStrings.CmdOutputDescription,
        HelpName = CliCommandStrings.TestCmdOutputDir
    }
    .ForwardAsOutputPath("OutputPath", true);
 
    public static readonly Option<string> DiagOption = new ForwardedOption<string>("--diag", "-d")
    {
        Description = CliCommandStrings.CmdPathTologFileDescription,
        HelpName = CliCommandStrings.CmdPathToLogFile
    }
    .ForwardAsSingle(o => $"-property:VSTestDiag={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}");
 
    public static readonly Option<bool> NoBuildOption = new ForwardedOption<bool>("--no-build")
    {
        Description = CliCommandStrings.CmdNoBuildDescription,
        Arity = ArgumentArity.Zero
    }.ForwardAs("-property:VSTestNoBuild=true");
 
    public static readonly Option<string> ResultsOption = new ForwardedOption<string>("--results-directory")
    {
        Description = CliCommandStrings.CmdResultsDirectoryDescription,
        HelpName = CliCommandStrings.CmdPathToResultsDirectory
    }.ForwardAsSingle(o => $"-property:VSTestResultsDirectory={SurroundWithDoubleQuotes(CommandDirectoryContext.GetFullPath(o))}");
 
    public static readonly Option<IEnumerable<string>> CollectOption = new ForwardedOption<IEnumerable<string>>("--collect")
    {
        Description = CliCommandStrings.cmdCollectDescription,
        HelpName = CliCommandStrings.cmdCollectFriendlyName
    }.ForwardAsSingle(o => $"-property:VSTestCollect=\"{string.Join(";", GetSemiColonEscapedArgs(o!))}\"")
    .AllowSingleArgPerToken();
 
    public static readonly Option<bool> BlameOption = new ForwardedOption<bool>("--blame")
    {
        Description = CliCommandStrings.CmdBlameDescription,
        Arity = ArgumentArity.Zero
    }.ForwardIfEnabled("-property:VSTestBlame=true");
 
    public static readonly Option<bool> BlameCrashOption = new ForwardedOption<bool>("--blame-crash")
    {
        Description = CliCommandStrings.CmdBlameCrashDescription,
        Arity = ArgumentArity.Zero
    }.ForwardIfEnabled("-property:VSTestBlameCrash=true");
 
    public static readonly Option<string> BlameCrashDumpOption = CreateBlameCrashDumpOption();
 
    private static Option<string> CreateBlameCrashDumpOption()
    {
        Option<string> result = new ForwardedOption<string>("--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<bool> BlameCrashAlwaysOption = new ForwardedOption<bool>("--blame-crash-collect-always")
    {
        Description = CliCommandStrings.CmdBlameCrashCollectAlwaysDescription,
        Arity = ArgumentArity.Zero
    }.ForwardIfEnabled(["-property:VSTestBlameCrash=true", "-property:VSTestBlameCrashCollectAlways=true"]);
 
    public static readonly Option<bool> BlameHangOption = new ForwardedOption<bool>("--blame-hang")
    {
        Description = CliCommandStrings.CmdBlameHangDescription,
        Arity = ArgumentArity.Zero
    }.ForwardAs("-property:VSTestBlameHang=true");
 
    public static readonly Option<string> BlameHangDumpOption = CreateBlameHangDumpOption();
 
    private static Option<string> CreateBlameHangDumpOption()
    {
        Option<string> result = new ForwardedOption<string>("--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<string> BlameHangTimeoutOption = new ForwardedOption<string>("--blame-hang-timeout")
    {
        Description = CliCommandStrings.CmdBlameHangTimeoutDescription,
        HelpName = CliCommandStrings.HangTimeoutArgumentName
    }.ForwardAsMany(o => ["-property:VSTestBlameHang=true", $"-property:VSTestBlameHangTimeout={o}"]);
 
    public static readonly Option<bool> NoLogoOption = new ForwardedOption<bool>("--nologo")
    {
        Description = CliCommandStrings.TestCmdNoLogo,
        Arity = ArgumentArity.Zero
    }.ForwardIfEnabled("-property:VSTestNoLogo=true");
 
    public static readonly Option<bool> NoRestoreOption = CommonOptions.NoRestoreOption;
 
    public static readonly Option<string> FrameworkOption = CommonOptions.FrameworkOption(CliCommandStrings.TestFrameworkOptionDescription);
 
    public static readonly Option ConfigurationOption = CommonOptions.ConfigurationOption(CliCommandStrings.TestConfigurationOptionDescription);
 
    public static readonly Option<Utils.VerbosityOptions?> VerbosityOption = CommonOptions.VerbosityOption();
    public static readonly Option<string[]> VsTestTargetOption = CommonOptions.RequiredMSBuildTargetOption("VSTest");
    public static readonly Option<string[]> MTPTargetOption = CommonOptions.RequiredMSBuildTargetOption(CliConstants.MTPTarget);
 
 
    private static readonly Command Command = ConstructCommand();
 
    public static Command GetCommand()
    {
        return Command;
    }
 
    private static string GetTestRunnerName()
    {
        string? globalJsonPath = GetGlobalJsonPath(Environment.CurrentDirectory);
        if (!File.Exists(globalJsonPath))
        {
            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<GlobalJsonModel>(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));
    }
 
    private static Command GetTestingPlatformCliCommand()
    {
        var command = new MicrosoftTestingPlatformTestCommand("test", CliCommandStrings.DotnetTestCommand);
        command.SetAction(parseResult => command.Run(parseResult));
        command.Options.Add(MicrosoftTestingPlatformOptions.ProjectOption);
        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()
    {
        DocumentedCommand command = new("test", DocsLink, CliCommandStrings.TestAppFullName)
        {
            TreatUnmatchedTokensAsErrors = false
        };
 
        // 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);
        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<string> args)
    {
        int counter = 0;
        string[] array = new string[args.Count()];
 
        foreach (string arg in args)
        {
            array[counter++] = GetSemiColonEscapedstring(arg);
        }
 
        return array;
    }
 
    /// <summary>
    /// Adding double quotes around the property helps MSBuild arguments parser and avoid incorrect splits on ',' or ';'.
    /// </summary>
    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, "\\\"");
    }
}