Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions TUnit.Engine.Tests/ParenthesisedFilterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

/// <summary>
/// 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.
/// </summary>
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)
]);
}
}
44 changes: 34 additions & 10 deletions TUnit.Engine/Services/MetadataFilterMatcher.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,22 +17,22 @@ namespace TUnit.Engine.Services;
internal readonly struct FilterHints
{
/// <summary>
/// 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).
/// </summary>
public string? AssemblyName { get; init; }

/// <summary>
/// The namespace pattern from the filter (null if wildcard or unparseable).
/// The namespace pattern from the filter (null if non-literal or unparseable).
/// </summary>
public string? Namespace { get; init; }

/// <summary>
/// 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).
/// </summary>
public string? ClassName { get; init; }

/// <summary>
/// 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).
/// </summary>
public string? MethodName { get; init; }

Expand Down Expand Up @@ -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];
}
Expand All @@ -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<char> _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

Expand Down
25 changes: 25 additions & 0 deletions TUnit.TestProject/Bugs/6026/ParenthesisedFilterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._6026;

/// <summary>
/// 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.
/// </summary>
[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();
}
}
183 changes: 183 additions & 0 deletions TUnit.UnitTests/MetadataFilterMatcherTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Direct unit tests for <see cref="MetadataFilterMatcher.ExtractFilterHints"/>.
/// Regression coverage for GitHub issue #6026: TreeNodeFilter operator characters
/// (parens, |, &amp;, !, 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.
/// </summary>
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();
}
}
Loading