File: Client\TestLoggerManager.cs
Web Access
Project: src\src\vstest\src\Microsoft.TestPlatform.CrossPlatEngine\Microsoft.TestPlatform.CrossPlatEngine.csproj (Microsoft.TestPlatform.CrossPlatEngine)
// 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.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Xml;

using Microsoft.VisualStudio.TestPlatform.Common.Exceptions;
using Microsoft.VisualStudio.TestPlatform.Common.ExtensionFramework;
using Microsoft.VisualStudio.TestPlatform.Common.Logging;
using Microsoft.VisualStudio.TestPlatform.Common.Telemetry;
using Microsoft.VisualStudio.TestPlatform.Common.Utilities;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Engine;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Utilities;
using Microsoft.VisualStudio.TestPlatform.PlatformAbstractions;
using Microsoft.VisualStudio.TestPlatform.PlatformAbstractions.Interfaces;

using CommonResources = Microsoft.VisualStudio.TestPlatform.Common.Resources.Resources;

namespace Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.Client;

/// <summary>
/// Responsible for managing logger extensions and broadcasting results
/// and error/warning/informational messages to them.
/// </summary>
internal class TestLoggerManager : ITestLoggerManager
{
    /// <summary>
    /// Keeps track if we are disposed.
    /// </summary>
    private bool _isDisposed;

    /// <summary>
    /// Used to keep track of which loggers have been initialized.
    /// </summary>
    private readonly HashSet<Type> _initializedLoggers = new();

    /// <summary>
    /// Test run directory.
    /// </summary>
    private string? _testRunDirectory;

    /// <summary>
    /// Target framework.
    /// </summary>
    private string? _targetFramework;

    /// <summary>
    /// TreatNoTestsAsError value;
    /// </summary>
    private bool _treatNoTestsAsError;

    /// <summary>
    /// Test Logger Events instance which will be passed to loggers when they are initialized.
    /// </summary>
    private readonly InternalTestLoggerEvents _loggerEvents;

    /// <summary>
    /// Message logger.
    /// </summary>
    private readonly IMessageLogger _messageLogger;

    /// <summary>
    /// Request data.
    /// </summary>
    private readonly IRequestData _requestData;

    /// <summary>
    /// Logger extension manager.
    /// </summary>
    private TestLoggerExtensionManager? _testLoggerExtensionManager;

    /// <summary>
    /// AssemblyLoadContext for current platform
    /// </summary>
    private readonly IAssemblyLoadContext _assemblyLoadContext;

    /// <summary>
    /// Test logger manager.
    /// </summary>
    /// <param name="requestData">Request Data for Providing Common Services/Data for Discovery and Execution.</param>
    /// <param name="messageLogger">Message Logger.</param>
    /// <param name="loggerEvents">Logger events.</param>
    public TestLoggerManager(IRequestData requestData, IMessageLogger messageLogger, InternalTestLoggerEvents loggerEvents) : this(requestData, messageLogger, loggerEvents, new PlatformAssemblyLoadContext())
    {
    }

    /// <summary>
    /// Test logger manager.
    /// </summary>
    /// <param name="requestData"></param>
    /// <param name="messageLogger"></param>
    /// <param name="loggerEvents"></param>
    /// <param name="assemblyLoadContext"></param>
    internal TestLoggerManager(IRequestData requestData, IMessageLogger messageLogger,
        InternalTestLoggerEvents loggerEvents, IAssemblyLoadContext assemblyLoadContext)
    {
        _requestData = requestData;
        _messageLogger = messageLogger;
        _testLoggerExtensionManager = null;
        _loggerEvents = loggerEvents;
        _assemblyLoadContext = assemblyLoadContext;
    }

    /// <summary>
    /// Loggers initialized flag.
    /// </summary>
    public bool LoggersInitialized => _initializedLoggers.Count != 0;

    private TestLoggerExtensionManager TestLoggerExtensionManager
    {
        get
        {
            _testLoggerExtensionManager ??= TestLoggerExtensionManager.Create(_messageLogger);

            return _testLoggerExtensionManager;
        }
    }

    /// <summary>
    /// Initializes all the loggers passed by user
    /// </summary>
    public void Initialize(string? runSettings)
    {
        // Enable logger events
        EnableLogging();

        // Store test run directory. This runsettings is the final runsettings merging CLI args and runsettings.
        _testRunDirectory = GetResultsDirectory(runSettings);
        _targetFramework = GetTargetFramework(runSettings)?.Name;
        _treatNoTestsAsError = GetTreatNoTestsAsError(runSettings);

        var loggers = XmlRunSettingsUtilities.GetLoggerRunSettings(runSettings);

        foreach (var logger in loggers?.LoggerSettingsList ?? Enumerable.Empty<LoggerSettings>())
        {
            // Don't add logger if its disabled.
            if (!logger.IsEnabled)
            {
                continue;
            }

            var parameters = GetParametersFromConfigurationElement(logger.Configuration);
            var loggerInitialized = false;

            // Try initializing logger by type.
            if (!StringUtils.IsNullOrWhiteSpace(logger.AssemblyQualifiedName))
            {
                loggerInitialized = InitializeLoggerByType(logger.AssemblyQualifiedName, logger.CodeBase!, parameters);
            }

            // Try initializing logger by uri.
            if (!loggerInitialized &&
                !StringUtils.IsNullOrWhiteSpace(logger.Uri?.ToString()))
            {
                loggerInitialized = InitializeLoggerByUri(logger.Uri, parameters);
            }

            // Try initializing logger by friendly name.
            if (!loggerInitialized &&
                TryGetUriFromFriendlyName(logger.FriendlyName, out var loggerUri) &&
                loggerUri != null)
            {
                loggerInitialized = InitializeLoggerByUri(loggerUri, parameters);
            }

            // Output error if logger is not initialized.
            if (!loggerInitialized)
            {
                var value = !StringUtils.IsNullOrWhiteSpace(logger.AssemblyQualifiedName)
                    ? logger.AssemblyQualifiedName
                    : !StringUtils.IsNullOrWhiteSpace(logger.Uri?.ToString())
                        ? logger.Uri.ToString()
                        : logger.FriendlyName;

                throw new InvalidLoggerException(
                    string.Format(CultureInfo.CurrentCulture, CommonResources.LoggerNotFound, value));
            }
        }

        _requestData.MetricsCollection.Add(TelemetryDataConstants.LoggerUsed, string.Join<Type>(",", _initializedLoggers.ToArray()));
    }

    /// <summary>
    /// Handles test run message event.
    /// </summary>
    /// <param name="e">TestRunMessage event args.</param>
    public void HandleTestRunMessage(TestRunMessageEventArgs e)
    {
        if (_isDisposed)
        {
            EqtTrace.Warning("TestLoggerManager.HandleTestRunMessage: Ignoring as the object is disposed.");
            return;
        }

        _loggerEvents.RaiseTestRunMessage(e);
    }

    /// <summary>
    /// Handle test run stats change event.
    /// </summary>
    /// <param name="e">TestRunChanged event args.</param>
    public void HandleTestRunStatsChange(TestRunChangedEventArgs e)
    {
        if (_isDisposed)
        {
            EqtTrace.Warning("TestLoggerManager.HandleTestRunStatsChange: Ignoring as the object is disposed.");
            return;
        }

        foreach (TestResult result in e.NewTestResults!)
        {
            _loggerEvents.RaiseTestResult(new TestResultEventArgs(result));
        }
    }

    /// <summary>
    /// Handles test run start event.
    /// </summary>
    /// <param name="e">TestRunStart event args.</param>
    public void HandleTestRunStart(TestRunStartEventArgs e)
    {
        if (_isDisposed)
        {
            EqtTrace.Warning("TestLoggerManager.HandleTestRunStart: Ignoring as the object is disposed.");
            return;
        }

        _loggerEvents.RaiseTestRunStart(e);
    }

    /// <summary>
    /// Handles test run complete.
    /// </summary>
    /// <param name="e">TestRunComplete event args.</param>
    public void HandleTestRunComplete(TestRunCompleteEventArgs e)
    {
        if (!_isDisposed)
        {
            try
            {
                _loggerEvents.CompleteTestRun(e.TestRunStatistics, e.IsCanceled, e.IsAborted, e.Error,
                    e.AttachmentSets, e.InvokedDataCollectors, e.ElapsedTimeInRunningTests);
            }
            finally
            {
                Dispose();
            }
        }
        else
        {
            // Note: We are not raising warning in case testLoggerManager is disposed as HandleRawMessage and HandleTestRunComplete both can call HandleTestRunComplete.
            EqtTrace.Verbose("TestLoggerManager.HandleTestRunComplete: Ignoring as the object is disposed.");
        }
    }

    /// <summary>
    /// Handles discovery message event.
    /// </summary>
    /// <param name="e">TestRunMessage event args.</param>
    public void HandleDiscoveryMessage(TestRunMessageEventArgs e)
    {
        if (_isDisposed)
        {
            EqtTrace.Warning("TestLoggerManager.HandleDiscoveryMessage: Ignoring as the object is disposed.");
            return;
        }

        _loggerEvents.RaiseDiscoveryMessage(e);
    }

    /// <summary>
    /// Handle discovered tests.
    /// </summary>
    /// <param name="e">DiscoveredTests event args.</param>
    public void HandleDiscoveredTests(DiscoveredTestsEventArgs e)
    {
        if (_isDisposed)
        {
            EqtTrace.Warning("TestLoggerManager.HandleDiscoveredTests: Ignoring as the object is disposed.");
            return;
        }

        _loggerEvents.RaiseDiscoveredTests(e);
    }

    /// <summary>
    /// Handles discovery complete event.
    /// </summary>
    /// <param name="e">DiscoveryComplete event args.</param>
    public void HandleDiscoveryComplete(DiscoveryCompleteEventArgs e)
    {
        if (!_isDisposed)
        {
            try
            {
                _loggerEvents.RaiseDiscoveryComplete(e);
            }
            finally
            {
                Dispose();
            }
        }
        else
        {
            // Note: We are not raising warning in case testLoggerManager is disposed as HandleRawMessage and HandleDiscoveryComplete both can call HandleDiscoveryComplete.
            EqtTrace.Verbose("TestLoggerManager.HandleDiscoveryComplete: Ignoring as the object is disposed.");
        }
    }

    /// <summary>
    /// Handles discovery start event.
    /// </summary>
    /// <param name="e">DiscoveryStart event args.</param>
    public void HandleDiscoveryStart(DiscoveryStartEventArgs e)
    {
        if (_isDisposed)
        {
            EqtTrace.Warning("TestLoggerManager.HandleDiscoveryStart: Ignoring as the object is disposed.");
            return;
        }

        _loggerEvents.RaiseDiscoveryStart(e);
    }

    /// <summary>
    /// Ensure that all pending messages are sent to the loggers.
    /// </summary>
    public void Dispose()
    {
        Dispose(true);

        // Use SupressFinalize in case a subclass
        // of this type implements a finalizer.
        GC.SuppressFinalize(this);
    }

    /// <summary>
    /// Initializes logger with the specified URI and parameters.
    /// For ex. TfsPublisher takes parameters such as  Platform, Flavor etc.
    /// </summary>
    /// <param name="uri">URI of the logger to add.</param>
    /// <param name="parameters">Logger parameters.</param>
    /// <returns>Logger Initialized flag.</returns>
    internal bool InitializeLoggerByUri(Uri uri, Dictionary<string, string?>? parameters)
    {
        ValidateArg.NotNull(uri, nameof(uri));
        CheckDisposed();

        // Look up the extension and initialize it if one is found.
        var extensionManager = TestLoggerExtensionManager;
        var logger = extensionManager.TryGetTestExtension(uri.AbsoluteUri);

        if (logger == null)
        {
            return false;
        }

        // If the logger has already been initialized just return.
        if (_initializedLoggers.Contains(logger.Value.GetType()))
        {
            EqtTrace.Verbose("TestLoggerManager: Skipping duplicate logger initialization: {0}", logger.Value.GetType());
            return true;
        }

        // Initialize logger.
        var initialized = InitializeLogger(logger.Value, logger.Metadata.ExtensionUri, parameters);

        // Add logger in initializedLoggers list.
        if (initialized)
        {
            _initializedLoggers.Add(logger.Value.GetType());
        }

        return initialized;
    }

    /// <summary>
    /// Tries to get uri of the logger corresponding to the friendly name. If no such logger exists return null.
    /// </summary>
    /// <param name="friendlyName">The friendly Name.</param>
    /// <param name="loggerUri">The logger Uri.</param>
    /// <returns><see cref="bool"/></returns>
    internal bool TryGetUriFromFriendlyName(string? friendlyName, [NotNullWhen(true)] out Uri? loggerUri)
    {
        var extensionManager = TestLoggerExtensionManager;
        foreach (var extension in extensionManager.TestExtensions)
        {
            if (!string.Equals(friendlyName, extension.Metadata.FriendlyName, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            try
            {
                loggerUri = new Uri(extension.Metadata.ExtensionUri);
            }
            catch (UriFormatException)
            {
                loggerUri = null;

                throw new InvalidLoggerException(
                    string.Format(
                        CultureInfo.CurrentCulture,
                        CommonResources.LoggerUriInvalid,
                        extension.Metadata.ExtensionUri));
            }

            return true;
        }

        loggerUri = null;
        return false;
    }

    /// <summary>
    /// Gets the test results directory.
    /// </summary>
    /// <param name="runSettings">Test run settings.</param>
    /// <returns>Test results directory</returns>
    internal static string? GetResultsDirectory(string? runSettings)
    {
        string? resultsDirectory = null;
        if (runSettings != null)
        {
            try
            {
                RunConfiguration runConfiguration = XmlRunSettingsUtilities.GetRunConfigurationNode(runSettings);
                resultsDirectory = RunSettingsUtilities.GetTestResultsDirectory(runConfiguration);
            }
            catch (SettingsException se)
            {
                EqtTrace.Error("TestLoggerManager.GetResultsDirectory: Unable to get the test results directory: Error {0}", se);
            }
        }

        return resultsDirectory;
    }

    /// <summary>
    /// Gets the target framework of the test run.
    /// </summary>
    /// <param name="runSettings">Test run settings.</param>
    /// <returns>Target framework</returns>
    internal static Framework? GetTargetFramework(string? runSettings)
    {
        Framework? targetFramework = null;
        if (runSettings != null)
        {
            try
            {
                RunConfiguration runConfiguration = XmlRunSettingsUtilities.GetRunConfigurationNode(runSettings);
                targetFramework = RunSettingsUtilities.GetTargetFramework(runConfiguration);
            }
            catch (SettingsException se)
            {
                EqtTrace.Error("TestLoggerManager.GetResultsDirectory: Unable to get the target framework: Error {0}", se);
            }
        }

        return targetFramework;
    }

    /// <summary>
    /// Get TreatNoTestsAsError value of the test run
    /// </summary>
    /// <param name="runSettings"></param>
    /// <returns></returns>
    internal static bool GetTreatNoTestsAsError(string? runSettings)
    {
        return RunSettingsUtilities.GetTreatNoTestsAsError(runSettings);
    }

    /// <summary>
    /// Enables sending of events to the loggers which are registered.
    /// </summary>
    /// <remarks>
    /// By default events are disabled and will not be raised until this method is called.
    /// This is done because during logger initialization, errors could be sent and we do not
    /// want them broadcast out to the loggers until all loggers have been enabled.  Without this
    /// all loggers would not receive the errors which were sent prior to initialization finishing.
    /// </remarks>
    internal void EnableLogging()
    {
        CheckDisposed();
        _loggerEvents.EnableEvents();
    }

    /// <summary>
    /// Ensure that all pending messages are sent to the loggers.
    /// </summary>
    /// <param name="disposing">
    /// The disposing.
    /// </param>
    protected virtual void Dispose(bool disposing)
    {
        if (!_isDisposed)
        {
            if (disposing)
            {
                _loggerEvents.Dispose();
            }

            _isDisposed = true;
        }
    }

    /// <summary>
    /// Get parameters from configuration element.
    /// </summary>
    /// <param name="configuration"></param>
    /// <returns></returns>
    private static Dictionary<string, string?> GetParametersFromConfigurationElement(XmlElement? configuration)
    {
        var configurationManager = new LoggerNameValueConfigurationManager(configuration);
        return configurationManager.NameValuePairs;
    }

    /// <summary>
    /// Initialize logger with the specified type and parameters.
    /// </summary>
    /// <param name="assemblyQualifiedName">Assembly qualified name.</param>
    /// <param name="codeBase">Code base.</param>
    /// <param name="parameters">Logger parameters.</param>
    /// <returns>Logger Initialized flag.</returns>
    private bool InitializeLoggerByType(string assemblyQualifiedName, string codeBase, Dictionary<string, string?> parameters)
    {
        CheckDisposed();
        try
        {
            // Load logger assembly.
            Assembly assembly = _assemblyLoadContext.LoadAssemblyFromPath(codeBase);
            var loggerType =
                assembly?.GetTypes()
                    .FirstOrDefault(x => string.Equals(x.AssemblyQualifiedName, assemblyQualifiedName));

            // Create logger instance
            var constructorInfo = loggerType?.GetConstructor(Type.EmptyTypes);
            var logger = constructorInfo?.Invoke([]);

            // Handle logger null scenario.
            if (logger == null)
            {
                return false;
            }

            // If the logger has already been initialized just return.
            if (_initializedLoggers.Contains(logger.GetType()))
            {
                EqtTrace.Verbose("TestLoggerManager: Skipping duplicate logger initialization: {0}", logger.GetType());
                return true;
            }

            // Get Logger instance and initialize.
            var initialized = InitializeLogger(logger, null, parameters);

            // Add logger in initializedLoggers list.
            if (initialized)
            {
                _initializedLoggers.Add(logger.GetType());
            }

            return initialized;
        }
        catch (Exception ex)
        {
            EqtTrace.Error(
                "TestLoggerManager: Error occurred while initializing the Logger assemblyQualifiedName : {0}, codeBase : {1} , Exception Details : {2}", assemblyQualifiedName, codeBase, ex);
            return false;
        }
    }

    private bool InitializeLogger(object? logger, string? extensionUri, Dictionary<string, string?>? parameters)
    {
        if (logger == null)
        {
            return false;
        }

        try
        {
            switch (logger)
            {
                case ITestLoggerWithParameters _:
                    ((ITestLoggerWithParameters)logger).Initialize(_loggerEvents, UpdateLoggerParameters(parameters));
                    break;

                case ITestLogger _:
                    ((ITestLogger)logger).Initialize(_loggerEvents, _testRunDirectory!);
                    break;

                default:
                    // If logger is of different type, then logger should not be initialized.
                    EqtTrace.Error(
                        "TestLoggerManager: Incorrect logger type: {0}", logger.GetType());
                    return false;
            }
        }
        catch (Exception ex)
        {
            var loggerUri = extensionUri.IsNullOrEmpty() ? logger.GetType().ToString() : extensionUri;
            EqtTrace.Error(
                "TestLoggerManager: Error while initializing logger: {0}, Exception details: {1}", loggerUri, ex);

            _messageLogger.SendMessage(
                TestMessageLevel.Error,
                string.Format(
                    CultureInfo.CurrentCulture,
                    CommonResources.LoggerInitializationError,
                    extensionUri.IsNullOrEmpty() ? "type" : "uri",
                    extensionUri.IsNullOrEmpty() ? logger.GetType().ToString() : extensionUri,
                    ex));

            throw new InvalidLoggerException($"Error while initializing logger: {loggerUri}, Exception details: {ex.Message}");
        }

        return true;
    }

    /// <summary>
    /// Populates user supplied and default logger parameters.
    /// </summary>
    private Dictionary<string, string?> UpdateLoggerParameters(Dictionary<string, string?>? parameters)
    {
        var loggerParams = parameters is not null
            ? new Dictionary<string, string?>(parameters, StringComparer.OrdinalIgnoreCase)
            : new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);

        // Add default logger parameters...
        loggerParams[DefaultLoggerParameterNames.TestRunDirectory] = _testRunDirectory;
        loggerParams[DefaultLoggerParameterNames.TargetFramework] = _targetFramework;

        // Add custom logger parameters
        if (_treatNoTestsAsError)
        {
            loggerParams[ObjectModel.Constants.TreatNoTestsAsError] = _treatNoTestsAsError.ToString();
        }

        return loggerParams;
    }

    private void CheckDisposed()
    {
        if (_isDisposed)
        {
            throw new ObjectDisposedException(typeof(TestLoggerManager).FullName);
        }
    }
}