diff --git a/TUnit.Engine.Tests/ParenthesisedFilterTests.cs b/TUnit.Engine.Tests/ParenthesisedFilterTests.cs new file mode 100644 index 0000000000..54278c71b8 --- /dev/null +++ b/TUnit.Engine.Tests/ParenthesisedFilterTests.cs @@ -0,0 +1,79 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +/// +/// End-to-end regression tests for GitHub issue #6026. +/// Parenthesised TreeNodeFilter expressions in any path segment must reach MTP's +/// authoritative matcher instead of being mis-classified as a literal method name +/// by the source-gen pre-filter. +/// +public class ParenthesisedFilterTests(TestMode testMode) : InvokableTestBase(testMode) +{ + private const string ClassPath = "/*/TUnit.TestProject.Bugs._6026/ParenthesisedFilterTests"; + + [Test] + public async Task Filter_LiteralMethodName_StillWorks() + { + // Sanity check — the plain-literal path was already green; guard against regression. + await RunTestsWithFilter( + $"{ClassPath}/MyTest1", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1, + $"Expected 1 test (MyTest1) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + [Test] + public async Task Filter_ParenthesisedSingleMethod_Matches() + { + // Exact repro from #6026 — returned 0 tests in source-gen mode before the fix. + await RunTestsWithFilter( + $"{ClassPath}/(MyTest1)", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1, + $"Expected 1 test (MyTest1) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + [Test] + public async Task Filter_OrAlternation_MatchesBoth() + { + await RunTestsWithFilter( + $"{ClassPath}/(MyTest1|MyTest2)", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(2, + $"Expected 2 tests (MyTest1, MyTest2) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"), + result => result.ResultSummary.Counters.Passed.ShouldBe(2), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } + + [Test] + public async Task Filter_NotOperator_ExcludesNamedMethod() + { + // MTP TreeNodeFilter requires NOT to appear inside a grouping expression at the + // path-segment level — bare "!MyTest1" yields zero matches in MTP itself. + await RunTestsWithFilter( + $"{ClassPath}/(!MyTest1)", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1, + $"Expected 1 test (MyTest2) but got {result.ResultSummary.Counters.Total}. " + + $"Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0) + ]); + } +} diff --git a/TUnit.Engine/Services/MetadataFilterMatcher.cs b/TUnit.Engine/Services/MetadataFilterMatcher.cs index b9e98e9a26..3850efcd75 100644 --- a/TUnit.Engine/Services/MetadataFilterMatcher.cs +++ b/TUnit.Engine/Services/MetadataFilterMatcher.cs @@ -1,6 +1,9 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Reflection; +#if NET8_0_OR_GREATER +using System.Buffers; +#endif using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Requests; using TUnit.Core; @@ -14,22 +17,22 @@ namespace TUnit.Engine.Services; internal readonly struct FilterHints { /// - /// The assembly name pattern from the filter (null if wildcard or unparseable). + /// The assembly name pattern from the filter (null if non-literal or unparseable). /// public string? AssemblyName { get; init; } /// - /// The namespace pattern from the filter (null if wildcard or unparseable). + /// The namespace pattern from the filter (null if non-literal or unparseable). /// public string? Namespace { get; init; } /// - /// The class name pattern from the filter (null if wildcard or unparseable). + /// The class name pattern from the filter (null if non-literal or unparseable). /// public string? ClassName { get; init; } /// - /// The method name pattern from the filter (null if wildcard or unparseable). + /// The method name pattern from the filter (null if non-literal or unparseable). /// public string? MethodName { get; init; } @@ -164,25 +167,25 @@ public static FilterHints ExtractFilterHints(ITestExecutionFilter? filter) string? methodName = null; // Extract assembly name (parts[1]) - if (parts.Length > 1 && !IsWildcard(parts[1])) + if (parts.Length > 1 && !IsNonLiteralSegment(parts[1])) { assemblyName = parts[1]; } // Extract namespace (parts[2]) - if (parts.Length > 2 && !IsWildcard(parts[2])) + if (parts.Length > 2 && !IsNonLiteralSegment(parts[2])) { namespaceName = parts[2]; } // Extract class name (parts[3]) - if (parts.Length > 3 && !IsWildcard(parts[3])) + if (parts.Length > 3 && !IsNonLiteralSegment(parts[3])) { className = parts[3]; } // Extract method name (parts[4]) - if (parts.Length > 4 && !IsWildcard(parts[4])) + if (parts.Length > 4 && !IsNonLiteralSegment(parts[4])) { methodName = parts[4]; } @@ -196,9 +199,30 @@ public static FilterHints ExtractFilterHints(ITestExecutionFilter? filter) }; } - private static bool IsWildcard(string value) + // Characters that make a TreeNodeFilter path segment non-literal: + // * ? wildcards + // ( ) | & ! grouping / logical operators + // \ escape character + // Property-bag brackets [ ] are stripped before ExtractFilterHints runs (line above). + // Characters that are NOT MTP operators and must stay literal: + (nested classes), + // . (namespaces), < > , space (generic class names), ^ (no meaning in the grammar). + // A segment containing any operator cannot be safely compared with string equality — + // hints are skipped for it, and MTP's TreeNodeFilter does the authoritative match + // downstream in CouldMatchTreeNodeFilter. +#if NET8_0_OR_GREATER + private static readonly SearchValues _filterOperatorChars = + SearchValues.Create("*?()|&!\\"); +#else + private static readonly char[] _filterOperatorChars = { '*', '?', '(', ')', '|', '&', '!', '\\' }; +#endif + + private static bool IsNonLiteralSegment(string value) { - return string.IsNullOrEmpty(value) || value == "*" || value.Contains('*') || value.Contains('?'); +#if NET8_0_OR_GREATER + return string.IsNullOrEmpty(value) || value.AsSpan().IndexOfAny(_filterOperatorChars) >= 0; +#else + return string.IsNullOrEmpty(value) || value.IndexOfAny(_filterOperatorChars) >= 0; +#endif } #pragma warning restore TPEXP diff --git a/TUnit.TestProject/Bugs/6026/ParenthesisedFilterTests.cs b/TUnit.TestProject/Bugs/6026/ParenthesisedFilterTests.cs new file mode 100644 index 0000000000..9a092de078 --- /dev/null +++ b/TUnit.TestProject/Bugs/6026/ParenthesisedFilterTests.cs @@ -0,0 +1,25 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._6026; + +/// +/// Regression fixture for GitHub issue #6026. The MetadataFilterMatcher pre-filter +/// must let TreeNodeFilter grouping expressions like (MyTest1) and (MyTest1|MyTest2) +/// through to MTP's authoritative path matcher instead of treating them as literal +/// method names. +/// +[EngineTest(ExpectedResult.Pass)] +public class ParenthesisedFilterTests +{ + [Test] + public async Task MyTest1() + { + await Assert.That(true).IsTrue(); + } + + [Test] + public async Task MyTest2() + { + await Assert.That(true).IsTrue(); + } +} diff --git a/TUnit.UnitTests/MetadataFilterMatcherTests.cs b/TUnit.UnitTests/MetadataFilterMatcherTests.cs new file mode 100644 index 0000000000..96e4610f69 --- /dev/null +++ b/TUnit.UnitTests/MetadataFilterMatcherTests.cs @@ -0,0 +1,183 @@ +using System.Reflection; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Requests; +using TUnit.Engine.Services; + +namespace TUnit.UnitTests; + +/// +/// Direct unit tests for . +/// Regression coverage for GitHub issue #6026: TreeNodeFilter operator characters +/// (parens, |, &, !, escape \) in any path segment must be treated as +/// non-literal so the source-gen pre-filter does not exclude valid descriptors +/// before MTP's authoritative TreeNodeFilter runs. +/// +public class MetadataFilterMatcherTests +{ +#pragma warning disable TPEXP + private static readonly ConstructorInfo? TreeNodeFilterCtor = + typeof(TreeNodeFilter).GetConstructor( + BindingFlags.NonPublic | BindingFlags.Instance, null, [typeof(string)], null); + + private static TreeNodeFilter CreateFilter(string pattern) + { + var ctor = TreeNodeFilterCtor + ?? throw new InvalidOperationException( + "TreeNodeFilter(string) non-public ctor not found — has Microsoft.Testing.Platform changed its API?"); + return (TreeNodeFilter)ctor.Invoke([pattern]); + } +#pragma warning restore TPEXP + + [Test] + public async Task PlainLiteralSegments_ExtractAllFourHints() + { + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/MyAsm/MyNs/MyClass/MyMethod")); + + await Assert.That(hints.AssemblyName).IsEqualTo("MyAsm"); + await Assert.That(hints.Namespace).IsEqualTo("MyNs"); + await Assert.That(hints.ClassName).IsEqualTo("MyClass"); + await Assert.That(hints.MethodName).IsEqualTo("MyMethod"); + await Assert.That(hints.HasHints).IsTrue(); + } + + [Test] + public async Task DottedNamespace_StaysLiteral() + { + // '.' is not an MTP operator — dotted namespaces must remain literal hints. + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/MyAsm/Foo.Bar.Baz/MyClass/MyMethod")); + + await Assert.That(hints.Namespace).IsEqualTo("Foo.Bar.Baz"); + } + + [Test] + public async Task NestedClassSegment_StaysLiteral() + { + // '+' is not an MTP operator — nested class hierarchies must remain literal. + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/MyAsm/MyNs/Outer+Inner/MyMethod")); + + await Assert.That(hints.ClassName).IsEqualTo("Outer+Inner"); + } + + [Test] + public async Task WildcardStar_SkipsHint() + { + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/*/*/*/MyMethod")); + + await Assert.That(hints.AssemblyName).IsNull(); + await Assert.That(hints.Namespace).IsNull(); + await Assert.That(hints.ClassName).IsNull(); + await Assert.That(hints.MethodName).IsEqualTo("MyMethod"); + } + + [Test] + public async Task EmbeddedWildcard_SkipsHint() + { + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/*/*/MyClass*/MyMethod")); + + await Assert.That(hints.ClassName).IsNull(); + await Assert.That(hints.MethodName).IsEqualTo("MyMethod"); + } + + [Test] + public async Task QuestionMarkWildcard_SkipsHint() + { + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/*/*/My?lass/MyMethod")); + + await Assert.That(hints.ClassName).IsNull(); + } + + [Test] + public async Task ParenthesisedMethod_SkipsMethodHint() + { + // The exact repro from #6026 — paren-wrapped method name must NOT become a literal. + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/*/*/*/(MyTest1)")); + + await Assert.That(hints.MethodName).IsNull(); + await Assert.That(hints.HasHints).IsFalse(); + } + + [Test] + public async Task OrExpressionMethod_SkipsMethodHint() + { + // Second pattern from #6026 — pipe-separated alternation. + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/*/*/*/(MyTest1|MyTest2)")); + + await Assert.That(hints.MethodName).IsNull(); + await Assert.That(hints.HasHints).IsFalse(); + } + + [Test] + public async Task AndExpressionMethod_SkipsMethodHint() + { + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/*/*/*/(MyTest1&MyTest2)")); + + await Assert.That(hints.MethodName).IsNull(); + } + + [Test] + public async Task NotExpressionMethod_SkipsMethodHint() + { + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/*/*/*/!MyTest1")); + + await Assert.That(hints.MethodName).IsNull(); + } + + [Test] + public async Task EscapeCharacter_SkipsHint() + { + // '\.' escapes a literal dot in TreeNodeFilter grammar; stored as-is it would + // never equal the actual method name. Skip the hint and let MTP unescape. + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter(@"/*/*/MyClass/Foo\.Bar")); + + await Assert.That(hints.MethodName).IsNull(); + } + + [Test] + public async Task ParenthesisedClassSegment_SkipsClassHint() + { + // Operator chars in any segment skip only that segment's hint. + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/MyAsm/MyNs/(ClassA|ClassB)/MyMethod")); + + await Assert.That(hints.AssemblyName).IsEqualTo("MyAsm"); + await Assert.That(hints.Namespace).IsEqualTo("MyNs"); + await Assert.That(hints.ClassName).IsNull(); + await Assert.That(hints.MethodName).IsEqualTo("MyMethod"); + } + + [Test] + public async Task ParenthesisedNamespaceSegment_SkipsNamespaceHint() + { + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/MyAsm/(NsA|NsB)/MyClass/MyMethod")); + + await Assert.That(hints.AssemblyName).IsEqualTo("MyAsm"); + await Assert.That(hints.Namespace).IsNull(); + await Assert.That(hints.ClassName).IsEqualTo("MyClass"); + await Assert.That(hints.MethodName).IsEqualTo("MyMethod"); + } + + [Test] + public async Task PropertyBagBrackets_StrippedBeforeHintExtraction() + { + // [key=value] is stripped before segments are inspected, so the surrounding + // literal text still becomes a hint. + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter("/MyAsm/MyNs/MyClass/MyMethod[Category=Smoke]")); + + await Assert.That(hints.MethodName).IsEqualTo("MyMethod"); + } + + [Test] + public async Task NullFilter_NoHints() + { + var hints = MetadataFilterMatcher.ExtractFilterHints(filter: null); + + await Assert.That(hints.HasHints).IsFalse(); + } + + [Test] + public async Task EmptyFilterString_NoHints() + { + var hints = MetadataFilterMatcher.ExtractFilterHints(CreateFilter(string.Empty)); + + await Assert.That(hints.HasHints).IsFalse(); + } +}