@@ -262,6 +262,54 @@ public async Task RetryFailedTests_WithPreexistingFilterUid_ReplacesFilterOnRetr
262262 Assert . DoesNotContain ( "TestMethod2" , trxContent ) ;
263263 }
264264
265+ [ TestMethod ]
266+ [ DynamicData ( nameof ( TargetFrameworks . NetForDynamicData ) , typeof ( TargetFrameworks ) ) ]
267+ public async Task RetryFailedTests_WithPreexistingTreeNodeFilter_ReplacesFilterOnRetry ( string tfm )
268+ {
269+ var testHost = TestInfrastructure . TestHost . LocateFrom ( AssetFixture . TargetAssetPath , AssetName , tfm ) ;
270+ string resultDirectory = Path . Combine ( testHost . DirectoryName , Guid . NewGuid ( ) . ToString ( "N" ) ) ;
271+
272+ // Use --treenode-filter to select tests TestMethod1 and TestMethod2. The filter intentionally starts
273+ // with '/' because TreeNodeFilter expressions are matched against slash-prefixed node paths such as
274+ // '/TestMethod1'. Test 1 will fail on first attempt, pass on second. TestMethod3 is not matched by
275+ // the filter, so it should never run.
276+ // On retry, the orchestrator strips --treenode-filter and replaces it with --filter-uid for the failed
277+ // tests only; this test guards against issue #5673 (Retry + tree node filter must not stack filters).
278+ TestHostResult testHostResult = await testHost . ExecuteAsync (
279+ $ "--retry-failed-tests 3 --treenode-filter \" /(TestMethod1|TestMethod2)\" --report-trx --results-directory { resultDirectory } ",
280+ new ( )
281+ {
282+ { EnvironmentVariableConstants . TESTINGPLATFORM_TELEMETRY_OPTOUT , "1" } ,
283+ { "METHOD1" , "1" } ,
284+ { "RESULTDIR" , resultDirectory } ,
285+ } ,
286+ cancellationToken : TestContext . CancellationToken ) ;
287+
288+ testHostResult . AssertExitCodeIs ( ExitCode . Success ) ;
289+ testHostResult . AssertOutputContains ( "Tests suite completed successfully in 2 attempts" ) ;
290+ testHostResult . AssertOutputContains ( "Tests suite failed, total failed tests: 1, exit code: 2, attempt: 1/4" ) ;
291+
292+ // Verify that the first attempt honored the treenode-filter.
293+ string [ ] retryTrxFiles = Directory . GetFiles ( Path . Combine ( resultDirectory , "Retries" ) , "*.trx" , SearchOption . AllDirectories ) ;
294+ Assert . HasCount ( 1 , retryTrxFiles ) ;
295+
296+ string retryTrxContent = File . ReadAllText ( retryTrxFiles [ 0 ] ) ;
297+ Assert . Contains ( "TestMethod1" , retryTrxContent ) ;
298+ Assert . Contains ( "TestMethod2" , retryTrxContent ) ;
299+ Assert . DoesNotContain ( "TestMethod3" , retryTrxContent ) ;
300+
301+ // Verify that the retry attempt only ran the failed test (TestMethod1) - i.e. the treenode-filter was
302+ // dropped and replaced by --filter-uid 1.
303+ // The TRX in the top-level results directory (not under Retries/) is from the last attempt.
304+ string [ ] topLevelTrxFiles = Directory . GetFiles ( resultDirectory , "*.trx" , SearchOption . TopDirectoryOnly ) ;
305+ Assert . HasCount ( 1 , topLevelTrxFiles ) ;
306+
307+ string trxContent = File . ReadAllText ( topLevelTrxFiles [ 0 ] ) ;
308+ Assert . Contains ( "TestMethod1" , trxContent ) ;
309+ Assert . DoesNotContain ( "TestMethod2" , trxContent ) ;
310+ Assert . DoesNotContain ( "TestMethod3" , trxContent ) ;
311+ }
312+
265313 public sealed class TestAssetFixture ( ) : TestAssetFixtureBase ( )
266314 {
267315 public string TargetAssetPath => GetAssetPath ( AssetName ) ;
@@ -303,8 +351,10 @@ public override (string ID, string Name, string Code) GetAssetsToGenerate() => (
303351using Microsoft.Testing.Extensions.TrxReport.Abstractions;
304352using Microsoft.Testing.Platform.Builder;
305353using Microsoft.Testing.Platform.Capabilities.TestFramework;
354+ using Microsoft.Testing.Platform.Extensions;
306355using Microsoft.Testing.Platform.Extensions.Messages;
307356using Microsoft.Testing.Platform.Extensions.TestFramework;
357+ using Microsoft.Testing.Platform.Helpers;
308358using Microsoft.Testing.Platform.MSBuild;
309359using Microsoft.Testing.Platform.Requests;
310360using Microsoft.Testing.Platform.Services;
@@ -314,18 +364,29 @@ public class Program
314364 public static async Task<int> Main(string[] args)
315365 {
316366 ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args);
367+ TreeNodeFilterExtension treeNodeFilterExtension = new();
317368 builder.RegisterTestFramework(
318369 sp => new TestFrameworkCapabilities(new TrxReportCapability()),
319370 (_,__) => new DummyTestFramework());
320371 builder.AddCrashDumpProvider();
321372 builder.AddTrxReportProvider();
322373 builder.AddRetryProvider();
323374 builder.AddMSBuild();
375+ builder.AddTreeNodeFilterService(treeNodeFilterExtension);
324376 using ITestApplication app = await builder.BuildAsync();
325377 return await app.RunAsync();
326378 }
327379}
328380
381+ public class TreeNodeFilterExtension : IExtension
382+ {
383+ public string Uid => nameof(TreeNodeFilterExtension);
384+ public string Version => "1.0.0";
385+ public string DisplayName => nameof(TreeNodeFilterExtension);
386+ public string Description => nameof(TreeNodeFilterExtension);
387+ public Task<bool> IsEnabledAsync() => Task.FromResult(true);
388+ }
389+
329390public class TrxReportCapability : ITrxReportCapability
330391{
331392 bool ITrxReportCapability.IsSupported { get; } = true;
@@ -361,13 +422,15 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
361422 string resultDir = Environment.GetEnvironmentVariable("RESULTDIR")!;
362423 bool crash = Environment.GetEnvironmentVariable("CRASH") == "1";
363424
364- var uidFilter = (context.Request as TestExecutionRequest)?.Filter as TestNodeUidListFilter;
425+ var filter = (context.Request as TestExecutionRequest)?.Filter;
426+ var uidFilter = filter as TestNodeUidListFilter;
427+ var treeNodeFilter = filter as TreeNodeFilter;
365428
366429 var testMethod1Identifier = new TestMethodIdentifierProperty(string.Empty, string.Empty, "DummyClassName", "TestMethod1", 0, Array.Empty<string>(), string.Empty);
367430 var testMethod2Identifier = new TestMethodIdentifierProperty(string.Empty, string.Empty, "DummyClassName", "TestMethod2", 0, Array.Empty<string>(), string.Empty);
368431 var testMethod3Identifier = new TestMethodIdentifierProperty(string.Empty, string.Empty, "DummyClassName", "TestMethod3", 0, Array.Empty<string>(), string.Empty);
369432
370- if (IsIncluded(uidFilter, "1 "))
433+ if (IsIncluded(uidFilter, treeNodeFilter, "1", "TestMethod1 "))
371434 {
372435 if (TestMethod1(fail, resultDir, crash))
373436 {
@@ -381,7 +444,7 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
381444 }
382445 }
383446
384- if (IsIncluded(uidFilter, "2 "))
447+ if (IsIncluded(uidFilter, treeNodeFilter, "2", "TestMethod2 "))
385448 {
386449 if (TestMethod2(fail, resultDir))
387450 {
@@ -395,7 +458,7 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
395458 }
396459 }
397460
398- if (IsIncluded(uidFilter, "3 "))
461+ if (IsIncluded(uidFilter, treeNodeFilter, "3", "TestMethod3 "))
399462 {
400463 if (TestMethod3(fail, resultDir))
401464 {
@@ -412,8 +475,20 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
412475 context.Complete();
413476 }
414477
415- private static bool IsIncluded(TestNodeUidListFilter? filter, string uid)
416- => filter is null || filter.TestNodeUids.Any(n => n.Value == uid);
478+ private static bool IsIncluded(TestNodeUidListFilter? uidFilter, TreeNodeFilter? treeNodeFilter, string uid, string displayName)
479+ {
480+ if (uidFilter is not null && !uidFilter.TestNodeUids.Any(n => n.Value == uid))
481+ {
482+ return false;
483+ }
484+
485+ if (treeNodeFilter is not null && !treeNodeFilter.MatchesFilter("/" + displayName, new PropertyBag()))
486+ {
487+ return false;
488+ }
489+
490+ return true;
491+ }
417492
418493 private bool TestMethod1(bool fail, string resultDir, bool crash)
419494 {
0 commit comments