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();
+ }
+}