From 069b5d5f16d0f3b59872a1eb330a855bb0dc97fb Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:29:41 +0100 Subject: [PATCH 1/4] Filter Extensibility --- .../Engine/BFSTestNodeVisitor.cs | 56 ++-- .../Hosts/ServerTestHost.cs | 23 +- .../Hosts/TestHostBuilder.cs | 10 +- .../PublicAPI/PublicAPI.Unshipped.txt | 6 + .../Requests/AggregateTestExecutionFilter.cs | 50 ++++ .../Requests/ITestExecutionFilter.cs | 12 +- .../Requests/NopFilter.cs | 8 +- .../Requests/TestNodeUidListFilter.cs | 3 + .../Requests/TreeNodeFilter/TreeNodeFilter.cs | 25 ++ .../TestHost/ITestHostManager.cs | 9 + .../TestHost/TestHostManager.cs | 66 ++++- .../Requests/TestExecutionFilterTests.cs | 256 ++++++++++++++++++ 12 files changed, 450 insertions(+), 74 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform/Requests/AggregateTestExecutionFilter.cs create mode 100644 test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/TestExecutionFilterTests.cs 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..3682cfb8b5 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs @@ -438,26 +438,19 @@ 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 = args.TestNodes is not null + ? new TestNodeUidListFilter(args.TestNodes.Select(node => node.Uid).ToArray()) + : args.GraphFilter is not null + ? new TreeNodeFilter(args.GraphFilter) + : new NopFilter(); + 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..f9342dfbaf 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs @@ -483,8 +483,14 @@ await LogTestHostCreatedAsync( } else { - // Add custom ITestExecutionFilterFactory to the service list if available - ActionResult testExecutionFilterFactoryResult = await ((TestHostManager)TestHost).TryBuildTestExecutionFilterFactoryAsync(serviceProvider).ConfigureAwait(false); + TestHostManager 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..1f5125fe9a 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -125,3 +125,9 @@ 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 +[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.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/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/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/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/TreeNodeFilter/TreeNodeFilter.cs b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs index d9cf58ac73..b6a7cbeb7d 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs @@ -476,6 +476,31 @@ private static IEnumerable TokenizeFilter(string filter) } } + /// + public bool Matches(TestNode testNode) + { + Guard.NotNull(testNode); + + TestMethodIdentifierProperty? methodIdentifier = testNode.Properties + .OfType() + .FirstOrDefault(); + + string fullPath; + if (methodIdentifier is not null) + { + fullPath = $"{PathSeparator}{Uri.EscapeDataString(new AssemblyName(methodIdentifier.AssemblyFullName).Name)}" + + $"{PathSeparator}{Uri.EscapeDataString(methodIdentifier.Namespace)}" + + $"{PathSeparator}{Uri.EscapeDataString(methodIdentifier.TypeName)}" + + $"{PathSeparator}{Uri.EscapeDataString(methodIdentifier.MethodName)}"; + } + else + { + fullPath = $"{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/TestHost/ITestHostManager.cs b/src/Platform/Microsoft.Testing.Platform/TestHost/ITestHostManager.cs index 862c3e50a6..c813df37b5 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,12 @@ 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); } diff --git a/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs b/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs index 0a076ac6bc..aed6ae7955 100644 --- a/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs +++ b/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs @@ -23,7 +23,7 @@ internal sealed class TestHostManager : ITestHostManager private readonly List _testSessionLifetimeHandlerCompositeFactories = []; // Non-exposed extension points - private Func? _testExecutionFilterFactory; + private readonly List> _testExecutionFilterFactories = []; private Func? _testFrameworkInvokerFactory; public void AddTestFrameworkInvoker(Func testFrameworkInvokerFactory) @@ -60,32 +60,68 @@ internal async Task> TryBuildTestAdapterInvo public void AddTestExecutionFilterFactory(Func testExecutionFilterFactory) { Guard.NotNull(testExecutionFilterFactory); - if (_testExecutionFilterFactory is not null) - { - throw new InvalidOperationException(PlatformResources.TEstExecutionFilterFactoryFactoryAlreadySetErrorMessage); - } - - _testExecutionFilterFactory = testExecutionFilterFactory; + _testExecutionFilterFactories.Add(testExecutionFilterFactory); } + internal bool HasFilterFactories() => _testExecutionFilterFactories.Count > 0; + internal async Task> TryBuildTestExecutionFilterFactoryAsync(ServiceProvider serviceProvider) { - if (_testExecutionFilterFactory is null) + List filters = []; + foreach (Func factory in _testExecutionFilterFactories) { - return ActionResult.Fail(); + 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); + } + } } - ITestExecutionFilterFactory testExecutionFilterFactory = _testExecutionFilterFactory(serviceProvider); + if (filters.Count == 0) + { + return ActionResult.Ok(new SingleFilterFactory(new NopFilter())); + } - // We initialize only if enabled - if (await testExecutionFilterFactory.IsEnabledAsync().ConfigureAwait(false)) + if (filters.Count > 1) { - await testExecutionFilterFactory.TryInitializeAsync().ConfigureAwait(false); + ITestExecutionFilter aggregateFilter = new AggregateTestExecutionFilter(filters); + return ActionResult.Ok(new SingleFilterFactory(aggregateFilter)); + } + + return ActionResult.Ok(new SingleFilterFactory(filters[0])); + } + + /// + /// Internal factory that wraps a single pre-built filter. + /// + private sealed class SingleFilterFactory : ITestExecutionFilterFactory + { + private readonly ITestExecutionFilter _filter; - return ActionResult.Ok(testExecutionFilterFactory); + public SingleFilterFactory(ITestExecutionFilter filter) + { + _filter = filter; } - return ActionResult.Fail(); + public string Uid => nameof(SingleFilterFactory); + + 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 AddTestHostApplicationLifetime(Func testHostApplicationLifetime) 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..4a72613221 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/TestExecutionFilterTests.cs @@ -0,0 +1,256 @@ +// 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() + { + // Act & Assert + 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.AreEqual(2, aggregateFilter.InnerFilters.Count); + 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"); + } +} From d62489e3e761b9107b45065c03de3b0a3959428b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:42:41 +0100 Subject: [PATCH 2/4] Implement request filter providers for server-mode test execution --- .../Hosts/ServerTestHost.cs | 8 +- .../Hosts/TestHostBuilder.cs | 10 +- .../Requests/IRequestFilterProvider.cs | 27 ++++ .../Requests/NopRequestFilterProvider.cs | 32 ++++ .../TestNodeUidRequestFilterProvider.cs | 32 ++++ .../Requests/TreeNodeRequestFilterProvider.cs | 32 ++++ .../TestHost/TestHostManager.cs | 24 +++ .../Requests/RequestFilterProviderTests.cs | 138 ++++++++++++++++++ 8 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform/Requests/IRequestFilterProvider.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/Requests/NopRequestFilterProvider.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidRequestFilterProvider.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeRequestFilterProvider.cs create mode 100644 test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs index 3682cfb8b5..a506d8ac62 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs @@ -438,11 +438,9 @@ private async Task ExecuteRequestAsync(RequestArgsBase args, s // catch and propagated as correct json rpc error cancellationToken.ThrowIfCancellationRequested(); - ITestExecutionFilter executionFilter = args.TestNodes is not null - ? new TestNodeUidListFilter(args.TestNodes.Select(node => node.Uid).ToArray()) - : args.GraphFilter is not null - ? new TreeNodeFilter(args.GraphFilter) - : new NopFilter(); + ITestExecutionFilter executionFilter = await _testSessionManager + .ResolveRequestFilterAsync(args, perRequestServiceProvider) + .ConfigureAwait(false); ServerTestExecutionRequestFactory requestFactory = new(session => method == JsonRpcMethods.TestingRunTests diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs index f9342dfbaf..e15f502f1a 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) { + TestHostManager 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 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..cadb14905c --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/IRequestFilterProvider.cs @@ -0,0 +1,27 @@ +// 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; +using Microsoft.Testing.Platform.ServerMode; + +namespace Microsoft.Testing.Platform.Requests; + +/// +/// Provides a filter for server-mode test execution requests based on request arguments. +/// +internal interface IRequestFilterProvider : IExtension +{ + /// + /// Determines whether this provider can handle the given request arguments. + /// + /// The request arguments. + /// true if this provider can create a filter for the given arguments; otherwise, false. + bool CanHandle(RequestArgsBase args); + + /// + /// Creates a test execution filter for the given request arguments. + /// + /// The request arguments. + /// A test execution filter. + Task CreateFilterAsync(RequestArgsBase args); +} 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..cd37145347 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/NopRequestFilterProvider.cs @@ -0,0 +1,32 @@ +// 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.ServerMode; + +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. +/// +internal 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(RequestArgsBase args) => true; + + public Task CreateFilterAsync(RequestArgsBase args) + { + ITestExecutionFilter filter = new NopFilter(); + return Task.FromResult(filter); + } +} 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..1cae0b03b0 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidRequestFilterProvider.cs @@ -0,0 +1,32 @@ +// 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.ServerMode; + +namespace Microsoft.Testing.Platform.Requests; + +/// +/// Provides test execution filters based on TestNode UIDs from server-mode requests. +/// +internal 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(RequestArgsBase args) => args.TestNodes is not null; + + public Task CreateFilterAsync(RequestArgsBase args) + { + Guard.NotNull(args.TestNodes); + ITestExecutionFilter filter = new TestNodeUidListFilter(args.TestNodes.Select(node => node.Uid).ToArray()); + return Task.FromResult(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..882a7c6b94 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeRequestFilterProvider.cs @@ -0,0 +1,32 @@ +// 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.ServerMode; + +namespace Microsoft.Testing.Platform.Requests; + +/// +/// Provides test execution filters based on tree-based graph filters from server-mode requests. +/// +internal 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(RequestArgsBase args) => args.GraphFilter is not null; + + public Task CreateFilterAsync(RequestArgsBase args) + { + Guard.NotNull(args.GraphFilter); + ITestExecutionFilter filter = new TreeNodeFilter(args.GraphFilter); + return Task.FromResult(filter); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs b/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs index aed6ae7955..7663053380 100644 --- a/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs +++ b/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs @@ -24,6 +24,7 @@ internal sealed class TestHostManager : ITestHostManager // Non-exposed extension points private readonly List> _testExecutionFilterFactories = []; + private readonly List> _requestFilterProviders = []; private Func? _testFrameworkInvokerFactory; public void AddTestFrameworkInvoker(Func testFrameworkInvokerFactory) @@ -124,6 +125,29 @@ public SingleFilterFactory(ITestExecutionFilter filter) => Task.FromResult((true, (ITestExecutionFilter?)_filter)); } + internal 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) + { + foreach (Func providerFactory in _requestFilterProviders) + { + IRequestFilterProvider provider = providerFactory(serviceProvider); + + if (await provider.IsEnabledAsync().ConfigureAwait(false) && provider.CanHandle(args)) + { + return await provider.CreateFilterAsync(args).ConfigureAwait(false); + } + } + + return new NopFilter(); + } + public void AddTestHostApplicationLifetime(Func testHostApplicationLifetime) { Guard.NotNull(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..6214feeadb --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs @@ -0,0 +1,138 @@ +// 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; +using Microsoft.Testing.Platform.ServerMode; + +namespace Microsoft.Testing.Platform.UnitTests; + +[TestClass] +public sealed class RequestFilterProviderTests +{ + [TestMethod] + public void TestNodeUidRequestFilterProvider_CanHandle_ReturnsTrueWhenTestNodesProvided() + { + TestNodeUidRequestFilterProvider provider = new(); + TestNode[] testNodes = [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }]; + RequestArgsBase args = new(Guid.NewGuid(), testNodes, null); + + bool result = provider.CanHandle(args); + + Assert.IsTrue(result); + } + + [TestMethod] + public void TestNodeUidRequestFilterProvider_CanHandle_ReturnsFalseWhenTestNodesNull() + { + TestNodeUidRequestFilterProvider provider = new(); + RequestArgsBase args = new(Guid.NewGuid(), null, "/Some/Filter"); + + bool result = provider.CanHandle(args); + + 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" }, + ]; + RequestArgsBase args = new(Guid.NewGuid(), testNodes, null); + + ITestExecutionFilter filter = await provider.CreateFilterAsync(args); + + Assert.IsInstanceOfType(filter); + TestNodeUidListFilter uidFilter = (TestNodeUidListFilter)filter; + Assert.AreEqual(2, uidFilter.TestNodeUids.Length); + } + + [TestMethod] + public void TreeNodeRequestFilterProvider_CanHandle_ReturnsTrueWhenGraphFilterProvided() + { + TreeNodeRequestFilterProvider provider = new(); + RequestArgsBase args = new(Guid.NewGuid(), null, "/**/Test*"); + + bool result = provider.CanHandle(args); + + Assert.IsTrue(result); + } + + [TestMethod] + public void TreeNodeRequestFilterProvider_CanHandle_ReturnsFalseWhenGraphFilterNull() + { + TreeNodeRequestFilterProvider provider = new(); + TestNode[] testNodes = [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }]; + RequestArgsBase args = new(Guid.NewGuid(), testNodes, null); + + bool result = provider.CanHandle(args); + + Assert.IsFalse(result); + } + + [TestMethod] + public async Task TreeNodeRequestFilterProvider_CreateFilterAsync_CreatesTreeNodeFilter() + { + TreeNodeRequestFilterProvider provider = new(); + RequestArgsBase args = new(Guid.NewGuid(), null, "/**/Test*"); + + ITestExecutionFilter filter = await provider.CreateFilterAsync(args); + + Assert.IsInstanceOfType(filter); + } + + [TestMethod] + public void NopRequestFilterProvider_CanHandle_AlwaysReturnsTrue() + { + NopRequestFilterProvider provider = new(); + RequestArgsBase args1 = new(Guid.NewGuid(), null, null); + RequestArgsBase args2 = new(Guid.NewGuid(), [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }], null); + RequestArgsBase args3 = new(Guid.NewGuid(), null, "/**/Test*"); + + Assert.IsTrue(provider.CanHandle(args1)); + Assert.IsTrue(provider.CanHandle(args2)); + Assert.IsTrue(provider.CanHandle(args3)); + } + + [TestMethod] + public async Task NopRequestFilterProvider_CreateFilterAsync_CreatesNopFilter() + { + NopRequestFilterProvider provider = new(); + RequestArgsBase args = new(Guid.NewGuid(), null, null); + + ITestExecutionFilter filter = await provider.CreateFilterAsync(args); + + Assert.IsInstanceOfType(filter); + } + + [TestMethod] + public void ProviderChain_TestNodeUidProvider_HasHigherPriorityThanGraphFilter() + { + TestNodeUidRequestFilterProvider uidProvider = new(); + TreeNodeRequestFilterProvider treeProvider = new(); + + TestNode[] testNodes = [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }]; + RequestArgsBase args = new(Guid.NewGuid(), testNodes, "/**/Test*"); + + Assert.IsTrue(uidProvider.CanHandle(args), "UID provider should handle when TestNodes is provided"); + Assert.IsTrue(treeProvider.CanHandle(args), "Tree provider should also handle when GraphFilter is provided"); + } + + [TestMethod] + public void ProviderChain_NopProvider_ActsAsFallback() + { + TestNodeUidRequestFilterProvider uidProvider = new(); + TreeNodeRequestFilterProvider treeProvider = new(); + NopRequestFilterProvider nopProvider = new(); + + RequestArgsBase args = new(Guid.NewGuid(), null, null); + + Assert.IsFalse(uidProvider.CanHandle(args), "UID provider should not handle when TestNodes is null"); + Assert.IsFalse(treeProvider.CanHandle(args), "Tree provider should not handle when GraphFilter is null"); + Assert.IsTrue(nopProvider.CanHandle(args), "Nop provider should always handle"); + } +} From 0cd61dff2df0181ec7f6eb22744ced5bdd379e43 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 6 Oct 2025 18:20:51 +0100 Subject: [PATCH 3/4] Add request filter provider interfaces and implementations for server-mode test execution --- .../Hosts/TestHostBuilder.cs | 4 +- .../PublicAPI/PublicAPI.Unshipped.txt | 15 +++ .../Requests/IRequestFilterProvider.cs | 21 ++-- .../Requests/ITestExecutionFilterFactory.cs | 11 +- .../Requests/ITestExecutionRequestContext.cs | 19 ++++ .../Requests/NopRequestFilterProvider.cs | 16 ++- .../Requests/TestExecutionRequestContext.cs | 17 +++ .../TestNodeUidRequestFilterProvider.cs | 27 +++-- .../Requests/TreeNodeFilter/TreeNodeFilter.cs | 18 ++-- .../Requests/TreeNodeRequestFilterProvider.cs | 32 ++++-- .../TestHost/ITestHostManager.cs | 9 ++ .../TestHost/TestHostManager.cs | 8 +- .../Requests/RequestFilterProviderTests.cs | 102 ++++++++++++------ 13 files changed, 222 insertions(+), 77 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform/Requests/ITestExecutionRequestContext.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/Requests/TestExecutionRequestContext.cs diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs index e15f502f1a..10a95d6c22 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs @@ -457,7 +457,7 @@ await LogTestHostCreatedAsync( // ServerMode and Console mode uses different host if (hasServerFlag && isJsonRpcProtocol) { - TestHostManager testHostManager = (TestHostManager)TestHost; + var testHostManager = (TestHostManager)TestHost; if (!testHostManager.HasRequestFilterProviders()) { testHostManager.AddRequestFilterProvider(sp => new TestNodeUidRequestFilterProvider()); @@ -491,7 +491,7 @@ await LogTestHostCreatedAsync( } else { - TestHostManager testHostManager = (TestHostManager)TestHost; + var testHostManager = (TestHostManager)TestHost; if (!testHostManager.HasFilterFactories()) { testHostManager.AddTestExecutionFilterFactory(serviceProvider => diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index 1f5125fe9a..85f6a5ff21 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -126,8 +126,23 @@ Microsoft.Testing.Platform.Extensions.Messages.TestNodeStateProperty.TestNodeSta *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/IRequestFilterProvider.cs b/src/Platform/Microsoft.Testing.Platform/Requests/IRequestFilterProvider.cs index cadb14905c..1f552d4887 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/IRequestFilterProvider.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/IRequestFilterProvider.cs @@ -2,26 +2,27 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.Platform.Extensions; -using Microsoft.Testing.Platform.ServerMode; namespace Microsoft.Testing.Platform.Requests; /// -/// Provides a filter for server-mode test execution requests based on request arguments. +/// Provides a filter for server-mode test execution requests. +/// Providers query request-specific information from the service provider. /// -internal interface IRequestFilterProvider : IExtension +[Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] +public interface IRequestFilterProvider : IExtension { /// - /// Determines whether this provider can handle the given request arguments. + /// Determines whether this provider can handle the current request context. /// - /// The request arguments. - /// true if this provider can create a filter for the given arguments; otherwise, false. - bool CanHandle(RequestArgsBase args); + /// 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 given request arguments. + /// Creates a test execution filter for the current request. /// - /// The request arguments. + /// The service provider containing request-specific services. /// A test execution filter. - Task CreateFilterAsync(RequestArgsBase args); + Task CreateFilterAsync(IServiceProvider serviceProvider); } 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/NopRequestFilterProvider.cs b/src/Platform/Microsoft.Testing.Platform/Requests/NopRequestFilterProvider.cs index cd37145347..ee91aeb332 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/NopRequestFilterProvider.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/NopRequestFilterProvider.cs @@ -2,7 +2,6 @@ // 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.ServerMode; namespace Microsoft.Testing.Platform.Requests; @@ -10,21 +9,30 @@ 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. /// -internal sealed class NopRequestFilterProvider : IRequestFilterProvider +[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(RequestArgsBase args) => true; + /// + public bool CanHandle(IServiceProvider serviceProvider) => true; - public Task CreateFilterAsync(RequestArgsBase args) + /// + 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/TestNodeUidRequestFilterProvider.cs b/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidRequestFilterProvider.cs index 1cae0b03b0..c544284e62 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidRequestFilterProvider.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TestNodeUidRequestFilterProvider.cs @@ -2,31 +2,46 @@ // 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.ServerMode; +using Microsoft.Testing.Platform.Services; namespace Microsoft.Testing.Platform.Requests; /// /// Provides test execution filters based on TestNode UIDs from server-mode requests. /// -internal sealed class TestNodeUidRequestFilterProvider : IRequestFilterProvider +[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(RequestArgsBase args) => args.TestNodes is not null; + /// + public bool CanHandle(IServiceProvider serviceProvider) + { + ITestExecutionRequestContext? context = serviceProvider.GetServiceInternal(); + return context?.TestNodes is not null; + } - public Task CreateFilterAsync(RequestArgsBase args) + /// + public Task CreateFilterAsync(IServiceProvider serviceProvider) { - Guard.NotNull(args.TestNodes); - ITestExecutionFilter filter = new TestNodeUidListFilter(args.TestNodes.Select(node => node.Uid).ToArray()); + 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 b6a7cbeb7d..f7c4eeab15 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs @@ -485,18 +485,12 @@ public bool Matches(TestNode testNode) .OfType() .FirstOrDefault(); - string fullPath; - if (methodIdentifier is not null) - { - fullPath = $"{PathSeparator}{Uri.EscapeDataString(new AssemblyName(methodIdentifier.AssemblyFullName).Name)}" + - $"{PathSeparator}{Uri.EscapeDataString(methodIdentifier.Namespace)}" + - $"{PathSeparator}{Uri.EscapeDataString(methodIdentifier.TypeName)}" + - $"{PathSeparator}{Uri.EscapeDataString(methodIdentifier.MethodName)}"; - } - else - { - fullPath = $"{PathSeparator}*{PathSeparator}*{PathSeparator}*{PathSeparator}{Uri.EscapeDataString(testNode.DisplayName)}"; - } + 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); } diff --git a/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeRequestFilterProvider.cs b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeRequestFilterProvider.cs index 882a7c6b94..0a5b7bc11a 100644 --- a/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeRequestFilterProvider.cs +++ b/src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeRequestFilterProvider.cs @@ -1,32 +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.ServerMode; +using Microsoft.Testing.Platform.Services; namespace Microsoft.Testing.Platform.Requests; /// -/// Provides test execution filters based on tree-based graph filters from server-mode requests. +/// Provides test execution filters based on tree-based graph filters from command line options. /// -internal sealed class TreeNodeRequestFilterProvider : IRequestFilterProvider +[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(RequestArgsBase args) => args.GraphFilter is not null; + /// + public bool CanHandle(IServiceProvider serviceProvider) + { + ICommandLineOptions commandLineOptions = serviceProvider.GetRequiredService(); + return commandLineOptions.TryGetOptionArgumentList(TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter, out _); + } - public Task CreateFilterAsync(RequestArgsBase args) + /// + public Task CreateFilterAsync(IServiceProvider serviceProvider) { - Guard.NotNull(args.GraphFilter); - ITestExecutionFilter filter = new TreeNodeFilter(args.GraphFilter); + 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 c813df37b5..4dcde6a1f0 100644 --- a/src/Platform/Microsoft.Testing.Platform/TestHost/ITestHostManager.cs +++ b/src/Platform/Microsoft.Testing.Platform/TestHost/ITestHostManager.cs @@ -53,4 +53,13 @@ void AddTestSessionLifetimeHandle(CompositeExtensionFactory compositeServi /// 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 7663053380..8be52a2b6d 100644 --- a/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs +++ b/src/Platform/Microsoft.Testing.Platform/TestHost/TestHostManager.cs @@ -125,7 +125,7 @@ public SingleFilterFactory(ITestExecutionFilter filter) => Task.FromResult((true, (ITestExecutionFilter?)_filter)); } - internal void AddRequestFilterProvider(Func requestFilterProvider) + public void AddRequestFilterProvider(Func requestFilterProvider) { Guard.NotNull(requestFilterProvider); _requestFilterProviders.Add(requestFilterProvider); @@ -135,13 +135,15 @@ internal void AddRequestFilterProvider(Func ResolveRequestFilterAsync(ServerMode.RequestArgsBase args, ServiceProvider serviceProvider) { + serviceProvider.AddService(new TestExecutionRequestContext(args)); + foreach (Func providerFactory in _requestFilterProviders) { IRequestFilterProvider provider = providerFactory(serviceProvider); - if (await provider.IsEnabledAsync().ConfigureAwait(false) && provider.CanHandle(args)) + if (await provider.IsEnabledAsync().ConfigureAwait(false) && provider.CanHandle(serviceProvider)) { - return await provider.CreateFilterAsync(args).ConfigureAwait(false); + return await provider.CreateFilterAsync(serviceProvider).ConfigureAwait(false); } } diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs index 6214feeadb..d87b4abd85 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs @@ -1,23 +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.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, 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" }]; - RequestArgsBase args = new(Guid.NewGuid(), testNodes, null); + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(ClientInfo: null, testNodes, GraphFilter: null))); - bool result = provider.CanHandle(args); + bool result = provider.CanHandle(serviceProvider); Assert.IsTrue(result); } @@ -26,9 +50,10 @@ public void TestNodeUidRequestFilterProvider_CanHandle_ReturnsTrueWhenTestNodesP public void TestNodeUidRequestFilterProvider_CanHandle_ReturnsFalseWhenTestNodesNull() { TestNodeUidRequestFilterProvider provider = new(); - RequestArgsBase args = new(Guid.NewGuid(), null, "/Some/Filter"); + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(ClientInfo: null, TestNodes: null, GraphFilter: "/Some/Filter"))); - bool result = provider.CanHandle(args); + bool result = provider.CanHandle(serviceProvider); Assert.IsFalse(result); } @@ -42,9 +67,10 @@ public async Task TestNodeUidRequestFilterProvider_CreateFilterAsync_CreatesTest new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }, new() { Uid = new TestNodeUid("test2"), DisplayName = "Test2" }, ]; - RequestArgsBase args = new(Guid.NewGuid(), testNodes, null); + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(ClientInfo: null, testNodes, GraphFilter: null))); - ITestExecutionFilter filter = await provider.CreateFilterAsync(args); + ITestExecutionFilter filter = await provider.CreateFilterAsync(serviceProvider); Assert.IsInstanceOfType(filter); TestNodeUidListFilter uidFilter = (TestNodeUidListFilter)filter; @@ -55,9 +81,12 @@ public async Task TestNodeUidRequestFilterProvider_CreateFilterAsync_CreatesTest public void TreeNodeRequestFilterProvider_CanHandle_ReturnsTrueWhenGraphFilterProvided() { TreeNodeRequestFilterProvider provider = new(); - RequestArgsBase args = new(Guid.NewGuid(), null, "/**/Test*"); + ServiceProvider serviceProvider = new(); + var commandLineOptions = new MockCommandLineOptions(); + commandLineOptions.AddOption(TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter, ["/**/Test*"]); + serviceProvider.AddService(commandLineOptions); - bool result = provider.CanHandle(args); + bool result = provider.CanHandle(serviceProvider); Assert.IsTrue(result); } @@ -66,10 +95,11 @@ public void TreeNodeRequestFilterProvider_CanHandle_ReturnsTrueWhenGraphFilterPr public void TreeNodeRequestFilterProvider_CanHandle_ReturnsFalseWhenGraphFilterNull() { TreeNodeRequestFilterProvider provider = new(); - TestNode[] testNodes = [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }]; - RequestArgsBase args = new(Guid.NewGuid(), testNodes, null); + ServiceProvider serviceProvider = new(); + var commandLineOptions = new MockCommandLineOptions(); + serviceProvider.AddService(commandLineOptions); - bool result = provider.CanHandle(args); + bool result = provider.CanHandle(serviceProvider); Assert.IsFalse(result); } @@ -78,9 +108,12 @@ public void TreeNodeRequestFilterProvider_CanHandle_ReturnsFalseWhenGraphFilterN public async Task TreeNodeRequestFilterProvider_CreateFilterAsync_CreatesTreeNodeFilter() { TreeNodeRequestFilterProvider provider = new(); - RequestArgsBase args = new(Guid.NewGuid(), null, "/**/Test*"); + ServiceProvider serviceProvider = new(); + var commandLineOptions = new MockCommandLineOptions(); + commandLineOptions.AddOption(TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter, ["/**/Test*"]); + serviceProvider.AddService(commandLineOptions); - ITestExecutionFilter filter = await provider.CreateFilterAsync(args); + ITestExecutionFilter filter = await provider.CreateFilterAsync(serviceProvider); Assert.IsInstanceOfType(filter); } @@ -89,22 +122,22 @@ public async Task TreeNodeRequestFilterProvider_CreateFilterAsync_CreatesTreeNod public void NopRequestFilterProvider_CanHandle_AlwaysReturnsTrue() { NopRequestFilterProvider provider = new(); - RequestArgsBase args1 = new(Guid.NewGuid(), null, null); - RequestArgsBase args2 = new(Guid.NewGuid(), [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }], null); - RequestArgsBase args3 = new(Guid.NewGuid(), null, "/**/Test*"); + ServiceProvider serviceProvider1 = new(); + ServiceProvider serviceProvider2 = new(); + ServiceProvider serviceProvider3 = new(); - Assert.IsTrue(provider.CanHandle(args1)); - Assert.IsTrue(provider.CanHandle(args2)); - Assert.IsTrue(provider.CanHandle(args3)); + 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(); - RequestArgsBase args = new(Guid.NewGuid(), null, null); + ServiceProvider serviceProvider = new(); - ITestExecutionFilter filter = await provider.CreateFilterAsync(args); + ITestExecutionFilter filter = await provider.CreateFilterAsync(serviceProvider); Assert.IsInstanceOfType(filter); } @@ -114,12 +147,15 @@ public void ProviderChain_TestNodeUidProvider_HasHigherPriorityThanGraphFilter() { TestNodeUidRequestFilterProvider uidProvider = new(); TreeNodeRequestFilterProvider treeProvider = new(); - TestNode[] testNodes = [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }]; - RequestArgsBase args = new(Guid.NewGuid(), testNodes, "/**/Test*"); - - Assert.IsTrue(uidProvider.CanHandle(args), "UID provider should handle when TestNodes is provided"); - Assert.IsTrue(treeProvider.CanHandle(args), "Tree provider should also handle when GraphFilter is provided"); + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(ClientInfo: null, testNodes, GraphFilter: "/**/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] @@ -128,11 +164,13 @@ public void ProviderChain_NopProvider_ActsAsFallback() TestNodeUidRequestFilterProvider uidProvider = new(); TreeNodeRequestFilterProvider treeProvider = new(); NopRequestFilterProvider nopProvider = new(); - - RequestArgsBase args = new(Guid.NewGuid(), null, null); - - Assert.IsFalse(uidProvider.CanHandle(args), "UID provider should not handle when TestNodes is null"); - Assert.IsFalse(treeProvider.CanHandle(args), "Tree provider should not handle when GraphFilter is null"); - Assert.IsTrue(nopProvider.CanHandle(args), "Nop provider should always handle"); + ServiceProvider serviceProvider = new(); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(ClientInfo: null, TestNodes: null, GraphFilter: 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"); } } From b277fbbfdc94631c22bb3d34fe2733b44b869296 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 6 Oct 2025 18:40:09 +0100 Subject: [PATCH 4/4] Refactor tests to improve readability and consistency in assertions --- .../Requests/RequestFilterProviderTests.cs | 18 ++++++++++-------- .../Requests/TestExecutionFilterTests.cs | 7 ++----- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs index d87b4abd85..3d8c87d51f 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/RequestFilterProviderTests.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 System.Diagnostics.CodeAnalysis; + using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Requests; @@ -20,7 +22,7 @@ private sealed class MockCommandLineOptions : ICommandLineOptions public bool IsOptionSet(string optionName) => _options.ContainsKey(optionName); - public bool TryGetOptionArgumentList(string optionName, out string[]? arguments) + public bool TryGetOptionArgumentList(string optionName, [NotNullWhen(true)] out string[]? arguments) { if (_options.TryGetValue(optionName, out string[]? values)) { @@ -39,7 +41,7 @@ public void TestNodeUidRequestFilterProvider_CanHandle_ReturnsTrueWhenTestNodesP TestNodeUidRequestFilterProvider provider = new(); TestNode[] testNodes = [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }]; ServiceProvider serviceProvider = new(); - serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(ClientInfo: null, testNodes, GraphFilter: null))); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(Guid.NewGuid(), testNodes, null))); bool result = provider.CanHandle(serviceProvider); @@ -51,7 +53,7 @@ public void TestNodeUidRequestFilterProvider_CanHandle_ReturnsFalseWhenTestNodes { TestNodeUidRequestFilterProvider provider = new(); ServiceProvider serviceProvider = new(); - serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(ClientInfo: null, TestNodes: null, GraphFilter: "/Some/Filter"))); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(Guid.NewGuid(), null, "/Some/Filter"))); bool result = provider.CanHandle(serviceProvider); @@ -68,13 +70,13 @@ public async Task TestNodeUidRequestFilterProvider_CreateFilterAsync_CreatesTest new() { Uid = new TestNodeUid("test2"), DisplayName = "Test2" }, ]; ServiceProvider serviceProvider = new(); - serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(ClientInfo: null, testNodes, GraphFilter: null))); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(Guid.NewGuid(), testNodes, null))); ITestExecutionFilter filter = await provider.CreateFilterAsync(serviceProvider); Assert.IsInstanceOfType(filter); - TestNodeUidListFilter uidFilter = (TestNodeUidListFilter)filter; - Assert.AreEqual(2, uidFilter.TestNodeUids.Length); + var uidFilter = (TestNodeUidListFilter)filter; + Assert.HasCount(2, uidFilter.TestNodeUids); } [TestMethod] @@ -149,7 +151,7 @@ public void ProviderChain_TestNodeUidProvider_HasHigherPriorityThanGraphFilter() TreeNodeRequestFilterProvider treeProvider = new(); TestNode[] testNodes = [new() { Uid = new TestNodeUid("test1"), DisplayName = "Test1" }]; ServiceProvider serviceProvider = new(); - serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(ClientInfo: null, testNodes, GraphFilter: "/**/Test*"))); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(Guid.NewGuid(), testNodes, "/**/Test*"))); var commandLineOptions = new MockCommandLineOptions(); commandLineOptions.AddOption(TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter, ["/**/Test*"]); serviceProvider.AddService(commandLineOptions); @@ -165,7 +167,7 @@ public void ProviderChain_NopProvider_ActsAsFallback() TreeNodeRequestFilterProvider treeProvider = new(); NopRequestFilterProvider nopProvider = new(); ServiceProvider serviceProvider = new(); - serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(ClientInfo: null, TestNodes: null, GraphFilter: null))); + serviceProvider.AddService(new TestExecutionRequestContext(new RunRequestArgs(Guid.NewGuid(), null, null))); var commandLineOptions = new MockCommandLineOptions(); serviceProvider.AddService(commandLineOptions); diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/TestExecutionFilterTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/TestExecutionFilterTests.cs index 4a72613221..374f2018e9 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/TestExecutionFilterTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Requests/TestExecutionFilterTests.cs @@ -190,11 +190,8 @@ public void AggregateTestExecutionFilter_Matches_ReturnsFalseWhenAnyFilterDoesNo } [TestMethod] - public void AggregateTestExecutionFilter_Constructor_ThrowsWhenNoFiltersProvided() - { - // Act & Assert + public void AggregateTestExecutionFilter_Constructor_ThrowsWhenNoFiltersProvided() => Assert.ThrowsExactly(() => new AggregateTestExecutionFilter([])); - } [TestMethod] public void AggregateTestExecutionFilter_InnerFilters_ReturnsProvidedFilters() @@ -208,7 +205,7 @@ public void AggregateTestExecutionFilter_InnerFilters_ReturnsProvidedFilters() AggregateTestExecutionFilter aggregateFilter = new(filters); // Assert - Assert.AreEqual(2, aggregateFilter.InnerFilters.Count); + Assert.HasCount(2, aggregateFilter.InnerFilters); Assert.IsTrue(aggregateFilter.InnerFilters.Contains(uidFilter)); Assert.IsTrue(aggregateFilter.InnerFilters.Contains(treeFilter)); }