Files
missions/tests/Azaion.Missions.E2E.Tests/Reporting/TrxToCsvPostProcessor.cs
T
Oleksandr Bezdieniezhnykh ccd85a09df
ci/woodpecker/push/build-arm Pipeline failed
[AZ-576] Add e2e test infrastructure (xUnit + jwks-mock + reporting)
Scaffold the blackbox test project the rest of epic AZ-575 (AZ-577..AZ-586)
will build on. Two new csprojs under tests/, plus the TLS materials and
TRX->CSV reporting hand-off the existing docker-compose.test.yml already
calls for.

JWKS mock (tests/Azaion.Missions.JwksMock/):
- ASP.NET Core minimal API on .NET 10, no NuGet deps; JWS is hand-rolled
  to keep the surface tight and avoid version drift with the SUT
- KeyStore with one in-memory ECDSA P-256 keypair + retired-key grace
  window for NFT-RES-07 / NFT-SEC-11 rotation observability
- Endpoints: GET /.well-known/jwks.json, POST /sign, POST /rotate-key
- Mock-only alg_override / kid_override switches drive NFT-SEC-09/10/11
- TLS keypair committed under tls/; tests/jwks-mock-ca.crt is a copy
  mounted into both missions and e2e-consumer per docker-compose.test.yml

E2E consumer (tests/Azaion.Missions.E2E.Tests/):
- xUnit 2.9.2 + Bogus 35.6.1 + Npgsql 10.0.2 + Xunit.SkippableFact 1.4.13
- TestBase / TokenMinter scaffolding for downstream tasks
- Fixtures/ for DbReset, DbSeed, ComposeRestart, JwksRotate, JwksMockReverse
- Helpers/ for DbAssertions (side-channel), HttpAssertions, FixtureSql
- 8 Tests/<category>/Sanity.cs discovery smoke tests (AC-3)
- Tests/InfrastructureSanity.cs SkippableFacts for AC-1/2/5/6
- Tests/AaaPatternEnforcement.cs greps source files for AC-7
- Tests/Reporting/TrxToCsvPostProcessorTests.cs covers AC-4
- Reporting/TrxToCsvPostProcessor.cs handles VSTest TRX -> environment.md
  CSV; xUnit traits are not propagated by the TRX logger so the converter
  reflects them out of the test DLL via GetCustomAttributesData
- Reporting.Cli/ is a separate console csproj that links the converter
  source files (test project excludes Reporting.Cli/** from compile)
- Dockerfile + entrypoint.sh wire dotnet test -> trx -> csv inside the
  e2e-consumer container the compose file already references

Local verification: 13 pass, 3 skip (with explicit reasons), 0 fail.
End-to-end TRX->CSV manually verified against environment.md header spec.
Docker stack build is handed off to autodev Step 7 (test-run skill).

Reports under _docs/03_implementation/.
AZ-576 task spec moved to _docs/tasks/done/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 06:57:40 +03:00

170 lines
6.8 KiB
C#

using System.Globalization;
using System.Reflection;
using System.Xml.Linq;
namespace Azaion.Missions.E2E.Reporting;
/// <summary>
/// Converts an xUnit TRX file into the flat CSV expected by
/// <c>_docs/02_document/tests/environment.md § Reporting</c>. Run from the
/// e2e-consumer Dockerfile entrypoint after <c>dotnet test --logger trx</c>.
/// </summary>
/// <remarks>
/// The VSTest TRX logger does not propagate xUnit <c>[Trait]</c> attributes
/// as <c>&lt;Property&gt;</c> 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 <c>FullyQualifiedName → (Category, Traces)</c> map, then merges
/// the map into each TRX result row. Reflection-based enrichment is opt-in
/// (<see cref="Run(string, string, string?)"/>); without a test DLL the
/// Category / Traces columns stay empty but the file structure is unchanged.
/// </remarks>
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<string, TraitTuple>(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<ResultRow> ExtractRows(XDocument trx, IReadOnlyDictionary<string, TraitTuple> 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);
}
}
/// <summary>
/// Build <c>fullyQualifiedName → (Category, Traces)</c> by reflecting over
/// the test assembly. Looks for any custom attribute whose type FullName
/// is <c>Xunit.TraitAttribute</c> and reads its 2-string constructor.
/// </summary>
public static Dictionary<string, TraitTuple> 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<string, TraitTuple>(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);