File: Commands\Test\TestCommandDefinition.cs
Web Access
Project: src\sdk\src\Cli\Microsoft.DotNet.Cli.Definitions\Microsoft.DotNet.Cli.Definitions.csproj (Microsoft.DotNet.Cli.Definitions)
// 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 Command = System.CommandLine.Command;

namespace Microsoft.DotNet.Cli.Commands.Test;

internal abstract partial class TestCommandDefinition : Command
{
    private const string Link = "https://aka.ms/dotnet-test";
    private const string VSTestRunnerName = "VSTest";
    private const string MicrosoftTestingPlatformRunnerName = "Microsoft.Testing.Platform";
    private const string TestRunnerEnvironmentVariableName = "DOTNET_TEST_RUNNER";

    public readonly TargetPlatformOptions TargetPlatformOptions = new(CommandDefinitionStrings.TestRuntimeOptionDescription);

    public readonly Option<string> FrameworkOption = CommonOptions.CreateFrameworkOption(CommandDefinitionStrings.TestFrameworkOptionDescription);

    public readonly Option<string?> ConfigurationOption = CommonOptions.CreateConfigurationOption(CommandDefinitionStrings.TestConfigurationOptionDescription);

    public readonly Option<Utils.VerbosityOptions?> VerbosityOption = CommonOptions.CreateVerbosityOption();

    public TestCommandDefinition(string description)
        : base("test", description)
    {
        this.DocsLink = Link;
        TreatUnmatchedTokensAsErrors = false;
    }

    public static TestCommandDefinition Create()
        => Create(Environment.CurrentDirectory, Environment.GetEnvironmentVariable(TestRunnerEnvironmentVariableName));

    internal static TestCommandDefinition Create(string startDirectory)
        => Create(startDirectory, Environment.GetEnvironmentVariable(TestRunnerEnvironmentVariableName));

    internal static TestCommandDefinition Create(string startDirectory, string? testRunnerEnvironmentValue)
    {
        // The DOTNET_TEST_RUNNER environment variable lets users pick a runner without editing
        // (or carrying multiple) global.json files. When set to a recognized value it takes
        // precedence over the global.json `test.runner` setting.
        //
        // The runner name is resolved during CLI parser construction, which runs for *every*
        // dotnet invocation (e.g. `dotnet --version`, `dotnet build`), so an unrecognized env
        // var value must not crash unrelated commands. Unknown/whitespace values silently fall
        // back to the global.json lookup; an unknown value in global.json itself remains a
        // hard error because that file is explicit, in-repo configuration the user can fix.
        string? trimmedEnvValue = testRunnerEnvironmentValue?.Trim();
        if (!string.IsNullOrEmpty(trimmedEnvValue) && TryResolveRunner(trimmedEnvValue) is { } runnerFromEnv)
        {
            return runnerFromEnv;
        }

        string? globalJsonRunnerName = TryGetRunnerNameFromGlobalJson(startDirectory);
        if (globalJsonRunnerName is null)
        {
            return new VSTest();
        }

        return TryResolveRunner(globalJsonRunnerName)
            ?? throw new InvalidOperationException(string.Format(CommandDefinitionStrings.CmdUnsupportedTestRunnerDescription, globalJsonRunnerName));
    }

    private static TestCommandDefinition? TryResolveRunner(string name)
    {
        if (name.Equals(VSTestRunnerName, StringComparison.OrdinalIgnoreCase))
        {
            return new VSTest();
        }

        if (name.Equals(MicrosoftTestingPlatformRunnerName, StringComparison.OrdinalIgnoreCase))
        {
            return new MicrosoftTestingPlatform();
        }

        return null;
    }

    private static string? TryGetRunnerNameFromGlobalJson(string startDirectory)
    {
        string? globalJsonPath = GetGlobalJsonPath(startDirectory);
        if (globalJsonPath is null)
        {
            return null;
        }

        try
        {
            string jsonText = File.ReadAllText(globalJsonPath);
            GlobalJsonModel? globalJson = JsonSerializer.Deserialize(jsonText, GlobalJsonSerializerContext.Default.GlobalJsonModel);
            return globalJson?.Test?.RunnerName;
        }
        catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
        {
            // The global.json file is unreadable or malformed (for example, empty or mid-edit). This
            // method is invoked very early during CLI parser construction, so throwing here would
            // bring down ALL commands (including `dotnet --version`). Fall back to the default
            // runner; if the user actually runs `dotnet test`, the test command itself will surface
            // a clearer error when it tries to load the configuration.
            return null;
        }
    }

    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 sealed class GlobalJsonModel
    {
        [JsonPropertyName("test")]
        public GlobalJsonTestNode Test { get; set; } = null!;
    }

    private sealed class GlobalJsonTestNode
    {
        [JsonPropertyName("runner")]
        public string RunnerName { get; set; } = null!;
    }

    [JsonSourceGenerationOptions(ReadCommentHandling = JsonCommentHandling.Skip)]
    [JsonSerializable(typeof(GlobalJsonModel))]
    private partial class GlobalJsonSerializerContext : JsonSerializerContext;
}