Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions TUnit.Engine.Tests/GitHubReporterTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Microsoft.Testing.Platform.Extensions;
using Shouldly;
using TUnit.Engine.Reporters;

Expand All @@ -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<bool> IsEnabledAsync() => Task.FromResult(true);
}

[After(Test)]
public void CleanupAfterTest()
{
Expand Down
94 changes: 94 additions & 0 deletions TUnit.Engine.Tests/HtmlReporterTests.cs
Original file line number Diff line number Diff line change
@@ -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<Microsoft.Testing.Platform.Extensions.Messages.IDataProducer>();
}

[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<Microsoft.Testing.Platform.Extensions.Messages.SessionFileArtifact>();
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<SessionFileArtifact>();
artifact.SessionUid.Value.ShouldBe("my-session-42");
}
finally
{
File.Delete(tempFile);
}
}
}
10 changes: 0 additions & 10 deletions TUnit.Engine.Tests/JUnitReporterTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Microsoft.Testing.Platform.Extensions;
using TUnit.Core;
using TUnit.Engine.Reporters;

Expand All @@ -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<bool> IsEnabledAsync() => Task.FromResult(true);
}

[After(Test)]
public void Cleanup()
{
Expand Down
3 changes: 3 additions & 0 deletions TUnit.Engine.Tests/TUnit.Engine.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>..\strongname.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Testing.Platform" />
<PackageReference Include="Microsoft.Testing.Extensions.CrashDump" />
<PackageReference Include="Microsoft.Testing.Extensions.HangDump" />
<PackageReference Include="Microsoft.Testing.Extensions.TrxReport" />
Expand Down
17 changes: 17 additions & 0 deletions TUnit.Engine.Tests/TestInfrastructure/CapturingMessageBus.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 12 additions & 0 deletions TUnit.Engine.Tests/TestInfrastructure/MockExtension.cs
Original file line number Diff line number Diff line change
@@ -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<bool> IsEnabledAsync() => Task.FromResult(true);
}
11 changes: 9 additions & 2 deletions TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ICommandLineOptions>();

Expand All @@ -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<ITestFrameworkCapability> CreateCapabilities(IServiceProvider serviceProvider)
Expand Down
56 changes: 50 additions & 6 deletions TUnit.Engine/Reporters/Html/HtmlReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, ConcurrentQueue<TestNodeUpdateMessage>> _updates = [];

#if NET
Expand Down Expand Up @@ -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))
Expand All @@ -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
{
Expand Down Expand Up @@ -105,17 +117,41 @@ 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)
{
Console.WriteLine($"Warning: HTML report generation failed: {ex.Message}");
}
}

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
Expand All @@ -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";
Expand Down Expand Up @@ -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<bool> WriteFileAsync(string path, string content, CancellationToken cancellationToken)
{
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
Expand All @@ -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))
{
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions TUnit.Engine/TUnit.Engine.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ItemGroup>
<InternalsVisibleTo Include="TUnit.UnitTests" />
<InternalsVisibleTo Include="TUnit.AspNetCore" />
<InternalsVisibleTo Include="TUnit.Engine.Tests" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading