mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 17:21:06 +00:00
ccd85a09df
ci/woodpecker/push/build-arm Pipeline failed
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>
170 lines
6.8 KiB
C#
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><Property></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);
|
|
|