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);