Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
120 changes: 120 additions & 0 deletions TUnit.Engine.Tests/JUnitReporterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using Microsoft.Testing.Platform.Extensions;
using TUnit.Core;
using TUnit.Engine.Reporters;

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()
{
// Clean up environment variables after each test
Environment.SetEnvironmentVariable("TUNIT_DISABLE_JUNIT_REPORTER", null);
Environment.SetEnvironmentVariable("TUNIT_ENABLE_JUNIT_REPORTER", null);
Environment.SetEnvironmentVariable("GITLAB_CI", null);
Environment.SetEnvironmentVariable("CI_SERVER", null);
Environment.SetEnvironmentVariable("JUNIT_XML_OUTPUT_PATH", null);
}

Comment on lines +19 to +29
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests will not work as expected because they set environment variables directly, but the JUnitReporter uses EnvironmentVariableCache.Get() which caches environment variables on first access. Once the cache is initialized, setting environment variables with Environment.SetEnvironmentVariable() won't affect the cached values.

To fix this, you need to either:

  1. Add a method to clear/invalidate the EnvironmentVariableCache and call it in the Cleanup() method
  2. Use reflection to directly call the cache initialization or clear it
  3. Refactor tests to use dependency injection for environment variable access

Copilot uses AI. Check for mistakes.
[Test]
public async Task IsEnabledAsync_Should_Return_False_When_TUNIT_DISABLE_JUNIT_REPORTER_Is_Set()
{
// Arrange
Environment.SetEnvironmentVariable("TUNIT_DISABLE_JUNIT_REPORTER", "true");
Environment.SetEnvironmentVariable("GITLAB_CI", "true"); // Even with GitLab CI, should be disabled
var extension = new MockExtension();
var reporter = new JUnitReporter(extension);

// Act
var isEnabled = await reporter.IsEnabledAsync();

// Assert
await Assert.That(isEnabled).IsFalse();
}

[Test]
public async Task IsEnabledAsync_Should_Return_True_When_GITLAB_CI_Is_Set()
{
// Arrange
Environment.SetEnvironmentVariable("GITLAB_CI", "true");
var extension = new MockExtension();
var reporter = new JUnitReporter(extension);

// Act
var isEnabled = await reporter.IsEnabledAsync();

// Assert
await Assert.That(isEnabled).IsTrue();
}

[Test]
public async Task IsEnabledAsync_Should_Return_True_When_CI_SERVER_Is_Set()
{
// Arrange
Environment.SetEnvironmentVariable("CI_SERVER", "yes");
var extension = new MockExtension();
var reporter = new JUnitReporter(extension);

// Act
var isEnabled = await reporter.IsEnabledAsync();

// Assert
await Assert.That(isEnabled).IsTrue();
}

[Test]
public async Task IsEnabledAsync_Should_Return_True_When_TUNIT_ENABLE_JUNIT_REPORTER_Is_Set()
{
// Arrange
Environment.SetEnvironmentVariable("TUNIT_ENABLE_JUNIT_REPORTER", "true");
var extension = new MockExtension();
var reporter = new JUnitReporter(extension);

// Act
var isEnabled = await reporter.IsEnabledAsync();

// Assert
await Assert.That(isEnabled).IsTrue();
}

[Test]
public async Task IsEnabledAsync_Should_Return_False_When_No_Environment_Variables_Are_Set()
{
// Arrange
var extension = new MockExtension();
var reporter = new JUnitReporter(extension);

// Act
var isEnabled = await reporter.IsEnabledAsync();

// Assert
await Assert.That(isEnabled).IsFalse();
}

[Test]
public async Task IsEnabledAsync_Should_Prefer_Disable_Over_Enable()
{
// Arrange
Environment.SetEnvironmentVariable("TUNIT_DISABLE_JUNIT_REPORTER", "true");
Environment.SetEnvironmentVariable("TUNIT_ENABLE_JUNIT_REPORTER", "true");
var extension = new MockExtension();
var reporter = new JUnitReporter(extension);

// Act
var isEnabled = await reporter.IsEnabledAsync();

// Assert
await Assert.That(isEnabled).IsFalse();
}
}
50 changes: 50 additions & 0 deletions TUnit.Engine/CommandLineProviders/JUnitReporterCommandProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Microsoft.Testing.Platform.CommandLine;
using Microsoft.Testing.Platform.Extensions;
using Microsoft.Testing.Platform.Extensions.CommandLine;

namespace TUnit.Engine.CommandLineProviders;

internal class JUnitReporterCommandProvider(IExtension extension) : ICommandLineOptionsProvider
{
public const string JUnitOutputPathOption = "junit-output-path";

public Task<bool> 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<CommandLineOption> GetCommandLineOptions()
{
return
[
new CommandLineOption(
JUnitOutputPathOption,
"Path to output JUnit XML file (default: TestResults/{AssemblyName}-junit.xml)",
ArgumentArity.ExactlyOne,
false)
];
}

public Task<ValidationResult> ValidateOptionArgumentsAsync(
CommandLineOption commandOption,
string[] arguments)
{
if (commandOption.Name == JUnitOutputPathOption && arguments.Length != 1)
{
return ValidationResult.InvalidTask("A single output path must be provided for --junit-output-path");
}
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation check arguments.Length != 1 is redundant because the command option is already configured with ArgumentArity.ExactlyOne on line 28, which ensures exactly one argument is provided by the framework. This check will never be triggered.

Suggested change
if (commandOption.Name == JUnitOutputPathOption && arguments.Length != 1)
{
return ValidationResult.InvalidTask("A single output path must be provided for --junit-output-path");
}

Copilot uses AI. Check for mistakes.

return ValidationResult.ValidTask;
}

public Task<ValidationResult> ValidateCommandLineOptionsAsync(
ICommandLineOptions commandLineOptions)
{
return ValidationResult.ValidTask;
}
}
18 changes: 18 additions & 0 deletions TUnit.Engine/Extensions/TestApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder)
var githubReporter = new GitHubReporter(extension);
var githubReporterCommandProvider = new GitHubReporterCommandProvider(extension);

var junitReporter = new JUnitReporter(extension);
var junitReporterCommandProvider = new JUnitReporterCommandProvider(extension);

testApplicationBuilder.RegisterTestFramework(
serviceProvider => new TestFrameworkCapabilities(CreateCapabilities(serviceProvider)),
(capabilities, serviceProvider) => new TUnitTestFramework(extension, serviceProvider, capabilities));
Expand All @@ -46,6 +49,9 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder)
// GitHub reporter configuration
testApplicationBuilder.CommandLine.AddProvider(() => githubReporterCommandProvider);

// JUnit reporter configuration
testApplicationBuilder.CommandLine.AddProvider(() => junitReporterCommandProvider);

testApplicationBuilder.TestHost.AddDataConsumer(serviceProvider =>
{
// Apply command-line configuration if provided
Expand All @@ -58,6 +64,18 @@ public static void AddTUnit(this ITestApplicationBuilder testApplicationBuilder)
return githubReporter;
});
testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => githubReporter);

testApplicationBuilder.TestHost.AddDataConsumer(serviceProvider =>
{
// Apply command-line configuration if provided
var commandLineOptions = serviceProvider.GetRequiredService<ICommandLineOptions>();
if (commandLineOptions.TryGetOptionArgumentList(JUnitReporterCommandProvider.JUnitOutputPathOption, out var pathArgs))
{
junitReporter.SetOutputPath(pathArgs[0]);
}
return junitReporter;
});
testApplicationBuilder.TestHost.AddTestHostApplicationLifetime(_ => junitReporter);
}

private static IReadOnlyCollection<ITestFrameworkCapability> CreateCapabilities(IServiceProvider serviceProvider)
Expand Down
160 changes: 160 additions & 0 deletions TUnit.Engine/Reporters/JUnitReporter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System.Collections.Concurrent;
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;
using TUnit.Engine.Helpers;
using TUnit.Engine.Xml;

namespace TUnit.Engine.Reporters;

public class JUnitReporter(IExtension extension) : IDataConsumer, ITestHostApplicationLifetime, IFilterReceiver
{
private string _outputPath = null!;
private bool _isEnabled;

public async Task<bool> IsEnabledAsync()
{
// Check if explicitly disabled
if (EnvironmentVariableCache.Get("TUNIT_DISABLE_JUNIT_REPORTER") is not null)
{
return false;
}

// Check if explicitly enabled OR running in GitLab CI
var explicitlyEnabled = EnvironmentVariableCache.Get("TUNIT_ENABLE_JUNIT_REPORTER") is not null;
var runningInGitLab = EnvironmentVariableCache.Get("GITLAB_CI") is not null ||
EnvironmentVariableCache.Get("CI_SERVER") is not null;

if (!explicitlyEnabled && !runningInGitLab)
{
return false;
}

// Determine output path
_outputPath = EnvironmentVariableCache.Get("JUNIT_XML_OUTPUT_PATH")
?? GetDefaultOutputPath();
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new JUnit reporter environment variables are not registered in EnvironmentVariableCache._tunitEnvironmentVariables array. The following variables need to be added:

  • TUNIT_DISABLE_JUNIT_REPORTER
  • DISABLE_JUNIT_REPORTER
  • TUNIT_ENABLE_JUNIT_REPORTER
  • CI_SERVER
  • JUNIT_XML_OUTPUT_PATH

This is important for performance as the cache avoids repeated system calls. Without these entries, calls to EnvironmentVariableCache.Get() for these variables will return null even when they are set.

Copilot uses AI. Check for mistakes.

_isEnabled = true;
return await extension.IsEnabledAsync();
}

public string Uid { get; } = $"{extension.Uid}JUnitReporter";

public string Version => extension.Version;

public string DisplayName => extension.DisplayName;

public string Description => extension.Description;

private readonly ConcurrentDictionary<string, List<TestNodeUpdateMessage>> _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 List<TestNodeUpdateMessage>(_updates.Count);
foreach (var kvp in _updates)
{
if (kvp.Value.Count > 0)
{
lastUpdates.Add(kvp.Value[kvp.Value.Count - 1]);
}
}

Comment on lines 79 to 82
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
// Generate JUnit XML
var xmlContent = JUnitXmlWriter.GenerateXml(lastUpdates, Filter);

if (string.IsNullOrEmpty(xmlContent))
{
return;
}

// Write to file with retry logic
await WriteXmlFileAsync(_outputPath, xmlContent, cancellation);
}

public string? Filter { get; set; }

internal void SetOutputPath(string path)
{
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SetOutputPath method is marked as internal and is only used for testing and command-line configuration. Consider adding validation to ensure the path is not null or empty, similar to how the method is called from TestApplicationBuilderExtensions. This would make the method more robust if called from other contexts.

Suggested change
{
{
if (string.IsNullOrEmpty(path))
{
throw new ArgumentException("Output path must not be null or empty.", nameof(path));
}

Copilot uses AI. Check for mistakes.
_outputPath = path;
}

private static string GetDefaultOutputPath()
{
var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults";
return Path.Combine("TestResults", $"{assemblyName}-junit.xml");
}

private static async Task WriteXmlFileAsync(string path, string content, CancellationToken cancellationToken)
{
// Ensure directory exists
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}

const int maxAttempts = 5;
var random = new Random();

Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new Random instance on each call can lead to predictable sequences if called in quick succession. Consider using Random.Shared (available in .NET 6+) for better randomness, or create a single static Random instance if targeting older frameworks. For async retry scenarios, Random.Shared is the modern best practice.

Copilot uses AI. Check for mistakes.
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($"JUnit XML report written to: {path}");
return;
}
catch (IOException ex) when (attempt < maxAttempts && IsFileLocked(ex))
{
var baseDelay = 50 * Math.Pow(2, attempt - 1);
var jitter = random.Next(0, 50);
var delay = (int)(baseDelay + jitter);

Console.WriteLine($"JUnit XML file is locked, retrying in {delay}ms (attempt {attempt}/{maxAttempts})");
await Task.Delay(delay, cancellationToken);
}
}

Console.WriteLine($"Failed to write JUnit XML report to: {path} after {maxAttempts} attempts");
}

private static bool IsFileLocked(IOException exception)
{
// Check if the exception is due to the file being locked/in use
// HResult 0x80070020 is ERROR_SHARING_VIOLATION on Windows
// HResult 0x80070021 is ERROR_LOCK_VIOLATION on Windows
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);
}
}
Loading
Loading