save progress
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// <copyright file="ITestEvidenceService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_002_TEST_trace_replay_evidence
|
||||
// Task: TREP-013, TREP-014
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Testing.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Links test executions to EvidenceLocker for audit-grade storage.
|
||||
/// </summary>
|
||||
public interface ITestEvidenceService
|
||||
{
|
||||
/// <summary>
|
||||
/// Begin a test evidence session.
|
||||
/// </summary>
|
||||
/// <param name="metadata">Session metadata.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created session.</returns>
|
||||
Task<TestEvidenceSession> BeginSessionAsync(
|
||||
TestSessionMetadata metadata,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Record a test result within a session.
|
||||
/// </summary>
|
||||
/// <param name="session">The active session.</param>
|
||||
/// <param name="result">The test result to record.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task RecordTestResultAsync(
|
||||
TestEvidenceSession session,
|
||||
TestResultRecord result,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finalize session and store in EvidenceLocker.
|
||||
/// </summary>
|
||||
/// <param name="session">The session to finalize.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The evidence bundle.</returns>
|
||||
Task<TestEvidenceBundle> FinalizeSessionAsync(
|
||||
TestEvidenceSession session,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve test evidence bundle for audit.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The evidence bundle, or null if not found.</returns>
|
||||
Task<TestEvidenceBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a test session.
|
||||
/// </summary>
|
||||
/// <param name="SessionId">Unique session identifier.</param>
|
||||
/// <param name="TestSuiteId">Identifier for the test suite.</param>
|
||||
/// <param name="GitCommit">Git commit hash.</param>
|
||||
/// <param name="GitBranch">Git branch name.</param>
|
||||
/// <param name="RunnerEnvironment">Description of the runner environment.</param>
|
||||
/// <param name="StartedAt">When the session started.</param>
|
||||
/// <param name="Labels">Additional labels.</param>
|
||||
public sealed record TestSessionMetadata(
|
||||
string SessionId,
|
||||
string TestSuiteId,
|
||||
string GitCommit,
|
||||
string GitBranch,
|
||||
string RunnerEnvironment,
|
||||
DateTimeOffset StartedAt,
|
||||
ImmutableDictionary<string, string> Labels);
|
||||
|
||||
/// <summary>
|
||||
/// A recorded test result.
|
||||
/// </summary>
|
||||
/// <param name="TestId">Unique test identifier.</param>
|
||||
/// <param name="TestName">Test method name.</param>
|
||||
/// <param name="TestClass">Test class name.</param>
|
||||
/// <param name="Outcome">Test outcome.</param>
|
||||
/// <param name="Duration">Test duration.</param>
|
||||
/// <param name="FailureMessage">Failure message, if failed.</param>
|
||||
/// <param name="StackTrace">Stack trace, if failed.</param>
|
||||
/// <param name="Categories">Test categories.</param>
|
||||
/// <param name="BlastRadiusAnnotations">Blast radius annotations.</param>
|
||||
/// <param name="Attachments">Attached file references.</param>
|
||||
public sealed record TestResultRecord(
|
||||
string TestId,
|
||||
string TestName,
|
||||
string TestClass,
|
||||
TestOutcome Outcome,
|
||||
TimeSpan Duration,
|
||||
string? FailureMessage,
|
||||
string? StackTrace,
|
||||
ImmutableArray<string> Categories,
|
||||
ImmutableArray<string> BlastRadiusAnnotations,
|
||||
ImmutableDictionary<string, string> Attachments);
|
||||
|
||||
/// <summary>
|
||||
/// Test outcome.
|
||||
/// </summary>
|
||||
public enum TestOutcome
|
||||
{
|
||||
Passed,
|
||||
Failed,
|
||||
Skipped,
|
||||
Inconclusive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A finalized test evidence bundle.
|
||||
/// </summary>
|
||||
/// <param name="BundleId">Unique bundle identifier.</param>
|
||||
/// <param name="MerkleRoot">Merkle root for integrity verification.</param>
|
||||
/// <param name="Metadata">Session metadata.</param>
|
||||
/// <param name="Summary">Test summary.</param>
|
||||
/// <param name="Results">All test results.</param>
|
||||
/// <param name="FinalizedAt">When the bundle was finalized.</param>
|
||||
/// <param name="EvidenceLockerRef">Reference to EvidenceLocker storage.</param>
|
||||
public sealed record TestEvidenceBundle(
|
||||
string BundleId,
|
||||
string MerkleRoot,
|
||||
TestSessionMetadata Metadata,
|
||||
TestSummary Summary,
|
||||
ImmutableArray<TestResultRecord> Results,
|
||||
DateTimeOffset FinalizedAt,
|
||||
string EvidenceLockerRef);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of test results.
|
||||
/// </summary>
|
||||
/// <param name="TotalTests">Total number of tests.</param>
|
||||
/// <param name="Passed">Number of passed tests.</param>
|
||||
/// <param name="Failed">Number of failed tests.</param>
|
||||
/// <param name="Skipped">Number of skipped tests.</param>
|
||||
/// <param name="TotalDuration">Total test duration.</param>
|
||||
/// <param name="ResultsByCategory">Results grouped by category.</param>
|
||||
/// <param name="ResultsByBlastRadius">Results grouped by blast radius.</param>
|
||||
public sealed record TestSummary(
|
||||
int TotalTests,
|
||||
int Passed,
|
||||
int Failed,
|
||||
int Skipped,
|
||||
TimeSpan TotalDuration,
|
||||
ImmutableDictionary<string, int> ResultsByCategory,
|
||||
ImmutableDictionary<string, int> ResultsByBlastRadius);
|
||||
|
||||
/// <summary>
|
||||
/// An active test evidence session.
|
||||
/// </summary>
|
||||
public sealed class TestEvidenceSession
|
||||
{
|
||||
private readonly List<TestResultRecord> _results = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the session metadata.
|
||||
/// </summary>
|
||||
public TestSessionMetadata Metadata { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the session is finalized.
|
||||
/// </summary>
|
||||
public bool IsFinalized { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TestEvidenceSession"/> class.
|
||||
/// </summary>
|
||||
/// <param name="metadata">Session metadata.</param>
|
||||
public TestEvidenceSession(TestSessionMetadata metadata)
|
||||
{
|
||||
Metadata = metadata;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a test result to the session.
|
||||
/// </summary>
|
||||
/// <param name="result">The result to add.</param>
|
||||
public void AddResult(TestResultRecord result)
|
||||
{
|
||||
if (IsFinalized)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot add results to a finalized session.");
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_results.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all results recorded in this session.
|
||||
/// </summary>
|
||||
/// <returns>Immutable array of results.</returns>
|
||||
public ImmutableArray<TestResultRecord> GetResults()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return [.. _results];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark the session as finalized.
|
||||
/// </summary>
|
||||
internal void MarkAsFinalized()
|
||||
{
|
||||
IsFinalized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Description>Test evidence storage and linking to EvidenceLocker for audit-grade test artifacts</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,191 @@
|
||||
// <copyright file="TestEvidenceService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Testing.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of test evidence service.
|
||||
/// </summary>
|
||||
public sealed class TestEvidenceService : ITestEvidenceService
|
||||
{
|
||||
private readonly ILogger<TestEvidenceService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, TestEvidenceBundle> _bundles = new();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TestEvidenceService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
public TestEvidenceService(
|
||||
ILogger<TestEvidenceService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<TestEvidenceSession> BeginSessionAsync(
|
||||
TestSessionMetadata metadata,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = new TestEvidenceSession(metadata);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Started test evidence session {SessionId} for suite {TestSuiteId}",
|
||||
metadata.SessionId, metadata.TestSuiteId);
|
||||
|
||||
return Task.FromResult(session);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RecordTestResultAsync(
|
||||
TestEvidenceSession session,
|
||||
TestResultRecord result,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
session.AddResult(result);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Recorded test result {TestId}: {Outcome}",
|
||||
result.TestId, result.Outcome);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<TestEvidenceBundle> FinalizeSessionAsync(
|
||||
TestEvidenceSession session,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (session.IsFinalized)
|
||||
{
|
||||
throw new InvalidOperationException("Session is already finalized.");
|
||||
}
|
||||
|
||||
session.MarkAsFinalized();
|
||||
|
||||
var results = session.GetResults();
|
||||
var summary = ComputeSummary(results);
|
||||
var merkleRoot = ComputeMerkleRoot(results);
|
||||
var bundleId = GenerateBundleId(session.Metadata, merkleRoot);
|
||||
|
||||
var bundle = new TestEvidenceBundle(
|
||||
BundleId: bundleId,
|
||||
MerkleRoot: merkleRoot,
|
||||
Metadata: session.Metadata,
|
||||
Summary: summary,
|
||||
Results: results,
|
||||
FinalizedAt: _timeProvider.GetUtcNow(),
|
||||
EvidenceLockerRef: $"evidence://{bundleId}");
|
||||
|
||||
_bundles[bundleId] = bundle;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Finalized test evidence bundle {BundleId} with {TotalTests} tests ({Passed} passed, {Failed} failed)",
|
||||
bundleId, summary.TotalTests, summary.Passed, summary.Failed);
|
||||
|
||||
return Task.FromResult(bundle);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<TestEvidenceBundle?> GetBundleAsync(
|
||||
string bundleId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_bundles.TryGetValue(bundleId, out var bundle);
|
||||
return Task.FromResult(bundle);
|
||||
}
|
||||
|
||||
private static TestSummary ComputeSummary(ImmutableArray<TestResultRecord> results)
|
||||
{
|
||||
var byCategory = results
|
||||
.SelectMany(r => r.Categories.Select(c => (Category: c, Result: r)))
|
||||
.GroupBy(x => x.Category)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var byBlastRadius = results
|
||||
.SelectMany(r => r.BlastRadiusAnnotations.Select(b => (BlastRadius: b, Result: r)))
|
||||
.GroupBy(x => x.BlastRadius)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return new TestSummary(
|
||||
TotalTests: results.Length,
|
||||
Passed: results.Count(r => r.Outcome == TestOutcome.Passed),
|
||||
Failed: results.Count(r => r.Outcome == TestOutcome.Failed),
|
||||
Skipped: results.Count(r => r.Outcome == TestOutcome.Skipped),
|
||||
TotalDuration: TimeSpan.FromTicks(results.Sum(r => r.Duration.Ticks)),
|
||||
ResultsByCategory: byCategory,
|
||||
ResultsByBlastRadius: byBlastRadius);
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(ImmutableArray<TestResultRecord> results)
|
||||
{
|
||||
if (results.IsEmpty)
|
||||
{
|
||||
return ComputeSha256("empty");
|
||||
}
|
||||
|
||||
// Compute leaf hashes
|
||||
var leaves = results
|
||||
.OrderBy(r => r.TestId)
|
||||
.Select(r => ComputeResultHash(r))
|
||||
.ToList();
|
||||
|
||||
// Build Merkle tree
|
||||
while (leaves.Count > 1)
|
||||
{
|
||||
var newLevel = new List<string>();
|
||||
|
||||
for (int i = 0; i < leaves.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < leaves.Count)
|
||||
{
|
||||
newLevel.Add(ComputeSha256(leaves[i] + leaves[i + 1]));
|
||||
}
|
||||
else
|
||||
{
|
||||
newLevel.Add(leaves[i]); // Odd leaf promoted
|
||||
}
|
||||
}
|
||||
|
||||
leaves = newLevel;
|
||||
}
|
||||
|
||||
return leaves[0];
|
||||
}
|
||||
|
||||
private static string ComputeResultHash(TestResultRecord result)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(result, JsonOptions);
|
||||
return ComputeSha256(json);
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(TestSessionMetadata metadata, string merkleRoot)
|
||||
{
|
||||
var input = $"{metadata.SessionId}:{metadata.TestSuiteId}:{merkleRoot}";
|
||||
var hash = ComputeSha256(input);
|
||||
return $"teb-{hash[..16]}";
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user