File: Commands\Test\MTP\SolutionAndProjectUtility.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.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Build.Definition;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Evaluation.Context;
using Microsoft.Build.Execution;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
using Microsoft.DotNet.ProjectTools;

namespace Microsoft.DotNet.Cli.Commands.Test;

internal static class SolutionAndProjectUtility
{
    private static readonly string[] s_computeRunArgumentsTarget = [Constants.ComputeRunArguments];
    private static readonly Lock s_buildLock = new();

    /// <summary>
    /// Parses MSBuild args with the standard set of test command options.
    /// </summary>
    internal static MSBuildArgs AnalyzeStandardTestMSBuildArgs(IEnumerable<string> args) =>
        MSBuildArgs.AnalyzeMSBuildArguments(
            args,
            CommonOptions.CreatePropertyOption(),
            CommonOptions.CreateRestorePropertyOption(),
            CommonOptions.CreateMSBuildTargetOption(),
            CommonOptions.CreateVerbosityOption(),
            CommonOptions.CreateNoLogoOption());

    public static (bool SolutionOrProjectFileFound, string Message) TryGetProjectOrSolutionFilePath(string directory, out string projectOrSolutionFilePath, out bool isSolution)
    {
        projectOrSolutionFilePath = string.Empty;
        isSolution = false;

        if (!Directory.Exists(directory))
        {
            return (false, string.Format(CliCommandStrings.CmdNonExistentDirectoryErrorDescription, directory));
        }

        var actualSolutionFiles = GetSolutionFilePaths(directory);
        var solutionFilterFiles = GetSolutionFilterFilePaths(directory);
        var actualProjectFiles = GetProjectFilePaths(directory);

        // NOTE: The logic here is duplicated from https://github.com/dotnet/msbuild/blob/b878078fbaa28491a3a7fb273474ba71675c1613/src/MSBuild/XMake.cs#L3589
        // If there is exactly 1 project file and exactly 1 solution file
        if (actualProjectFiles.Length == 1 && actualSolutionFiles.Length == 1)
        {
            // Grab the name of both project and solution without extensions
            string solutionName = Path.GetFileNameWithoutExtension(actualSolutionFiles[0]);
            string projectName = Path.GetFileNameWithoutExtension(actualProjectFiles[0]);

            // Compare the names and error if they are not identical
            if (!string.Equals(solutionName, projectName))
            {
                return (false, CliCommandStrings.CmdMultipleProjectOrSolutionFilesErrorDescription);
            }

            projectOrSolutionFilePath = actualSolutionFiles[0];
            isSolution = true;
        }
        // If there is more than one solution file in the current directory we have no idea which one to use
        else if (actualSolutionFiles.Length > 1)
        {
            return (false, string.Format(CliStrings.MoreThanOneSolutionInDirectory, directory));
        }
        // If there is more than one project file in the current directory we may be able to figure it out
        else if (actualProjectFiles.Length > 1)
        {
            // We have more than one project, it is ambiguous at the moment
            bool isAmbiguousProject = true;

            // If there are exactly two projects and one of them is a .proj use that one and ignore the other
            if (actualProjectFiles.Length == 2)
            {
                string firstPotentialProjectExtension = Path.GetExtension(actualProjectFiles[0]);
                string secondPotentialProjectExtension = Path.GetExtension(actualProjectFiles[1]);

                // If the two projects have the same extension we can't decide which one to pick
                if (!string.Equals(firstPotentialProjectExtension, secondPotentialProjectExtension, StringComparison.OrdinalIgnoreCase))
                {
                    // Check to see if the first project is the proj, if it is use it
                    if (string.Equals(firstPotentialProjectExtension, ".proj", StringComparison.OrdinalIgnoreCase))
                    {
                        projectOrSolutionFilePath = actualProjectFiles[0];
                        // We have made a decision
                        isAmbiguousProject = false;
                    }
                    // If the first project is not the proj check to see if the second one is the proj, if so use it
                    else if (string.Equals(secondPotentialProjectExtension, ".proj", StringComparison.OrdinalIgnoreCase))
                    {
                        projectOrSolutionFilePath = actualProjectFiles[1];
                        // We have made a decision
                        isAmbiguousProject = false;
                    }
                }
            }

            if (isAmbiguousProject)
            {
                return (false, string.Format(CliStrings.MoreThanOneProjectInDirectory, directory));
            }
        }
        // if there are no project, solution filter, or solution files in the directory, we can't build
        else if (actualProjectFiles.Length == 0 &&
                 actualSolutionFiles.Length == 0 &&
                 solutionFilterFiles.Length == 0)
        {
            return (false, CliCommandStrings.CmdNoProjectOrSolutionFileErrorDescription);
        }
        else
        {
            // We are down to only one project, solution, or solution filter.
            // If only 1 solution build the solution.  If only 1 project build the project. Otherwise, build the solution filter.
            projectOrSolutionFilePath = actualSolutionFiles.Length == 1 ? actualSolutionFiles[0] : actualProjectFiles.Length == 1 ? actualProjectFiles[0] : solutionFilterFiles[0];
            isSolution = actualSolutionFiles.Length == 1 || (actualProjectFiles.Length != 1 && solutionFilterFiles.Length == 1);
            if (actualSolutionFiles.Length != 1 &&
                actualProjectFiles.Length != 1 &&
                solutionFilterFiles.Length != 1)
            {
                return (false, CliCommandStrings.CmdMultipleProjectOrSolutionFilesErrorDescription);
            }
        }

        return (true, string.Empty);
    }

    public static (bool SolutionFileFound, string Message) TryGetSolutionFilePath(string directory, out string solutionFilePath)
    {
        solutionFilePath = string.Empty;

        if (!Directory.Exists(directory))
        {
            return (false, string.Format(CliCommandStrings.CmdNonExistentDirectoryErrorDescription, directory));
        }

        var actualSolutionFiles = GetSolutionFilePaths(directory);

        if (actualSolutionFiles.Length == 0)
        {
            return (false, string.Format(CliStrings.SolutionDoesNotExist, directory + Path.DirectorySeparatorChar));
        }

        if (actualSolutionFiles.Length > 1)
        {
            return (false, string.Format(CliStrings.MoreThanOneSolutionInDirectory, directory + Path.DirectorySeparatorChar));
        }

        solutionFilePath = actualSolutionFiles[0];
        return (true, string.Empty);
    }

    private static string[] GetSolutionFilePaths(string directory) => [
            .. Directory.GetFiles(directory, CliConstants.SolutionExtensionPattern, SearchOption.TopDirectoryOnly),
            .. Directory.GetFiles(directory, CliConstants.SolutionXExtensionPattern, SearchOption.TopDirectoryOnly)
        ];

    private static string[] GetSolutionFilterFilePaths(string directory)
    {
        return Directory.GetFiles(directory, CliConstants.SolutionFilterExtensionPattern, SearchOption.TopDirectoryOnly);
    }

    private static string[] GetProjectFilePaths(string directory) => Directory.GetFiles(directory, CliConstants.ProjectExtensionPattern, SearchOption.TopDirectoryOnly);

    private static ProjectInstance EvaluateProject(
        ProjectCollection collection,
        EvaluationContext evaluationContext,
        string projectFilePath,
        string? tfm,
        string? configuration,
        string? platform,
        IReadOnlyDictionary<string, string>? additionalGlobalProperties)
    {
        Debug.Assert(projectFilePath is not null);

        Dictionary<string, string>? globalProperties = null;
        var capacity = 0;

        if (tfm is not null)
        {
            capacity++;
        }

        if (configuration is not null)
        {
            capacity++;
        }

        if (platform is not null)
        {
            capacity++;
        }

        if (capacity > 0)
        {
            globalProperties = new Dictionary<string, string>(capacity);
            if (tfm is not null)
            {
                globalProperties.Add(ProjectProperties.TargetFramework, tfm);
            }

            if (configuration is not null)
            {
                globalProperties.Add(ProjectProperties.Configuration, configuration);
            }

            if (platform is not null)
            {
                globalProperties.Add(ProjectProperties.Platform, platform);
            }
        }

        // Properties that apply to this project only - for example the device and runtime identifier
        // selected for a target framework. They are passed explicitly rather than through a dedicated
        // project collection, because all the projects of the run share the collection of the build
        // session so that they can be built together.
        if (additionalGlobalProperties is not null)
        {
            foreach (var property in additionalGlobalProperties)
            {
                if (!(globalProperties ??= new Dictionary<string, string>()).ContainsKey(property.Key))
                {
                    globalProperties.Add(property.Key, property.Value);
                }
            }
        }

        // Merge the global properties from the project collection.
        // It's unclear why MSBuild isn't considering the global properties defined in the ProjectCollection when
        // the collection is passed in ProjectOptions below.
        foreach (var property in collection.GlobalProperties)
        {
            if (!(globalProperties ??= new Dictionary<string, string>()).ContainsKey(property.Key))
            {
                globalProperties.Add(property.Key, property.Value);
            }
        }

        return ProjectInstance.FromFile(projectFilePath, new ProjectOptions
        {
            GlobalProperties = globalProperties,
            EvaluationContext = evaluationContext,
            ProjectCollection = collection,
        });
    }

    [RequiresDynamicCode("Uses MSBuild Object Model types, which are not AOT-safe")]
    public static IEnumerable<ParallelizableTestModuleGroupWithSequentialInnerModules> GetProjectProperties(
        string projectFilePath,
        ProjectCollection projectCollection,
        EvaluationContext evaluationContext,
        BuildOptions buildOptions,
        MSBuildSession buildSession,
        string? configuration,
        string? platform,
        IReadOnlyDictionary<string, string>? additionalGlobalProperties = null,
        HashSet<string>? visitedTraversalProjects = null)
    {
        var projects = new List<ParallelizableTestModuleGroupWithSequentialInnerModules>();
        ProjectInstance projectInstance = EvaluateProject(projectCollection, evaluationContext, projectFilePath, tfm: null, configuration, platform, additionalGlobalProperties);

        // Traversal projects (e.g. Microsoft.Build.Traversal "dirs.proj") are not test projects themselves.
        // They act as a container that forwards build/test operations to their ProjectReference items.
        // Special-case them the same way solutions are handled: expand into the referenced projects and
        // evaluate each of them. This is done recursively so that nested traversal projects work as well.
        if (IsTraversalProject(projectInstance))
        {
            // Track visited (project, configuration, platform) tuples across the whole traversal graph so
            // that a project referenced by multiple traversal projects with the same configuration/platform
            // (a "diamond") is only tested once, while the same project referenced with a *different*
            // configuration/platform is still tested for each distinct combination. This also guards against
            // cycles (a traversal project that transitively references itself).
            visitedTraversalProjects ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            visitedTraversalProjects.Add(GetTraversalVisitKey(Path.GetFullPath(projectFilePath), configuration, platform));

            foreach (var reference in GetTraversalReferencedProjects(projectInstance, configuration, platform))
            {
                if (!visitedTraversalProjects.Add(GetTraversalVisitKey(reference.FullPath, reference.Configuration, reference.Platform)))
                {
                    // Already handled via another traversal path (diamond) or a cycle, with the same
                    // configuration/platform combination.
                    continue;
                }

                projects.AddRange(GetProjectProperties(reference.FullPath, projectCollection, evaluationContext, buildOptions, buildSession, reference.Configuration, reference.Platform, additionalGlobalProperties, visitedTraversalProjects));
            }

            return projects;
        }

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

        Logger.LogTrace($"Loaded project '{Path.GetFileName(projectFilePath)}' with TargetFramework '{targetFramework}', TargetFrameworks '{targetFrameworks}', IsTestProject '{projectInstance.GetPropertyValue(ProjectProperties.IsTestProject)}', and '{ProjectProperties.IsTestingPlatformApplication}' is '{projectInstance.GetPropertyValue(ProjectProperties.IsTestingPlatformApplication)}'.");

        if (!string.IsNullOrEmpty(targetFramework) || string.IsNullOrEmpty(targetFrameworks))
        {
            if (GetModuleFromProject(projectInstance, buildOptions, buildSession) is { } module)
            {
                projects.Add(new ParallelizableTestModuleGroupWithSequentialInnerModules(module));
            }
        }
        else
        {
            if (!bool.TryParse(projectInstance.GetPropertyValue(ProjectProperties.TestTfmsInParallel), out bool testTfmsInParallel) &&
                !bool.TryParse(projectInstance.GetPropertyValue(ProjectProperties.BuildInParallel), out testTfmsInParallel))
            {
                // TestTfmsInParallel takes precedence over BuildInParallel.
                // If, for some reason, we cannot parse either property as bool, we default to true.
                testTfmsInParallel = true;
            }

            var frameworks = targetFrameworks
                .Split(CliConstants.SemiColon, StringSplitOptions.RemoveEmptyEntries)
                .Select(f => f.Trim())
                .Where(f => !string.IsNullOrEmpty(f))
                .Distinct();

            if (testTfmsInParallel)
            {
                foreach (var framework in frameworks)
                {
                    projectInstance = EvaluateProject(projectCollection, evaluationContext, projectFilePath, framework, configuration, platform, additionalGlobalProperties);
                    Logger.LogTrace($"Loaded inner project '{Path.GetFileName(projectFilePath)}' has '{ProjectProperties.IsTestingPlatformApplication}' = '{projectInstance.GetPropertyValue(ProjectProperties.IsTestingPlatformApplication)}' (TFM: '{framework}').");

                    if (GetModuleFromProject(projectInstance, buildOptions, buildSession) is { } module)
                    {
                        projects.Add(new ParallelizableTestModuleGroupWithSequentialInnerModules(module));
                    }
                }
            }
            else
            {
                List<TestModule>? innerModules = null;
                foreach (var framework in frameworks)
                {
                    projectInstance = EvaluateProject(projectCollection, evaluationContext, projectFilePath, framework, configuration, platform, additionalGlobalProperties);
                    Logger.LogTrace($"Loaded inner project '{Path.GetFileName(projectFilePath)}' has '{ProjectProperties.IsTestingPlatformApplication}' = '{projectInstance.GetPropertyValue(ProjectProperties.IsTestingPlatformApplication)}' (TFM: '{framework}').");

                    if (GetModuleFromProject(projectInstance, buildOptions, buildSession) is { } module)
                    {
                        innerModules ??= new List<TestModule>();
                        innerModules.Add(module);
                    }
                }

                if (innerModules is not null)
                {
                    projects.Add(new ParallelizableTestModuleGroupWithSequentialInnerModules(innerModules));
                }
            }
        }

        return projects;
    }

    /// <summary>
    /// Determines whether the evaluated project is a traversal project (e.g. a
    /// <c>Microsoft.Build.Traversal</c> "dirs.proj"). Traversal projects set the
    /// <c>IsTraversal</c> property to <c>true</c> and merely forward operations to their
    /// <c>ProjectReference</c> items rather than producing a test module of their own.
    /// </summary>
    private static bool IsTraversalProject(ProjectInstance projectInstance)
        => bool.TryParse(projectInstance.GetPropertyValue(ProjectProperties.IsTraversal), out bool isTraversal) && isTraversal;

    /// <summary>
    /// Builds a stable key identifying a (project, configuration, platform) combination for
    /// traversal-graph de-duplication and cycle detection.
    /// </summary>
    private static string GetTraversalVisitKey(string fullPath, string? configuration, string? platform)
        => $"{fullPath}|{configuration}|{platform}";

    /// <summary>
    /// Returns the projects a traversal project references. The globs and conditions in the traversal
    /// project are already expanded by MSBuild during evaluation, so the resolved <c>ProjectReference</c>
    /// items represent the effective set of projects to test. Per-reference <c>Configuration</c>/
    /// <c>Platform</c> metadata is honored when present (falling back to the values inherited from the
    /// traversal project), mirroring how MSBuild lets a <c>ProjectReference</c> target a specific
    /// configuration or platform.
    /// </summary>
    private static IEnumerable<(string FullPath, string? Configuration, string? Platform)> GetTraversalReferencedProjects(
        ProjectInstance projectInstance,
        string? inheritedConfiguration,
        string? inheritedPlatform)
    {
        // Resolve reference paths relative to the traversal project's directory (not the process working
        // directory). MSBuild's "FullPath" well-known metadata is normally already absolute, but resolving
        // against the project directory explicitly keeps us correct even if a relative value is returned or
        // the current directory differs from the project directory.
        var projectDirectory = projectInstance.Directory;

        foreach (ProjectItemInstance projectReference in projectInstance.GetItems(ProjectProperties.ProjectReferenceItemName))
        {
            // "FullPath" is a well-known item metadata that MSBuild computes relative to the project directory.
            var fullPath = projectReference.GetMetadataValue("FullPath");
            if (string.IsNullOrEmpty(fullPath))
            {
                continue;
            }

            var configurationMetadata = projectReference.GetMetadataValue(ProjectProperties.Configuration);
            var platformMetadata = projectReference.GetMetadataValue(ProjectProperties.Platform);

            yield return (
                Path.GetFullPath(fullPath, projectDirectory),
                string.IsNullOrEmpty(configurationMetadata) ? inheritedConfiguration : configurationMetadata,
                string.IsNullOrEmpty(platformMetadata) ? inheritedPlatform : platformMetadata);
        }
    }

    /// <summary>
    /// RuntimeIdentifiers are included in the build. Returns a result with device mappings
    /// and TestTfmsInParallel setting, or null if no device selection is needed.
    /// The evaluation happens in the collection of <paramref name="buildSession"/>, which is the one
    /// every project of the run has to share; pass an <paramref name="evaluationContext"/> to reuse an
    /// existing one and avoid redundant evaluation.
    /// </summary>
    internal static DeviceSelectionResult? SelectDevicesBeforeBuild(
        string projectFilePath,
        BuildOptions buildOptions,
        MSBuildSession buildSession,
        EvaluationContext? evaluationContext = null)
    {
        // --device is already handled by HandleDeviceWithTargetFrameworkSelection
        if (!string.IsNullOrWhiteSpace(buildOptions.Device))
        {
            return null;
        }

        var msbuildArgs = AnalyzeStandardTestMSBuildArgs(buildOptions.MSBuildArgs);

        var globalProperties = CommonRunHelpers.GetGlobalPropertiesFromArgs(msbuildArgs);

        // If Device is already set via -p:Device=..., skip device selection
        if (globalProperties.TryGetValue("Device", out var deviceProp) && !string.IsNullOrWhiteSpace(deviceProp))
        {
            return null;
        }

        var collection = buildSession.ProjectCollection;
        evaluationContext ??= EvaluationContext.Create(EvaluationContext.SharingPolicy.Shared);

        var projectInstance = ProjectInstance.FromFile(projectFilePath, new ProjectOptions
        {
            GlobalProperties = globalProperties,
            EvaluationContext = evaluationContext,
            ProjectCollection = collection,
        });

        // If the project doesn't support device selection, skip entirely
        if (!projectInstance.Targets.ContainsKey(Constants.ComputeAvailableDevices))
        {
            return null;
        }

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

        // Read TestTfmsInParallel from the initial evaluation so callers don't need to re-evaluate
        bool testTfmsInParallel = true;
        if (bool.TryParse(projectInstance.GetPropertyValue(ProjectProperties.TestTfmsInParallel), out bool parsed) ||
            bool.TryParse(projectInstance.GetPropertyValue(ProjectProperties.BuildInParallel), out parsed))
        {
            testTfmsInParallel = parsed;
        }

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

        IEnumerable<string> frameworks;
        if (!string.IsNullOrEmpty(targetFramework) || string.IsNullOrEmpty(targetFrameworks))
        {
            // Single TFM (either explicit or via -f/--framework)
            frameworks = [targetFramework ?? string.Empty];
        }
        else
        {
            frameworks = targetFrameworks
                .Split(CliConstants.SemiColon, StringSplitOptions.RemoveEmptyEntries)
                .Select(f => f.Trim())
                .Where(f => !string.IsNullOrEmpty(f));
        }

        var devicesByTfm = new Dictionary<string, (string? Device, string? RuntimeIdentifier)>();
        foreach (var framework in frameworks)
        {
            var (device, rid) = SelectDeviceForTfm(projectFilePath, buildOptions, framework, isInteractive, buildSession);
            devicesByTfm[framework] = (device, rid);
        }

        return devicesByTfm.Values.Any(v => v.Device is not null)
            ? new DeviceSelectionResult(devicesByTfm, testTfmsInParallel)
            : null;
    }

    internal sealed record DeviceSelectionResult(
        Dictionary<string, (string? Device, string? RuntimeIdentifier)> DevicesByTfm,
        bool TestTfmsInParallel);

    /// <summary>
    /// Selects a device for a specific TFM using RunCommandSelector.
    /// Returns (null, null) if no device support or no devices available for this TFM.
    /// </summary>
    private static (string? device, string? runtimeIdentifier) SelectDeviceForTfm(
        string projectFilePath,
        BuildOptions buildOptions,
        string? tfm,
        bool isInteractive,
        MSBuildSession buildSession)
    {
        var msbuildArgsToAppend = buildOptions.MSBuildArgs;
        if (!string.IsNullOrEmpty(tfm))
        {
            msbuildArgsToAppend = msbuildArgsToAppend.Append($"-p:{ProjectProperties.TargetFramework}={tfm}");
        }

        var msbuildArgs = AnalyzeStandardTestMSBuildArgs(msbuildArgsToAppend);

        using var selector = new RunCommandSelector(
            projectFilePath,
            isInteractive,
            msbuildArgs,
            buildOptions.EnvironmentVariables,
            commandName: "dotnet test",
            binaryLogger: null,
            buildSession: buildSession);

        lock (s_buildLock)
        {
            if (!selector.TrySelectDevice(
                listDevices: false,
                noRestore: buildOptions.HasNoRestore || buildOptions.HasNoBuild,
                out var selectedDevice,
                out var runtimeIdentifier,
                out _))
            {
                throw new GracefulException(
                    string.Format(CliCommandStrings.RunCommandExceptionUnableToRunSpecifyDevice, "--device"));
            }

            return (selectedDevice, runtimeIdentifier);
        }
    }

    [RequiresDynamicCode("Uses MSBuild Object Model types, which are not AOT-safe")]
    private static TestModule? GetModuleFromProject(
        ProjectInstance project,
        BuildOptions buildOptions,
        MSBuildSession buildSession)
    {
        _ = bool.TryParse(project.GetPropertyValue(ProjectProperties.IsTestProject), out bool isTestProject);
        _ = bool.TryParse(project.GetPropertyValue(ProjectProperties.IsTestingPlatformApplication), out bool isTestingPlatformApplication);

        if (!isTestProject && !isTestingPlatformApplication)
        {
            return null;
        }

        string targetFramework = project.GetPropertyValue(ProjectProperties.TargetFramework);
        string projectFullPath = project.GetPropertyValue(ProjectProperties.ProjectFullPath);


        // Only get run properties if IsTestingPlatformApplication is true
        RunProperties runProperties;
        IReadOnlyDictionary<string, string> runtimeEnvironmentVariables;
        if (isTestingPlatformApplication)
        {
            runProperties = DeployAndGetRunProperties(
                project,
                buildSession,
                buildOptions.EnvironmentVariables,
                out runtimeEnvironmentVariables);

            // dotnet run throws the same if RunCommand is null or empty.
            // In dotnet test, we are additionally checking that RunCommand is not dll.
            // In any "default" scenario, RunCommand is never dll.
            // If we found it to be dll, that is user explicitly setting RunCommand incorrectly.
            if (string.IsNullOrEmpty(runProperties.Command) || runProperties.Command.HasExtension(CliConstants.DLLExtension))
            {
                throw new GracefulException(
                    string.Format(
                        CliCommandStrings.RunCommandExceptionUnableToRun,
                        projectFullPath,
                        Product.TargetFrameworkVersion,
                        project.GetPropertyValue("OutputType")));
            }
        }
        else
        {
            // For VSTest test projects, create minimal RunProperties
            runProperties = new RunProperties(
                project.GetPropertyValue(ProjectProperties.TargetPath),
                null,
                null);
            runtimeEnvironmentVariables = buildOptions.EnvironmentVariables;
        }

        // TODO: Support --launch-profile and pass it here.
        var launchSettings = TryGetLaunchProfileSettings(Path.GetDirectoryName(projectFullPath)!, Path.GetFileNameWithoutExtension(projectFullPath), project.GetPropertyValue(ProjectProperties.AppDesignerFolder), buildOptions, profileName: null);

        var rootVariableName = EnvironmentVariableNames.TryGetDotNetRootArchVariableName(
            runProperties.RuntimeIdentifier,
            runProperties.DefaultAppHostRuntimeIdentifier);

        if (rootVariableName is not null && Environment.GetEnvironmentVariable(rootVariableName) != null)
        {
            // If already set, we do not override it.
            rootVariableName = null;
        }

        return new TestModule(runProperties, PathUtility.FixFilePath(projectFullPath), targetFramework, isTestingPlatformApplication, launchSettings, project.GetPropertyValue(ProjectProperties.TargetPath), rootVariableName, runtimeEnvironmentVariables);

        [RequiresDynamicCode("Uses MSBuild Object Model types, which are not AOT-safe")]
        [UnconditionalSuppressMessage("AOT", "IL2026", Justification = "Temporary unblock for dotnet/msbuild#14064 (MSBuild build APIs are now [RequiresUnreferencedCode]). dotnet CLI runs MSBuild in-proc (not trimmed). Remove when dotnet/sdk#55225 is fixed.")]
        static RunProperties DeployAndGetRunProperties(
            ProjectInstance project,
            MSBuildSession buildSession,
            IReadOnlyDictionary<string, string> environmentVariables,
            out IReadOnlyDictionary<string, string> runtimeEnvironmentVariables)
        {
            bool hasRuntimeEnvironmentVariableSupport = EnvironmentVariablesToMSBuild.HasRuntimeEnvironmentVariableSupport(project);
            if (hasRuntimeEnvironmentVariableSupport)
            {
                EnvironmentVariablesToMSBuild.AddAsItems(project, environmentVariables);
            }

            // Every project of the run shares the same build session, which serializes the requests
            // internally: the MSBuild build APIs cannot be called in parallel, even for different
            // projects ("The operation cannot be completed because a build is already in progress.").
            if (project.Targets.ContainsKey(Constants.DeployToDevice))
            {
                // Deploy on a fresh ProjectInstance to avoid accumulating state (existing item
                // groups) that would leak into the ComputeRunArguments build below, which has to
                // build the original instance since the run properties are read back from it.
                // Same reason as dotnet run, see RunCommandSelector.OpenProjectIfNeeded.
                if (!buildSession.Build(project.DeepCopy(), [Constants.DeployToDevice]))
                {
                    throw new GracefulException(CliCommandStrings.RunCommandDeployFailed);
                }
            }

            if (!buildSession.Build(project, s_computeRunArgumentsTarget))
            {
                throw new GracefulException(CliCommandStrings.RunCommandEvaluationExceptionBuildFailed, s_computeRunArgumentsTarget[0]);
            }

            runtimeEnvironmentVariables = hasRuntimeEnvironmentVariableSupport
                ? EnvironmentVariablesToMSBuild.ReadFromItems(project)
                : environmentVariables;
            return RunProperties.FromProject(project);
        }
    }

    private static LaunchProfile? TryGetLaunchProfileSettings(string projectDirectory, string projectNameWithoutExtension, string appDesignerFolder, BuildOptions buildOptions, string? profileName)
    {
        if (buildOptions.NoLaunchProfile)
        {
            return null;
        }

        var launchSettingsPath = LaunchSettings.GetPropertiesLaunchSettingsPath(projectDirectory, appDesignerFolder);
        bool hasLaunchSettings = File.Exists(launchSettingsPath);

        var runJsonPath = LaunchSettings.GetFlatLaunchSettingsPath(projectDirectory, projectNameWithoutExtension);
        bool hasRunJson = File.Exists(runJsonPath);

        if (hasLaunchSettings)
        {
            if (hasRunJson)
            {
                Reporter.Output.WriteLine(string.Format(CliCommandStrings.RunCommandWarningRunJsonNotUsed, runJsonPath, launchSettingsPath).Yellow());
            }
        }
        else if (hasRunJson)
        {
            launchSettingsPath = runJsonPath;
        }
        else
        {
            return null;
        }

        // If buildOptions.Verbosity is null, we still want to print the message.
        if (buildOptions.Verbosity != VerbosityOptions.quiet)
        {
            Reporter.Error.WriteLine(string.Format(CliCommandStrings.UsingLaunchSettingsFromMessage, launchSettingsPath));
        }

        var result = LaunchSettings.ReadProfileSettingsFromFile(launchSettingsPath, profileName);
        if (!result.Successful)
        {
            Reporter.Error.WriteLine(string.Format(CliCommandStrings.RunCommandExceptionCouldNotApplyLaunchSettings, profileName, result.FailureReason).Bold().Red());
            return null;
        }

        return result.Profile;
    }
}