File: TestPlatform.cs
Web Access
Project: src\src\vstest\src\Microsoft.TestPlatform.Client\Microsoft.TestPlatform.Client.csproj (Microsoft.VisualStudio.TestPlatform.Client)
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

using Microsoft.VisualStudio.TestPlatform.Client.Discovery;
using Microsoft.VisualStudio.TestPlatform.Client.Execution;
using Microsoft.VisualStudio.TestPlatform.Common;
using Microsoft.VisualStudio.TestPlatform.Common.ExtensionFramework;
using Microsoft.VisualStudio.TestPlatform.Common.Hosting;
using Microsoft.VisualStudio.TestPlatform.Common.Utilities;
using Microsoft.VisualStudio.TestPlatform.CrossPlatEngine;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Engine;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Utilities;
using Microsoft.VisualStudio.TestPlatform.PlatformAbstractions;
using Microsoft.VisualStudio.TestPlatform.Utilities.Helpers;
using Microsoft.VisualStudio.TestPlatform.Utilities.Helpers.Interfaces;

namespace Microsoft.VisualStudio.TestPlatform.Client;

/// <summary>
/// Implementation for TestPlatform.
/// </summary>
internal class TestPlatform : ITestPlatform
{
    private readonly ITestRuntimeProviderManager _testHostProviderManager;

    private readonly IFileHelper _fileHelper;

    static TestPlatform()
    {
        // TODO: This is not the right way to force initialization of default extensions.
        // Test runtime providers require this today. They're getting initialized even before
        // test adapter paths are provided, which is incorrect.
        AddExtensionAssembliesFromExtensionDirectory();
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="TestPlatform"/> class.
    /// </summary>
    public TestPlatform()
        : this(
            new TestEngine(),
            new FileHelper(),
            TestRuntimeProviderManager.Instance)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="TestPlatform"/> class.
    /// </summary>
    ///
    /// <param name="testEngine">The test engine.</param>
    /// <param name="filehelper">The file helper.</param>
    /// <param name="testHostProviderManager">The data.</param>
    protected internal TestPlatform(
        ITestEngine testEngine,
        IFileHelper filehelper,
        ITestRuntimeProviderManager testHostProviderManager)
    {
        _testEngine = testEngine;
        _fileHelper = filehelper;
        _testHostProviderManager = testHostProviderManager;
    }

    private readonly ITestEngine _testEngine;

    /// <inheritdoc/>
    public IDiscoveryRequest CreateDiscoveryRequest(
        IRequestData requestData,
        DiscoveryCriteria discoveryCriteria,
        TestPlatformOptions? options,
        Dictionary<string, SourceDetail> sourceToSourceDetailMap,
        IWarningLogger warningLogger)
    {
        ValidateArg.NotNull(discoveryCriteria, nameof(discoveryCriteria));

        PopulateExtensions(discoveryCriteria.RunSettings, discoveryCriteria.Sources);

        // Initialize loggers.
        ITestLoggerManager loggerManager = _testEngine.GetLoggerManager(requestData);
        loggerManager.Initialize(discoveryCriteria.RunSettings);

        IProxyDiscoveryManager discoveryManager = _testEngine.GetDiscoveryManager(requestData, discoveryCriteria, sourceToSourceDetailMap, warningLogger);
        discoveryManager.Initialize(GetSkipDefaultAdapters(options, discoveryCriteria.RunSettings));

        return new DiscoveryRequest(requestData, discoveryCriteria, discoveryManager, loggerManager);
    }

    /// <inheritdoc/>
    public ITestRunRequest CreateTestRunRequest(
        IRequestData requestData,
        TestRunCriteria testRunCriteria,
        TestPlatformOptions? options,
        Dictionary<string, SourceDetail> sourceToSourceDetailMap,
        IWarningLogger warningLogger)
    {
        ValidateArg.NotNull(testRunCriteria, nameof(testRunCriteria));

        IEnumerable<string> sources = GetSources(testRunCriteria);
        PopulateExtensions(testRunCriteria.TestRunSettings, sources);

        // Initialize loggers.
        ITestLoggerManager loggerManager = _testEngine.GetLoggerManager(requestData);
        loggerManager.Initialize(testRunCriteria.TestRunSettings);

        IProxyExecutionManager executionManager = _testEngine.GetExecutionManager(requestData, testRunCriteria, sourceToSourceDetailMap, warningLogger);
        executionManager.Initialize(GetSkipDefaultAdapters(options, testRunCriteria.TestRunSettings));

        return new TestRunRequest(requestData, testRunCriteria, executionManager, loggerManager);
    }

    private static bool GetSkipDefaultAdapters(TestPlatformOptions? options, string? runSettings)
    {
        if (options?.SkipDefaultAdapters ?? false)
        {
            EqtTrace.Verbose($"TestPlatform.GetSkipDefaultAdapters: Skipping default adapters because of TestPlatform options SkipDefaultAdapters.");
            return true;
        }

        RunConfiguration runConfiguration = XmlRunSettingsUtilities.GetRunConfigurationNode(runSettings);
        var skipping = runConfiguration.SkipDefaultAdapters;
        if (skipping)
        {
            EqtTrace.Verbose($"TestPlatform.GetSkipDefaultAdapters: Skipping default adapters because of RunConfiguration SkipDefaultAdapters.");
            return true;
        }

        EqtTrace.Verbose($"TestPlatform.GetSkipDefaultAdapters: Not skipping default adapters SkipDefaultAdapters was false.");
        return false;
    }

    /// <inheritdoc/>
    public bool StartTestSession(
        IRequestData requestData,
        StartTestSessionCriteria testSessionCriteria,
        ITestSessionEventsHandler eventsHandler,
        Dictionary<string, SourceDetail> sourceToSourceDetailMap,
        IWarningLogger warningLogger)
    {
        ValidateArg.NotNull(testSessionCriteria, nameof(testSessionCriteria));

        RunConfiguration runConfiguration = XmlRunSettingsUtilities.GetRunConfigurationNode(testSessionCriteria.RunSettings);
        TestAdapterLoadingStrategy strategy = runConfiguration.TestAdapterLoadingStrategy;

        AddExtensionAssemblies(testSessionCriteria.RunSettings, strategy);

        if (!runConfiguration.DesignMode)
        {
            return false;
        }

        IProxyTestSessionManager? testSessionManager = _testEngine.GetTestSessionManager(requestData, testSessionCriteria, sourceToSourceDetailMap, warningLogger);
        if (testSessionManager == null)
        {
            // The test session manager is null because the combination of runsettings and
            // sources tells us we should run in-process (i.e. in vstest.console). Because
            // of this no session will be created because there's no testhost to be launched.
            // Expecting a subsequent call to execute tests with the same set of parameters.
            return false;
        }

        return testSessionManager.StartSession(eventsHandler, requestData);
    }

    private void PopulateExtensions(string? runSettings, IEnumerable<string> sources)
    {
        RunConfiguration runConfiguration = XmlRunSettingsUtilities.GetRunConfigurationNode(runSettings);
        TestAdapterLoadingStrategy strategy = runConfiguration.TestAdapterLoadingStrategy;

        // Update cache with Extension folder's files.
        AddExtensionAssemblies(runSettings, strategy);

        // Update extension assemblies from source when design mode is false.
        if (!runConfiguration.DesignMode)
        {
            AddLoggerAssembliesFromSource(sources, strategy);
        }
    }

    /// <summary>
    /// The dispose.
    /// </summary>
    public void Dispose()
    {
        throw new NotImplementedException();
    }

    /// <inheritdoc/>
    public void UpdateExtensions(
        IEnumerable<string>? pathToAdditionalExtensions,
        bool skipExtensionFilters)
    {
        _testEngine.GetExtensionManager().UseAdditionalExtensions(pathToAdditionalExtensions, skipExtensionFilters);
    }

    /// <inheritdoc/>
    public void ClearExtensions()
    {
        _testEngine.GetExtensionManager().ClearExtensions();
    }

    private void AddExtensionAssemblies(string? runSettings, TestAdapterLoadingStrategy adapterLoadingStrategy)
    {
        IEnumerable<string> customTestAdaptersPaths = RunSettingsUtilities.GetTestAdaptersPaths(runSettings);

        if (customTestAdaptersPaths == null)
        {
            return;
        }

        foreach (string customTestAdaptersPath in customTestAdaptersPaths)
        {
            IEnumerable<string> extensionAssemblies = ExpandTestAdapterPaths(customTestAdaptersPath, _fileHelper, adapterLoadingStrategy);

            if (extensionAssemblies.Any())
            {
                UpdateExtensions(extensionAssemblies, skipExtensionFilters: false);
            }

        }
    }

    /// <summary>
    /// Updates the test logger paths from source directory.
    /// </summary>
    ///
    /// <param name="sources">The list of sources.</param>
    /// <param name="strategy">Adapter loading strategy</param>
    private void AddLoggerAssembliesFromSource(IEnumerable<string> sources, TestAdapterLoadingStrategy strategy)
    {
        // Skip discovery unless we're using the default behavior, or NextToSource is specified.
        if (strategy != TestAdapterLoadingStrategy.Default && !strategy.HasFlag(TestAdapterLoadingStrategy.NextToSource))
        {
            return;
        }

        // Currently we support discovering loggers only from Source directory.
        List<string> loggersToUpdate = new();

        foreach (string source in sources)
        {
            var sourceDirectory = Path.GetDirectoryName(source);
            if (!string.IsNullOrEmpty(sourceDirectory) && _fileHelper.DirectoryExists(sourceDirectory))
            {
                SearchOption searchOption = GetSearchOption(strategy, SearchOption.TopDirectoryOnly);

                loggersToUpdate.AddRange(
                    _fileHelper.EnumerateFiles(
                        sourceDirectory,
                        searchOption,
                        TestPlatformConstants.TestLoggerEndsWithPattern));
            }
        }

        if (loggersToUpdate.Count > 0)
        {
            UpdateExtensions(loggersToUpdate, skipExtensionFilters: false);
        }
    }

    /// <summary>
    /// Finds all test platform extensions from the `.\Extensions` directory. This is used to
    /// load the inbox extensions like TrxLogger and legacy test extensions like MSTest v1,
    /// MSTest C++, etc..
    /// </summary>
    private static void AddExtensionAssembliesFromExtensionDirectory()
    {
        // This method needs to run statically before we have any adapter discovery.
        // TestHostProviderManager get initialized just after this call and it
        // requires DefaultExtensionPaths to be set to resolve a TestHostProvider.
        // Since it's static, it forces us to set the adapter paths.
        //
        // Otherwise we will always get a "No suitable test runtime provider found for this run." error.
        // I (@haplois) will modify this behavior later on, but we also need to consider legacy adapters
        // and make sure they still work after modification.
        string? runSettings = RunSettingsManager.Instance.ActiveRunSettings.SettingsXml;
        RunConfiguration runConfiguration = XmlRunSettingsUtilities.GetRunConfigurationNode(runSettings);
        TestAdapterLoadingStrategy strategy = runConfiguration.TestAdapterLoadingStrategy;

        FileHelper fileHelper = new();
        IEnumerable<string> defaultExtensionPaths = [];

        // Explicit adapter loading
        if (strategy.HasFlag(TestAdapterLoadingStrategy.Explicit))
        {
            defaultExtensionPaths = RunSettingsUtilities.GetTestAdaptersPaths(runSettings)
                .SelectMany(path => ExpandTestAdapterPaths(path, fileHelper, strategy))
                .Union(defaultExtensionPaths);
        }

        string extensionsFolder = Path.Combine(
            Path.GetDirectoryName(typeof(TestPlatform).Assembly.GetAssemblyLocation())!,
            "Extensions");
        if (!fileHelper.DirectoryExists(extensionsFolder))
        {
            // TODO: Since we no-longer run from <playground>\vstest.console\vstest.conosle.exe in Playground, the relative
            // extensions folder location changed and we need to patch it. This should be a TEMPORARY solution though, we
            // should come up with a better way of fixing this.
            // NOTE: This is specific to Playground which references vstest.console from a location that doesn't contain
            // the Extensions folder. Normal projects shouldn't have this issue.
            extensionsFolder = Path.Combine(Path.GetDirectoryName(extensionsFolder)!, "vstest.console", "Extensions");
        }

        if (fileHelper.DirectoryExists(extensionsFolder))
        {
            // Load default runtime providers
            if (strategy.HasFlag(TestAdapterLoadingStrategy.DefaultRuntimeProviders))
            {
                defaultExtensionPaths = fileHelper
                    .EnumerateFiles(extensionsFolder, SearchOption.TopDirectoryOnly, TestPlatformConstants.RunTimeEndsWithPattern)
                    .Union(defaultExtensionPaths);
            }

            // Default extension loader
            if (strategy == TestAdapterLoadingStrategy.Default || strategy.HasFlag(TestAdapterLoadingStrategy.ExtensionsDirectory))
            {
                defaultExtensionPaths = fileHelper
                    .EnumerateFiles(extensionsFolder, SearchOption.TopDirectoryOnly, ".dll", ".exe")
                    .Union(defaultExtensionPaths);
            }
        }

        TestPluginCache.Instance.DefaultExtensionPaths = defaultExtensionPaths.Distinct();
    }

    private static SearchOption GetSearchOption(TestAdapterLoadingStrategy strategy, SearchOption defaultStrategyOption)
    {
        return strategy == TestAdapterLoadingStrategy.Default
            ? defaultStrategyOption
            : strategy.HasFlag(TestAdapterLoadingStrategy.Recursive) ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
    }

    private static IEnumerable<string> ExpandTestAdapterPaths(string path, IFileHelper fileHelper, TestAdapterLoadingStrategy strategy)
    {
        string adapterPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(path));

        // Default behavior is to only accept directories.
        if (strategy == TestAdapterLoadingStrategy.Default)
        {
            return ExpandAdaptersWithDefaultStrategy(adapterPath, fileHelper);
        }

        IEnumerable<string> adapters = ExpandAdaptersWithExplicitStrategy(adapterPath, fileHelper, strategy);

        return adapters.Distinct();
    }

    private static IEnumerable<string> ExpandAdaptersWithExplicitStrategy(string path, IFileHelper fileHelper, TestAdapterLoadingStrategy strategy)
    {
        if (!strategy.HasFlag(TestAdapterLoadingStrategy.Explicit))
        {
            return [];
        }

        if (fileHelper.Exists(path))
        {
            return new[] { path };
        }
        else if (fileHelper.DirectoryExists(path))
        {
            SearchOption searchOption = GetSearchOption(strategy, SearchOption.TopDirectoryOnly);

            IEnumerable<string> adapterPaths = fileHelper.EnumerateFiles(
                path,
                searchOption,
                TestPlatformConstants.TestAdapterEndsWithPattern,
                TestPlatformConstants.TestLoggerEndsWithPattern,
                TestPlatformConstants.DataCollectorEndsWithPattern,
                TestPlatformConstants.RunTimeEndsWithPattern);

            return adapterPaths;
        }

        EqtTrace.Warning($"{nameof(TestPlatform)}.{nameof(ExpandAdaptersWithExplicitStrategy)} AdapterPath Not Found: {path}");
        return [];
    }

    private static IEnumerable<string> ExpandAdaptersWithDefaultStrategy(string path, IFileHelper fileHelper)
    {
        // This is the legacy behavior, please do not modify this method unless you're sure of
        // side effect when running tests with legacy adapters.
        if (!fileHelper.DirectoryExists(path))
        {
            EqtTrace.Warning($"{nameof(TestPlatform)}.{nameof(ExpandAdaptersWithDefaultStrategy)} AdapterPath Not Found: {path}");

            return [];
        }

        return fileHelper.EnumerateFiles(
                path,
                SearchOption.AllDirectories,
                TestPlatformConstants.TestAdapterEndsWithPattern,
                TestPlatformConstants.TestLoggerEndsWithPattern,
                TestPlatformConstants.DataCollectorEndsWithPattern,
                TestPlatformConstants.RunTimeEndsWithPattern);
    }

    private static IEnumerable<string> GetSources(TestRunCriteria testRunCriteria)
    {
        if (testRunCriteria.HasSpecificTests)
        {
            // If the test execution is with a test filter, filter sources too.
            return testRunCriteria.Tests.Select(tc => tc.Source).Distinct();
        }

        TPDebug.Assert(testRunCriteria.Sources is not null, "testRunCriteria.Sources is null");
        return testRunCriteria.Sources;
    }
}