File: Commands\Test\MTP\MicrosoftTestingPlatformTestCommand.cs
Web Access
Project: src\sdk\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.Collections.Immutable;
using System.CommandLine;
using System.Globalization;
using System.Runtime.CompilerServices;
using Microsoft.Build.Definition;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.DotNet.Cli.CommandLine;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Commands.Test.Terminal;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.Telemetry;
using Microsoft.DotNet.Cli.Utils;

namespace Microsoft.DotNet.Cli.Commands.Test;

internal partial class MicrosoftTestingPlatformTestCommand
{
    private const string MinimumExpectedTestsOptionName = "--minimum-expected-tests";

    public int Run(ParseResult parseResult, bool isHelp)
    {
        var definition = (TestCommandDefinition.MicrosoftTestingPlatform)parseResult.CommandResult.Command;
        string invocationWorkingDirectory = Directory.GetCurrentDirectory();

        BuildOptions buildOptions = MSBuildUtility.GetBuildOptions(parseResult);
        (buildOptions, bool forwardedCollectTestMap, bool forwardedAffectedTests) =
            NormalizeForwardedAffectedTestsOptions(buildOptions);
        bool forwardedMinimumExpectedTests = HasForwardedOption(
            buildOptions.TestApplicationArguments,
            MinimumExpectedTestsOptionName);

        bool collectTestMap = parseResult.HasOption(definition.CollectTestMapOption) || forwardedCollectTestMap;
        bool affectedTests = parseResult.HasOption(definition.AffectedTestsOption) || forwardedAffectedTests;
        ValidateAffectedTestsOptions(
            definition,
            parseResult,
            collectTestMap,
            affectedTests,
            forwardedMinimumExpectedTests);

        ValidationUtility.ValidateMutuallyExclusiveOptions(parseResult, buildOptions.PathOptions);

        // --list-devices and --list-tests describe incompatible behaviors: the former lists
        // devices and exits without building, the latter discovers tests in built assemblies.
        if (buildOptions.ListDevices && parseResult.HasOption(definition.ListTestsOption))
        {
            throw new GracefulException(CliCommandStrings.CmdListDevicesAndListTestsMutuallyExclusive);
        }

        if (buildOptions.ListDevices && (collectTestMap || affectedTests))
        {
            throw new GracefulException(CliCommandStrings.CmdListDevicesAndAffectedTestsMutuallyExclusive);
        }

        // --list-devices and --device require a project to evaluate; --test-modules bypasses
        // project evaluation entirely, so the combination is meaningless.
        if (buildOptions.PathOptions.TestModules is not null
            && (buildOptions.ListDevices || !string.IsNullOrWhiteSpace(buildOptions.Device)))
        {
            throw new GracefulException(CliCommandStrings.CmdDeviceOptionsRequireProject);
        }

        FacadeLogger? logger = LoggerUtility.DetermineBinlogger([.. buildOptions.MSBuildArgs], "dotnet-test");
        ITestHandler testHandler;
        MSBuildSession? buildSession = null;
        try
        {
            // --list-devices: list available devices for the project and exit early.
            // Never builds, deploys, or runs tests.
            if (buildOptions.ListDevices)
            {
                using var listDevicesSession = CreateBuildSession(buildOptions, logger);
                int listDevicesExitCode = HandleListDevices(buildOptions, listDevicesSession);
                if (listDevicesExitCode == ExitCode.Success)
                {
                    listDevicesSession.Complete();
                }

                return listDevicesExitCode;
            }

            // When --device is specified, force single target framework selection because
            // a device is platform-specific and we need to know which TFM was intended.
            if (!string.IsNullOrWhiteSpace(buildOptions.Device))
            {
                buildOptions = HandleDeviceWithTargetFrameworkSelection(buildOptions, logger);
            }

            // Every MSBuild target the test command invokes itself - device selection, deployment and
            // ComputeRunArguments for every test module - runs inside this single build session, so the
            // binary log behind -bl holds one well formed build instead of one per target invocation.
            buildSession = CreateBuildSession(buildOptions, logger);

            testHandler = buildOptions.PathOptions.TestModules is { } testModules
                ? new TestModulesFilterHandler(testModules, parseResult)
                : RuntimeFeature.IsDynamicCodeSupported ? new MSBuildHandler(buildOptions, buildSession)
                    : throw new PlatformNotSupportedException("Dynamic code is not supported on this platform.");

            if (!testHandler.Initialize())
            {
                return ExitCode.GenericFailure;
            }

            (bool responseFileCollectTestMap, bool responseFileAffectedTests, bool responseFileMinimumExpectedTests) =
                DetectAffectedTestsOptionsInForwardedResponseFiles(
                    buildOptions.TestApplicationArguments,
                    testHandler.GetTestApplicationWorkingDirectories(),
                    invocationWorkingDirectory);
            collectTestMap |= responseFileCollectTestMap;
            affectedTests |= responseFileAffectedTests;
            forwardedCollectTestMap |= responseFileCollectTestMap;
            forwardedAffectedTests |= responseFileAffectedTests;
            forwardedMinimumExpectedTests |= responseFileMinimumExpectedTests;

            // Ends the session on the success path, so a failure MSBuild only reports from EndBuild -
            // a binary logger failing to write, for example - is surfaced rather than swallowed.
            buildSession.Complete();
        }
        finally
        {
            // Ends the build session (and so writes its build-finished event) before the binary
            // logger behind it is shut down. A no-op once Complete() has run.
            buildSession?.Dispose();
            logger?.ReallyShutdown();
        }

        ValidateAffectedTestsOptions(
            definition,
            parseResult,
            collectTestMap,
            affectedTests,
            forwardedMinimumExpectedTests);

        int degreeOfParallelism = GetDegreeOfParallelism(parseResult, collectTestMap);

        var testOptions = new TestOptions(
            IsHelp: isHelp,
            IsDiscovery: parseResult.HasOption(definition.ListTestsOption),
            ListTestsFormat: GetListTestsFormat(parseResult, definition))
        {
            CollectTestMap = collectTestMap,
            AffectedTests = affectedTests,
            CollectTestMapForwarded = forwardedCollectTestMap,
            AffectedTestsForwarded = forwardedAffectedTests,
        };

        var output = InitializeOutput(degreeOfParallelism, parseResult, testOptions);
        var resultsDirectoryResolver = TestResultsDirectoryResolver.Create(
            buildOptions.PathOptions,
            testHandler.EnumerateTestModules(),
            Directory.GetCurrentDirectory());

        using var testRunPolicy = new TestRunPolicy(
            testOptions.IsDiscovery || testOptions.IsHelp
                ? null
                : parseResult.GetValue(definition.MaximumFailedTestsOption),
            testOptions.IsDiscovery || testOptions.IsHelp
                ? null
                : parseResult.GetValue(definition.TimeoutOption),
            onCancellation: _ => output.MarkCancelled());

        using var ctrlC = new CtrlCCancellationManager(output.StartCancelling);
        using var queueCancellation = CancellationTokenSource.CreateLinkedTokenSource(ctrlC.Token, testRunPolicy.Token);
        var artifactPostProcessingManager = new ArtifactPostProcessingManager();
        int? exitCode = null;
        try
        {
            var actionQueue = new TestApplicationActionQueue(
                degreeOfParallelism,
                buildOptions,
                testOptions,
                resultsDirectoryResolver,
                output,
                OnHelpRequested,
                ctrlC,
                artifactPostProcessingManager,
                testRunPolicy,
                queueCancellation.Token);
            exitCode = testHandler.RunTestApplications(actionQueue);
            TestRunCancellationReason cancellationReason = testRunPolicy.Complete();

            if (cancellationReason == TestRunCancellationReason.MaximumFailedTests)
            {
                exitCode = ExitCode.TestExecutionStoppedForMaxFailedTests;
            }
            else if (cancellationReason == TestRunCancellationReason.Timeout)
            {
                exitCode = ExitCode.TestSessionAborted;
            }

            if (ShouldPostProcessArtifacts(
                testOptions,
                parseResult.GetValue(definition.NoArtifactPostProcessingOption),
                ctrlC.Token.IsCancellationRequested,
                cancellationReason))
            {
                artifactPostProcessingManager.ExecuteAsync(buildOptions, output, ctrlC).GetAwaiter().GetResult();
            }

            // If all test apps exited with 0 exit code, but we detected that handshake didn't happen correctly, map that to generic failure.
            if (exitCode == ExitCode.Success && output.HasHandshakeFailure)
            {
                exitCode = ExitCode.GenericFailure;
            }

            if (exitCode == ExitCode.Success &&
                parseResult.HasOption(definition.MinimumExpectedTestsOption) &&
                parseResult.GetValue(definition.MinimumExpectedTestsOption) is { } minimumExpectedTests &&
                output.TotalTests < minimumExpectedTests)
            {
                exitCode = ExitCode.MinimumExpectedTestsPolicyViolation;
            }
            else if (exitCode == ExitCode.Success &&
                !isHelp &&
                !parseResult.HasOption(definition.MinimumExpectedTestsOption) &&
                ShouldFailForNoExecutedTests(testOptions.IsAffectedTestsMode, output.TotalTests, output.SkippedTests))
            {
                // Whole-run "zero tests ran" verdict. Individual modules that matched no tests return exit
                // code 8, but TestApplicationActionQueue normalizes that to success so a single empty module
                // does not fail the whole run (e.g. with --test-modules or a global --filter). The aggregate
                // zero-tests case is decided here so it surfaces once at the run level instead of once per
                // module. A stricter per-module minimum via -- --minimum-expected-tests N still fails that
                // module with exit code 9. See https://github.com/microsoft/testfx/issues/7457.
                exitCode = ExitCode.ZeroTests;
            }

            return exitCode.Value;
        }
        finally
        {
            output.TestExecutionCompleted(DateTimeOffset.Now, exitCode);
        }
    }

    /// <summary>
    /// Decides whether the artifacts of a finished run should be consolidated.
    /// </summary>
    /// <remarks>
    /// Help and discovery produce no artifacts to merge, and <c>--no-artifact-post-processing</c> is
    /// the explicit opt-out. The two cancellation cases are the interesting ones: a run stopped by
    /// Ctrl+C, <c>--maximum-failed-tests</c> or <c>--timeout</c> produced the artifacts of a
    /// truncated run — modules that never started contributed nothing, and modules killed mid-flight
    /// wrote whatever they had. Merging those into a single report would hide the truncation behind
    /// one authoritative-looking artifact, so the per-module artifacts are left as they are.
    /// </remarks>
    internal static bool ShouldPostProcessArtifacts(
        TestOptions testOptions,
        bool noArtifactPostProcessingRequested,
        bool cancellationRequested,
        TestRunCancellationReason cancellationReason)
        => !testOptions.IsHelp
            && !testOptions.IsDiscovery
            && !noArtifactPostProcessingRequested
            && !cancellationRequested
            && cancellationReason == TestRunCancellationReason.None;

    internal static (BuildOptions BuildOptions, bool CollectTestMap, bool AffectedTests) NormalizeForwardedAffectedTestsOptions(
        BuildOptions buildOptions)
    {
        bool collectTestMap = false;
        bool affectedTests = false;
        ImmutableArray<string>.Builder remainingArguments = ImmutableArray.CreateBuilder<string>();
        foreach (string argument in buildOptions.TestApplicationArguments)
        {
            if (IsAffectedTestsOption(argument, TestCommandDefinition.MicrosoftTestingPlatform.CollectTestMapOptionName))
            {
                collectTestMap = true;
                remainingArguments.Add(argument);
            }
            else if (IsAffectedTestsOption(argument, TestCommandDefinition.MicrosoftTestingPlatform.AffectedTestsOptionName))
            {
                affectedTests = true;
                remainingArguments.Add(argument);
            }
            else
            {
                remainingArguments.Add(argument);
            }
        }

        return (
            buildOptions with { TestApplicationArguments = remainingArguments.ToImmutable() },
            collectTestMap,
            affectedTests);
    }

    private static void ValidateAffectedTestsOptions(
        TestCommandDefinition.MicrosoftTestingPlatform definition,
        ParseResult parseResult,
        bool collectTestMap,
        bool affectedTests,
        bool forwardedMinimumExpectedTests)
    {
        if (!definition.AffectedTestsEnabled && (collectTestMap || affectedTests))
        {
            throw new GracefulException(
                string.Format(
                    CliCommandStrings.CmdAffectedTestsFeatureDisabled,
                    TestCommandDefinition.MicrosoftTestingPlatform.EnableAffectedTestsEnvironmentVariable));
        }

        if (collectTestMap && affectedTests)
        {
            throw new GracefulException(CliCommandStrings.CmdAffectedTestsOptionsMutuallyExclusive);
        }

        if (collectTestMap && parseResult.HasOption(definition.MaxParallelTestModulesOption))
        {
            throw new GracefulException(CliCommandStrings.CmdCollectTestMapCannotRunModulesInParallel);
        }

        if (collectTestMap &&
            (parseResult.HasOption(definition.MinimumExpectedTestsOption) || forwardedMinimumExpectedTests))
        {
            throw new GracefulException(CliCommandStrings.CmdCollectTestMapCannotRequireMinimumTests);
        }
    }

    internal static (bool CollectTestMap, bool AffectedTests, bool MinimumExpectedTests) DetectAffectedTestsOptionsInForwardedResponseFiles(
        ImmutableArray<string> testApplicationArguments,
        IEnumerable<string?> testApplicationWorkingDirectories,
        string invocationWorkingDirectory)
    {
        var workingDirectories = testApplicationWorkingDirectories
            .Select(directory => string.IsNullOrEmpty(directory)
                ? invocationWorkingDirectory
                : Path.GetFullPath(directory, invocationWorkingDirectory))
            .Distinct(StringComparer.Ordinal)
            .ToArray();

        ForwardedOptionState? commonState = null;
        bool foundInvalidResponseFile = false;
        foreach (string workingDirectory in workingDirectories)
        {
            ForwardedOptionState workingDirectoryState = default;
            foreach (string argument in testApplicationArguments)
            {
                if (argument.Length > 1 && argument[0] == '@')
                {
                    if (!TryDetectAffectedTestsOptionsInResponseFile(
                        argument[1..],
                        workingDirectory,
                        new HashSet<string>(StringComparer.Ordinal),
                        out ForwardedOptionState responseFileState))
                    {
                        foundInvalidResponseFile = true;
                        continue;
                    }

                    workingDirectoryState = workingDirectoryState.Merge(responseFileState);
                }
            }

            if (commonState is { } previousState &&
                (previousState.CollectTestMap != workingDirectoryState.CollectTestMap ||
                 previousState.AffectedTests != workingDirectoryState.AffectedTests))
            {
                throw new GracefulException(CliCommandStrings.CmdAffectedTestsResponseFilesMustBeConsistent);
            }

            commonState = workingDirectoryState with
            {
                MinimumExpectedTests =
                    (commonState?.MinimumExpectedTests ?? false) || workingDirectoryState.MinimumExpectedTests,
            };
        }

        ForwardedOptionState state = commonState ?? default;
        if (foundInvalidResponseFile && (state.CollectTestMap || state.AffectedTests))
        {
            throw new GracefulException(CliCommandStrings.CmdAffectedTestsResponseFilesMustBeConsistent);
        }

        if (foundInvalidResponseFile)
        {
            // MTP will report the response-file error. Do not partially activate a mode
            // or replace its diagnostic with an SDK validation error.
            return default;
        }

        return (state.CollectTestMap, state.AffectedTests, state.MinimumExpectedTests);
    }

    private static bool TryDetectAffectedTestsOptionsInResponseFile(
        string responseFilePath,
        string workingDirectory,
        HashSet<string> recursionStack,
        out ForwardedOptionState state)
    {
        state = default;
        string fullPath = Path.GetFullPath(responseFilePath, workingDirectory);
        if (!recursionStack.Add(fullPath) || !File.Exists(fullPath))
        {
            return false;
        }

        try
        {
            string[] tokens = [..
                File.ReadAllLines(fullPath)
                    .Select(static line => line.Trim())
                    .Where(static line => line.Length > 0 && line[0] != '#')
                    .SelectMany(SplitResponseFileLine)];

            ForwardedOptionState detectedState = default;
            foreach (string token in tokens)
            {
                if (token.Length > 1 && token[0] == '@')
                {
                    if (!TryDetectAffectedTestsOptionsInResponseFile(
                        token[1..],
                        workingDirectory,
                        recursionStack,
                        out ForwardedOptionState nestedState))
                    {
                        return false;
                    }

                    detectedState = detectedState.Merge(nestedState);
                }
                else
                {
                    if (IsAffectedTestsOption(token, TestCommandDefinition.MicrosoftTestingPlatform.CollectTestMapOptionName))
                    {
                        detectedState = detectedState with { CollectTestMap = true };
                    }
                    else if (IsAffectedTestsOption(token, TestCommandDefinition.MicrosoftTestingPlatform.AffectedTestsOptionName))
                    {
                        detectedState = detectedState with { AffectedTests = true };
                    }
                    else if (IsOption(token, MinimumExpectedTestsOptionName, allowValue: true))
                    {
                        detectedState = detectedState with { MinimumExpectedTests = true };
                    }
                }
            }

            state = detectedState;
            return true;
        }
        catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FormatException)
        {
            // MTP reports response-file read and format errors. Do not replace its diagnostic here.
            return false;
        }
        finally
        {
            recursionStack.Remove(fullPath);
        }
    }

    private static bool HasForwardedOption(ImmutableArray<string> arguments, string canonicalOption)
        => arguments.Any(argument => IsOption(argument, canonicalOption, allowValue: true));

    private static bool IsAffectedTestsOption(string argument, string canonicalOption)
        => IsOption(argument, canonicalOption, allowValue: false);

    private static bool IsOption(string argument, string canonicalOption, bool allowValue)
    {
        if (argument.Length < 2 ||
            argument[0] != '-' ||
            (argument[1] == '-' && (argument.Length < 3 || argument[2] == '-')))
        {
            return false;
        }

        string option = argument[1] == '-' ? argument[2..] : argument[1..];
        int separatorIndex = option.IndexOfAny('=', ':');
        if (separatorIndex >= 0 && !allowValue)
        {
            return false;
        }

        ReadOnlySpan<char> optionName = separatorIndex >= 0 ? option.AsSpan(0, separatorIndex) : option;
        return optionName.Equals(canonicalOption.AsSpan().TrimStart('-'), StringComparison.OrdinalIgnoreCase);
    }

    private static IEnumerable<string> SplitResponseFileLine(string line)
    {
        int tokenStart = 0;
        int position = 0;
        bool seekingTokenStart = true;
        bool insideQuotes = false;

        while (position < line.Length)
        {
            char character = line[position];

            if (char.IsWhiteSpace(character))
            {
                if (!insideQuotes)
                {
                    if (!seekingTokenStart)
                    {
                        yield return CurrentToken();
                        tokenStart = position;
                        seekingTokenStart = true;
                    }
                    else
                    {
                        tokenStart = position;
                    }
                }
            }
            if (character == '"')
            {
                if (seekingTokenStart)
                {
                    if (insideQuotes)
                    {
                        yield return CurrentToken();
                        tokenStart = position;
                        insideQuotes = false;
                    }
                    else
                    {
                        tokenStart = position + 1;
                        insideQuotes = true;
                    }
                }
                else
                {
                    insideQuotes = !insideQuotes;
                }
            }
            else if (seekingTokenStart && !insideQuotes && !char.IsWhiteSpace(character))
            {
                seekingTokenStart = false;
                tokenStart = position;
            }

            position++;

            if (position == line.Length)
            {
                if (insideQuotes)
                {
                    throw new FormatException();
                }

                if (!seekingTokenStart)
                {
                    yield return CurrentToken();
                }
            }
        }

        string CurrentToken() => line.Substring(tokenStart, position - tokenStart).Replace("\"", string.Empty);
    }

    private readonly record struct ForwardedOptionState(
        bool CollectTestMap,
        bool AffectedTests,
        bool MinimumExpectedTests)
    {
        public ForwardedOptionState Merge(ForwardedOptionState other)
            => new(
                CollectTestMap || other.CollectTestMap,
                AffectedTests || other.AffectedTests,
                MinimumExpectedTests || other.MinimumExpectedTests);
    }

    internal static bool ShouldFailForNoExecutedTests(bool isAffectedTestsMode, int totalTests, int skippedTests)
        => (!isAffectedTestsMode && totalTests == 0) ||
            (totalTests > 0 && totalTests == skippedTests);

    private static TestListFormat GetListTestsFormat(ParseResult parseResult, TestCommandDefinition.MicrosoftTestingPlatform definition)
    {
        // '--list-tests' has ZeroOrOne arity. A bare '--list-tests' (no value) defaults to text.
        // The accepted values are constrained to 'text'/'json' by the option definition.
        string? value = parseResult.GetValue(definition.ListTestsOption);
        return string.Equals(value, TestCommandDefinition.MicrosoftTestingPlatform.ListTestsFormatJson, StringComparison.Ordinal)
            ? TestListFormat.Json
            : TestListFormat.Text;
    }

    private static TerminalTestReporter InitializeOutput(int degreeOfParallelism, ParseResult parseResult, TestOptions testOptions)
    {
        var definition = (TestCommandDefinition.MicrosoftTestingPlatform)parseResult.CommandResult.Command;

        var console = new SystemConsole();
        var showPassedTests = parseResult.GetValue(definition.OutputOption) == OutputOptions.Detailed;
        var noProgress = parseResult.HasOption(definition.NoProgressOption);
        var noAnsi = parseResult.HasOption(definition.NoAnsiOption);

        // When emitting machine-readable JSON discovery output, stdout must contain only the JSON
        // document. Force off ANSI, progress rendering and the per-assembly "Discovering tests from..."
        // banners so nothing else is interleaved with the JSON.
        bool isJsonDiscovery = testOptions.IsDiscovery && testOptions.ListTestsFormat == TestListFormat.Json;
        if (isJsonDiscovery)
        {
            noProgress = true;
            noAnsi = true;
        }

        // TODO: Replace this with proper CI detection that we already have in telemetry. https://github.com/microsoft/testfx/issues/5533#issuecomment-2838893327
        bool inCI = string.Equals(Environment.GetEnvironmentVariable("TF_BUILD"), "true", StringComparison.OrdinalIgnoreCase) || string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase);

        AnsiMode ansiMode = AnsiMode.AnsiIfPossible;
        // In LLM environments, prefer simple text output so that LLM can parse it easily.
        // Note that NoAnsi also implies no progress.
        if (noAnsi || new LLMEnvironmentDetectorForTelemetry().IsLLMEnvironment())
        {
            // User explicitly specified --no-ansi.
            // We should respect that.
            ansiMode = AnsiMode.NoAnsi;
        }
        else if (inCI)
        {
            ansiMode = AnsiMode.SimpleAnsi;
        }

        var output = new TerminalTestReporter(console, new TerminalTestReporterOptions()
        {
            ShowPassedTests = showPassedTests,
            ShowProgress = !noProgress,
            ShowActiveTests = !noProgress && ansiMode == AnsiMode.AnsiIfPossible,
            AnsiMode = ansiMode,
            ShowAssembly = !isJsonDiscovery,
            ShowAssemblyStartAndComplete = !isJsonDiscovery,
            MinimumExpectedTests = parseResult.GetValue(definition.MinimumExpectedTestsOption),
            AllowZeroTests = testOptions.IsAffectedTestsMode,
            ListTestsFormat = testOptions.ListTestsFormat,
            SlowestTestsCount = GetSlowestTestsCount(parseResult.GetArguments()),
            ShowFlakyTests = GetShowFlakyTests(parseResult.GetArguments()),
        });

        // Ctrl+C handling is wired in Run() through CtrlCCancellationManager so that
        // a second press can force-kill running test app child processes and exit with
        // ExitCode.TestSessionAborted (see issue https://github.com/dotnet/sdk/issues/50732).

        // This is ugly, and we need to replace it by passing out some info from testing platform to inform us that some process level retry plugin is active.
        var isRetry = parseResult.GetArguments().Contains("--retry-failed-tests");

        output.TestExecutionStarted(DateTimeOffset.Now, degreeOfParallelism, testOptions.IsDiscovery, testOptions.IsHelp, isRetry);
        return output;
    }

    /// <summary>
    /// Reads the Microsoft.Testing.Platform <c>--show-slowest-tests N</c> option out of the raw command line.
    /// </summary>
    /// <remarks>
    /// The option belongs to the test application, not to the 'dotnet test' CLI, so it is forwarded verbatim. Under
    /// the pipe protocol the test host's own terminal reporter is not plugged in (the SDK owns user-facing output),
    /// so the section has to be rendered by the SDK's reporter instead — which means the SDK has to observe the
    /// option. Same approach as the '--retry-failed-tests' detection above. A missing, non-numeric or non-positive
    /// argument leaves the section off, mirroring the upstream option validator.
    /// </remarks>
    internal static int GetSlowestTestsCount(IReadOnlyList<string> arguments)
    {
        for (int i = 0; i < arguments.Count; i++)
        {
            if (!string.Equals(arguments[i], "--show-slowest-tests", StringComparison.Ordinal))
            {
                continue;
            }

            if (i + 1 < arguments.Count &&
                int.TryParse(arguments[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int count) &&
                count >= 1)
            {
                return count;
            }

            return 0;
        }

        return 0;
    }

    /// <summary>
    /// Reads the Microsoft.Testing.Platform <c>--show-flaky-tests [on|off]</c> option out of the raw command line.
    /// A bare '--show-flaky-tests' means "on", which is also the default when the option is absent.
    /// See <see cref="GetSlowestTestsCount"/> for why the SDK inspects the forwarded arguments.
    /// </summary>
    internal static bool GetShowFlakyTests(IReadOnlyList<string> arguments)
    {
        for (int i = 0; i < arguments.Count; i++)
        {
            if (!string.Equals(arguments[i], "--show-flaky-tests", StringComparison.Ordinal))
            {
                continue;
            }

            return i + 1 >= arguments.Count || !IsOffValue(arguments[i + 1]);
        }

        return true;

        static bool IsOffValue(string argument)
            => string.Equals(argument, "off", StringComparison.OrdinalIgnoreCase)
                || string.Equals(argument, "false", StringComparison.OrdinalIgnoreCase)
                || string.Equals(argument, "disable", StringComparison.OrdinalIgnoreCase)
                || string.Equals(argument, "0", StringComparison.Ordinal);
    }

    private static int GetDegreeOfParallelism(ParseResult parseResult, bool collectTestMap)
    {
        if (collectTestMap)
        {
            return 1;
        }

        var definition = (TestCommandDefinition.MicrosoftTestingPlatform)parseResult.CommandResult.Command;

        var degreeOfParallelism = parseResult.GetValue(definition.MaxParallelTestModulesOption);
        if (degreeOfParallelism <= 0)
            degreeOfParallelism = Environment.ProcessorCount;
        return degreeOfParallelism;
    }

    /// <summary>
    /// Creates the MSBuild session shared by every target the test command invokes itself. It owns the
    /// project collection all those projects have to be evaluated in; the collection has no global
    /// properties of its own, so each project passes the ones it needs when it is evaluated.
    /// </summary>
    private static MSBuildSession CreateBuildSession(BuildOptions buildOptions, FacadeLogger? logger)
        => new(SolutionAndProjectUtility.AnalyzeStandardTestMSBuildArgs(buildOptions.MSBuildArgs), logger);

    /// <summary>
    /// When --device is specified, we need to ensure a single target framework is selected
    /// because a device is platform-specific. If -f/--framework wasn't provided, this method
    /// evaluates the project to get TargetFrameworks and prompts for selection.
    /// The selected device is also added to MSBuild args so the build sees it.
    /// Solutions are rejected because each project may have its own device list, so
    /// applying a single --device value across a solution is ambiguous.
    /// </summary>
    private static BuildOptions HandleDeviceWithTargetFrameworkSelection(BuildOptions buildOptions, FacadeLogger? logger)
    {
        var msbuildArgs = SolutionAndProjectUtility.AnalyzeStandardTestMSBuildArgs(buildOptions.MSBuildArgs);

        var globalProperties = CommonRunHelpers.GetGlobalPropertiesFromArgs(msbuildArgs);

        // Device selection requires a single project (each project may have its own
        // device list). Reject solutions up front, regardless of whether -f/--framework
        // was provided, so `--device` + `-f` + `--solution` fails the same as `--device`
        // + `--solution` (which mirrors `--list-devices` + `--solution`).
        if (!ValidationUtility.ValidateBuildPathOptions(buildOptions.PathOptions, out var projectPath, out bool isSolution))
        {
            throw new GracefulException(CliCommandStrings.CmdTestNoTestProjectsFound);
        }

        if (isSolution)
        {
            throw new GracefulException(CliCommandStrings.TestCommandUseProject);
        }

        // Check if TargetFramework is already specified via -f/--framework or -p:TargetFramework=
        if (!globalProperties.ContainsKey(ProjectProperties.TargetFramework))
        {
            using var _ = MSBuildForwardingAppWithoutLogging.SetMSBuildRequiredEnvironmentVariables();

            // Evaluate the project to get TargetFrameworks
            using var collection = new ProjectCollection(
                globalProperties,
                logger is null ? null : [logger],
                ToolsetDefinitionLocations.Default);
            var projectInstance = ProjectInstance.FromFile(projectPath, new ProjectOptions
            {
                GlobalProperties = globalProperties,
                ProjectCollection = collection,
            });

            var targetFramework = projectInstance.GetPropertyValue(ProjectProperties.TargetFramework);
            var targetFrameworks = projectInstance.GetPropertyValue(ProjectProperties.TargetFrameworks);

            // Only prompt if multi-targeted (no single TargetFramework set)
            if (string.IsNullOrEmpty(targetFramework) && !string.IsNullOrEmpty(targetFrameworks))
            {
                var frameworks = targetFrameworks
                    .Split(CliConstants.SemiColon, StringSplitOptions.RemoveEmptyEntries)
                    .Select(f => f.Trim())
                    .Where(f => !string.IsNullOrEmpty(f))
                    .ToArray();

                bool isInteractive = !Console.IsOutputRedirected && !new Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment();
                if (!RunCommandSelector.TrySelectTargetFramework(frameworks, isInteractive, "dotnet test", out string? selectedFramework))
                {
                    // Error already written to stderr by TrySelectTargetFramework
                    throw new GracefulException(
                        string.Format(CliCommandStrings.RunCommandExceptionUnableToRunSpecifyFramework, "--framework"));
                }

                if (selectedFramework is not null)
                {
                    buildOptions = buildOptions with
                    {
                        MSBuildArgs = [.. buildOptions.MSBuildArgs, $"-p:{ProjectProperties.TargetFramework}={selectedFramework}"]
                    };
                }
            }
        }

        // Add Device to MSBuild args so the build and evaluation see it
        return buildOptions with
        {
            MSBuildArgs = [.. buildOptions.MSBuildArgs, $"-p:Device={buildOptions.Device}"]
        };
    }

    /// <summary>
    /// Handles `dotnet test --list-devices`. Resolves the project, prompts for
    /// target framework if multi-targeted, lists devices via
    /// <see cref="RunCommandSelector.TrySelectDevice"/>, and exits without
    /// building, deploying, or running tests.
    /// </summary>
    private static int HandleListDevices(BuildOptions buildOptions, MSBuildSession buildSession)
    {
        if (!ValidationUtility.ValidateBuildPathOptions(buildOptions.PathOptions, out var projectPath, out bool isSolution))
        {
            throw new GracefulException(CliCommandStrings.CmdTestNoTestProjectsFound);
        }

        if (isSolution)
        {
            // Listing devices across a solution is ambiguous: each project may have its own
            // set of devices. Require the user to pick a specific project via --project.
            throw new GracefulException(CliCommandStrings.TestCommandUseProject);
        }

        bool isInteractive = !Console.IsOutputRedirected && !new CIEnvironmentDetectorForTelemetry().IsCIEnvironment();

        var standardArgs = SolutionAndProjectUtility.AnalyzeStandardTestMSBuildArgs(buildOptions.MSBuildArgs);
        // Mirror the `dotnet run --list-devices` flow: a single RunCommandSelector
        // handles both target framework selection and device listing, with
        // InvalidateGlobalProperties between steps so the device list is computed
        // for the selected framework.
        using var selector = new RunCommandSelector(
            projectPath,
            isInteractive,
            standardArgs,
            buildOptions.EnvironmentVariables,
            commandName: "dotnet test",
            binaryLogger: null,
            buildSession: buildSession);

        // Step 1: Prompt for TargetFramework if the project is multi-targeted and -f wasn't provided.
        if (!selector.TrySelectTargetFramework(out string? selectedFramework))
        {
            // Mirror `dotnet run --list-devices` (RunCommand.cs:164): the guidance has
            // already been printed; `--list-devices` itself is not a build, so a missing
            // framework selection is not a failure to exit non-zero on.
            return ExitCode.Success;
        }

        if (selectedFramework is not null)
        {
            selector.InvalidateGlobalProperties(new Dictionary<string, string>
            {
                { ProjectProperties.TargetFramework, selectedFramework }
            });
        }

        // Step 2: List devices. This calls ComputeAvailableDevices if the target exists;
        // otherwise it silently no-ops (matches `dotnet run --list-devices`).
        if (!selector.TrySelectDevice(
            listDevices: true,
            noRestore: buildOptions.HasNoRestore || buildOptions.HasNoBuild,
            out _,
            out _,
            out _))
        {
            return ExitCode.GenericFailure;
        }

        return ExitCode.Success;
    }
}