diff --git a/src/Adapter/MSTest.Engine/Engine/BFSTestNodeVisitor.cs b/src/Adapter/MSTest.Engine/Engine/BFSTestNodeVisitor.cs index faa7301a1b..faf62d6319 100644 --- a/src/Adapter/MSTest.Engine/Engine/BFSTestNodeVisitor.cs +++ b/src/Adapter/MSTest.Engine/Engine/BFSTestNodeVisitor.cs @@ -1,12 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under dual-license. See LICENSE.PLATFORMTOOLS.txt file in the project root for full license information. -using System.Web; - using Microsoft.Testing.Framework.Helpers; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Requests; +using PlatformTestNode = Microsoft.Testing.Platform.Extensions.Messages.TestNode; + namespace Microsoft.Testing.Framework; internal sealed class BFSTestNodeVisitor @@ -18,11 +18,6 @@ internal sealed class BFSTestNodeVisitor public BFSTestNodeVisitor(IEnumerable rootTestNodes, ITestExecutionFilter testExecutionFilter, TestArgumentsManager testArgumentsManager) { - if (testExecutionFilter is not TreeNodeFilter and not TestNodeUidListFilter and not NopFilter) - { - throw new ArgumentOutOfRangeException(nameof(testExecutionFilter)); - } - _rootTestNodes = rootTestNodes; _testExecutionFilter = testExecutionFilter; _testArgumentsManager = testArgumentsManager; @@ -34,15 +29,15 @@ public async Task VisitAsync(Func onIncludedTestNo { // This is case sensitive, and culture insensitive, to keep UIDs unique, and comparable between different system. Dictionary> testNodesByUid = []; - Queue<(TestNode CurrentNode, TestNodeUid? ParentNodeUid, StringBuilder NodeFullPath)> queue = new(); + Queue<(TestNode CurrentNode, TestNodeUid? ParentNodeUid)> queue = new(); foreach (TestNode node in _rootTestNodes) { - queue.Enqueue((node, null, new())); + queue.Enqueue((node, null)); } while (queue.Count > 0) { - (TestNode currentNode, TestNodeUid? parentNodeUid, StringBuilder nodeFullPath) = queue.Dequeue(); + (TestNode currentNode, TestNodeUid? parentNodeUid) = queue.Dequeue(); if (!testNodesByUid.TryGetValue(currentNode.StableUid, out List? testNodes)) { @@ -52,44 +47,28 @@ public async Task VisitAsync(Func onIncludedTestNo testNodes.Add(currentNode); - StringBuilder nodeFullPathForChildren = new StringBuilder().Append(nodeFullPath); - - if (nodeFullPathForChildren.Length == 0 - || nodeFullPathForChildren[^1] != TreeNodeFilter.PathSeparator) + if (TestArgumentsManager.IsExpandableTestNode(currentNode)) { - nodeFullPathForChildren.Append(TreeNodeFilter.PathSeparator); + currentNode = await _testArgumentsManager.ExpandTestNodeAsync(currentNode).ConfigureAwait(false); } - // We want to encode the path fragment to avoid conflicts with the separator. We are using URL encoding because it is - // a well-known proven standard encoding that is reversible. - nodeFullPathForChildren.Append(EncodeString(currentNode.OverriddenEdgeName ?? currentNode.DisplayName)); - string currentNodeFullPath = nodeFullPathForChildren.ToString(); - - // When we are filtering as tree filter and the current node does not match the filter, we skip the node and its children. - if (_testExecutionFilter is TreeNodeFilter treeNodeFilter) + PlatformTestNode platformTestNode = new() { - if (!treeNodeFilter.MatchesFilter(currentNodeFullPath, CreatePropertyBagForFilter(currentNode.Properties))) - { - continue; - } - } + Uid = currentNode.StableUid.ToPlatformTestNodeUid(), + DisplayName = currentNode.DisplayName, + Properties = CreatePropertyBagForFilter(currentNode.Properties), + }; - // If the node is expandable, we expand it (replacing the original node) - if (TestArgumentsManager.IsExpandableTestNode(currentNode)) + if (!_testExecutionFilter.Matches(platformTestNode)) { - currentNode = await _testArgumentsManager.ExpandTestNodeAsync(currentNode).ConfigureAwait(false); + continue; } - // If the node is not filtered out by the test execution filter, we call the callback with the node. - if (_testExecutionFilter is not TestNodeUidListFilter listFilter - || listFilter.TestNodeUids.Any(uid => currentNode.StableUid.ToPlatformTestNodeUid() == uid)) - { - await onIncludedTestNodeAsync(currentNode, parentNodeUid).ConfigureAwait(false); - } + await onIncludedTestNodeAsync(currentNode, parentNodeUid).ConfigureAwait(false); foreach (TestNode childNode in currentNode.Tests) { - queue.Enqueue((childNode, currentNode.StableUid, nodeFullPathForChildren)); + queue.Enqueue((childNode, currentNode.StableUid)); } } @@ -106,7 +85,4 @@ private static PropertyBag CreatePropertyBagForFilter(IProperty[] properties) return propertyBag; } - - private static string EncodeString(string value) - => HttpUtility.UrlEncode(value); } diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs index f5c38c1721..a506d8ac62 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs @@ -438,26 +438,17 @@ private async Task ExecuteRequestAsync(RequestArgsBase args, s // catch and propagated as correct json rpc error cancellationToken.ThrowIfCancellationRequested(); - // Note: Currently the request generation and filtering isn't extensible - // in server mode, we create NoOp services, so that they're always available. + ITestExecutionFilter executionFilter = await _testSessionManager + .ResolveRequestFilterAsync(args, perRequestServiceProvider) + .ConfigureAwait(false); + ServerTestExecutionRequestFactory requestFactory = new(session => - { - ICollection? testNodes = args.TestNodes; - string? filter = args.GraphFilter; - ITestExecutionFilter executionFilter = testNodes is not null - ? new TestNodeUidListFilter(testNodes.Select(node => node.Uid).ToArray()) - : filter is not null - ? new TreeNodeFilter(filter) - : new NopFilter(); - - return method == JsonRpcMethods.TestingRunTests + method == JsonRpcMethods.TestingRunTests ? new RunTestExecutionRequest(session, executionFilter) : method == JsonRpcMethods.TestingDiscoverTests ? new DiscoverTestExecutionRequest(session, executionFilter) - : throw new NotImplementedException($"Request not implemented '{method}'"); - }); + : throw new NotImplementedException($"Request not implemented '{method}'")); - // Build the per request objects ServerTestExecutionFilterFactory filterFactory = new(); TestHostTestFrameworkInvoker invoker = new(perRequestServiceProvider); PerRequestServerDataConsumer testNodeUpdateProcessor = new(perRequestServiceProvider, this, args.RunId, perRequestServiceProvider.GetTask()); diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs index 76e68664cc..10a95d6c22 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs @@ -457,6 +457,14 @@ await LogTestHostCreatedAsync( // ServerMode and Console mode uses different host if (hasServerFlag && isJsonRpcProtocol) { + var testHostManager = (TestHostManager)TestHost; + if (!testHostManager.HasRequestFilterProviders()) + { + testHostManager.AddRequestFilterProvider(sp => new TestNodeUidRequestFilterProvider()); + testHostManager.AddRequestFilterProvider(sp => new TreeNodeRequestFilterProvider()); + testHostManager.AddRequestFilterProvider(sp => new NopRequestFilterProvider()); + } + // Build the server mode with the user preferences IMessageHandlerFactory messageHandlerFactory = ServerModeManager.Build(serviceProvider); @@ -464,7 +472,7 @@ await LogTestHostCreatedAsync( // note that we pass the BuildTestFrameworkAsync as callback because server mode will call it per-request // this is not needed in console mode where we have only 1 request. ServerTestHost serverTestHost = - new(serviceProvider, BuildTestFrameworkAsync, messageHandlerFactory, (TestFrameworkManager)TestFramework, (TestHostManager)TestHost); + new(serviceProvider, BuildTestFrameworkAsync, messageHandlerFactory, (TestFrameworkManager)TestFramework, testHostManager); // If needed we wrap the host inside the TestHostControlledHost to automatically handle the shutdown of the connected pipe. IHost actualTestHost = testControllerConnection is not null @@ -483,8 +491,14 @@ await LogTestHostCreatedAsync( } else { - // Add custom ITestExecutionFilterFactory to the service list if available - ActionResult testExecutionFilterFactoryResult = await ((TestHostManager)TestHost).TryBuildTestExecutionFilterFactoryAsync(serviceProvider).ConfigureAwait(false); + var testHostManager = (TestHostManager)TestHost; + if (!testHostManager.HasFilterFactories()) + { + testHostManager.AddTestExecutionFilterFactory(serviceProvider => + new ConsoleTestExecutionFilterFactory(serviceProvider.GetCommandLineOptions())); + } + + ActionResult testExecutionFilterFactoryResult = await testHostManager.TryBuildTestExecutionFilterFactoryAsync(serviceProvider).ConfigureAwait(false); if (testExecutionFilterFactoryResult.IsSuccess) { serviceProvider.TryAddService(testExecutionFilterFactoryResult.Result); diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index a4da3966c6..85f6a5ff21 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -125,3 +125,24 @@ Microsoft.Testing.Platform.Extensions.Messages.TestNodeStateProperty.TestNodeSta *REMOVED*virtual Microsoft.Testing.Platform.Extensions.Messages.FileArtifactProperty.EqualityContract.get -> System.Type! *REMOVED*virtual Microsoft.Testing.Platform.Extensions.Messages.FileArtifactProperty.Equals(Microsoft.Testing.Platform.Extensions.Messages.FileArtifactProperty? other) -> bool *REMOVED*virtual Microsoft.Testing.Platform.Extensions.Messages.FileArtifactProperty.PrintMembers(System.Text.StringBuilder! builder) -> bool +Microsoft.Testing.Platform.Requests.ITestExecutionFilter.Matches(Microsoft.Testing.Platform.Extensions.Messages.TestNode! testNode) -> bool +Microsoft.Testing.Platform.Requests.TestNodeUidListFilter.Matches(Microsoft.Testing.Platform.Extensions.Messages.TestNode! testNode) -> bool +[TPEXP]Microsoft.Testing.Platform.Requests.AggregateTestExecutionFilter +[TPEXP]Microsoft.Testing.Platform.Requests.AggregateTestExecutionFilter.AggregateTestExecutionFilter(System.Collections.Generic.IReadOnlyCollection! innerFilters) -> void +[TPEXP]Microsoft.Testing.Platform.Requests.AggregateTestExecutionFilter.InnerFilters.get -> System.Collections.Generic.IReadOnlyCollection! +[TPEXP]Microsoft.Testing.Platform.Requests.AggregateTestExecutionFilter.Matches(Microsoft.Testing.Platform.Extensions.Messages.TestNode! testNode) -> bool +[TPEXP]Microsoft.Testing.Platform.Requests.IRequestFilterProvider +[TPEXP]Microsoft.Testing.Platform.Requests.IRequestFilterProvider.CanHandle(System.IServiceProvider! serviceProvider) -> bool +[TPEXP]Microsoft.Testing.Platform.Requests.IRequestFilterProvider.CreateFilterAsync(System.IServiceProvider! serviceProvider) -> System.Threading.Tasks.Task! +[TPEXP]Microsoft.Testing.Platform.Requests.ITestExecutionFilterFactory +[TPEXP]Microsoft.Testing.Platform.Requests.ITestExecutionFilterFactory.TryCreateAsync() -> System.Threading.Tasks.Task<(bool Success, Microsoft.Testing.Platform.Requests.ITestExecutionFilter? TestExecutionFilter)>! +[TPEXP]Microsoft.Testing.Platform.Requests.ITestExecutionRequestContext +[TPEXP]Microsoft.Testing.Platform.Requests.ITestExecutionRequestContext.TestNodes.get -> System.Collections.Generic.ICollection? +[TPEXP]Microsoft.Testing.Platform.Requests.NopRequestFilterProvider +[TPEXP]Microsoft.Testing.Platform.Requests.NopRequestFilterProvider.NopRequestFilterProvider() -> void +[TPEXP]Microsoft.Testing.Platform.Requests.TestNodeUidRequestFilterProvider +[TPEXP]Microsoft.Testing.Platform.Requests.TestNodeUidRequestFilterProvider.TestNodeUidRequestFilterProvider() -> void +[TPEXP]Microsoft.Testing.Platform.Requests.TreeNodeRequestFilterProvider +[TPEXP]Microsoft.Testing.Platform.Requests.TreeNodeRequestFilterProvider.TreeNodeRequestFilterProvider() -> void +[TPEXP]Microsoft.Testing.Platform.TestHost.ITestHostManager.AddRequestFilterProvider(System.Func! requestFilterProvider) -> void +[TPEXP]Microsoft.Testing.Platform.TestHost.ITestHostManager.AddTestExecutionFilterFactory(System.Func! testExecutionFilterFactory) -> void diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/AggregateTestExecutionFilter.cs b/src/Platform/Microsoft.Testing.Platform/Requests/AggregateTestExecutionFilter.cs new file mode 100644 index 0000000000..2c92c6f36e --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/AggregateTestExecutionFilter.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace Microsoft.Testing.Platform.Requests; + +/// +/// Represents an aggregate filter that combines multiple test execution filters using AND logic. +/// A test node must match all inner filters to pass this aggregate filter. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +[SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "Experimental API")] +public sealed class AggregateTestExecutionFilter : ITestExecutionFilter +{ + /// + /// Initializes a new instance of the class. + /// + /// The collection of inner filters to aggregate. + public AggregateTestExecutionFilter(IReadOnlyCollection innerFilters) + { + Guard.NotNull(innerFilters); + if (innerFilters.Count == 0) + { + throw new ArgumentException("At least one inner filter must be provided.", nameof(innerFilters)); + } + + InnerFilters = innerFilters; + } + + /// + /// Gets the collection of inner filters. + /// + public IReadOnlyCollection InnerFilters { get; } + + /// + public bool Matches(TestNode testNode) + { + // AND logic: all inner filters must match + foreach (ITestExecutionFilter filter in InnerFilters) + { + if (!filter.Matches(testNode)) + { + return false; + } + } + + return true; + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/IRequestFilterProvider.cs b/src/Platform/Microsoft.Testing.Platform/Requests/IRequestFilterProvider.cs new file mode 100644 index 0000000000..1f552d4887 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/IRequestFilterProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions; + +namespace Microsoft.Testing.Platform.Requests; + +/// +/// Provides a filter for server-mode test execution requests. +/// Providers query request-specific information from the service provider. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +public interface IRequestFilterProvider : IExtension +{ + /// + /// Determines whether this provider can handle the current request context. + /// + /// The service provider containing request-specific services. + /// true if this provider can create a filter for the current request; otherwise, false. + bool CanHandle(IServiceProvider serviceProvider); + + /// + /// Creates a test execution filter for the current request. + /// + /// The service provider containing request-specific services. + /// A test execution filter. + Task CreateFilterAsync(IServiceProvider serviceProvider); +} diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionFilter.cs b/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionFilter.cs index 2073f78dcc..dad8c86411 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionFilter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionFilter.cs @@ -1,9 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Microsoft.Testing.Platform.Extensions.Messages; + namespace Microsoft.Testing.Platform.Requests; /// /// Represents a filter for test execution. /// -public interface ITestExecutionFilter; +public interface ITestExecutionFilter +{ + /// + /// Determines whether the specified test node matches the filter criteria. + /// + /// The test node to evaluate. + /// true if the test node matches the filter; otherwise, false. + bool Matches(TestNode testNode); +} diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionFilterFactory.cs b/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionFilterFactory.cs index 37b51a3d82..81b03249ee 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionFilterFactory.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionFilterFactory.cs @@ -5,7 +5,16 @@ namespace Microsoft.Testing.Platform.Requests; -internal interface ITestExecutionFilterFactory : IExtension +/// +/// Factory for creating test execution filters in console mode. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +[SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "Experimental API")] +public interface ITestExecutionFilterFactory : IExtension { + /// + /// Attempts to create a test execution filter. + /// + /// A task containing a tuple with success status and the created filter if successful. Task<(bool Success, ITestExecutionFilter? TestExecutionFilter)> TryCreateAsync(); } diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionRequestContext.cs b/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionRequestContext.cs new file mode 100644 index 0000000000..8cb8534878 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionRequestContext.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace Microsoft.Testing.Platform.Requests; + +/// +/// Provides access to test execution request context for filter providers. +/// Available in the per-request service provider in server mode. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +public interface ITestExecutionRequestContext +{ + /// + /// Gets the collection of test nodes specified in the request, or null if not filtering by specific nodes. + /// + ICollection? TestNodes { get; } +} diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/NopFilter.cs b/src/Platform/Microsoft.Testing.Platform/Requests/NopFilter.cs index 40785fa7a2..8332875aeb 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/NopFilter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/NopFilter.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Microsoft.Testing.Platform.Extensions.Messages; + namespace Microsoft.Testing.Platform.Requests; /// @@ -8,4 +10,8 @@ namespace Microsoft.Testing.Platform.Requests; /// [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] [SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "Experimental API")] -public sealed class NopFilter : ITestExecutionFilter; +public sealed class NopFilter : ITestExecutionFilter +{ + /// + public bool Matches(TestNode testNode) => true; +} diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/NopRequestFilterProvider.cs b/src/Platform/Microsoft.Testing.Platform/Requests/NopRequestFilterProvider.cs new file mode 100644 index 0000000000..ee91aeb332 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/NopRequestFilterProvider.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Helpers; + +namespace Microsoft.Testing.Platform.Requests; + +/// +/// Provides a no-op filter that allows all tests to execute. +/// This is the fallback provider when no other provider can handle the request. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +[SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "Experimental API")] +public sealed class NopRequestFilterProvider : IRequestFilterProvider +{ + /// + public string Uid => nameof(NopRequestFilterProvider); + + /// + public string Version => AppVersion.DefaultSemVer; + + /// + public string DisplayName => "No-Operation Filter Provider"; + + /// + public string Description => "Fallback provider that allows all tests to execute"; + + /// + public Task IsEnabledAsync() => Task.FromResult(true); + + /// + public bool CanHandle(IServiceProvider serviceProvider) => true; + + /// + public Task CreateFilterAsync(IServiceProvider serviceProvider) + { + ITestExecutionFilter filter = new NopFilter(); + return Task.FromResult(filter); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/TestExecutionRequestContext.cs b/src/Platform/Microsoft.Testing.Platform/Requests/TestExecutionRequestContext.cs new file mode 100644 index 0000000000..b86fb8d0a5 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TestExecutionRequestContext.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.ServerMode; + +namespace Microsoft.Testing.Platform.Requests; + +internal sealed class TestExecutionRequestContext : ITestExecutionRequestContext +{ + public TestExecutionRequestContext(RequestArgsBase requestArgs) + { + TestNodes = requestArgs.TestNodes; + } + + public ICollection? TestNodes { get; } +} diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidListFilter.cs b/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidListFilter.cs index 43ab5940cc..8119b1aa4d 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidListFilter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidListFilter.cs @@ -20,4 +20,7 @@ public sealed class TestNodeUidListFilter : ITestExecutionFilter /// Gets the test node UIDs to filter. /// public TestNodeUid[] TestNodeUids { get; } + + /// + public bool Matches(TestNode testNode) => TestNodeUids.Contains(testNode.Uid); } diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidRequestFilterProvider.cs b/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidRequestFilterProvider.cs new file mode 100644 index 0000000000..c544284e62 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidRequestFilterProvider.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Services; + +namespace Microsoft.Testing.Platform.Requests; + +/// +/// Provides test execution filters based on TestNode UIDs from server-mode requests. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +[SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "Experimental API")] +public sealed class TestNodeUidRequestFilterProvider : IRequestFilterProvider +{ + /// + public string Uid => nameof(TestNodeUidRequestFilterProvider); + + /// + public string Version => AppVersion.DefaultSemVer; + + /// + public string DisplayName => "TestNode UID Request Filter Provider"; + + /// + public string Description => "Creates filters for requests that specify test nodes by UID"; + + /// + public Task IsEnabledAsync() => Task.FromResult(true); + + /// + public bool CanHandle(IServiceProvider serviceProvider) + { + ITestExecutionRequestContext? context = serviceProvider.GetServiceInternal(); + return context?.TestNodes is not null; + } + + /// + public Task CreateFilterAsync(IServiceProvider serviceProvider) + { + ITestExecutionRequestContext context = serviceProvider.GetRequiredService(); + Guard.NotNull(context.TestNodes); + + ITestExecutionFilter filter = new TestNodeUidListFilter([.. context.TestNodes.Select(node => node.Uid)]); + return Task.FromResult(filter); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs index d9cf58ac73..f7c4eeab15 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs @@ -476,6 +476,25 @@ private static IEnumerable TokenizeFilter(string filter) } } + /// + public bool Matches(TestNode testNode) + { + Guard.NotNull(testNode); + + TestMethodIdentifierProperty? methodIdentifier = testNode.Properties + .OfType() + .FirstOrDefault(); + + string fullPath = methodIdentifier is not null + ? $"{PathSeparator}{Uri.EscapeDataString(new AssemblyName(methodIdentifier.AssemblyFullName).Name ?? string.Empty)}" + + $"{PathSeparator}{Uri.EscapeDataString(methodIdentifier.Namespace)}" + + $"{PathSeparator}{Uri.EscapeDataString(methodIdentifier.TypeName)}" + + $"{PathSeparator}{Uri.EscapeDataString(methodIdentifier.MethodName)}" + : $"{PathSeparator}*{PathSeparator}*{PathSeparator}*{PathSeparator}{Uri.EscapeDataString(testNode.DisplayName)}"; + + return MatchesFilter(fullPath, testNode.Properties); + } + /// /// Checks whether a node path matches the tree node filter. /// diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeRequestFilterProvider.cs b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeRequestFilterProvider.cs new file mode 100644 index 0000000000..0a5b7bc11a --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeRequestFilterProvider.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Services; + +namespace Microsoft.Testing.Platform.Requests; + +/// +/// Provides test execution filters based on tree-based graph filters from command line options. +/// +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +[SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "Experimental API")] +public sealed class TreeNodeRequestFilterProvider : IRequestFilterProvider +{ + /// + public string Uid => nameof(TreeNodeRequestFilterProvider); + + /// + public string Version => AppVersion.DefaultSemVer; + + /// + public string DisplayName => "TreeNode Graph Filter Provider"; + + /// + public string Description => "Creates filters for requests that specify a graph filter expression"; + + /// + public Task IsEnabledAsync() => Task.FromResult(true); + + /// + public bool CanHandle(IServiceProvider serviceProvider) + { + ICommandLineOptions commandLineOptions = serviceProvider.GetRequiredService(); + return commandLineOptions.TryGetOptionArgumentList(TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter, out _); + } + + /// + public Task CreateFilterAsync(IServiceProvider serviceProvider) + { + ICommandLineOptions commandLineOptions = serviceProvider.GetRequiredService(); + bool hasFilter = commandLineOptions.TryGetOptionArgumentList(TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter, out string[]? treenodeFilter); + ApplicationStateGuard.Ensure(hasFilter); + Guard.NotNull(treenodeFilter); + + ITestExecutionFilter filter = new TreeNodeFilter(treenodeFilter[0]); + return Task.FromResult(filter); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/TestHost/ITestHostManager.cs b/src/Platform/Microsoft.Testing.Platform/TestHost/ITestHostManager.cs index 862c3e50a6..4dcde6a1f0 100644 --- a/src/Platform/Microsoft.Testing.Platform/TestHost/ITestHostManager.cs +++ b/src/Platform/Microsoft.Testing.Platform/TestHost/ITestHostManager.cs @@ -3,6 +3,7 @@ using Microsoft.Testing.Platform.Extensions; using Microsoft.Testing.Platform.Extensions.TestHost; +using Microsoft.Testing.Platform.Requests; namespace Microsoft.Testing.Platform.TestHost; @@ -44,4 +45,21 @@ void AddDataConsumer(CompositeExtensionFactory compositeServiceFactory) /// The composite extension factory for creating the test session lifetime handle. void AddTestSessionLifetimeHandle(CompositeExtensionFactory compositeServiceFactory) where T : class, ITestSessionLifetimeHandler; + + /// + /// Adds a test execution filter factory. Multiple filter factories can be registered, + /// and if more than one filter is enabled, they will be combined using AND logic. + /// + /// The factory method for creating the test execution filter factory. + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + void AddTestExecutionFilterFactory(Func testExecutionFilterFactory); + + /// + /// Adds a request filter provider for server-mode test execution requests. + /// Multiple providers can be registered and will be evaluated in registration order. + /// The first provider that can handle the request will create the filter. + /// + /// The factory method for creating the request filter provider. + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + void AddRequestFilterProvider(Func requestFilterProvider); } diff --git a/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs b/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs index 0a076ac6bc..8be52a2b6d 100644 --- a/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs +++ b/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs @@ -23,7 +23,8 @@ internal sealed class TestHostManager : ITestHostManager private readonly List _testSessionLifetimeHandlerCompositeFactories = []; // Non-exposed extension points - private Func? _testExecutionFilterFactory; + private readonly List> _testExecutionFilterFactories = []; + private readonly List> _requestFilterProviders = []; private Func? _testFrameworkInvokerFactory; public void AddTestFrameworkInvoker(Func testFrameworkInvokerFactory) @@ -60,32 +61,93 @@ internal async Task> TryBuildTestAdapterInvo public void AddTestExecutionFilterFactory(Func testExecutionFilterFactory) { Guard.NotNull(testExecutionFilterFactory); - if (_testExecutionFilterFactory is not null) + _testExecutionFilterFactories.Add(testExecutionFilterFactory); + } + + internal bool HasFilterFactories() => _testExecutionFilterFactories.Count > 0; + + internal async Task> TryBuildTestExecutionFilterFactoryAsync(ServiceProvider serviceProvider) + { + List filters = []; + foreach (Func factory in _testExecutionFilterFactories) + { + ITestExecutionFilterFactory filterFactory = factory(serviceProvider); + + if (await filterFactory.IsEnabledAsync().ConfigureAwait(false)) + { + await filterFactory.TryInitializeAsync().ConfigureAwait(false); + + (bool success, ITestExecutionFilter? filter) = await filterFactory.TryCreateAsync().ConfigureAwait(false); + if (success && filter is not null) + { + filters.Add(filter); + } + } + } + + if (filters.Count == 0) + { + return ActionResult.Ok(new SingleFilterFactory(new NopFilter())); + } + + if (filters.Count > 1) { - throw new InvalidOperationException(PlatformResources.TEstExecutionFilterFactoryFactoryAlreadySetErrorMessage); + ITestExecutionFilter aggregateFilter = new AggregateTestExecutionFilter(filters); + return ActionResult.Ok(new SingleFilterFactory(aggregateFilter)); } - _testExecutionFilterFactory = testExecutionFilterFactory; + return ActionResult.Ok(new SingleFilterFactory(filters[0])); } - internal async Task> TryBuildTestExecutionFilterFactoryAsync(ServiceProvider serviceProvider) + /// + /// Internal factory that wraps a single pre-built filter. + /// + private sealed class SingleFilterFactory : ITestExecutionFilterFactory { - if (_testExecutionFilterFactory is null) + private readonly ITestExecutionFilter _filter; + + public SingleFilterFactory(ITestExecutionFilter filter) { - return ActionResult.Fail(); + _filter = filter; } - ITestExecutionFilterFactory testExecutionFilterFactory = _testExecutionFilterFactory(serviceProvider); + public string Uid => nameof(SingleFilterFactory); - // We initialize only if enabled - if (await testExecutionFilterFactory.IsEnabledAsync().ConfigureAwait(false)) + public string Version => AppVersion.DefaultSemVer; + + public string DisplayName => "Single Filter Factory"; + + public string Description => "Factory that wraps a pre-built filter"; + + public Task IsEnabledAsync() => Task.FromResult(true); + + public Task<(bool Success, ITestExecutionFilter? TestExecutionFilter)> TryCreateAsync() + => Task.FromResult((true, (ITestExecutionFilter?)_filter)); + } + + public void AddRequestFilterProvider(Func requestFilterProvider) + { + Guard.NotNull(requestFilterProvider); + _requestFilterProviders.Add(requestFilterProvider); + } + + internal bool HasRequestFilterProviders() => _requestFilterProviders.Count > 0; + + internal async Task ResolveRequestFilterAsync(ServerMode.RequestArgsBase args, ServiceProvider serviceProvider) + { + serviceProvider.AddService(new TestExecutionRequestContext(args)); + + foreach (Func providerFactory in _requestFilterProviders) { - await testExecutionFilterFactory.TryInitializeAsync().ConfigureAwait(false); + IRequestFilterProvider provider = providerFactory(serviceProvider); - return ActionResult.Ok(testExecutionFilterFactory); + if (await provider.IsEnabledAsync().ConfigureAwait(false) && provider.CanHandle(serviceProvider)) + { + return await provider.CreateFilterAsync(serviceProvider).ConfigureAwait(false); + } } - return ActionResult.Fail(); + return new NopFilter(); } public void AddTestHostApplicationLifetime(Func testHostApplicationLifetime) diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs new file mode 100644 index 0000000000..3d8c87d51f --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs @@ -0,0 +1,178 @@ +// 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.Diagnostics.CodeAnalysis; + +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Requests; +using Microsoft.Testing.Platform.ServerMode; +using Microsoft.Testing.Platform.Services; + +namespace Microsoft.Testing.Platform.UnitTests; + +[TestClass] +public sealed class RequestFilterProviderTests +{ + private sealed class MockCommandLineOptions : ICommandLineOptions + { + private readonly Dictionary _options = []; + + public void AddOption(string key, string[] values) => _options[key] = values; + + public bool IsOptionSet(string optionName) => _options.ContainsKey(optionName); + + public bool TryGetOptionArgumentList(string optionName, [NotNullWhen(true)] out string[]? arguments) + { + if (_options.TryGetValue(optionName, out string[]? values)) + { + arguments = values; + return true; + } + + arguments = null; + return false; + } + } + + [TestMethod] + public void TestNodeUidRequestFilterProvider_CanHandle_ReturnsTrueWhenTestNodesProvided() + { + TestNodeUidRequestFilterProvider provider = new(); + TestNode[] testNodes = [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }]; + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(Guid.NewGuid(), testNodes, null))); + + bool result = provider.CanHandle(serviceProvider); + + Assert.IsTrue(result); + } + + [TestMethod] + public void TestNodeUidRequestFilterProvider_CanHandle_ReturnsFalseWhenTestNodesNull() + { + TestNodeUidRequestFilterProvider provider = new(); + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(Guid.NewGuid(), null, "/Some/Filter"))); + + bool result = provider.CanHandle(serviceProvider); + + Assert.IsFalse(result); + } + + [TestMethod] + public async Task TestNodeUidRequestFilterProvider_CreateFilterAsync_CreatesTestNodeUidListFilter() + { + TestNodeUidRequestFilterProvider provider = new(); + TestNode[] testNodes = + [ + new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }, + new() { Uid = new TestNodeUid("test2"), DisplayName = "Test2" }, + ]; + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(Guid.NewGuid(), testNodes, null))); + + ITestExecutionFilter filter = await provider.CreateFilterAsync(serviceProvider); + + Assert.IsInstanceOfType(filter); + var uidFilter = (TestNodeUidListFilter)filter; + Assert.HasCount(2, uidFilter.TestNodeUids); + } + + [TestMethod] + public void TreeNodeRequestFilterProvider_CanHandle_ReturnsTrueWhenGraphFilterProvided() + { + TreeNodeRequestFilterProvider provider = new(); + ServiceProvider serviceProvider = new(); + var commandLineOptions = new MockCommandLineOptions(); + commandLineOptions.AddOption(TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter, ["/**/Test*"]); + serviceProvider.AddService(commandLineOptions); + + bool result = provider.CanHandle(serviceProvider); + + Assert.IsTrue(result); + } + + [TestMethod] + public void TreeNodeRequestFilterProvider_CanHandle_ReturnsFalseWhenGraphFilterNull() + { + TreeNodeRequestFilterProvider provider = new(); + ServiceProvider serviceProvider = new(); + var commandLineOptions = new MockCommandLineOptions(); + serviceProvider.AddService(commandLineOptions); + + bool result = provider.CanHandle(serviceProvider); + + Assert.IsFalse(result); + } + + [TestMethod] + public async Task TreeNodeRequestFilterProvider_CreateFilterAsync_CreatesTreeNodeFilter() + { + TreeNodeRequestFilterProvider provider = new(); + ServiceProvider serviceProvider = new(); + var commandLineOptions = new MockCommandLineOptions(); + commandLineOptions.AddOption(TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter, ["/**/Test*"]); + serviceProvider.AddService(commandLineOptions); + + ITestExecutionFilter filter = await provider.CreateFilterAsync(serviceProvider); + + Assert.IsInstanceOfType(filter); + } + + [TestMethod] + public void NopRequestFilterProvider_CanHandle_AlwaysReturnsTrue() + { + NopRequestFilterProvider provider = new(); + ServiceProvider serviceProvider1 = new(); + ServiceProvider serviceProvider2 = new(); + ServiceProvider serviceProvider3 = new(); + + Assert.IsTrue(provider.CanHandle(serviceProvider1)); + Assert.IsTrue(provider.CanHandle(serviceProvider2)); + Assert.IsTrue(provider.CanHandle(serviceProvider3)); + } + + [TestMethod] + public async Task NopRequestFilterProvider_CreateFilterAsync_CreatesNopFilter() + { + NopRequestFilterProvider provider = new(); + ServiceProvider serviceProvider = new(); + + ITestExecutionFilter filter = await provider.CreateFilterAsync(serviceProvider); + + Assert.IsInstanceOfType(filter); + } + + [TestMethod] + public void ProviderChain_TestNodeUidProvider_HasHigherPriorityThanGraphFilter() + { + TestNodeUidRequestFilterProvider uidProvider = new(); + TreeNodeRequestFilterProvider treeProvider = new(); + TestNode[] testNodes = [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }]; + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(Guid.NewGuid(), testNodes, "/**/Test*"))); + var commandLineOptions = new MockCommandLineOptions(); + commandLineOptions.AddOption(TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter, ["/**/Test*"]); + serviceProvider.AddService(commandLineOptions); + + Assert.IsTrue(uidProvider.CanHandle(serviceProvider), "UID provider should handle when TestNodes is provided"); + Assert.IsTrue(treeProvider.CanHandle(serviceProvider), "Tree provider should also handle when GraphFilter is provided"); + } + + [TestMethod] + public void ProviderChain_NopProvider_ActsAsFallback() + { + TestNodeUidRequestFilterProvider uidProvider = new(); + TreeNodeRequestFilterProvider treeProvider = new(); + NopRequestFilterProvider nopProvider = new(); + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(Guid.NewGuid(), null, null))); + var commandLineOptions = new MockCommandLineOptions(); + serviceProvider.AddService(commandLineOptions); + + Assert.IsFalse(uidProvider.CanHandle(serviceProvider), "UID provider should not handle when TestNodes is null"); + Assert.IsFalse(treeProvider.CanHandle(serviceProvider), "Tree provider should not handle when GraphFilter is null"); + Assert.IsTrue(nopProvider.CanHandle(serviceProvider), "Nop provider should always handle"); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/TestExecutionFilterTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/TestExecutionFilterTests.cs new file mode 100644 index 0000000000..374f2018e9 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/TestExecutionFilterTests.cs @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Requests; + +namespace Microsoft.Testing.Platform.UnitTests; + +[TestClass] +public sealed class TestExecutionFilterTests +{ + [TestMethod] + public void NopFilter_Matches_ReturnsTrue() + { + // Arrange + NopFilter filter = new(); + TestNode testNode = new() + { + Uid = new TestNodeUid("test1"), + DisplayName = "TestMethod1", + }; + + // Act + bool result = filter.Matches(testNode); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public void TestNodeUidListFilter_Matches_ReturnsTrueWhenUidInList() + { + // Arrange + TestNodeUid uid1 = new("test1"); + TestNodeUid uid2 = new("test2"); + TestNodeUidListFilter filter = new([uid1, uid2]); + TestNode testNode = new() + { + Uid = uid1, + DisplayName = "TestMethod1", + }; + + // Act + bool result = filter.Matches(testNode); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public void TestNodeUidListFilter_Matches_ReturnsFalseWhenUidNotInList() + { + // Arrange + TestNodeUid uid1 = new("test1"); + TestNodeUid uid2 = new("test2"); + TestNodeUid uid3 = new("test3"); + TestNodeUidListFilter filter = new([uid1, uid2]); + TestNode testNode = new() + { + Uid = uid3, + DisplayName = "TestMethod3", + }; + + // Act + bool result = filter.Matches(testNode); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void TreeNodeFilter_Matches_UsesTestMethodIdentifierProperty() + { + // Arrange + TreeNodeFilter filter = new("/*MyNamespace*/**"); + TestMethodIdentifierProperty methodIdentifier = new( + assemblyFullName: "MyAssembly", + @namespace: "MyNamespace.SubNamespace", + typeName: "MyTestClass", + methodName: "MyTestMethod", + methodArity: 0, + parameterTypeFullNames: [], + returnTypeFullName: "System.Void"); + + TestNode testNode = new() + { + Uid = new TestNodeUid("test1"), + DisplayName = "MyTestMethod", + Properties = new PropertyBag(methodIdentifier), + }; + + // Act + bool result = filter.Matches(testNode); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public void TreeNodeFilter_Matches_FallsBackToDisplayNameWhenNoMethodIdentifier() + { + // Arrange + TreeNodeFilter filter = new("/*MyTest*"); + TestNode testNode = new() + { + Uid = new TestNodeUid("test1"), + DisplayName = "MyTestMethod", + }; + + // Act + bool result = filter.Matches(testNode); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public void TreeNodeFilter_Matches_MatchesNamespaceTypeMethod() + { + // Arrange + TreeNodeFilter filter = new("/MyNamespace.SubNamespace/MyTestClass/MyTestMethod"); + TestMethodIdentifierProperty methodIdentifier = new( + assemblyFullName: "MyAssembly", + @namespace: "MyNamespace.SubNamespace", + typeName: "MyTestClass", + methodName: "MyTestMethod", + methodArity: 0, + parameterTypeFullNames: [], + returnTypeFullName: "System.Void"); + + TestNode testNode = new() + { + Uid = new TestNodeUid("test1"), + DisplayName = "MyTestMethod", + Properties = new PropertyBag(methodIdentifier), + }; + + // Act + bool result = filter.Matches(testNode); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public void AggregateTestExecutionFilter_Matches_ReturnsTrueWhenAllFiltersMatch() + { + // Arrange + TestNodeUid uid1 = new("test1"); + TestNodeUidListFilter uidFilter = new([uid1]); + TreeNodeFilter treeFilter = new("/**"); + + AggregateTestExecutionFilter aggregateFilter = new([uidFilter, treeFilter]); + + TestNode testNode = new() + { + Uid = uid1, + DisplayName = "TestMethod1", + }; + + // Act + bool result = aggregateFilter.Matches(testNode); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public void AggregateTestExecutionFilter_Matches_ReturnsFalseWhenAnyFilterDoesNotMatch() + { + // Arrange + TestNodeUid uid1 = new("test1"); + TestNodeUid uid2 = new("test2"); + TestNodeUidListFilter uidFilter = new([uid1]); // Only matches uid1 + TreeNodeFilter treeFilter = new("/**"); // Matches everything + + AggregateTestExecutionFilter aggregateFilter = new([uidFilter, treeFilter]); + + TestNode testNode = new() + { + Uid = uid2, // Different UID + DisplayName = "TestMethod2", + }; + + // Act + bool result = aggregateFilter.Matches(testNode); + + // Assert + Assert.IsFalse(result); // Should be false because uidFilter doesn't match + } + + [TestMethod] + public void AggregateTestExecutionFilter_Constructor_ThrowsWhenNoFiltersProvided() => + Assert.ThrowsExactly(() => new AggregateTestExecutionFilter([])); + + [TestMethod] + public void AggregateTestExecutionFilter_InnerFilters_ReturnsProvidedFilters() + { + // Arrange + TestNodeUidListFilter uidFilter = new([new TestNodeUid("test1")]); + TreeNodeFilter treeFilter = new("/**"); + List filters = [uidFilter, treeFilter]; + + // Act + AggregateTestExecutionFilter aggregateFilter = new(filters); + + // Assert + Assert.HasCount(2, aggregateFilter.InnerFilters); + Assert.IsTrue(aggregateFilter.InnerFilters.Contains(uidFilter)); + Assert.IsTrue(aggregateFilter.InnerFilters.Contains(treeFilter)); + } + + [TestMethod] + public void AggregateTestExecutionFilter_Matches_ANDLogic_AllMustMatch() + { + // Arrange + TestNodeUid targetUid = new("test1"); + + // Filter 1: Only matches "test1" UID + TestNodeUidListFilter uidFilter = new([targetUid]); + + // Filter 2: Only matches names starting with "Test" + TreeNodeFilter nameFilter = new("/Test*"); + + AggregateTestExecutionFilter aggregateFilter = new([uidFilter, nameFilter]); + + // Test case 1: Matches both filters + TestNode matchingNode = new() + { + Uid = targetUid, + DisplayName = "TestMethod1", + }; + + // Test case 2: Matches UID but not name + TestNode wrongNameNode = new() + { + Uid = targetUid, + DisplayName = "MyMethod", + }; + + // Test case 3: Matches name but not UID + TestNode wrongUidNode = new() + { + Uid = new TestNodeUid("test2"), + DisplayName = "TestMethod2", + }; + + // Act & Assert + Assert.IsTrue(aggregateFilter.Matches(matchingNode), "Should match when both filters match"); + Assert.IsFalse(aggregateFilter.Matches(wrongNameNode), "Should not match when name filter fails"); + Assert.IsFalse(aggregateFilter.Matches(wrongUidNode), "Should not match when UID filter fails"); + } +}