using System.Globalization;
using System.Reflection;
using System.Xml.Linq;
namespace Azaion.Missions.E2E.Reporting;
///
/// Converts an xUnit TRX file into the flat CSV expected by
/// _docs/02_document/tests/environment.md § Reporting. Run from the
/// e2e-consumer Dockerfile entrypoint after dotnet test --logger trx.
///
///
/// The VSTest TRX logger does not propagate xUnit [Trait] attributes
/// as <Property> elements (this has been a long-standing gap
/// between the xUnit VSTest adapter and the TRX schema). To recover them,
/// the post-processor optionally loads the test assembly via reflection and
/// builds a FullyQualifiedName → (Category, Traces) map, then merges
/// the map into each TRX result row. Reflection-based enrichment is opt-in
/// (); without a test DLL the
/// Category / Traces columns stay empty but the file structure is unchanged.
///
public static class TrxToCsvPostProcessor
{
private static readonly XNamespace TrxNs = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
public static int Run(string trxPath, string csvOutputPath, string? testAssemblyPath = null)
{
if (!File.Exists(trxPath))
throw new FileNotFoundException($"TRX file not found: {trxPath}", trxPath);
var doc = XDocument.Load(trxPath);
var traitMap = testAssemblyPath is not null
? BuildTraitMap(testAssemblyPath)
: new Dictionary(0);
var rows = ExtractRows(doc, traitMap).ToList();
Directory.CreateDirectory(Path.GetDirectoryName(csvOutputPath)!);
using var writer = new StreamWriter(csvOutputPath);
writer.WriteLine(ResultRow.CsvHeader);
foreach (var row in rows)
writer.WriteLine(row.ToCsv());
return rows.Count;
}
public static IEnumerable ExtractRows(XDocument trx, IReadOnlyDictionary traitMap)
{
foreach (var result in trx.Descendants(TrxNs + "UnitTestResult"))
{
var testId = (string?)result.Attribute("testId") ?? "";
var testName = (string?)result.Attribute("testName") ?? "";
var outcome = (string?)result.Attribute("outcome") ?? "Unknown";
var durationStr = (string?)result.Attribute("duration") ?? "00:00:00";
var execTimeMs = ParseDurationMs(durationStr);
var errorMsg = result.Descendants(TrxNs + "Message").FirstOrDefault()?.Value;
traitMap.TryGetValue(testName, out var traits);
yield return new ResultRow(
TestId: testId,
TestName: testName,
Category: traits.Category,
Traces: traits.Traces,
ExecutionTimeMs: execTimeMs,
Result: NormaliseResult(outcome),
ErrorMessage: errorMsg);
}
}
///
/// Build fullyQualifiedName → (Category, Traces) by reflecting over
/// the test assembly. Looks for any custom attribute whose type FullName
/// is Xunit.TraitAttribute and reads its 2-string constructor.
///
public static Dictionary BuildTraitMap(string testAssemblyPath)
{
if (!File.Exists(testAssemblyPath))
throw new FileNotFoundException($"Test assembly not found: {testAssemblyPath}", testAssemblyPath);
// MetadataLoadContext-style reflection avoids actually loading dependencies.
// Falling back to Assembly.LoadFrom keeps the post-processor reusable in
// dev shells where xunit deps are co-located next to the dll.
Assembly asm;
try
{
asm = Assembly.LoadFrom(testAssemblyPath);
}
catch (Exception ex) when (ex is BadImageFormatException or FileLoadException)
{
throw new InvalidOperationException(
$"Failed to load test assembly '{testAssemblyPath}'. Run `dotnet build` against the test project first.",
ex);
}
var map = new Dictionary(StringComparer.Ordinal);
Type[] types;
try
{
types = asm.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
// Some types may fail to load (analyzers, optional deps); use what we have.
types = ex.Types.Where(t => t is not null).ToArray()!;
}
foreach (var type in types)
{
if (!type.IsClass || type.IsAbstract) continue;
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static))
{
if (!IsXunitTestMethod(method)) continue;
var category = "";
var traces = "";
foreach (var attrData in method.GetCustomAttributesData())
{
if (attrData.AttributeType.FullName != "Xunit.TraitAttribute") continue;
if (attrData.ConstructorArguments.Count < 2) continue;
var key = attrData.ConstructorArguments[0].Value as string ?? "";
var value = attrData.ConstructorArguments[1].Value as string ?? "";
if (string.Equals(key, "Category", StringComparison.OrdinalIgnoreCase))
category = AppendTrait(category, value);
else if (string.Equals(key, "Traces", StringComparison.OrdinalIgnoreCase))
traces = AppendTrait(traces, value);
}
var fqn = $"{type.FullName}.{method.Name}";
map[fqn] = new TraitTuple(category, traces);
}
}
return map;
}
private static bool IsXunitTestMethod(MethodInfo method)
{
foreach (var attr in method.CustomAttributes)
{
var fullName = attr.AttributeType.FullName;
if (fullName == "Xunit.FactAttribute" || fullName == "Xunit.TheoryAttribute")
return true;
}
return false;
}
private static string AppendTrait(string existing, string value)
{
if (string.IsNullOrEmpty(existing)) return value;
return $"{existing};{value}";
}
private static long ParseDurationMs(string duration) =>
TimeSpan.TryParse(duration, CultureInfo.InvariantCulture, out var ts)
? (long)ts.TotalMilliseconds
: 0L;
private static string NormaliseResult(string outcome) => outcome switch
{
"Passed" => "pass",
"Failed" => "fail",
"NotExecuted" => "skip",
_ => outcome.ToLowerInvariant()
};
}
public readonly record struct TraitTuple(string Category, string Traces);