diff --git a/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs b/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs index cfcba1b1ae..c507ca1cdc 100644 --- a/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs +++ b/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs @@ -7,6 +7,7 @@ using TUnit.Engine.Framework; using TUnit.Engine.Reporters; using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Configurations; #pragma warning disable TPEXP @@ -79,6 +80,11 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) { junitReporter.SetOutputPath(pathArgs[0]); } + + // Set results directory as specified by --results-directory, + // so it can be used in the default output path if --report-html-filename is not provided + junitReporter.SetResultsDirectory(serviceProvider.GetRequiredService().GetTestResultDirectory()); + return junitReporter; }); testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => junitReporter); @@ -106,6 +112,10 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) // OnTestSessionFinishingAsync (called before the bus is drained/disabled). htmlReporter.SetMessageBus(serviceProvider.GetMessageBus()); + // Set results directory as specified by --results-directory, + // so it can be used in the default output path if --report-html-filename is not provided + htmlReporter.SetResultsDirectory(serviceProvider.GetRequiredService().GetTestResultDirectory()); + return htmlReporter; }); } diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 7c7a274547..f96a59f392 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -23,6 +23,7 @@ internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, IDataP { private string? _outputPath; private IMessageBus? _messageBus; + private string _resultsDirectory = "TestResults"; private readonly ConcurrentDictionary> _updates = []; #if NET @@ -64,11 +65,6 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo public Task BeforeRunAsync(CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(_outputPath)) - { - _outputPath = GetDefaultOutputPath(); - } - #if NET _activityCollector = new ActivityCollector(); _activityCollector.Start(); @@ -117,6 +113,11 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon return; } + if (string.IsNullOrEmpty(_outputPath)) + { + _outputPath = GetDefaultOutputPath(); + } + var outputPath = _outputPath!; // WriteFileAsync returns false if all retry attempts are exhausted (locked file, bad path, etc.). // Artifact publishing is gated on a successful write — no file means no artifact. @@ -178,6 +179,13 @@ internal void SetMessageBus(IMessageBus? messageBus) _messageBus = messageBus; } + // Called by the AddTestSessionLifetimeHandler factory at startup, before any session events fire, + // so _resultsDirectory is guaranteed to be set before OnTestSessionFinishingAsync is invoked. + internal void SetResultsDirectory(string path) + { + _resultsDirectory = path; + } + private ReportData BuildReportData() { var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; @@ -527,13 +535,13 @@ private static (string Status, ReportExceptionData? Exception, string? SkipReaso }; } - private static string GetDefaultOutputPath() + private string GetDefaultOutputPath() { var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; var sanitizedName = string.Concat(assemblyName.Split(Path.GetInvalidFileNameChars())); var os = GetShortOsName(); var tfm = GetShortFrameworkName(); - return Path.GetFullPath(Path.Combine("TestResults", $"{sanitizedName}-{os}-{tfm}-report.html")); + return Path.GetFullPath(Path.Combine(_resultsDirectory, $"{sanitizedName}-{os}-{tfm}-report.html")); } private static string GetShortOsName() diff --git a/TUnit.Engine/Reporters/JUnitReporter.cs b/TUnit.Engine/Reporters/JUnitReporter.cs index 501cd30ef7..450dbaddd0 100644 --- a/TUnit.Engine/Reporters/JUnitReporter.cs +++ b/TUnit.Engine/Reporters/JUnitReporter.cs @@ -16,6 +16,7 @@ public class JUnitReporter(IExtension extension) : IDataConsumer, ITestHostAppli { private string _outputPath = null!; private bool _isEnabled; + private string _resultsDirectory = "TestResults"; public async Task IsEnabledAsync() { @@ -35,16 +36,6 @@ public async Task IsEnabledAsync() return false; } - // Determine output path (only if not already set via command-line argument) - if (string.IsNullOrEmpty(_outputPath)) - { - var envPath = Environment.GetEnvironmentVariable(EnvironmentConstants.JUnitXmlOutputPath); - - _outputPath = envPath is not null - ? PathValidator.ValidateAndNormalizePath(envPath, nameof(EnvironmentConstants.JUnitXmlOutputPath)) - : GetDefaultOutputPath(); - } - _isEnabled = true; return await extension.IsEnabledAsync(); } @@ -97,6 +88,16 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) return; } + // Determine output path (only if not already set via command-line argument) + if (string.IsNullOrEmpty(_outputPath)) + { + var envPath = Environment.GetEnvironmentVariable(EnvironmentConstants.JUnitXmlOutputPath); + + _outputPath = envPath is not null + ? PathValidator.ValidateAndNormalizePath(envPath, nameof(EnvironmentConstants.JUnitXmlOutputPath)) + : GetDefaultOutputPath(); + } + // Write to file with retry logic await WriteXmlFileAsync(_outputPath, xmlContent, cancellation); } @@ -108,14 +109,21 @@ internal void SetOutputPath(string path) _outputPath = PathValidator.ValidateAndNormalizePath(path, nameof(path)); } - private static string GetDefaultOutputPath() + // Called by the AddTestSessionLifetimeHandler factory at startup, before any session events fire, + // so _resultsDirectory is guaranteed to be set before AfterRunAsync is invoked. + internal void SetResultsDirectory(string path) + { + _resultsDirectory = path; + } + + private string GetDefaultOutputPath() { var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; // Sanitize assembly name to remove any characters that could be used for path traversal var sanitizedName = string.Concat(assemblyName.Split(Path.GetInvalidFileNameChars())); - return Path.GetFullPath(Path.Combine("TestResults", $"{sanitizedName}-junit.xml")); + return Path.GetFullPath(Path.Combine(_resultsDirectory, $"{sanitizedName}-junit.xml")); } private static async Task WriteXmlFileAsync(string path, string content, CancellationToken cancellationToken)