From 7ca39a4fa2ebd2e480df1c09aaddf6abddb02491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 9 Mar 2026 10:58:08 +0100 Subject: [PATCH 1/4] Simplify retry extension logic to use built-in filters --- .../RetryExecutionFilterFactory.cs | 50 -------- .../RetryExtensions.cs | 10 -- .../RetryOrchestrator.cs | 37 ++++++ .../RetryFailedTestsTests.cs | 113 ++++++++++++++---- 4 files changed, 126 insertions(+), 84 deletions(-) delete mode 100644 src/Platform/Microsoft.Testing.Extensions.Retry/RetryExecutionFilterFactory.cs diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryExecutionFilterFactory.cs b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryExecutionFilterFactory.cs deleted file mode 100644 index 5e75951d3f..0000000000 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryExecutionFilterFactory.cs +++ /dev/null @@ -1,50 +0,0 @@ -// 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 Microsoft.Testing.Extensions.Policy.Resources; -using Microsoft.Testing.Platform.CommandLine; -using Microsoft.Testing.Platform.Extensions.Messages; -using Microsoft.Testing.Platform.Helpers; -using Microsoft.Testing.Platform.Requests; -using Microsoft.Testing.Platform.Services; - -namespace Microsoft.Testing.Extensions.Policy; - -[UnsupportedOSPlatform("browser")] -internal sealed class RetryExecutionFilterFactory : ITestExecutionFilterFactory -{ - private readonly IServiceProvider _serviceProvider; - private readonly ICommandLineOptions _commandLineOptions; - private RetryLifecycleCallbacks? _retryFailedTestsLifecycleCallbacks; - - public RetryExecutionFilterFactory(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - _commandLineOptions = serviceProvider.GetCommandLineOptions(); - } - - public string Uid => nameof(RetryExecutionFilterFactory); - - public string Version => AppVersion.DefaultSemVer; - - public string DisplayName => ExtensionResources.RetryFailedTestsExtensionDisplayName; - - public string Description => ExtensionResources.RetryFailedTestsExtensionDescription; - - public Task IsEnabledAsync() - => Task.FromResult(_commandLineOptions.IsOptionSet(RetryCommandLineOptionsProvider.RetryFailedTestsPipeNameOptionName)); - - public async Task<(bool, ITestExecutionFilter?)> TryCreateAsync() - { - _retryFailedTestsLifecycleCallbacks = _serviceProvider.GetRequiredService(); - if (_retryFailedTestsLifecycleCallbacks.FailedTestsIDToRetry?.Length > 0) - { - return (true, new TestNodeUidListFilter([.. _retryFailedTestsLifecycleCallbacks.FailedTestsIDToRetry.Select(x => new TestNodeUid(x))])); - } - else - { - ConsoleTestExecutionFilterFactory consoleTestExecutionFilterFactory = new(_commandLineOptions); - return await consoleTestExecutionFilterFactory.TryCreateAsync().ConfigureAwait(false); - } - } -} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryExtensions.cs index 404fce2aaf..7146c1cc7e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryExtensions.cs @@ -5,7 +5,6 @@ using Microsoft.Testing.Extensions.Policy.Resources; using Microsoft.Testing.Platform.Builder; using Microsoft.Testing.Platform.Extensions; -using Microsoft.Testing.Platform.TestHost; namespace Microsoft.Testing.Extensions; @@ -38,14 +37,5 @@ CompositeExtensionFactory compositeExtensionFactory builder.TestHostOrchestrator .AddTestHostOrchestrator(serviceProvider => new RetryOrchestrator(serviceProvider)); - - if (builder.TestHost is not TestHostManager testHostManager) - { - throw new InvalidOperationException( - ExtensionResources.RetryProviderRequiresDefaultTestHostManagerErrorMessage); - } - - testHostManager - .AddTestExecutionFilterFactory(serviceProvider => new RetryExecutionFilterFactory(serviceProvider)); } } diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs index 5aeb86304a..8b321cddeb 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs @@ -170,6 +170,15 @@ public async Task OrchestrateTestHostExecutionAsync(CancellationToken cance finalArguments.Add($"--{RetryCommandLineOptionsProvider.RetryFailedTestsPipeNameOptionName}"); finalArguments.Add(retryFailedTestsPipeServer.PipeName); + // When retrying, replace any existing test filter with --filter-uid for the failed tests + if (lastListOfFailedId is { Length: > 0 }) + { + RemoveOption(finalArguments, TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter); + RemoveOption(finalArguments, PlatformCommandLineProvider.FilterUidOptionKey); + finalArguments.Add($"--{PlatformCommandLineProvider.FilterUidOptionKey}"); + finalArguments.AddRange(lastListOfFailedId); + } + #if NET8_0_OR_GREATER // On net8.0+, we can pass the arguments as a collection directly to ProcessStartInfo. // When passing the collection, it's expected to be unescaped, so we pass what we have directly. @@ -351,4 +360,32 @@ private static int GetOptionArgumentIndex(string optionName, string[] executable index = Array.IndexOf(executableArgs, "--" + optionName); return index >= 0 ? index : -1; } + + private static void RemoveOption(List arguments, string optionName) + { + string longForm = $"--{optionName}"; + string shortForm = $"-{optionName}"; + + // Remove all occurrences since options like --filter-uid can appear multiple times. + while (true) + { + int idx = arguments.IndexOf(longForm); + if (idx < 0) + { + idx = arguments.IndexOf(shortForm); + } + + if (idx < 0) + { + break; + } + + // Remove the option key and all its values + arguments.RemoveAt(idx); + while (idx < arguments.Count && !arguments[idx].StartsWith('-')) + { + arguments.RemoveAt(idx); + } + } + } } diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs index 229c18e078..6f76873aa0 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs @@ -205,6 +205,56 @@ public async Task RetryFailedTests_PassingFromFirstTime_UsingTestTarget_MoveFile } } + [TestMethod] + [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))] + public async Task RetryFailedTests_WithPreexistingFilterUid_ReplacesFilterOnRetry(string tfm) + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, tfm); + string resultDirectory = Path.Combine(testHost.DirectoryName, Guid.NewGuid().ToString("N")); + + // Use --filter-uid to select tests 1 and 2. Test 1 will fail on first attempt, pass on second. + TestHostResult testHostResult = await testHost.ExecuteAsync( + $"--retry-failed-tests 3 --filter-uid 1 --filter-uid 2 --results-directory {resultDirectory}", + new() + { + { EnvironmentVariableConstants.TESTINGPLATFORM_TELEMETRY_OPTOUT, "1" }, + { "METHOD1", "1" }, + { "RESULTDIR", resultDirectory }, + }, + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCodes.Success); + testHostResult.AssertOutputContains("Tests suite completed successfully in 2 attempts"); + + // The retry attempt should only retry the failed test (UID 1), not all originally filtered tests. + testHostResult.AssertOutputContains("Tests suite failed, total failed tests: 1, exit code: 2, attempt: 1/4"); + } + + [TestMethod] + [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))] + public async Task RetryFailedTests_WithPreexistingTreenodeFilter_ReplacesFilterOnRetry(string tfm) + { + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, tfm); + string resultDirectory = Path.Combine(testHost.DirectoryName, Guid.NewGuid().ToString("N")); + + // Use --treenode-filter to select all tests. Test 1 will fail on first attempt, pass on second. + TestHostResult testHostResult = await testHost.ExecuteAsync( + $"--retry-failed-tests 3 --treenode-filter /** --results-directory {resultDirectory}", + new() + { + { EnvironmentVariableConstants.TESTINGPLATFORM_TELEMETRY_OPTOUT, "1" }, + { "METHOD1", "1" }, + { "RESULTDIR", resultDirectory }, + }, + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCodes.Success); + testHostResult.AssertOutputContains("Tests suite completed successfully in 2 attempts"); + + // The retry attempt should only retry the failed test (UID 1), not all tests matching the tree filter. + testHostResult.AssertOutputContains("Tests suite failed, total failed tests: 1, exit code: 2, attempt: 1/4"); + } + public sealed class TestAssetFixture() : TestAssetFixtureBase(AcceptanceFixture.NuGetGlobalPackagesFolder) { public string TargetAssetPath => GetAssetPath(AssetName); @@ -252,6 +302,7 @@ public sealed class TestAssetFixture() : TestAssetFixtureBase(AcceptanceFixture. using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Extensions.TestFramework; using Microsoft.Testing.Platform.MSBuild; +using Microsoft.Testing.Platform.Requests; using Microsoft.Testing.Platform.Services; public class Program @@ -306,46 +357,60 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context) string resultDir = Environment.GetEnvironmentVariable("RESULTDIR")!; bool crash = Environment.GetEnvironmentVariable("CRASH") == "1"; + var uidFilter = (context.Request as TestExecutionRequest)?.Filter as TestNodeUidListFilter; + var testMethod1Identifier = new TestMethodIdentifierProperty(string.Empty, string.Empty, "DummyClassName", "TestMethod1", 0, Array.Empty(), string.Empty); var testMethod2Identifier = new TestMethodIdentifierProperty(string.Empty, string.Empty, "DummyClassName", "TestMethod2", 0, Array.Empty(), string.Empty); var testMethod3Identifier = new TestMethodIdentifierProperty(string.Empty, string.Empty, "DummyClassName", "TestMethod3", 0, Array.Empty(), string.Empty); - if (TestMethod1(fail, resultDir, crash)) + if (IsIncluded(uidFilter, "1")) { - await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, - new TestNode() { Uid = "1", DisplayName = "TestMethod1", Properties = new(PassedTestNodeStateProperty.CachedInstance, testMethod1Identifier) })); - } - else - { - await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, - new TestNode() { Uid = "1", DisplayName = "TestMethod1", Properties = new(new FailedTestNodeStateProperty(), testMethod1Identifier) })); + if (TestMethod1(fail, resultDir, crash)) + { + await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, + new TestNode() { Uid = "1", DisplayName = "TestMethod1", Properties = new(PassedTestNodeStateProperty.CachedInstance, testMethod1Identifier) })); + } + else + { + await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, + new TestNode() { Uid = "1", DisplayName = "TestMethod1", Properties = new(new FailedTestNodeStateProperty(), testMethod1Identifier) })); + } } - if (TestMethod2(fail, resultDir)) + if (IsIncluded(uidFilter, "2")) { - await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, - new TestNode() { Uid = "2", DisplayName = "TestMethod2", Properties = new(PassedTestNodeStateProperty.CachedInstance, testMethod2Identifier) })); - } - else - { - await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, - new TestNode() { Uid = "2", DisplayName = "TestMethod2", Properties = new(new FailedTestNodeStateProperty(), testMethod2Identifier) })); + if (TestMethod2(fail, resultDir)) + { + await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, + new TestNode() { Uid = "2", DisplayName = "TestMethod2", Properties = new(PassedTestNodeStateProperty.CachedInstance, testMethod2Identifier) })); + } + else + { + await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, + new TestNode() { Uid = "2", DisplayName = "TestMethod2", Properties = new(new FailedTestNodeStateProperty(), testMethod2Identifier) })); + } } - if (TestMethod3(fail, resultDir)) - { - await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, - new TestNode() { Uid = "3", DisplayName = "TestMethod3", Properties = new(PassedTestNodeStateProperty.CachedInstance, testMethod3Identifier) })); - } - else + if (IsIncluded(uidFilter, "3")) { - await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, - new TestNode() { Uid = "3", DisplayName = "TestMethod3", Properties = new(new FailedTestNodeStateProperty(), testMethod3Identifier) })); + if (TestMethod3(fail, resultDir)) + { + await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, + new TestNode() { Uid = "3", DisplayName = "TestMethod3", Properties = new(PassedTestNodeStateProperty.CachedInstance, testMethod3Identifier) })); + } + else + { + await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, + new TestNode() { Uid = "3", DisplayName = "TestMethod3", Properties = new(new FailedTestNodeStateProperty(), testMethod3Identifier) })); + } } context.Complete(); } + private static bool IsIncluded(TestNodeUidListFilter? filter, string uid) + => filter is null || filter.TestNodeUids.Any(n => n.Value == uid); + private bool TestMethod1(bool fail, string resultDir, bool crash) { if (crash) From ca8df8e9213078bc370f22d70ed5e02dccaec23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 9 Mar 2026 12:46:12 +0100 Subject: [PATCH 2/4] Address review comments --- .../Resources/ExtensionResources.resx | 3 -- .../Resources/xlf/ExtensionResources.cs.xlf | 5 -- .../Resources/xlf/ExtensionResources.de.xlf | 5 -- .../Resources/xlf/ExtensionResources.es.xlf | 5 -- .../Resources/xlf/ExtensionResources.fr.xlf | 5 -- .../Resources/xlf/ExtensionResources.it.xlf | 5 -- .../Resources/xlf/ExtensionResources.ja.xlf | 5 -- .../Resources/xlf/ExtensionResources.ko.xlf | 5 -- .../Resources/xlf/ExtensionResources.pl.xlf | 5 -- .../xlf/ExtensionResources.pt-BR.xlf | 5 -- .../Resources/xlf/ExtensionResources.ru.xlf | 5 -- .../Resources/xlf/ExtensionResources.tr.xlf | 5 -- .../xlf/ExtensionResources.zh-Hans.xlf | 5 -- .../xlf/ExtensionResources.zh-Hant.xlf | 5 -- .../RetryOrchestrator.cs | 54 ++++++++++++++++--- .../RetryFailedTestsTests.cs | 37 ++++--------- 16 files changed, 59 insertions(+), 100 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/ExtensionResources.resx b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/ExtensionResources.resx index 089fc89eed..8179e3290f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/ExtensionResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/ExtensionResources.resx @@ -149,9 +149,6 @@ Moving last attempt asset files to the default result directory Retry failed tests - - The retry provider requires the default TestHostManager implementation. - The retry extension is not supported on browser platform. Browser-based tests cannot be retried due to platform limitations. diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.cs.xlf index 5bdadf2632..0ec43e125a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.cs.xlf @@ -100,11 +100,6 @@ Přesouvání souborů prostředků posledního pokusu do výchozího adresáře Možnost {0} nelze používat společně s možností {1}. - - The retry provider requires the default TestHostManager implementation. - Zprostředkovatel opakování vyžaduje výchozí implementaci TestHostManager. - - Test host process exited before the retry service could connect to it. Exit code: {0} Hostitelský proces testu byl ukončen dříve, než se k němu mohla služba opakování připojit. Ukončovací kód: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.de.xlf index e059b1b060..54f48e40b8 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.de.xlf @@ -100,11 +100,6 @@ Medienobjektdateien des letzten Versuchs werden in das Standardergebnisverzeichn Optionen "{0}" und "{1}" können nicht zusammen verwendet werden - - The retry provider requires the default TestHostManager implementation. - Der Wiederholungsanbieter erfordert die standardmäßige TestHostManager-Implementierung. - - Test host process exited before the retry service could connect to it. Exit code: {0} Der Testhostprozess wurde beendet, bevor der Wiederholungsdienst eine Verbindung herstellen konnte. Exitcode: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.es.xlf index 019d9943a9..55edc18d71 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.es.xlf @@ -100,11 +100,6 @@ Moviendo los archivos de recursos del último intento al directorio de resultado Las opciones '{0}' y '{1}' no se pueden usar juntas - - The retry provider requires the default TestHostManager implementation. - El proveedor de reintentos requiere la implementación predeterminada de TestHostManager. - - Test host process exited before the retry service could connect to it. Exit code: {0} El proceso de host de prueba se cerró antes de que el servicio de reintento pudiera conectarse a él. Código de salida: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.fr.xlf index 27f4471ef2..2fc7921866 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.fr.xlf @@ -100,11 +100,6 @@ Déplacement des fichiers de ressources de la dernière tentative vers le réper Les options «{0}» et «{1}» ne peuvent pas être utilisées ensemble - - The retry provider requires the default TestHostManager implementation. - Le fournisseur de nouvelles tentatives requiert l'implémentation par défaut de TestHostManager. - - Test host process exited before the retry service could connect to it. Exit code: {0} Le processus hôte de test s’est arrêté avant que le service de nouvelle tentative puisse s’y connecter. Code de sortie : {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.it.xlf index 0bdcf6f330..a55bf502da 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.it.xlf @@ -100,11 +100,6 @@ Spostamento dei file di asset dell'ultimo tentativo nella directory dei risultat Le opzioni '{0}' e '{1}' non possono essere usate insieme - - The retry provider requires the default TestHostManager implementation. - Il provider di tentativi richiede l'implementazione predefinita di TestHostManager. - - Test host process exited before the retry service could connect to it. Exit code: {0} Il processo host di test è stato chiuso prima che il servizio di ripetizione potesse connettersi ad esso. Codice di uscita: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ja.xlf index 644934883a..23d77b2232 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ja.xlf @@ -100,11 +100,6 @@ Moving last attempt asset files to the default result directory オプション '{0}' と '{1}' を一緒に使用することはできません - - The retry provider requires the default TestHostManager implementation. - 再試行プロバイダーには、既定の TestHostManager 実装が必要です。 - - Test host process exited before the retry service could connect to it. Exit code: {0} 再試行サービスが接続する前に、テスト ホスト プロセスが終了しました。終了コード: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ko.xlf index fd8219655f..df4c3290dd 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ko.xlf @@ -100,11 +100,6 @@ Moving last attempt asset files to the default result directory '{0}' 및 '{1}' 옵션은 함께 사용할 수 없습니다. - - The retry provider requires the default TestHostManager implementation. - 다시 시도 공급자에는 기본 TestHostManager 구현이 필요합니다. - - Test host process exited before the retry service could connect to it. Exit code: {0} 다시 시도 서비스가 연결되기 전에 테스트 호스트 프로세스가 종료되었습니다. 종료 코드: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.pl.xlf index 6ab00fd1b3..cde119184e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.pl.xlf @@ -100,11 +100,6 @@ Przeniesienie plików zasobów ostatniej próby do domyślnego katalogu wyników Opcji „{0}” i „{1}” nie można używać razem - - The retry provider requires the default TestHostManager implementation. - Dostawca ponawiania wymaga domyślnej implementacji TestHostManager. - - Test host process exited before the retry service could connect to it. Exit code: {0} Proces hosta testowego zakończył się, zanim usługa ponawiania próby mogła nawiązać z nim połączenie. Kod zakończenia: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.pt-BR.xlf index 3c806a6d8f..e7b749064a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.pt-BR.xlf @@ -100,11 +100,6 @@ Movendo arquivos de ativo da última tentativa para o diretório de resultados p As opções ''{0}'' e ''{1}'' não podem ser usadas juntas - - The retry provider requires the default TestHostManager implementation. - O provedor de repetição requer a implementação padrão de TestHostManager. - - Test host process exited before the retry service could connect to it. Exit code: {0} O processo de host de teste foi encerrado antes que o serviço de repetição pudesse se conectar a ele. Código de saída: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ru.xlf index b709eba3b0..eecf000105 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.ru.xlf @@ -100,11 +100,6 @@ Moving last attempt asset files to the default result directory Параметры "{0}" и "{1}" не могут использоваться вместе - - The retry provider requires the default TestHostManager implementation. - Для поставщика повторных попыток требуется реализация TestHostManager по умолчанию. - - Test host process exited before the retry service could connect to it. Exit code: {0} Тестовый хост-процесс завершился прежде, чем к нему смогла подключиться служба повторной попытки. Код выхода: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.tr.xlf index 92f4e47e5f..8879edbaee 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.tr.xlf @@ -100,11 +100,6 @@ Son deneme varlık dosyaları, varsayılan sonuç dizinine taşınıyor '{0}' ve '{1}' seçenekleri birlikte kullanılamaz - - The retry provider requires the default TestHostManager implementation. - Yeniden deneme sağlayıcısı varsayılan TestHostManager'ın uygulanmasını gerektiriyor. - - Test host process exited before the retry service could connect to it. Exit code: {0} Yeniden deneme hizmeti ona bağlanamadan test ana makinesi işleminden çıkıldı. Çıkış kodu: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.zh-Hans.xlf index db949f4223..643ff90857 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.zh-Hans.xlf @@ -100,11 +100,6 @@ Moving last attempt asset files to the default result directory 选项“{0}”和“{1}”不能一起使用 - - The retry provider requires the default TestHostManager implementation. - 重试提供程序需要默认的 TestHostManager 实现。 - - Test host process exited before the retry service could connect to it. Exit code: {0} 测试主机进程已在重试服务可以连接到它之前退出。退出代码: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.zh-Hant.xlf index 2a1b38e138..417569a8bc 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/Resources/xlf/ExtensionResources.zh-Hant.xlf @@ -100,11 +100,6 @@ Moving last attempt asset files to the default result directory 選項 '{0}' 和 '{1}' 不能同時使用 - - The retry provider requires the default TestHostManager implementation. - 重試提供者需要預設的 TestHostManager 實作。 - - Test host process exited before the retry service could connect to it. Exit code: {0} 測試主機處理序在重試服務連線到它之前已結束。結束代碼: {0} diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs index 8b321cddeb..cd464ed853 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs @@ -175,8 +175,34 @@ public async Task OrchestrateTestHostExecutionAsync(CancellationToken cance { RemoveOption(finalArguments, TreeNodeFilterCommandLineOptionsProvider.TreenodeFilter); RemoveOption(finalArguments, PlatformCommandLineProvider.FilterUidOptionKey); - finalArguments.Add($"--{PlatformCommandLineProvider.FilterUidOptionKey}"); - finalArguments.AddRange(lastListOfFailedId); + + // Estimate command line length to avoid hitting OS limits (notably ~32K on Windows). + const int CommandLineLengthLimit = 30_000; + int predictedLength = 0; + foreach (string arg in finalArguments) + { + predictedLength += arg.Length + 1; + } + + predictedLength += 2 + PlatformCommandLineProvider.FilterUidOptionKey.Length + 1; + foreach (string uid in lastListOfFailedId) + { + predictedLength += uid.Length + 1; + } + + if (predictedLength <= CommandLineLengthLimit) + { + finalArguments.Add($"--{PlatformCommandLineProvider.FilterUidOptionKey}"); + finalArguments.AddRange(lastListOfFailedId); + } + else + { + // Use a response file to avoid exceeding command-line length limits. + string responseFilePath = Path.Combine(currentTryResultFolder, "retry-filter-uids.rsp"); + _fileSystem.CreateDirectory(currentTryResultFolder); + File.WriteAllText(responseFilePath, $"--{PlatformCommandLineProvider.FilterUidOptionKey} {string.Join(" ", lastListOfFailedId)}"); + finalArguments.Add($"@{responseFilePath}"); + } } #if NET8_0_OR_GREATER @@ -367,12 +393,20 @@ private static void RemoveOption(List arguments, string optionName) string shortForm = $"-{optionName}"; // Remove all occurrences since options like --filter-uid can appear multiple times. + // Also handle --option=value and --option:value forms produced by the command-line parser. while (true) { - int idx = arguments.IndexOf(longForm); - if (idx < 0) + int idx = -1; + for (int i = 0; i < arguments.Count; i++) { - idx = arguments.IndexOf(shortForm); + string arg = arguments[i]; + if (arg == longForm || arg == shortForm + || arg.StartsWith(longForm + "=", StringComparison.Ordinal) || arg.StartsWith(longForm + ":", StringComparison.Ordinal) + || arg.StartsWith(shortForm + "=", StringComparison.Ordinal) || arg.StartsWith(shortForm + ":", StringComparison.Ordinal)) + { + idx = i; + break; + } } if (idx < 0) @@ -380,8 +414,16 @@ private static void RemoveOption(List arguments, string optionName) break; } - // Remove the option key and all its values + string found = arguments[idx]; arguments.RemoveAt(idx); + + // If the option used = or : separator, the value is inline — nothing more to remove. + if (found.Contains('=') || found.Contains(':')) + { + continue; + } + + // Otherwise, remove subsequent non-option arguments (the option's values). while (idx < arguments.Count && !arguments[idx].StartsWith('-')) { arguments.RemoveAt(idx); diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs index 6f76873aa0..6131441209 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs @@ -213,8 +213,9 @@ public async Task RetryFailedTests_WithPreexistingFilterUid_ReplacesFilterOnRetr string resultDirectory = Path.Combine(testHost.DirectoryName, Guid.NewGuid().ToString("N")); // Use --filter-uid to select tests 1 and 2. Test 1 will fail on first attempt, pass on second. + // Test 3 is not in the filter, so it should never run. TestHostResult testHostResult = await testHost.ExecuteAsync( - $"--retry-failed-tests 3 --filter-uid 1 --filter-uid 2 --results-directory {resultDirectory}", + $"--retry-failed-tests 3 --filter-uid 1 --filter-uid 2 --report-trx --results-directory {resultDirectory}", new() { { EnvironmentVariableConstants.TESTINGPLATFORM_TELEMETRY_OPTOUT, "1" }, @@ -225,34 +226,18 @@ public async Task RetryFailedTests_WithPreexistingFilterUid_ReplacesFilterOnRetr testHostResult.AssertExitCodeIs(ExitCodes.Success); testHostResult.AssertOutputContains("Tests suite completed successfully in 2 attempts"); - - // The retry attempt should only retry the failed test (UID 1), not all originally filtered tests. testHostResult.AssertOutputContains("Tests suite failed, total failed tests: 1, exit code: 2, attempt: 1/4"); - } - - [TestMethod] - [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))] - public async Task RetryFailedTests_WithPreexistingTreenodeFilter_ReplacesFilterOnRetry(string tfm) - { - var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, tfm); - string resultDirectory = Path.Combine(testHost.DirectoryName, Guid.NewGuid().ToString("N")); - - // Use --treenode-filter to select all tests. Test 1 will fail on first attempt, pass on second. - TestHostResult testHostResult = await testHost.ExecuteAsync( - $"--retry-failed-tests 3 --treenode-filter /** --results-directory {resultDirectory}", - new() - { - { EnvironmentVariableConstants.TESTINGPLATFORM_TELEMETRY_OPTOUT, "1" }, - { "METHOD1", "1" }, - { "RESULTDIR", resultDirectory }, - }, - cancellationToken: TestContext.CancellationToken); - testHostResult.AssertExitCodeIs(ExitCodes.Success); - testHostResult.AssertOutputContains("Tests suite completed successfully in 2 attempts"); + // Verify that the retry attempt only ran the failed test (UID 1). + // The TRX from the last attempt should contain only TestMethod1 (the retried test), not TestMethod2. + string[] trxFiles = Directory.GetFiles(resultDirectory, "*.trx", SearchOption.AllDirectories); + Assert.IsTrue(trxFiles.Length >= 2, $"Expected at least 2 TRX files but found {trxFiles.Length}"); - // The retry attempt should only retry the failed test (UID 1), not all tests matching the tree filter. - testHostResult.AssertOutputContains("Tests suite failed, total failed tests: 1, exit code: 2, attempt: 1/4"); + // The last TRX file (from the retry attempt) should only contain the retried test. + string lastTrx = trxFiles.OrderBy(f => File.GetLastWriteTimeUtc(f)).Last(); + string trxContent = File.ReadAllText(lastTrx); + Assert.Contains("TestMethod1", trxContent); + Assert.DoesNotContain("TestMethod2", trxContent); } public sealed class TestAssetFixture() : TestAssetFixtureBase(AcceptanceFixture.NuGetGlobalPackagesFolder) From a56ca619f268a057ac08674d05cec872e5936da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 9 Mar 2026 12:56:04 +0100 Subject: [PATCH 3/4] Fix diagnostic --- .../RetryFailedTestsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs index 6131441209..e964c31fd5 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs @@ -231,7 +231,7 @@ public async Task RetryFailedTests_WithPreexistingFilterUid_ReplacesFilterOnRetr // Verify that the retry attempt only ran the failed test (UID 1). // The TRX from the last attempt should contain only TestMethod1 (the retried test), not TestMethod2. string[] trxFiles = Directory.GetFiles(resultDirectory, "*.trx", SearchOption.AllDirectories); - Assert.IsTrue(trxFiles.Length >= 2, $"Expected at least 2 TRX files but found {trxFiles.Length}"); + Assert.IsGreaterThanOrEqualTo(2, trxFiles.Length, $"Expected at least 2 TRX files but found {trxFiles.Length}"); // The last TRX file (from the retry attempt) should only contain the retried test. string lastTrx = trxFiles.OrderBy(f => File.GetLastWriteTimeUtc(f)).Last(); From 9fe70789de2e4d71910d921fa62aeffbcceba4d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 9 Mar 2026 13:10:42 +0100 Subject: [PATCH 4/4] Fixes --- .../RetryOrchestrator.cs | 27 +++++++++++-------- .../RetryFailedTestsTests.cs | 10 +++---- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs index cd464ed853..0bc6041dd3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/RetryOrchestrator.cs @@ -198,9 +198,19 @@ public async Task OrchestrateTestHostExecutionAsync(CancellationToken cance else { // Use a response file to avoid exceeding command-line length limits. - string responseFilePath = Path.Combine(currentTryResultFolder, "retry-filter-uids.rsp"); - _fileSystem.CreateDirectory(currentTryResultFolder); - File.WriteAllText(responseFilePath, $"--{PlatformCommandLineProvider.FilterUidOptionKey} {string.Join(" ", lastListOfFailedId)}"); + // Write to retryRootFolder (not the per-attempt folder) so it won't be included + // in the final results move. + string responseFilePath = Path.Combine(retryRootFolder, $"retry-filter-uids-{attemptCount}.rsp"); + using (IFileStream stream = _fileSystem.NewFileStream(responseFilePath, FileMode.Create, FileAccess.Write)) + using (var writer = new StreamWriter(stream.Stream)) + { + await writer.WriteAsync($"--{PlatformCommandLineProvider.FilterUidOptionKey}").ConfigureAwait(false); + foreach (string uid in lastListOfFailedId) + { + await writer.WriteAsync($" \"{uid}\"").ConfigureAwait(false); + } + } + finalArguments.Add($"@{responseFilePath}"); } } @@ -414,16 +424,11 @@ private static void RemoveOption(List arguments, string optionName) break; } - string found = arguments[idx]; arguments.RemoveAt(idx); - // If the option used = or : separator, the value is inline — nothing more to remove. - if (found.Contains('=') || found.Contains(':')) - { - continue; - } - - // Otherwise, remove subsequent non-option arguments (the option's values). + // Always remove subsequent non-option arguments (the option's values), + // even when the first value was provided inline with = or :, because + // multi-arity options (e.g. --filter-uid=1 2) can have trailing values. while (idx < arguments.Count && !arguments[idx].StartsWith('-')) { arguments.RemoveAt(idx); diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs index e964c31fd5..1a6e1c36a0 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/RetryFailedTestsTests.cs @@ -229,13 +229,11 @@ public async Task RetryFailedTests_WithPreexistingFilterUid_ReplacesFilterOnRetr testHostResult.AssertOutputContains("Tests suite failed, total failed tests: 1, exit code: 2, attempt: 1/4"); // Verify that the retry attempt only ran the failed test (UID 1). - // The TRX from the last attempt should contain only TestMethod1 (the retried test), not TestMethod2. - string[] trxFiles = Directory.GetFiles(resultDirectory, "*.trx", SearchOption.AllDirectories); - Assert.IsGreaterThanOrEqualTo(2, trxFiles.Length, $"Expected at least 2 TRX files but found {trxFiles.Length}"); + // The TRX in the top-level results directory (not under Retries/) is from the last attempt. + string[] topLevelTrxFiles = Directory.GetFiles(resultDirectory, "*.trx", SearchOption.TopDirectoryOnly); + Assert.HasCount(1, topLevelTrxFiles); - // The last TRX file (from the retry attempt) should only contain the retried test. - string lastTrx = trxFiles.OrderBy(f => File.GetLastWriteTimeUtc(f)).Last(); - string trxContent = File.ReadAllText(lastTrx); + string trxContent = File.ReadAllText(topLevelTrxFiles[0]); Assert.Contains("TestMethod1", trxContent); Assert.DoesNotContain("TestMethod2", trxContent); }