From 98ef10f40708f9d2385637a8c9f90c773450d250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 27 Aug 2026 17:02:26 +0200 Subject: [PATCH 1/8] Default TRX to controller-backed recovery - Make plain `--report-trx` use controller-backed recovery whenever the current platform supports launching a test-host process, by having TrxProcessLifetimeHandler/TrxEnvironmentVariableProvider require isolation whenever TrxModeHelpers.IsTestHostControllerSupported is true, without a `--crashdump` gate and without introducing a `--trx-mode` option or in-process opt-out. - Replace the direct `--crashdump` check in TrxModeHelpers with actual controller-presence state: TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration now checks PlatformCommandLineProvider.TestHostControllerPIDOptionKey in the child test host, instead of assuming which extension caused isolation. - Keep the existing in-process implementation as the automatic compatibility fallback on browser, iOS, tvOS, and WASI, where process restart is unavailable. - Update TRX artifact-heading expectations ("In process" -> "Out of process") in TrxTests.cs and TrxDataRowTests.cs to reflect the new default, drop the now-unnecessary `--crashdump` flag from crash/timeout acceptance tests, and add explicit coverage (Trx_WhenOnlyReportTrxIsSpecified_UsesControllerBackedRecoveryByDefault) proving plain `--report-trx` is controller-backed on supported platforms. - Add TrxModeHelpersTests covering actual controller-presence handling. Depends on #10797 (prerequisite timeout-lifecycle work), currently at d521a2c28; this branch is based on main and does not include its timeout changes. Fixes #10792 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../InternalAPI/InternalAPI.Unshipped.txt | 1 + .../TrxDataConsumer.cs | 2 +- .../TrxEnvironmentVariableProvider.cs | 5 ++- .../TrxModeHelpers.cs | 35 ++++++++++++--- .../TrxProcessLifetimeHandler.cs | 14 +++--- .../TrxReportExtensions.cs | 14 +++--- .../TrxTestApplicationLifecycleCallbacks.cs | 7 ++- .../TrxDataRowTests.cs | 4 +- .../TrxTests.cs | 27 ++++++++++-- .../TrxModeHelpersTests.cs | 44 +++++++++++++++++++ .../TrxProcessLifetimeHandlerTests.cs | 2 - 11 files changed, 124 insertions(+), 31 deletions(-) create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxModeHelpersTests.cs diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt index bdac54e837..fea8640e6c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt @@ -96,3 +96,4 @@ Microsoft.Testing.Extensions.TrxReport.Abstractions.Streaming.TrxResultStreaming Microsoft.Testing.Extensions.TrxReport.Abstractions.Streaming.TrxResultStreamingStore.TrxResultStreamingStore(string! filePath, Microsoft.Testing.Platform.Helpers.IFileSystem! fileSystem, Microsoft.Testing.Platform.Helpers.ITask! task, Microsoft.Testing.Platform.Logging.ILogger! logger, int batchSize, int flushIntervalMs, bool useBackgroundWriter) -> void Microsoft.Testing.Platform.IPC.NamedPipeServer.NamedPipeServer(Microsoft.Testing.Platform.IPC.PipeNameDescription! pipeNameDescription, System.Func!>! callback, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Logging.ILogger! logger, Microsoft.Testing.Platform.Helpers.ITask! task, int maxNumberOfServerInstances, System.Collections.Generic.IReadOnlyList? authorizedSecurityIdentities, System.Threading.CancellationToken cancellationToken) -> void Microsoft.Testing.Platform.IPC.NamedPipeServer.NamedPipeServer(string! name, System.Func!>! callback, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Logging.ILogger! logger, Microsoft.Testing.Platform.Helpers.ITask! task, System.Collections.Generic.IReadOnlyList? authorizedSecurityIdentities, System.Threading.CancellationToken cancellationToken) -> void +static Microsoft.Testing.Extensions.TrxReport.TrxModeHelpers.IsTestHostControllerSupported.get -> bool diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs index 3122496a86..9e39bb8917 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs @@ -167,8 +167,8 @@ public async Task OnTestSessionStartingAsync(ITestSessionContext testSessionCont if (_logger.IsEnabled(LogLevel.Debug)) { await _logger.LogDebugAsync($""" -CrashDumpCommandLineOptions.CrashDumpOptionName: {_commandLineOptionsService.IsOptionSet(CrashDumpCommandLineOptions.CrashDumpOptionName)} TrxReportGeneratorCommandLine.IsTrxReportEnabled: {_commandLineOptionsService.IsOptionSet(TrxReportGeneratorCommandLine.TrxReportOptionName)} +TrxModeHelpers.IsTestHostControllerSupported: {TrxModeHelpers.IsTestHostControllerSupported} shouldUseOutOfProcessTrxGeneration: {shouldUseOutOfProcessTrxGeneration} """).ConfigureAwait(false); } diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxEnvironmentVariableProvider.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxEnvironmentVariableProvider.cs index bfa5fefda1..223fe6a023 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxEnvironmentVariableProvider.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxEnvironmentVariableProvider.cs @@ -32,8 +32,9 @@ public override Task IsEnabledAsync() => Task.FromResult( // TrxReportGenerator is enabled only when trx report is enabled _commandLineOptions.IsOptionSet(TrxReportGeneratorCommandLine.TrxReportOptionName) - // If crash dump is not enabled we run trx in-process only - && TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(_commandLineOptions)); + // TRX requires (and will trigger) a controller-managed test host whenever the current + // platform supports process restart; this is what makes plain --report-trx controller-backed. + && TrxModeHelpers.IsTestHostControllerSupported); #pragma warning restore SA1114 // Parameter list should follow declaration protected override string GetMissingEnvironmentVariableErrorMessage(string environmentVariableName) diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs index 753a155420..e255640e6e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs @@ -3,16 +3,37 @@ using Microsoft.Testing.Platform.CommandLine; -#if !NETCOREAPP -using Polyfills; -#endif - namespace Microsoft.Testing.Extensions.TrxReport; internal static class TrxModeHelpers { - [UnsupportedOSPlatformGuard("BROWSER")] + // TRX requires a controller-managed test host to recover the report when the test host crashes, + // hangs, or is terminated by --timeout. Platforms that cannot launch a test-host process at all + // fall back to the in-process implementation as a compatibility fallback (see + // TrxReportExtensions.AddTrxReportProvider). + [UnsupportedOSPlatformGuard("browser")] + [UnsupportedOSPlatformGuard("ios")] + [UnsupportedOSPlatformGuard("tvos")] + [UnsupportedOSPlatformGuard("wasi")] + public static bool IsTestHostControllerSupported { get; } = +#if NETCOREAPP + !OperatingSystem.IsBrowser() + && !OperatingSystem.IsIOS() + && !OperatingSystem.IsTvOS() + && !OperatingSystem.IsWasi(); +#else + true; +#endif + + // Used from within the test host (child) process: rely on the controller-presence state that MTP + // actually established for this process, rather than recomputing which extension requested + // isolation. This stays true even when another extension (HangDump, --timeout, ...) is the one + // that caused the controller to be used. + [UnsupportedOSPlatformGuard("browser")] + [UnsupportedOSPlatformGuard("ios")] + [UnsupportedOSPlatformGuard("tvos")] + [UnsupportedOSPlatformGuard("wasi")] public static bool ShouldUseOutOfProcessTrxGeneration(ICommandLineOptions commandLineOptions) - => commandLineOptions.IsOptionSet(CrashDumpCommandLineOptions.CrashDumpOptionName) && - !OperatingSystem.IsBrowser(); + => IsTestHostControllerSupported + && commandLineOptions.IsOptionSet(PlatformCommandLineProvider.TestHostControllerPIDOptionKey); } diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs index c70e8f8662..4830d9534c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs @@ -97,8 +97,9 @@ public Task IsEnabledAsync() => Task.FromResult( // TrxReportGenerator is enabled only when trx report is enabled _commandLineOptions.IsOptionSet(TrxReportGeneratorCommandLine.TrxReportOptionName) - // If crash dump is not enabled we run trx in-process only - && TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(_commandLineOptions)); + // TRX requires (and will trigger) a controller-managed test host whenever the current + // platform supports process restart; this is what makes plain --report-trx controller-backed. + && TrxModeHelpers.IsTestHostControllerSupported); #pragma warning restore SA1114 // Parameter list should follow declaration public Task BeforeTestHostProcessStartAsync(CancellationToken cancellationToken) @@ -106,7 +107,7 @@ public Task BeforeTestHostProcessStartAsync(CancellationToken cancellationToken) // IsEnabledAsync will only return true if we are out of process. // If we are not out of process, then we are disabled. Hence, this won't be called. // The extra check is to let the platform compatibility analyzer know that we are not running in browser. - if (!TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(_commandLineOptions)) + if (!TrxModeHelpers.IsTestHostControllerSupported) { throw ApplicationStateGuard.Unreachable(); } @@ -117,7 +118,10 @@ public Task BeforeTestHostProcessStartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - [UnsupportedOSPlatform("BROWSER")] + [UnsupportedOSPlatform("browser")] + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + [UnsupportedOSPlatform("wasi")] private void BeforeTestHostProcessStartCore(CancellationToken cancellationToken) { _singleConnectionNamedPipeServer = new(_pipeNameDescription, CallbackAsync, _environment, _logger, _task, cancellationToken); @@ -389,7 +393,7 @@ public async ValueTask DisposeAsync() public void Dispose() { - if (TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(_commandLineOptions)) + if (TrxModeHelpers.IsTestHostControllerSupported) { _singleConnectionNamedPipeServer?.Dispose(); } diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportExtensions.cs index 08df022e7b..7b70ee4cc4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Microsoft.Testing.Extensions.TrxReport; using Microsoft.Testing.Extensions.TrxReport.Abstractions; using Microsoft.Testing.Extensions.TrxReport.Resources; using Microsoft.Testing.Platform.Builder; @@ -11,10 +12,6 @@ using Microsoft.Testing.Platform.Services; using Microsoft.Testing.Platform.TestHostControllers; -#if !NETCOREAPP -using Polyfills; -#endif - namespace Microsoft.Testing.Extensions; /// @@ -53,9 +50,9 @@ public static void AddTrxReportProvider(this ITestApplicationBuilder builder) serviceProvider.GetService(), serviceProvider.GetLoggerFactory().CreateLogger())); - if (!OperatingSystem.IsBrowser()) + if (TrxModeHelpers.IsTestHostControllerSupported) { - NonBrowserRegistrations(builder); + ControllerBackedRegistrations(builder); } builder.TestHost.AddDataConsumer(compositeTestSessionTrxService); @@ -80,7 +77,10 @@ public static void AddTrxReportProvider(this ITestApplicationBuilder builder) } [UnsupportedOSPlatform("browser")] - private static void NonBrowserRegistrations(ITestApplicationBuilder builder) + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + [UnsupportedOSPlatform("wasi")] + private static void ControllerBackedRegistrations(ITestApplicationBuilder builder) { builder.TestHost.AddTestHostApplicationLifetime(serviceProvider => new TrxTestApplicationLifecycleCallbacks( diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxTestApplicationLifecycleCallbacks.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxTestApplicationLifecycleCallbacks.cs index 7aa37d87bf..e213095877 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxTestApplicationLifecycleCallbacks.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxTestApplicationLifecycleCallbacks.cs @@ -14,7 +14,10 @@ namespace Microsoft.Testing.Extensions.TrxReport.Abstractions; internal sealed class TrxTestApplicationLifecycleCallbacks : ITestHostApplicationLifetime, IDisposable { - [UnsupportedOSPlatformGuard("BROWSER")] + [UnsupportedOSPlatformGuard("browser")] + [UnsupportedOSPlatformGuard("ios")] + [UnsupportedOSPlatformGuard("tvos")] + [UnsupportedOSPlatformGuard("wasi")] private readonly bool _isEnabled; private readonly IEnvironment _environment; @@ -26,7 +29,7 @@ public TrxTestApplicationLifecycleCallbacks( _isEnabled = // TrxReportGenerator is enabled only when trx report is enabled commandLineOptionsService.IsOptionSet(TrxReportGeneratorCommandLine.TrxReportOptionName) && - // If crash dump is not enabled we run trx in-process only + // The controller pipe is available only in a controller-launched test host. TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(commandLineOptionsService); _environment = environment; diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxDataRowTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxDataRowTests.cs index fe6e9ea2ea..e55171818c 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxDataRowTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxDataRowTests.cs @@ -24,8 +24,10 @@ private async Task AssertTrxReportWasGeneratedAsync(TestHostResult testHostResul { testHostResult.AssertExitCodeIs(ExitCode.Success); + // Plain --report-trx is controller-backed by default on this platform: the TRX artifact is + // reported by the surviving controller process, not the test host, hence "Out of process". string outputPattern = $""" - In process file artifacts produced: + Out of process file artifacts produced: - {trxPathPattern} """; testHostResult.AssertOutputMatchesRegex(outputPattern); diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs index cfe7e8de36..6f0c3fe9ea 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs @@ -38,6 +38,21 @@ public async Task Trx_WhenReportTrxIsSpecified_TrxReportIsGeneratedInDefaultLoca await AssertTrxReportWasGeneratedAsync(testHostResult, trxPathPattern, 1); } + [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] + [TestMethod] + public async Task Trx_WhenOnlyReportTrxIsSpecified_UsesControllerBackedRecoveryByDefault(string tfm) + { + // Plain --report-trx (no --crashdump, no --timeout, no other extension requiring isolation) must + // be controller-backed by default on this platform. The "Out of process" heading is only emitted + // when the surviving controller (rather than the test host itself) reports the TRX file artifact, + // so its presence here is direct proof that a bare --report-trx run went through the controller. + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, TestAssetFixture.AssetName, tfm); + TestHostResult testHostResult = await testHost.ExecuteAsync("--report-trx", cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.Success); + testHostResult.AssertOutputContains("Out of process file artifacts produced:"); + } + [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))] [TestMethod] public async Task Trx_WhenOutOfProcessReportHasNoSelectedTests_LifetimeHandshakeCompletes(string tfm) @@ -47,7 +62,7 @@ public async Task Trx_WhenOutOfProcessReportHasNoSelectedTests_LifetimeHandshake var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, TestAssetFixture.AssetName, tfm); TestHostResult testHostResult = await testHost.ExecuteAsync( - $"--filter-uid 2 --ignore-exit-code 8 --crashdump --report-trx --report-trx-filename {fileName} --results-directory \"{testResultsPath}\"", + $"--filter-uid 2 --ignore-exit-code 8 --report-trx --report-trx-filename {fileName} --results-directory \"{testResultsPath}\"", cancellationToken: TestContext.CancellationToken); testHostResult.AssertExitCodeIs(ExitCode.Success); @@ -177,10 +192,12 @@ public async Task Trx_WhenPerTestArtifactCannotBeCopied_WarningIsSurfacedAndResu [TestMethod] public async Task Trx_WhenTestHostCrash_ErrorIsDisplayedInsideTheTrx(string tfm) { + // Plain --report-trx (no --crashdump, no other extension) is controller-backed by default on + // this platform, so it alone is enough to recover a failed-run TRX when the test host crashes. string fileName = Guid.NewGuid().ToString("N"); var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, TestAssetFixture.AssetName, tfm); TestHostResult testHostResult = await testHost.ExecuteAsync( - $"--crashdump --report-trx --report-trx-filename {fileName}.trx", + $"--report-trx --report-trx-filename {fileName}.trx", new() { ["CRASHPROCESS"] = "1" }, cancellationToken: TestContext.CancellationToken); testHostResult.AssertExitCodeIs(ExitCode.TestHostProcessExitedNonGracefully); @@ -201,7 +218,7 @@ public async Task Trx_WhenTestHostCrash_RunningUnderDotnetTest_ErrorIsDisplayedI string testResultsPath = Path.Combine(AssetFixture.TargetAssetPath, Guid.NewGuid().ToString("N")); DotnetMuxerResult result = await DotnetCli.RunAsync( - $"test --project \"{AssetFixture.TargetAssetPath}\" --no-build -c Release -f {tfm} --crashdump --report-trx --report-trx-filename {fileName}.trx --results-directory \"{testResultsPath}\"", + $"test --project \"{AssetFixture.TargetAssetPath}\" --no-build -c Release -f {tfm} --report-trx --report-trx-filename {fileName}.trx --results-directory \"{testResultsPath}\"", workingDirectory: AssetFixture.TargetAssetPath, environmentVariables: new() { ["CRASHPROCESS"] = "1" }, failIfReturnValueIsNotZero: false, @@ -317,8 +334,10 @@ private async Task AssertTrxReportWasGeneratedAsync(TestHostResult testHostResul { testHostResult.AssertExitCodeIs(ExitCode.Success); + // Plain --report-trx is controller-backed by default on this platform: the TRX artifact is + // reported by the surviving controller process, not the test host, hence "Out of process". string outputPattern = $""" - In process file artifacts produced: + Out of process file artifacts produced: - {trxPathPattern} """; testHostResult.AssertOutputMatchesRegex(outputPattern); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxModeHelpersTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxModeHelpersTests.cs new file mode 100644 index 0000000000..f3f9638e30 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxModeHelpersTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions.TrxReport; +using Microsoft.Testing.Extensions.UnitTests.Helpers; +using Microsoft.Testing.Platform.CommandLine; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class TrxModeHelpersTests +{ + [TestMethod] + public void ShouldUseOutOfProcessTrxGeneration_IsFalse_WhenTestHostControllerPidOptionIsNotSet() + { + // The child test host only recovers via the controller when it can see that it was actually + // launched by one (the platform sets internal-testhostcontroller-pid on that child process). + // Without that option, ShouldUseOutOfProcessTrxGeneration must not assume controller-backed mode + // even if the current platform generally supports it. + var commandLineOptions = new TestCommandLineOptions(new Dictionary + { + [TrxReport.Abstractions.TrxReportGeneratorCommandLine.TrxReportOptionName] = [], + }); + + Assert.IsFalse(TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(commandLineOptions)); + } + + [TestMethod] + public void ShouldUseOutOfProcessTrxGeneration_ReflectsControllerSupport_WhenTestHostControllerPidOptionIsSet() + { + // Once the platform has actually placed this process under a controller (the PID option is + // present), whether TRX recovers out-of-process should track platform support alone: the child + // no longer needs to know which extension (TRX, HangDump, --timeout, ...) caused the isolation. + var commandLineOptions = new TestCommandLineOptions(new Dictionary + { + [TrxReport.Abstractions.TrxReportGeneratorCommandLine.TrxReportOptionName] = [], + [PlatformCommandLineProvider.TestHostControllerPIDOptionKey] = ["42"], + }); + + Assert.AreEqual( + TrxModeHelpers.IsTestHostControllerSupported, + TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(commandLineOptions)); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxProcessLifetimeHandlerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxProcessLifetimeHandlerTests.cs index c983940e3c..64a747faef 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxProcessLifetimeHandlerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxProcessLifetimeHandlerTests.cs @@ -4,7 +4,6 @@ using System.IO.Pipes; using System.Reflection; -using Microsoft.Testing.Extensions.CrashDump; using Microsoft.Testing.Extensions.TrxReport; using Microsoft.Testing.Extensions.TrxReport.Abstractions; using Microsoft.Testing.Extensions.UnitTests.Helpers; @@ -30,7 +29,6 @@ public async Task BeforeTestHostProcessStartAsync_CreatesPipeBeforeSchedulingCon { var commandLineOptions = new TestCommandLineOptions(new Dictionary { - [CrashDumpCommandLineOptions.CrashDumpOptionName] = [], [TrxReportGeneratorCommandLine.TrxReportOptionName] = [], }); Mock environment = new(); From 161a0f11d5115b9b2d8ed193caf192265e68d9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 27 Aug 2026 17:45:55 +0200 Subject: [PATCH 2/8] Address review feedback: WASI TRX fallback coverage and package docs - Add RawPlatform_TrxReport_FallsBackInProcessUnderWasi acceptance test (WasmExecutionTests.cs) asserting plain --report-trx on wasi-wasm reports its artifact "In process" (never "Out of process"), directly proving TRX falls back automatically instead of attempting controller-backed recovery on a platform that cannot launch a test-host process. - Extend WasmRuntime.RunUnderWasmtimeAsync with an optional arguments parameter so wasmtime invocations can pass extra MTP command-line options. - Document the controller-backed-by-default behavior and its reliability/startup-cost tradeoff in TrxReport's PACKAGE.md, per issue #10792 acceptance criterion 6, including measured startup overhead for a trivial run: no statistically significant difference on .NET, roughly 700-800ms on .NET Framework. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PACKAGE.md | 4 ++ .../WasmExecutionTests.cs | 72 ++++++++++++++++++- .../WasmRuntime.cs | 4 +- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.TrxReport/PACKAGE.md index 349e949a00..047307bacf 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/PACKAGE.md @@ -22,6 +22,10 @@ This package extends Microsoft.Testing.Platform with: Enable TRX report generation via the `--report-trx` command line option. +On platforms that can launch a test-host process (all except browser, iOS, tvOS, and WASI), TRX uses controller-backed recovery by default: the test host still streams results and generates the report during normal execution, but a surviving controller process can recover completed results into a TRX report if the test host crashes, hangs, or is stopped by `--timeout`. Browser, iOS, tvOS, and WASI cannot launch a test-host process, so TRX automatically falls back to its original in-process implementation there — no controller-backed recovery is attempted, and no configuration is required to get this fallback. + +The extra process has a measurable startup cost, which varies by target framework: for a trivial single-test run, launching the controller added no statistically measurable overhead on .NET (differences were within normal process-launch noise) but added roughly 700-800ms on .NET Framework in local measurements. Weigh this against the reliability benefit for your scenario, especially on .NET Framework or in tight inner-loop test runs. + ## Related packages - [Microsoft.Testing.Extensions.TrxReport.Abstractions](https://www.nuget.org/packages/Microsoft.Testing.Extensions.TrxReport.Abstractions): interfaces for extensions interoperating with TRX reports diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/WasmExecutionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/WasmExecutionTests.cs index 20dd32d7b3..818145bcb7 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/WasmExecutionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/WasmExecutionTests.cs @@ -31,7 +31,7 @@ namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; /// (see BrowserWasmExecutionTests), not here. /// /// -/// Two complementary assertions: +/// Three complementary assertions: /// /// /// is an always-on build assertion. It only relies on @@ -44,6 +44,12 @@ namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; /// skipped (inconclusive) when the wasm publish toolchain (wasm-tools workload) or /// wasmtime is not available, so it only runs where the wasm runtime exists. /// +/// +/// runs the same app with +/// --report-trx and asserts the TRX artifact is reported "In process" (never "Out of +/// process"), proving that TRX automatically falls back to its in-process implementation on wasi +/// instead of attempting controller-backed recovery, which wasi cannot support. +/// /// /// [TestClass] @@ -87,11 +93,13 @@ public sealed class WasmExecutionTests : AcceptanceTestBase + #file Program.cs +using Microsoft.Testing.Extensions; using Microsoft.Testing.Platform.Builder; using Microsoft.Testing.Platform.Capabilities.TestFramework; using Microsoft.Testing.Platform.Extensions.Messages; @@ -99,6 +107,7 @@ public sealed class WasmExecutionTests : AcceptanceTestBase ITestApplicationBuilder testApplicationBuilder = await TestApplication.CreateBuilderAsync(args); testApplicationBuilder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, _) => new DummyFramework()); +testApplicationBuilder.AddTrxReportProvider(); using ITestApplication testApplication = await testApplicationBuilder.BuildAsync(); return await testApplication.RunAsync(); @@ -214,6 +223,67 @@ public async Task RawPlatform_RunsUnderWasmtime() $"Expected a zero exit code because all tests pass.{Environment.NewLine}{combined}"); } + [TestMethod] + public async Task RawPlatform_TrxReport_FallsBackInProcessUnderWasi() + { + // WASI cannot launch a test-host controller process, so --report-trx must fall back to the + // in-process implementation automatically: the TRX artifact must still be generated and + // reported as an "In process" file artifact (never "Out of process"), and no controller + // registration should be attempted. + using TestAsset generator = await GenerateAssetAsync(); + + DotnetMuxerResult publishResult = await WasmRuntime.PublishForWasiAsync( + generator.TargetAssetPath, TargetFramework, TestContext.CancellationToken); + if (publishResult.ExitCode != 0) + { + Assert.IsTrue( + WasmRuntime.IsMissingWasmToolsWorkload(publishResult), + $"'dotnet publish -r wasi-wasm' failed for an unexpected reason (not a missing 'wasm-tools' workload).{Environment.NewLine}{publishResult}"); + Assert.Inconclusive( + $"Skipping wasm execution: the 'wasm-tools' workload is not installed.{Environment.NewLine}{publishResult}"); + return; + } + + string appBundle = WasmRuntime.GetAppBundlePath(generator.TargetAssetPath, TargetFramework); + Assert.IsTrue( + Directory.Exists(appBundle), + $"Expected the wasi AppBundle directory at '{appBundle}'."); + WasmRuntime.StageIcuData(appBundle); + + string? wasmtime = WasmRuntime.LocateWasmtime(); + if (wasmtime is null) + { + Assert.Inconclusive(WasmRuntime.WasmtimeUnavailableMessage); + return; + } + + const string TrxFileName = "wasi.trx"; + (int exitCode, _, _, string combined) = await WasmRuntime.RunUnderWasmtimeAsync( + wasmtime, appBundle, "WasmPlatformProject", TestContext.CancellationToken, + $"--report-trx --report-trx-filename {TrxFileName}"); + + Assert.IsFalse( + combined.Contains("PlatformNotSupportedException", StringComparison.Ordinal), + $"--report-trx must not hit a PlatformNotSupportedException under wasi-wasm.{Environment.NewLine}{combined}"); + + Assert.IsTrue( + combined.Contains("succeeded: 1", StringComparison.Ordinal) && combined.Contains("failed: 0", StringComparison.Ordinal), + $"Expected the wasi-wasm run summary to be unaffected by TRX reporting.{Environment.NewLine}{combined}"); + + // The heading only reads "In process" when the test host itself reported the artifact — a + // controller-backed run would instead say "Out of process". This is the direct, observable + // proof that TRX did not attempt controller registration on wasi. + Assert.Contains("In process file artifacts produced:", combined); + Assert.DoesNotContain("Out of process file artifacts produced:", combined); + + Assert.HasCount(1, Directory.GetFiles(appBundle, TrxFileName, SearchOption.AllDirectories)); + + Assert.AreEqual( + 0, + exitCode, + $"Expected a zero exit code because all tests pass.{Environment.NewLine}{combined}"); + } + private Task GenerateAssetAsync() => TestAsset.GenerateAssetAsync( "WasmPlatformProject", diff --git a/test/Utilities/Microsoft.Testing.TestInfrastructure/WasmRuntime.cs b/test/Utilities/Microsoft.Testing.TestInfrastructure/WasmRuntime.cs index 8fa40c5242..6ffe1709a0 100644 --- a/test/Utilities/Microsoft.Testing.TestInfrastructure/WasmRuntime.cs +++ b/test/Utilities/Microsoft.Testing.TestInfrastructure/WasmRuntime.cs @@ -132,11 +132,11 @@ public static string GetAppBundlePath(string targetAssetPath, string targetFrame /// to the bundle so the platform can read/write its files. /// public static async Task<(int ExitCode, string Output, string Error, string Combined)> RunUnderWasmtimeAsync( - string wasmtime, string appBundle, string appName, CancellationToken cancellationToken) + string wasmtime, string appBundle, string appName, CancellationToken cancellationToken, string? arguments = null) { var commandLine = new CommandLine(); int exitCode = await commandLine.RunAsyncAndReturnExitCodeAsync( - $"\"{wasmtime}\" run -S http --dir . -- dotnet.wasm {appName}", + $"\"{wasmtime}\" run -S http --dir . -- dotnet.wasm {appName}{(string.IsNullOrWhiteSpace(arguments) ? string.Empty : $" {arguments}")}", workingDirectory: appBundle, cancellationToken: cancellationToken); From 16d9829e84d59afcea323b761c971a636ebdd7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 27 Aug 2026 18:03:09 +0200 Subject: [PATCH 3/8] Regenerate PlatformResources xlf after merging main main (via #10798 "Improve unknown command-line option guidance", merged before this branch's main sync) added two new PlatformResources.resx entries without a corresponding OneLocBuild pass, which fails CI's `--report-trx`-unrelated Build Linux/Windows checks with an "xlf is out-of-date" error from Microsoft.DotNet.XliffTasks. Regenerated via `dotnet msbuild src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj /t:UpdateXlf` per this repo's localization guidelines; no manual xlf edits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Resources/xlf/PlatformResources.cs.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.de.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.es.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.fr.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.it.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.ja.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.ko.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.pl.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.pt-BR.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.ru.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.tr.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.zh-Hans.xlf | 10 ++++++++++ .../Resources/xlf/PlatformResources.zh-Hant.xlf | 10 ++++++++++ 13 files changed, 130 insertions(+) diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf index ba68da0ca6..49ff70ad26 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.cs.xlf @@ -222,6 +222,16 @@ Možnost --{0} od zprostředkovatele {1} (UID: {2}) používá vyhrazenou předponu --internal. {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. Rozhraní ICommandLineOptions ještě není sestavené. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf index 7b6c6a0071..8f7ca414a8 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.de.xlf @@ -222,6 +222,16 @@ Die Option "--{0}" vom Anbieter "{1}" (UID: {2}) verwendet das reservierte Präfix "--internal". {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions wurde noch nicht erstellt. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf index 3aefad80cf..d06e30e3fa 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.es.xlf @@ -222,6 +222,16 @@ La opción “--{0}” del proveedor “{1}” (UID: {2}) usa el prefijo reservado “--internal” {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions aún no se ha compilado. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf index 5a2f48c3e7..068fa5dddd 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.fr.xlf @@ -222,6 +222,16 @@ L’option « --{0} » du fournisseur « {1} » (UID : {2}) utilise le préfixe réservé « --internal » {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions n’a pas encore été généré. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf index b1267606a9..1da226298d 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.it.xlf @@ -222,6 +222,16 @@ L'opzione '--{0}' del provider '{1}' (UID: {2}) usa il prefisso riservato '--internal' {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions non è stato ancora compilato. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf index 3cd6efed06..ae24f83ddf 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ja.xlf @@ -222,6 +222,16 @@ プロバイダー '{1}' のオプション '--{0}' (UID:{2}) は予約済みプレフィックス '--internal' を 使用しています {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions はまだ構築されていません。 diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf index 061924bf0b..736b4ebdfa 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ko.xlf @@ -222,6 +222,16 @@ 공급자 '{1}'(UID: {2})의 옵션 '-- {0}'이 예약된 접두사 '--internal'을 사용하고 있습니다. {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions가 아직 빌드되지 않았습니다. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf index c1395fa9fd..d01deb9c38 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pl.xlf @@ -222,6 +222,16 @@ Opcja „--{0}” od dostawcy „{1}” (UID: {2}) używa zastrzeżonego prefiksu „--internal” {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. Obiekt ICommandLineOptions nie został jeszcze skompilowany. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf index b2fc6ea470..dadd1bfcd2 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.pt-BR.xlf @@ -222,6 +222,16 @@ A opção '--{0}' do provedor '{1}' (UID: {2}) está usando o prefixo reservado '--internal' {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. O ICommandLineOptions ainda não foi criado. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf index 5faedd71ee..78aaf8c9b9 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.ru.xlf @@ -222,6 +222,16 @@ Параметр "--{0}" от поставщика "{1}" (UID: {2}) использует зарезервированный префикс "--internal" {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. Параметр ICommandLineOptions еще не создан. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf index 3b79922318..73b8e7a330 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.tr.xlf @@ -222,6 +222,16 @@ '{1}' sağlayıcısındaki (UID: {2}) `--{0}` seçeneği ayrılmış '--internal' önekini kullanıyor {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions henüz derlenmedi. diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf index 633d554b0c..f85d1f5021 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hans.xlf @@ -222,6 +222,16 @@ 来自提供程序“{1}” (UID: {2}) 的选项“--{0}”正在使用保留前缀“--internal” {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. ICommandLineOptions 尚未生成。 diff --git a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf index 50790821e8..3112912762 100644 --- a/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Platform/Resources/xlf/PlatformResources.zh-Hant.xlf @@ -222,6 +222,16 @@ 提供者 '{1}' (UID: {2}) 中的選項 '--{0}' 使用保留的前置詞 '--internal' {0} is the option name without the leading dashes. {1} is the provider display name. {2} is the provider UID. {Locked="--internal"} + + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + Option '--{0}' is provided by the '{1}' extension. Add a package reference to use it. + {0} is the option name without the leading dashes. {1} is the extension's NuGet package name. + + + Did you mean '--{0}'? + Did you mean '--{0}'? + {0} is the suggested option name without the leading dashes. + The ICommandLineOptions has not been built yet. 尚未建置 ICommandLineOptions。 From 9746f70ebb8af11238d415bf30c25e5162861515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 27 Aug 2026 18:21:38 +0200 Subject: [PATCH 4/8] Fix netstandard2.0 controller-support detection TrxModeHelpers.IsTestHostControllerSupported hardcoded `true` for every non-NETCOREAPP build, but this assembly also ships a netstandard2.0 asset that is explicitly marked as supporting browser/iOS/tvOS/WASI. Consumers selecting that asset would register the named-pipe/process controller path unconditionally instead of the promised in-process fallback, and browser controller registration can throw. Restore the `Polyfills` import and evaluate all four OperatingSystem.Is*() checks unconditionally: under NETCOREAPP they resolve via the BCL, and under netstandard2.0/.NET Framework they resolve via the Polyfills OperatingSystem extension, which already returns constant false for these platforms on .NET Framework (which cannot run on them) while evaluating the actual runtime via RuntimeInformation.IsOSPlatform for netstandard2.0 hosts that can (e.g. Mono/MAUI). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TrxModeHelpers.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs index e255640e6e..f7e64a5a8f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs @@ -3,6 +3,10 @@ using Microsoft.Testing.Platform.CommandLine; +#if !NETCOREAPP +using Polyfills; +#endif + namespace Microsoft.Testing.Extensions.TrxReport; internal static class TrxModeHelpers @@ -11,19 +15,23 @@ internal static class TrxModeHelpers // hangs, or is terminated by --timeout. Platforms that cannot launch a test-host process at all // fall back to the in-process implementation as a compatibility fallback (see // TrxReportExtensions.AddTrxReportProvider). + // + // This assembly also ships a netstandard2.0 asset (see the csproj's SupportedPlatform entries for + // browser/ios/tvos/wasi), so these checks must run for every target, not just NETCOREAPP: under + // NETCOREAPP they resolve via the BCL OperatingSystem APIs, and under netstandard2.0/.NET Framework + // they resolve via the Polyfills OperatingSystem extension, which itself already returns constant + // false for these platforms on .NET Framework (which cannot run on them) while evaluating the + // actual runtime via RuntimeInformation.IsOSPlatform for netstandard2.0 hosts (e.g. Mono/MAUI) that + // can. [UnsupportedOSPlatformGuard("browser")] [UnsupportedOSPlatformGuard("ios")] [UnsupportedOSPlatformGuard("tvos")] [UnsupportedOSPlatformGuard("wasi")] public static bool IsTestHostControllerSupported { get; } = -#if NETCOREAPP !OperatingSystem.IsBrowser() && !OperatingSystem.IsIOS() && !OperatingSystem.IsTvOS() && !OperatingSystem.IsWasi(); -#else - true; -#endif // Used from within the test host (child) process: rely on the controller-presence state that MTP // actually established for this process, rather than recomputing which extension requested From ac9a9d0e3dcf9102e23cb01f4068c6a15f07247c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 28 Aug 2026 13:31:44 +0200 Subject: [PATCH 5/8] Address bot review: verify TRX file was actually produced Trx_WhenOnlyReportTrxIsSpecified_UsesControllerBackedRecoveryByDefault only asserted the console heading text, which would still pass even if a regression printed the right heading but skipped writing the TRX file. Give the run an explicit --report-trx-filename and assert the file exists, matching the sibling tests in this file. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TrxTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs index 6f0c3fe9ea..c8cf7b39d3 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/TrxTests.cs @@ -46,11 +46,15 @@ public async Task Trx_WhenOnlyReportTrxIsSpecified_UsesControllerBackedRecoveryB // be controller-backed by default on this platform. The "Out of process" heading is only emitted // when the surviving controller (rather than the test host itself) reports the TRX file artifact, // so its presence here is direct proof that a bare --report-trx run went through the controller. + string fileName = Guid.NewGuid().ToString("N"); var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, TestAssetFixture.AssetName, tfm); - TestHostResult testHostResult = await testHost.ExecuteAsync("--report-trx", cancellationToken: TestContext.CancellationToken); + TestHostResult testHostResult = await testHost.ExecuteAsync($"--report-trx --report-trx-filename {fileName}.trx", cancellationToken: TestContext.CancellationToken); testHostResult.AssertExitCodeIs(ExitCode.Success); testHostResult.AssertOutputContains("Out of process file artifacts produced:"); + + string[] trxFiles = Directory.GetFiles(testHost.DirectoryName, $"{fileName}.trx", SearchOption.AllDirectories); + Assert.HasCount(1, trxFiles, $"Expected exactly one trx file but found {trxFiles.Length}: {string.Join(", ", trxFiles)}"); } [DynamicData(nameof(TargetFrameworks.NetForDynamicData), typeof(TargetFrameworks))] From e1519d15f31df2bbf8c9cfb797820fa8d5eaa354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 28 Aug 2026 13:46:04 +0200 Subject: [PATCH 6/8] Fix self-referential unit test assertion flagged by test-quality bot ShouldUseOutOfProcessTrxGeneration_ReflectsControllerSupport_WhenTestHostControllerPidOptionIsSet compared its result against TrxModeHelpers.IsTestHostControllerSupported itself, which is tautological on every CI host this project targets (net462/net472/net8.0/net9.0, none of which are browser/ios/tvos/wasi): the assertion would still pass even if both sides broke identically. Assert the fixed expected value (true) instead, since this test always runs on a supported platform. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TrxModeHelpersTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxModeHelpersTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxModeHelpersTests.cs index f3f9638e30..65bb13ce4d 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxModeHelpersTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxModeHelpersTests.cs @@ -31,14 +31,16 @@ public void ShouldUseOutOfProcessTrxGeneration_ReflectsControllerSupport_WhenTes // Once the platform has actually placed this process under a controller (the PID option is // present), whether TRX recovers out-of-process should track platform support alone: the child // no longer needs to know which extension (TRX, HangDump, --timeout, ...) caused the isolation. + // This test always runs on a platform that supports test-host controllers (Windows/Linux/macOS), + // so assert the fixed expected value rather than comparing against IsTestHostControllerSupported + // itself, which would make the assertion self-referential and unable to catch a regression that + // breaks both sides identically. var commandLineOptions = new TestCommandLineOptions(new Dictionary { [TrxReport.Abstractions.TrxReportGeneratorCommandLine.TrxReportOptionName] = [], [PlatformCommandLineProvider.TestHostControllerPIDOptionKey] = ["42"], }); - Assert.AreEqual( - TrxModeHelpers.IsTestHostControllerSupported, - TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(commandLineOptions)); + Assert.IsTrue(TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(commandLineOptions)); } } From 484e3a1ccd011cc1cf79a908c5f27fc48505f7a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 28 Aug 2026 14:26:25 +0200 Subject: [PATCH 7/8] Add Android to TRX's in-process fallback platforms .NET Android cannot use the platform's default Process.Start-based launch path (TestHostControllersTestHost.ProcessLifecycle) to spawn an arbitrary child test host, even though this package targets android as a supported platform (see the csproj's SupportedPlatform entries). Before this fix, TrxModeHelpers.IsTestHostControllerSupported treated Android as controller-capable, so making --report-trx controller-backed by default (per #10792) would have regressed Android runs with a process-launch failure: previously TRX was in-process everywhere by default and only went out-of-process when --crashdump was explicitly set, so Android was never exposed to this failure mode until now. Add OperatingSystem.IsAndroid() to the same exclusion set already used for browser/ios/tvos/wasi throughout TrxModeHelpers, TrxProcessLifetimeHandler, TrxReportExtensions, and TrxTestApplicationLifecycleCallbacks, and document the fallback in PACKAGE.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PACKAGE.md | 2 +- .../TrxModeHelpers.cs | 23 +++++++++++-------- .../TrxProcessLifetimeHandler.cs | 1 + .../TrxReportExtensions.cs | 1 + .../TrxTestApplicationLifecycleCallbacks.cs | 1 + 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/PACKAGE.md b/src/Platform/Microsoft.Testing.Extensions.TrxReport/PACKAGE.md index 047307bacf..5f5371fa74 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/PACKAGE.md @@ -22,7 +22,7 @@ This package extends Microsoft.Testing.Platform with: Enable TRX report generation via the `--report-trx` command line option. -On platforms that can launch a test-host process (all except browser, iOS, tvOS, and WASI), TRX uses controller-backed recovery by default: the test host still streams results and generates the report during normal execution, but a surviving controller process can recover completed results into a TRX report if the test host crashes, hangs, or is stopped by `--timeout`. Browser, iOS, tvOS, and WASI cannot launch a test-host process, so TRX automatically falls back to its original in-process implementation there — no controller-backed recovery is attempted, and no configuration is required to get this fallback. +On platforms that can launch a test-host process (all except Android, browser, iOS, tvOS, and WASI), TRX uses controller-backed recovery by default: the test host still streams results and generates the report during normal execution, but a surviving controller process can recover completed results into a TRX report if the test host crashes, hangs, or is stopped by `--timeout`. Android, browser, iOS, tvOS, and WASI cannot launch a test-host process, so TRX automatically falls back to its original in-process implementation there — no controller-backed recovery is attempted, and no configuration is required to get this fallback. The extra process has a measurable startup cost, which varies by target framework: for a trivial single-test run, launching the controller added no statistically measurable overhead on .NET (differences were within normal process-launch noise) but added roughly 700-800ms on .NET Framework in local measurements. Weigh this against the reliability benefit for your scenario, especially on .NET Framework or in tight inner-loop test runs. diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs index f7e64a5a8f..0617aa4161 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxModeHelpers.cs @@ -14,21 +14,25 @@ internal static class TrxModeHelpers // TRX requires a controller-managed test host to recover the report when the test host crashes, // hangs, or is terminated by --timeout. Platforms that cannot launch a test-host process at all // fall back to the in-process implementation as a compatibility fallback (see - // TrxReportExtensions.AddTrxReportProvider). + // TrxReportExtensions.AddTrxReportProvider). Android cannot use the platform's default + // Process.Start-based launch path (TestHostControllersTestHost.ProcessLifecycle) to spawn an + // arbitrary child test host, so it must fall back in-process too even though the package targets + // it (see the csproj's SupportedPlatform entries for android/browser/ios/tvos/wasi). // - // This assembly also ships a netstandard2.0 asset (see the csproj's SupportedPlatform entries for - // browser/ios/tvos/wasi), so these checks must run for every target, not just NETCOREAPP: under - // NETCOREAPP they resolve via the BCL OperatingSystem APIs, and under netstandard2.0/.NET Framework - // they resolve via the Polyfills OperatingSystem extension, which itself already returns constant - // false for these platforms on .NET Framework (which cannot run on them) while evaluating the - // actual runtime via RuntimeInformation.IsOSPlatform for netstandard2.0 hosts (e.g. Mono/MAUI) that - // can. + // This assembly also ships a netstandard2.0 asset, so these checks must run for every target, not + // just NETCOREAPP: under NETCOREAPP they resolve via the BCL OperatingSystem APIs, and under + // netstandard2.0/.NET Framework they resolve via the Polyfills OperatingSystem extension, which + // itself already returns constant false for these platforms on .NET Framework (which cannot run on + // them) while evaluating the actual runtime via RuntimeInformation.IsOSPlatform for netstandard2.0 + // hosts (e.g. Mono/MAUI) that can. + [UnsupportedOSPlatformGuard("android")] [UnsupportedOSPlatformGuard("browser")] [UnsupportedOSPlatformGuard("ios")] [UnsupportedOSPlatformGuard("tvos")] [UnsupportedOSPlatformGuard("wasi")] public static bool IsTestHostControllerSupported { get; } = - !OperatingSystem.IsBrowser() + !OperatingSystem.IsAndroid() + && !OperatingSystem.IsBrowser() && !OperatingSystem.IsIOS() && !OperatingSystem.IsTvOS() && !OperatingSystem.IsWasi(); @@ -37,6 +41,7 @@ internal static class TrxModeHelpers // actually established for this process, rather than recomputing which extension requested // isolation. This stays true even when another extension (HangDump, --timeout, ...) is the one // that caused the controller to be used. + [UnsupportedOSPlatformGuard("android")] [UnsupportedOSPlatformGuard("browser")] [UnsupportedOSPlatformGuard("ios")] [UnsupportedOSPlatformGuard("tvos")] diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs index 4830d9534c..5374fadfde 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs @@ -118,6 +118,7 @@ public Task BeforeTestHostProcessStartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } + [UnsupportedOSPlatform("android")] [UnsupportedOSPlatform("browser")] [UnsupportedOSPlatform("ios")] [UnsupportedOSPlatform("tvos")] diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportExtensions.cs index 7b70ee4cc4..c2116d037f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportExtensions.cs @@ -76,6 +76,7 @@ public static void AddTrxReportProvider(this ITestApplicationBuilder builder) artifactPostProcessingBuilder.Tools.AddTool(serviceProvider => toolTrxMergeFactory.CreateTool(serviceProvider.GetCommandLineOptions())); } + [UnsupportedOSPlatform("android")] [UnsupportedOSPlatform("browser")] [UnsupportedOSPlatform("ios")] [UnsupportedOSPlatform("tvos")] diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxTestApplicationLifecycleCallbacks.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxTestApplicationLifecycleCallbacks.cs index e213095877..db661e3f3e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxTestApplicationLifecycleCallbacks.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxTestApplicationLifecycleCallbacks.cs @@ -14,6 +14,7 @@ namespace Microsoft.Testing.Extensions.TrxReport.Abstractions; internal sealed class TrxTestApplicationLifecycleCallbacks : ITestHostApplicationLifetime, IDisposable { + [UnsupportedOSPlatformGuard("android")] [UnsupportedOSPlatformGuard("browser")] [UnsupportedOSPlatformGuard("ios")] [UnsupportedOSPlatformGuard("tvos")] From de7de7d5b7611e1cdc681c8d0462a14b16e24666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 28 Aug 2026 14:41:06 +0200 Subject: [PATCH 8/8] Add HangDump + TRX termination acceptance coverage Address review feedback: the new PID-based controller-backed decision in TrxModeHelpers is meant to make TRX recover correctly whenever a controller is present for any reason (HangDump, --timeout, plain --report-trx, ...), but no test exercised a HangDump termination combined with TRX. ForwardCompatibilityTests runs --hangdump --report-trx together, but only on a host that completes normally, so it never reaches the controller's crash-recovery path. Add HangDumpPlusTrxTests: a dedicated asset simulates a real hang (one test completes and is streamed to the TRX sidecar, the next sleeps well past --hangdump-timeout), asserts HangDump actually kills the host, and verifies the recovered TRX has a failed ResultSummary and contains the completed test but not the one that never finished. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../HangDumpPlusTrxTests.cs | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpPlusTrxTests.cs diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpPlusTrxTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpPlusTrxTests.cs new file mode 100644 index 0000000000..192e367d86 --- /dev/null +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpPlusTrxTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; + +/// +/// Covers the HangDump + TRX combination explicitly required by issue +/// : when HangDump terminates a hung test +/// host, the surviving controller must still recover the completed results into a TRX report with a +/// failed ResultSummary and run diagnostics, exactly like the plain-crash and +/// --timeout-triggered cases already covered by . +/// +/// also runs --hangdump --report-trx together, but only on +/// a test host that completes normally, so it never exercises the controller's crash-recovery path — a +/// regression there would go undetected. This class instead simulates a real hang (one test completes, +/// the next sleeps well past --hangdump-timeout) so HangDump actually kills the host. +/// +[TestClass] +public sealed class HangDumpPlusTrxTests : AcceptanceTestBase +{ + [TestMethod] + public async Task HangDumpPlusTrx_WhenTestHostHangs_RecoversCompletedResultsAndFailedResultSummary() + { + string resultDirectory = Path.Combine(AssetFixture.TargetAssetPath, Guid.NewGuid().ToString("N")); + string fileName = $"{Guid.NewGuid():N}.trx"; + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, TestAssetFixture.AssetName, TargetFrameworks.NetCurrent); + + TestHostResult testHostResult = await testHost.ExecuteAsync( + $"--hangdump --hangdump-timeout 8s --report-trx --report-trx-filename {fileName} --results-directory \"{resultDirectory}\"", + new Dictionary + { + // Test1 completes quickly, well before the streaming store's 500ms default flush + // interval and the 8s hangdump timeout, so it is durably recorded in the sidecar. Test2 + // then sleeps far longer than the hangdump timeout so HangDump actually kills the host. + { "SLEEPTIMEMS1", "1000" }, + { "SLEEPTIMEMS2", "600000" }, + }, + cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(ExitCode.TestHostProcessExitedNonGracefully); + + // Two copies of the dump are expected: the original HangDump wrote, plus TRX's own copy of it + // into the deployment "In" attachment folder (the same attachment-copying behavior asserted in + // Trx_WhenReportTrxAndResultsDirectoryAreSpecifiedWithArtifact_ArtifactIsCopiedUnderRelativeResultsDirectory). + string[] dumpFiles = Directory.GetFiles(resultDirectory, "*.dmp", SearchOption.AllDirectories); + Assert.IsGreaterThan(0, dumpFiles.Length, $"Expected at least one dump file. Found: {Environment.NewLine}{string.Join(Environment.NewLine, dumpFiles)}{Environment.NewLine}{testHostResult}"); + + string[] trxFiles = Directory.GetFiles(resultDirectory, fileName, SearchOption.AllDirectories); + Assert.HasCount(1, trxFiles, $"Expected exactly one trx file but found {trxFiles.Length}: {string.Join(", ", trxFiles)}"); + + string trxContent = File.ReadAllText(trxFiles[0]); + + // The controller's crash-recovery path (OnTestHostProcessExitedAsync, taken whenever the host + // exits before it can report its own TRX file name) always reports a failed run, regardless of + // whether HangDump or an actual crash caused the termination. + Assert.Contains("""""", trxContent, trxContent); + + // Direct proof of recovery: Test1 completed and was streamed to the sidecar before the hang, so + // it must appear in the TRX even though the process was killed mid-run. + Assert.Contains("Test1", trxContent, trxContent); + + // Test2 never completed (the sleep that trips the hangdump timeout runs after it, before the + // update publishes), so it must be absent from the recovered results. + Assert.DoesNotContain("Test2", trxContent, trxContent); + } + + public sealed class TestAssetFixture() : TestAssetFixtureBase() + { + public const string AssetName = "HangDumpPlusTrxTest"; + + public string TargetAssetPath => GetAssetPath(AssetName); + + public override (string ID, string Name, string Code) GetAssetsToGenerate() => (AssetName, AssetName, + SourceCode + .PatchTargetFrameworks(TargetFrameworks.NetCurrent) + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion)); + + private const string SourceCode = """ +#file HangDumpPlusTrxTest.csproj + + + $TargetFrameworks$ + enable + enable + Exe + preview + + + + + + + +#file Program.cs +using System.Globalization; +using Microsoft.Testing.Extensions; +using Microsoft.Testing.Extensions.TrxReport.Abstractions; +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.Capabilities.TestFramework; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.TestFramework; +using Microsoft.Testing.Platform.Requests; +using Microsoft.Testing.Platform.Services; + +public class Program +{ + public static async Task Main(string[] args) + { + ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); + builder.RegisterTestFramework( + sp => new TestFrameworkCapabilities(new TrxReportCapability()), + (_, _) => new DummyTestFramework()); + builder.AddHangDumpProvider(); + builder.AddTrxReportProvider(); + using ITestApplication app = await builder.BuildAsync(); + return await app.RunAsync(); + } +} + +public class TrxReportCapability : ITrxReportCapability +{ + bool ITrxReportCapability.IsSupported { get; } = true; + void ITrxReportCapability.Enable() + { + } +} + +public class DummyTestFramework : ITestFramework, IDataProducer +{ + public string Uid => nameof(DummyTestFramework); + + public string Version => "2.0.0"; + + public string DisplayName => nameof(DummyTestFramework); + + public string Description => nameof(DummyTestFramework); + + public Type[] DataTypesProduced => new[] { typeof(TestNodeUpdateMessage) }; + + public Task IsEnabledAsync() => Task.FromResult(true); + + public Task CreateTestSessionAsync(CreateTestSessionContext context) + => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true }); + + public Task CloseTestSessionAsync(CloseTestSessionContext context) + => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true }); + + public async Task ExecuteRequestAsync(ExecuteRequestContext context) + { + Thread.Sleep(int.Parse(Environment.GetEnvironmentVariable("SLEEPTIMEMS1")!, CultureInfo.InvariantCulture)); + + var test1Identifier = new TestMethodIdentifierProperty(string.Empty, string.Empty, "DummyClassName", "Test1", 0, Array.Empty(), string.Empty); + await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, new TestNode() + { + Uid = "Test1", + DisplayName = "Test1", + Properties = new PropertyBag(PassedTestNodeStateProperty.CachedInstance, test1Identifier), + })); + + Thread.Sleep(int.Parse(Environment.GetEnvironmentVariable("SLEEPTIMEMS2")!, CultureInfo.InvariantCulture)); + + var test2Identifier = new TestMethodIdentifierProperty(string.Empty, string.Empty, "DummyClassName", "Test2", 0, Array.Empty(), string.Empty); + await context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(context.Request.Session.SessionUid, new TestNode() + { + Uid = "Test2", + DisplayName = "Test2", + Properties = new PropertyBag(PassedTestNodeStateProperty.CachedInstance, test2Identifier), + })); + + context.Complete(); + } +} +"""; + } + + public TestContext TestContext { get; set; } = null!; +}