diff --git a/TUnit.Engine.Tests/GitHubReporterTests.cs b/TUnit.Engine.Tests/GitHubReporterTests.cs index b701109baf..3b0f32c6b0 100644 --- a/TUnit.Engine.Tests/GitHubReporterTests.cs +++ b/TUnit.Engine.Tests/GitHubReporterTests.cs @@ -1,4 +1,3 @@ -using Microsoft.Testing.Platform.Extensions; using Shouldly; using TUnit.Engine.Reporters; @@ -7,15 +6,6 @@ namespace TUnit.Engine.Tests; [NotInParallel] public class GitHubReporterTests { - private sealed class MockExtension : IExtension - { - public string Uid => "MockExtension"; - public string DisplayName => "Mock"; - public string Version => "1.0.0"; - public string Description => "Mock Extension"; - public Task IsEnabledAsync() => Task.FromResult(true); - } - [After(Test)] public void CleanupAfterTest() { diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs new file mode 100644 index 0000000000..030df930e8 --- /dev/null +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -0,0 +1,94 @@ +#pragma warning disable TPEXP + +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.TestHost; +using Shouldly; +using TUnit.Engine.Reporters.Html; + +namespace TUnit.Engine.Tests; + +public class HtmlReporterTests +{ + [Test] + public void HtmlReporter_Implements_IDataProducer() + { + var reporter = new HtmlReporter(new MockExtension()); + reporter.ShouldBeAssignableTo(); + } + + [Test] + public void HtmlReporter_DataTypesProduced_Contains_SessionFileArtifact() + { + var reporter = new HtmlReporter(new MockExtension()); + var producer = (Microsoft.Testing.Platform.Extensions.Messages.IDataProducer)reporter; + producer.DataTypesProduced.ShouldContain(typeof(SessionFileArtifact)); + } + + [Test] + public async Task PublishArtifactAsync_Publishes_SessionFileArtifact_When_SessionContext_Set_And_File_Exists() + { + // Arrange + var reporter = new HtmlReporter(new MockExtension()); + var bus = new CapturingMessageBus(); + reporter.SetMessageBus(bus); + + var tempFile = Path.GetTempFileName(); + try + { + // Act + await reporter.PublishArtifactAsync(tempFile, new SessionUid("test-session-1"), CancellationToken.None); + + // Assert + bus.Published.Count.ShouldBe(1); + var artifact = bus.Published[0].Data.ShouldBeOfType(); + artifact.FileInfo.FullName.ShouldBe(new FileInfo(tempFile).FullName); + artifact.DisplayName.ShouldBe("HTML Test Report"); + } + finally + { + File.Delete(tempFile); + } + } + + [Test] + public async Task PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injected() + { + var reporter = new HtmlReporter(new MockExtension()); + var bus = new CapturingMessageBus(); + // Intentionally not calling reporter.SetMessageBus(bus) + + var tempFile = Path.GetTempFileName(); + try + { + await reporter.PublishArtifactAsync(tempFile, new SessionUid("test-session-1"), CancellationToken.None); + bus.Published.ShouldBeEmpty(); + } + finally + { + File.Delete(tempFile); + } + } + + + [Test] + public async Task PublishArtifactAsync_Publishes_With_Correct_SessionUid() + { + var reporter = new HtmlReporter(new MockExtension()); + var bus = new CapturingMessageBus(); + reporter.SetMessageBus(bus); + + var tempFile = Path.GetTempFileName(); + try + { + var uid = new SessionUid("my-session-42"); + await reporter.PublishArtifactAsync(tempFile, uid, CancellationToken.None); + + var artifact = bus.Published[0].Data.ShouldBeOfType(); + artifact.SessionUid.Value.ShouldBe("my-session-42"); + } + finally + { + File.Delete(tempFile); + } + } +} diff --git a/TUnit.Engine.Tests/JUnitReporterTests.cs b/TUnit.Engine.Tests/JUnitReporterTests.cs index 9320db65d6..65ce4b7cc8 100644 --- a/TUnit.Engine.Tests/JUnitReporterTests.cs +++ b/TUnit.Engine.Tests/JUnitReporterTests.cs @@ -1,4 +1,3 @@ -using Microsoft.Testing.Platform.Extensions; using TUnit.Core; using TUnit.Engine.Reporters; @@ -7,15 +6,6 @@ namespace TUnit.Engine.Tests; [NotInParallel] public class JUnitReporterTests { - private sealed class MockExtension : IExtension - { - public string Uid => "MockExtension"; - public string DisplayName => "Mock"; - public string Version => "1.0.0"; - public string Description => "Mock Extension"; - public Task IsEnabledAsync() => Task.FromResult(true); - } - [After(Test)] public void Cleanup() { diff --git a/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj b/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj index 99a1694787..1deb9374a0 100644 --- a/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj +++ b/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj @@ -4,9 +4,12 @@ net10.0 + true + ..\strongname.snk + diff --git a/TUnit.Engine.Tests/TestInfrastructure/CapturingMessageBus.cs b/TUnit.Engine.Tests/TestInfrastructure/CapturingMessageBus.cs new file mode 100644 index 0000000000..1a5fa68cde --- /dev/null +++ b/TUnit.Engine.Tests/TestInfrastructure/CapturingMessageBus.cs @@ -0,0 +1,17 @@ +#pragma warning disable TPEXP + +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Messages; + +namespace TUnit.Engine.Tests; + +internal sealed class CapturingMessageBus : IMessageBus +{ + public List<(IDataProducer Producer, IData Data)> Published = []; + + public Task PublishAsync(IDataProducer dataProducer, IData value) + { + Published.Add((dataProducer, value)); + return Task.CompletedTask; + } +} diff --git a/TUnit.Engine.Tests/TestInfrastructure/MockExtension.cs b/TUnit.Engine.Tests/TestInfrastructure/MockExtension.cs new file mode 100644 index 0000000000..9882775ffe --- /dev/null +++ b/TUnit.Engine.Tests/TestInfrastructure/MockExtension.cs @@ -0,0 +1,12 @@ +using Microsoft.Testing.Platform.Extensions; + +namespace TUnit.Engine.Tests; + +internal sealed class MockExtension : IExtension +{ + public string Uid => "MockExtension"; + public string DisplayName => "Mock"; + public string Version => "1.0.0"; + public string Description => "Mock Extension"; + public Task IsEnabledAsync() => Task.FromResult(true); +} diff --git a/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs b/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs index 29731c3b53..cfcba1b1ae 100644 --- a/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs +++ b/TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs @@ -83,7 +83,11 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) }); testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => junitReporter); - testApplicationBuilder.TestHost.AddDataConsumer(serviceProvider => + testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => htmlReporter); + // MTP auto-registers IDataConsumer implementations returned from AddTestSessionLifetimeHandler, + // so no separate AddDataConsumer call is needed. Adding one causes a startup exception: + // "Consumer registered two time for data type TestNodeUpdateMessage". + testApplicationBuilder.TestHost.AddTestSessionLifetimeHandler(serviceProvider => { var commandLineOptions = serviceProvider.GetRequiredService(); @@ -98,9 +102,12 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder) htmlReporter.SetOutputPath(Helpers.PathValidator.ValidateAndNormalizePath(pathArgs[0], HtmlReporterCommandProvider.ReportHtmlFilename)); } + // Inject the application-level message bus so PublishArtifactAsync works in + // OnTestSessionFinishingAsync (called before the bus is drained/disabled). + htmlReporter.SetMessageBus(serviceProvider.GetMessageBus()); + return htmlReporter; }); - testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => htmlReporter); } private static IReadOnlyCollection CreateCapabilities(IServiceProvider serviceProvider) diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 8f07c6cb38..7c7a274547 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -7,6 +7,9 @@ using Microsoft.Testing.Platform.Extensions; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Extensions.TestHost; +using Microsoft.Testing.Platform.Messages; +using Microsoft.Testing.Platform.Services; +using Microsoft.Testing.Platform.TestHost; using TUnit.Core; using TUnit.Engine.Configuration; using TUnit.Engine.Constants; @@ -16,9 +19,10 @@ namespace TUnit.Engine.Reporters.Html; -internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, ITestHostApplicationLifetime, IFilterReceiver, IDisposable +internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, IDataProducer, ITestHostApplicationLifetime, ITestSessionLifetimeHandler, IFilterReceiver, IDisposable { private string? _outputPath; + private IMessageBus? _messageBus; private readonly ConcurrentDictionary> _updates = []; #if NET @@ -56,6 +60,8 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; + public Type[] DataTypesProduced { get; } = [typeof(SessionFileArtifact)]; + public Task BeforeRunAsync(CancellationToken cancellationToken) { if (string.IsNullOrEmpty(_outputPath)) @@ -70,7 +76,13 @@ public Task BeforeRunAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) + public Task AfterRunAsync(int exitCode, CancellationToken cancellation) + => Task.CompletedTask; // All work happens in OnTestSessionFinishingAsync. + + public Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) + => Task.CompletedTask; + + public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) { try { @@ -105,10 +117,18 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) return; } - await WriteFileAsync(_outputPath!, html, cancellation); + 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. + var written = await WriteFileAsync(outputPath, html, testSessionContext.CancellationToken); + + if (written) + { + await PublishArtifactAsync(outputPath, testSessionContext.SessionUid, testSessionContext.CancellationToken); + } // GitHub Actions integration (artifact upload + step summary) - await TryGitHubIntegrationAsync(_outputPath!, cancellation); + await TryGitHubIntegrationAsync(outputPath, testSessionContext.CancellationToken); } catch (Exception ex) { @@ -116,6 +136,22 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) } } + internal async Task PublishArtifactAsync(string outputPath, SessionUid sessionUid, CancellationToken cancellationToken) + { + if (_messageBus is null) + { + return; + } + + // SessionFileArtifact is consumed by MTP itself (not user-defined consumers), + // so no AddDataProducer registration is required — same pattern as TUnitMessageBus. + await _messageBus.PublishAsync(this, new SessionFileArtifact( + sessionUid, + new FileInfo(outputPath), + "HTML Test Report", + "TUnit HTML test results report")); + } + public void Dispose() { #if NET @@ -135,6 +171,13 @@ internal void SetOutputPath(string path) _outputPath = path; } + // Called by the AddTestSessionLifetimeHandler factory at startup, before any session events fire, + // so _messageBus is guaranteed to be set before OnTestSessionFinishingAsync is invoked. + internal void SetMessageBus(IMessageBus? messageBus) + { + _messageBus = messageBus; + } + private ReportData BuildReportData() { var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; @@ -538,7 +581,7 @@ private static string GetShortFrameworkName() return "unknown"; } - private static async Task WriteFileAsync(string path, string content, CancellationToken cancellationToken) + private static async Task WriteFileAsync(string path, string content, CancellationToken cancellationToken) { var directory = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) @@ -558,7 +601,7 @@ private static async Task WriteFileAsync(string path, string content, Cancellati File.WriteAllText(path, content, Encoding.UTF8); #endif Console.WriteLine($"HTML test report written to: {path}"); - return; + return true; } catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex)) { @@ -572,6 +615,7 @@ private static async Task WriteFileAsync(string path, string content, Cancellati } Console.WriteLine($"Failed to write HTML test report to: {path} after {maxAttempts} attempts"); + return false; } private static bool IsFileLocked(IOException exception) diff --git a/TUnit.Engine/TUnit.Engine.csproj b/TUnit.Engine/TUnit.Engine.csproj index 7dcf1ce2f5..33b43df182 100644 --- a/TUnit.Engine/TUnit.Engine.csproj +++ b/TUnit.Engine/TUnit.Engine.csproj @@ -9,6 +9,7 @@ +