diff --git a/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs b/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs new file mode 100644 index 0000000000..2f1b7fd749 --- /dev/null +++ b/TUnit.Engine/CommandLineProviders/HtmlReporterCommandProvider.cs @@ -0,0 +1,56 @@ +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.CommandLine; + +namespace TUnit.Engine.CommandLineProviders; + +internal class HtmlReporterCommandProvider(IExtension extension) : ICommandLineOptionsProvider +{ + public const string ReportHtml = "report-html"; + public const string ReportHtmlFilename = "report-html-filename"; + + public Task IsEnabledAsync() => extension.IsEnabledAsync(); + + public string Uid => extension.Uid; + + public string Version => extension.Version; + + public string DisplayName => extension.DisplayName; + + public string Description => extension.Description; + + public IReadOnlyCollection GetCommandLineOptions() + { + return + [ + new CommandLineOption( + ReportHtml, + "Generate an HTML test report", + ArgumentArity.Zero, + false), + new CommandLineOption( + ReportHtmlFilename, + "Path for the HTML test report file (default: TestResults/{AssemblyName}-report.html)", + ArgumentArity.ExactlyOne, + false) + ]; + } + + public Task ValidateOptionArgumentsAsync( + CommandLineOption commandOption, + string[] arguments) + { + if (commandOption.Name == ReportHtmlFilename && arguments.Length != 1) + { + return ValidationResult.InvalidTask("A single output path must be provided for the HTML report"); + } + + return ValidationResult.ValidTask; + } + + public Task ValidateCommandLineOptionsAsync( + ICommandLineOptions commandLineOptions) + { + return ValidationResult.ValidTask; + } +} diff --git a/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs b/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs index 435894225f..6726b9cbe8 100644 --- a/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs +++ b/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs @@ -24,6 +24,9 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) var junitReporter = new JUnitReporter(extension); var junitReporterCommandProvider = new JUnitReporterCommandProvider(extension); + var htmlReporter = new HtmlReporter(extension); + var htmlReporterCommandProvider = new HtmlReporterCommandProvider(extension); + testApplicationBuilder.RegisterTestFramework( serviceProvider => new TestFrameworkCapabilities(CreateCapabilities(serviceProvider)), (capabilities, serviceProvider) => new TUnitTestFramework(extension, serviceProvider, capabilities)); @@ -52,6 +55,9 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) // JUnit reporter configuration testApplicationBuilder.CommandLine.AddProvider(() => junitReporterCommandProvider); + // HTML reporter configuration + testApplicationBuilder.CommandLine.AddProvider(() => htmlReporterCommandProvider); + testApplicationBuilder.TestHost.AddDataConsumer(serviceProvider => { // Apply command-line configuration if provided @@ -76,6 +82,26 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) return junitReporter; }); testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => junitReporter); + + testApplicationBuilder.TestHost.AddDataConsumer(serviceProvider => + { + var commandLineOptions = serviceProvider.GetRequiredService(); + + // Enable if --report-html flag is set or --report-html-filename is provided + if (commandLineOptions.IsOptionSet(HtmlReporterCommandProvider.ReportHtml) + || commandLineOptions.TryGetOptionArgumentList(HtmlReporterCommandProvider.ReportHtmlFilename, out _)) + { + htmlReporter.Enable(); + } + + if (commandLineOptions.TryGetOptionArgumentList(HtmlReporterCommandProvider.ReportHtmlFilename, out var pathArgs)) + { + htmlReporter.SetOutputPath(pathArgs[0]); + } + + return htmlReporter; + }); + testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => htmlReporter); } private static IReadOnlyCollection CreateCapabilities(IServiceProvider serviceProvider) diff --git a/TUnit.Engine/Reporters/HtmlReporter.cs b/TUnit.Engine/Reporters/HtmlReporter.cs new file mode 100644 index 0000000000..972108b938 --- /dev/null +++ b/TUnit.Engine/Reporters/HtmlReporter.cs @@ -0,0 +1,368 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Reflection; +using System.Text; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.TestHost; +using TUnit.Engine.Framework; + +namespace TUnit.Engine.Reporters; + +public class HtmlReporter(IExtension extension) : IDataConsumer, ITestHostApplicationLifetime, IFilterReceiver +{ + private string _outputPath = null!; + private bool _isEnabled; + + public async Task IsEnabledAsync() + { + if (!_isEnabled) + { + return false; + } + + if (string.IsNullOrEmpty(_outputPath)) + { + _outputPath = GetDefaultOutputPath(); + } + + return await extension.IsEnabledAsync(); + } + + internal void Enable() + { + _isEnabled = true; + } + + public string Uid { get; } = $"{extension.Uid}HtmlReporter"; + + public string Version => extension.Version; + + public string DisplayName => extension.DisplayName; + + public string Description => extension.Description; + + private readonly ConcurrentDictionary> _updates = []; + + public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) + { + var testNodeUpdateMessage = (TestNodeUpdateMessage)value; + + _updates.GetOrAdd(testNodeUpdateMessage.TestNode.Uid.Value, []).Add(testNodeUpdateMessage); + + return Task.CompletedTask; + } + + public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; + + public Task BeforeRunAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) + { + if (!_isEnabled || _updates.Count == 0) + { + return; + } + + // Get the last update for each test + var lastUpdates = new Dictionary(_updates.Count); + foreach (var kvp in _updates) + { + if (kvp.Value.Count > 0) + { + lastUpdates[kvp.Key] = kvp.Value[kvp.Value.Count - 1]; + } + } + + var htmlContent = GenerateHtml(lastUpdates); + + if (string.IsNullOrEmpty(htmlContent)) + { + return; + } + + await WriteFileAsync(_outputPath, htmlContent, cancellation); + } + + public string? Filter { get; set; } + + internal void SetOutputPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Output path cannot be null or empty", nameof(path)); + } + + _outputPath = path; + } + + private string GenerateHtml(Dictionary lastUpdates) + { + var passedCount = 0; + var failedCount = 0; + var skippedCount = 0; + var otherCount = 0; + + foreach (var kvp in lastUpdates) + { + var stateProperty = kvp.Value.TestNode.Properties.AsEnumerable() + .FirstOrDefault(p => p is TestNodeStateProperty); + + switch (stateProperty) + { + case PassedTestNodeStateProperty: + passedCount++; + break; + case FailedTestNodeStateProperty or ErrorTestNodeStateProperty or TimeoutTestNodeStateProperty: + failedCount++; + break; + case SkippedTestNodeStateProperty: + skippedCount++; + break; + default: + otherCount++; + break; + } + } + + var totalCount = lastUpdates.Count; + var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; + + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"Test Report - {WebUtility.HtmlEncode(assemblyName)}"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("
"); + + // Header + sb.AppendLine($"

Test Report: {WebUtility.HtmlEncode(assemblyName)}

"); + sb.AppendLine($"

Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC

"); + + if (!string.IsNullOrEmpty(Filter)) + { + sb.AppendLine($"

Filter: {WebUtility.HtmlEncode(Filter)}

"); + } + + // Summary + sb.AppendLine("
"); + sb.AppendLine($"
{totalCount}Total
"); + sb.AppendLine($"
{passedCount}Passed
"); + sb.AppendLine($"
{failedCount}Failed
"); + sb.AppendLine($"
{skippedCount}Skipped
"); + + if (otherCount > 0) + { + sb.AppendLine($"
{otherCount}Other
"); + } + + sb.AppendLine("
"); + + // Test results table + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + + foreach (var kvp in lastUpdates) + { + var testNode = kvp.Value.TestNode; + + var testMethodIdentifier = testNode.Properties.AsEnumerable() + .OfType() + .FirstOrDefault(); + + var className = testMethodIdentifier?.TypeName; + var displayName = testNode.DisplayName; + var name = string.IsNullOrEmpty(className) ? displayName : $"{className}.{displayName}"; + + var stateProperty = testNode.Properties.AsEnumerable() + .FirstOrDefault(p => p is TestNodeStateProperty); + + var status = GetStatus(stateProperty); + var cssClass = GetStatusCssClass(stateProperty); + + var timingProperty = testNode.Properties.AsEnumerable() + .OfType() + .FirstOrDefault(); + + var duration = timingProperty?.GlobalTiming.Duration; + var durationText = duration.HasValue ? FormatDuration(duration.Value) : "-"; + + var details = GetDetails(stateProperty); + + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine(""); + } + + sb.AppendLine(""); + sb.AppendLine("
TestStatusDurationDetails
{WebUtility.HtmlEncode(name)}{WebUtility.HtmlEncode(status)}{WebUtility.HtmlEncode(durationText)}{(string.IsNullOrEmpty(details) ? "" : $"
{WebUtility.HtmlEncode(details)}
")}
"); + + sb.AppendLine("
"); // container + sb.AppendLine(""); + sb.AppendLine(""); + + return sb.ToString(); + } + + private static string GetCss() + { + return """ + * { margin: 0; padding: 0; box-sizing: border-box; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 20px; } + .container { max-width: 1200px; margin: 0 auto; } + h1 { margin-bottom: 8px; color: #1a1a1a; } + .timestamp { color: #666; margin-bottom: 4px; } + .filter { color: #666; margin-bottom: 16px; } + .filter code { background: #e8e8e8; padding: 2px 6px; border-radius: 3px; } + .summary { display: flex; gap: 16px; margin: 20px 0; flex-wrap: wrap; } + .summary-item { background: #fff; border-radius: 8px; padding: 16px 24px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); min-width: 120px; } + .summary-item .count { display: block; font-size: 2em; font-weight: bold; } + .summary-item .label { display: block; font-size: 0.9em; color: #666; margin-top: 4px; } + .summary-item.total { border-top: 4px solid #2196F3; } + .summary-item.passed { border-top: 4px solid #4CAF50; } + .summary-item.failed { border-top: 4px solid #F44336; } + .summary-item.skipped { border-top: 4px solid #FF9800; } + .summary-item.other { border-top: 4px solid #9E9E9E; } + table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 20px; } + thead th { background: #fafafa; padding: 12px 16px; text-align: left; font-weight: 600; border-bottom: 2px solid #eee; } + tbody td { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; vertical-align: top; } + tbody tr:last-child td { border-bottom: none; } + .test-name { word-break: break-word; } + .badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.85em; font-weight: 600; } + .badge.row-passed { background: #E8F5E9; color: #2E7D32; } + .badge.row-failed { background: #FFEBEE; color: #C62828; } + .badge.row-skipped { background: #FFF3E0; color: #E65100; } + .badge.row-other { background: #F5F5F5; color: #616161; } + .details pre { white-space: pre-wrap; word-break: break-word; font-size: 0.85em; background: #f8f8f8; padding: 8px; border-radius: 4px; max-height: 200px; overflow: auto; } + .duration { white-space: nowrap; } + """; + } + + private static string GetStatus(IProperty? stateProperty) + { + return stateProperty switch + { + PassedTestNodeStateProperty => "Passed", + FailedTestNodeStateProperty => "Failed", + ErrorTestNodeStateProperty => "Error", + TimeoutTestNodeStateProperty => "Timed Out", + SkippedTestNodeStateProperty => "Skipped", +#pragma warning disable CS0618 // CancelledTestNodeStateProperty is obsolete + CancelledTestNodeStateProperty => "Cancelled", +#pragma warning restore CS0618 + InProgressTestNodeStateProperty => "In Progress", + _ => "Unknown" + }; + } + + private static string GetStatusCssClass(IProperty? stateProperty) + { + return stateProperty switch + { + PassedTestNodeStateProperty => "row-passed", + FailedTestNodeStateProperty or ErrorTestNodeStateProperty or TimeoutTestNodeStateProperty => "row-failed", + SkippedTestNodeStateProperty => "row-skipped", + _ => "row-other" + }; + } + + private static string GetDetails(IProperty? stateProperty) + { + return stateProperty switch + { + FailedTestNodeStateProperty failed => failed.Exception?.ToString() ?? "Test failed", + ErrorTestNodeStateProperty error => error.Exception?.ToString() ?? "Test error", + TimeoutTestNodeStateProperty timeout => timeout.Explanation ?? "Test timed out", + SkippedTestNodeStateProperty skipped => skipped.Explanation ?? "", +#pragma warning disable CS0618 // CancelledTestNodeStateProperty is obsolete + CancelledTestNodeStateProperty => "Test was cancelled", +#pragma warning restore CS0618 + _ => "" + }; + } + + private static string FormatDuration(TimeSpan duration) + { + if (duration.TotalMilliseconds < 1) + { + var microseconds = duration.Ticks / 10.0; + return $"{microseconds:F0}us"; + } + + if (duration.TotalSeconds < 1) + { + return $"{duration.TotalMilliseconds:F0}ms"; + } + + if (duration.TotalMinutes < 1) + { + return $"{duration.TotalSeconds:F2}s"; + } + + return $"{duration.TotalMinutes:F1}m"; + } + + private static string GetDefaultOutputPath() + { + var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; + return Path.Combine("TestResults", $"{assemblyName}-report.html"); + } + + private static async Task WriteFileAsync(string path, string content, CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + const int maxAttempts = 5; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { +#if NET + await File.WriteAllTextAsync(path, content, Encoding.UTF8, cancellationToken); +#else + File.WriteAllText(path, content, Encoding.UTF8); +#endif + Console.WriteLine($"HTML test report written to: {path}"); + return; + } + catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex)) + { + var baseDelay = 50 * Math.Pow(2, attempt - 1); + var jitter = Random.Shared.Next(0, 50); + var delay = (int)(baseDelay + jitter); + + Console.WriteLine($"HTML report file is locked, retrying in {delay}ms (attempt {attempt}/{maxAttempts})"); + await Task.Delay(delay, cancellationToken); + } + } + + Console.WriteLine($"Failed to write HTML test report to: {path} after {maxAttempts} attempts"); + } + + private static bool IsFileLocked(IOException exception) + { + var errorCode = exception.HResult & 0xFFFF; + return errorCode == 0x20 || errorCode == 0x21 || + exception.Message.Contains("being used by another process") || + exception.Message.Contains("access denied", StringComparison.OrdinalIgnoreCase); + } +}