File: Processors\ListFullyQualifiedTestsArgumentProcessor.cs
Web Access
Project: src\src\vstest\src\vstest.console\vstest.console.csproj (vstest.console)
// 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.RequestHelper;
using Microsoft.VisualStudio.TestPlatform.CommandLine.Internal;
using Microsoft.VisualStudio.TestPlatform.CommandLine.TestPlatformHelpers;
using Microsoft.VisualStudio.TestPlatform.Common;
using Microsoft.VisualStudio.TestPlatform.Common.Filtering;
using Microsoft.VisualStudio.TestPlatform.Common.Interfaces;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client;
using Microsoft.VisualStudio.TestPlatform.Utilities;

using CommandLineResources = Microsoft.VisualStudio.TestPlatform.CommandLine.Resources.Resources;

namespace Microsoft.VisualStudio.TestPlatform.CommandLine.Processors;

/// <summary>
/// Argument Executor for the "--ListFullyQualifiedTests|/ListFullyQualifiedTests" command line argument.
/// </summary>
internal class ListFullyQualifiedTestsArgumentProcessor : IArgumentProcessor
{
    /// <summary>
    /// The name of the command line argument that the ListFullyQualifiedTestsArgumentProcessor handles.
    /// </summary>
    public const string CommandName = "/ListFullyQualifiedTests";

    private Lazy<IArgumentProcessorCapabilities>? _metadata;
    private Lazy<IArgumentExecutor>? _executor;

    /// <summary>
    /// Gets the metadata.
    /// </summary>
    public Lazy<IArgumentProcessorCapabilities> Metadata
        => _metadata ??= new Lazy<IArgumentProcessorCapabilities>(() =>
            new ListFullyQualifiedTestsArgumentProcessorCapabilities());

    /// <summary>
    /// Gets or sets the executor.
    /// </summary>
    public Lazy<IArgumentExecutor>? Executor
    {
        get => _executor ??= new Lazy<IArgumentExecutor>(() =>
            new ListFullyQualifiedTestsArgumentExecutor(
                CommandLineOptions.Instance,
                RunSettingsManager.Instance,
                TestRequestManager.Instance));

        set => _executor = value;
    }
}

internal class ListFullyQualifiedTestsArgumentProcessorCapabilities : BaseArgumentProcessorCapabilities
{
    public override string CommandName => ListFullyQualifiedTestsArgumentProcessor.CommandName;

    public override bool AllowMultiple => false;

    public override bool IsAction => true;

    public override ArgumentProcessorPriority Priority => ArgumentProcessorPriority.Normal;
}

/// <summary>
/// Argument Executor for the "/ListTests" command line argument.
/// </summary>
internal class ListFullyQualifiedTestsArgumentExecutor : IArgumentExecutor
{
    /// <summary>
    /// Used for getting sources.
    /// </summary>
    private readonly CommandLineOptions _commandLineOptions;

    /// <summary>
    /// Used for getting tests.
    /// </summary>
    private readonly ITestRequestManager _testRequestManager;

    /// <summary>
    /// Used for sending output.
    /// </summary>
    internal IOutput Output;

    /// <summary>
    /// RunSettingsManager to get currently active run settings.
    /// </summary>
    private readonly IRunSettingsProvider _runSettingsManager;

    /// <summary>
    /// Registers for discovery events during discovery
    /// </summary>
    private readonly ITestDiscoveryEventsRegistrar _discoveryEventsRegistrar;

    /// <summary>
    /// List to store the discovered tests
    /// </summary>
    private readonly List<string> _discoveredTests = new();

    /// <summary>
    /// Default constructor.
    /// </summary>
    /// <param name="options">
    /// The options.
    /// </param>
    /// <param name="runSettingsProvider">Runsettings provider.</param>
    /// <param name="testRequestManager">Test request manager.</param>
    public ListFullyQualifiedTestsArgumentExecutor(
        CommandLineOptions options,
        IRunSettingsProvider runSettingsProvider,
        ITestRequestManager testRequestManager) :
        this(options, runSettingsProvider, testRequestManager, ConsoleOutput.Instance)
    {
    }

    /// <summary>
    /// Default constructor.
    /// </summary>
    /// <param name="options">
    /// The options.
    /// </param>
    /// <param name="runSettingsProvider">Runsettings provider.</param>
    /// <param name="testRequestManager">Test request manager.</param>
    /// <param name="output">Output such as console.</param>
    internal ListFullyQualifiedTestsArgumentExecutor(
        CommandLineOptions options,
        IRunSettingsProvider runSettingsProvider,
        ITestRequestManager testRequestManager,
        IOutput output)
    {
        ValidateArg.NotNull(options, nameof(options));

        _commandLineOptions = options;
        Output = output;
        _testRequestManager = testRequestManager;

        _runSettingsManager = runSettingsProvider;
        _discoveryEventsRegistrar = new DiscoveryEventsRegistrar(_discoveredTests, _commandLineOptions);
    }

    #region IArgumentExecutor

    /// <summary>
    /// Initializes with the argument that was provided with the command.
    /// </summary>
    /// <param name="argument">Argument that was provided with the command.</param>
    public void Initialize(string? argument)
    {
        if (!argument.IsNullOrWhiteSpace())
        {
            _commandLineOptions.AddSource(argument);
        }
    }

    /// <summary>
    /// Lists out the available discoverers.
    /// </summary>
    public ArgumentProcessorResult Execute()
    {
        TPDebug.Assert(Output != null);
        TPDebug.Assert(_commandLineOptions != null);
        TPDebug.Assert(!StringUtils.IsNullOrWhiteSpace(_runSettingsManager?.ActiveRunSettings?.SettingsXml));

        if (!_commandLineOptions.Sources.Any())
        {
            throw new CommandLineException(CommandLineResources.MissingTestSourceFile);
        }

        if (!EqtTrace.LogFile.IsNullOrEmpty())
        {
            Output.Information(false, CommandLineResources.VstestDiagLogOutputPath, EqtTrace.LogFile);
        }

        var runSettings = _runSettingsManager.ActiveRunSettings.SettingsXml;

        _testRequestManager.DiscoverTests(
            new DiscoveryRequestPayload { Sources = _commandLineOptions.Sources, RunSettings = runSettings },
            _discoveryEventsRegistrar, Constants.DefaultProtocolConfig);

        if (_commandLineOptions.ListTestsTargetPath.IsNullOrEmpty())
        {
            // This string does not need to go to Resources. Reason - only internal consumption
            throw new CommandLineException("Target Path should be specified for listing FQDN tests!");
        }

        File.WriteAllLines(_commandLineOptions.ListTestsTargetPath, _discoveredTests);
        return ArgumentProcessorResult.Success;
    }

    #endregion

    private class DiscoveryEventsRegistrar : ITestDiscoveryEventsRegistrar
    {
        private readonly List<string> _discoveredTests;
        private readonly CommandLineOptions _options;

        public DiscoveryEventsRegistrar(List<string> discoveredTests, CommandLineOptions cmdOptions)
        {
            _discoveredTests = discoveredTests;
            _options = cmdOptions;
        }

        public void LogWarning(string message)
        {
            ConsoleLogger.RaiseTestRunWarning(message);
        }
        public void RegisterDiscoveryEvents(IDiscoveryRequest discoveryRequest)
        {
            discoveryRequest.OnDiscoveredTests += DiscoveryRequest_OnDiscoveredTests;
        }

        public void UnregisterDiscoveryEvents(IDiscoveryRequest discoveryRequest)
        {
            discoveryRequest.OnDiscoveredTests -= DiscoveryRequest_OnDiscoveredTests;
        }

        private void DiscoveryRequest_OnDiscoveredTests(object? sender, DiscoveredTestsEventArgs args)
        {
            if (args == null)
            {
                throw new TestPlatformException("DiscoveredTestsEventArgs cannot be null.");
            }

            // Initializing the test case filter here because the filter value is read late.
            TestCaseFilter.Initialize(_options.TestCaseFilterValue);
            var discoveredTests = args.DiscoveredTestCases!.ToList();
            var filteredTests = TestCaseFilter.FilterTests(discoveredTests).ToList();

            // remove any duplicate tests
            filteredTests = filteredTests.Select(test => test.FullyQualifiedName)
                .Distinct()
                .Select(fqdn => filteredTests.First(test => test.FullyQualifiedName == fqdn))
                .ToList();
            _discoveredTests.AddRange(filteredTests.Select(test => test.FullyQualifiedName));
        }
    }

    private static class TestCaseFilter
    {
        private static TestCaseFilterExpression? s_filterExpression;
        private const string TestCategory = "TestCategory";
        private const string Category = "Category";
        private const string Traits = "Traits";

        public static void Initialize(string? filterString)
        {
            ValidateFilter(filterString);
        }

        /// <summary>
        /// Filter tests
        /// </summary>
        public static IEnumerable<TestCase> FilterTests(IEnumerable<TestCase> testCases)
        {
            EqtTrace.Verbose("TestCaseFilter.FilterTests : Test Filtering invoked.");

            List<TestCase> filteredList;

            try
            {
                filteredList = GetFilteredTestCases(testCases);
            }
            catch (Exception ex)
            {
                EqtTrace.Error("TestCaseFilter.FilterTests : Exception during filtering : {0}", ex.ToString());
                throw;
            }

            return filteredList;
        }

        private static void ValidateFilter(string? filterString)
        {
            if (filterString.IsNullOrEmpty())
            {
                s_filterExpression = null;
                return;
            }

            var filterWrapper = new FilterExpressionWrapper(filterString);

            if (filterWrapper.ParseError != null)
            {
                var fe = new FormatException($"Invalid Test Case Filter: {filterString}");
                EqtTrace.Error("TestCaseFilter.ValidateFilter : Filtering failed with exception : " + fe.Message);
                throw fe;
            }

            s_filterExpression = new TestCaseFilterExpression(filterWrapper);

        }

        /// <summary>
        /// get list of test cases that satisfy the filter criteria
        /// </summary>
        private static List<TestCase> GetFilteredTestCases(IEnumerable<TestCase> testCases)
        {
            var filteredList = new List<TestCase>();

            if (s_filterExpression == null)
            {
                filteredList = testCases.ToList();
                return filteredList;
            }

            foreach (var testCase in testCases)
            {
                var traitDictionary = GetTestPropertiesInTraitDictionary(testCase);// Dictionary with trait key to value mapping
                traitDictionary = GetTraitsInTraitDictionary(traitDictionary, testCase.Traits);

                // Skip test if not fitting filter criteria.
                if (!s_filterExpression.MatchTestCase(testCase, p => PropertyValueProvider(p, traitDictionary)))
                {
                    continue;
                }

                filteredList.Add(testCase);
            }

            return filteredList;
        }

        /// <summary>
        /// fetch the test properties on this test method as traits and populate a trait dictionary
        /// </summary>
        private static Dictionary<string, List<string>> GetTestPropertiesInTraitDictionary(TestCase testCase)
        {
            var traitDictionary = new Dictionary<string, List<string>>();
            foreach (var testProperty in testCase.Properties)
            {
                string testPropertyKey = testProperty.Label;

                if (testPropertyKey.Equals(Traits))
                {
                    // skip the "Traits" property. traits to be set separately
                    continue;
                }

                var testPropertyValue = testCase.GetPropertyValue(testProperty);

                if (testPropertyKey.Equals(TestCategory))
                {
                    if (testPropertyValue is string[] testPropertyValueArray)
                    {
                        var testPropertyValueList = new List<string>(testPropertyValueArray);
                        traitDictionary.Add(testPropertyKey, testPropertyValueList);
                        continue;
                    }
                }

                //always return value as a list of string
                if (testPropertyValue != null)
                {
                    var multiValue = new List<string> { testPropertyValue.ToString()! };
                    traitDictionary.Add(testPropertyKey, multiValue);
                }
            }

            return traitDictionary;
        }

        /// <summary>
        /// fetch the traits on this test method and populate a trait dictionary
        /// </summary>
        private static Dictionary<string, List<string>> GetTraitsInTraitDictionary(Dictionary<string, List<string>> traitDictionary, TraitCollection traits)
        {
            foreach (var trait in traits)
            {
                var newTraitValueList = new List<string> { trait.Value };
                if (!traitDictionary.TryGetValue(trait.Name, out List<string>? currentTraitValue))
                {
                    // if the current trait's key is not already present, add the current trait key-value pair
                    traitDictionary.Add(trait.Name, newTraitValueList);
                }
                else
                {
                    if (null == currentTraitValue)
                    {
                        // if the current trait's value is null, replace the previous value with the current value
                        traitDictionary[trait.Name] = newTraitValueList;
                    }
                    else
                    {
                        // if the current trait's key is already present and is not null, append current value to the previous value list
                        List<string> traitValueList = currentTraitValue;
                        traitValueList.Add(trait.Value);
                    }
                }
            }

            //This is hack for NUnit, XUnit to understand test category -> This method is called only for NUnit/XUnit
            if (!traitDictionary.ContainsKey(TestCategory) && traitDictionary.ContainsKey(Category))
            {
                traitDictionary.TryGetValue(Category, out var categoryValue);
                traitDictionary.Add(TestCategory, categoryValue!);
            }

            return traitDictionary;
        }

        /// <summary>
        /// Provides value for property name 'propertyName' as used in filter.
        /// </summary>
        private static string[]? PropertyValueProvider(string propertyName, Dictionary<string, List<string>> traitDictionary)
        {
            traitDictionary.TryGetValue(propertyName, out List<string>? propertyValueList);
            if (propertyValueList != null)
            {
                var propertyValueArray = propertyValueList.ToArray();

                return propertyValueArray;
            }
            return null;
        }
    }
}