save progress
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
// <copyright file="BlastRadiusTestRunner.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
|
||||
// Task: CCUT-002
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.TestKit.BlastRadius;
|
||||
|
||||
/// <summary>
|
||||
/// Runs tests filtered by blast radius for incident response.
|
||||
/// </summary>
|
||||
public static class BlastRadiusTestRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Get xUnit filter for specific blast radii.
|
||||
/// </summary>
|
||||
/// <param name="blastRadii">Blast radii to filter by.</param>
|
||||
/// <returns>xUnit filter string.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when no blast radii provided.</exception>
|
||||
public static string GetFilter(params string[] blastRadii)
|
||||
{
|
||||
if (blastRadii.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one blast radius required", nameof(blastRadii));
|
||||
}
|
||||
|
||||
var filters = blastRadii.Select(br => $"BlastRadius={br}");
|
||||
return string.Join("|", filters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get xUnit filter for specific blast radii (IEnumerable overload).
|
||||
/// </summary>
|
||||
/// <param name="blastRadii">Blast radii to filter by.</param>
|
||||
/// <returns>xUnit filter string.</returns>
|
||||
public static string GetFilter(IEnumerable<string> blastRadii)
|
||||
{
|
||||
return GetFilter(blastRadii.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the dotnet test command for specific blast radii.
|
||||
/// </summary>
|
||||
/// <param name="testProject">Test project path or solution.</param>
|
||||
/// <param name="blastRadii">Blast radii to filter by.</param>
|
||||
/// <param name="additionalArgs">Additional dotnet test arguments.</param>
|
||||
/// <returns>Complete dotnet test command.</returns>
|
||||
public static string GetCommand(
|
||||
string testProject,
|
||||
IEnumerable<string> blastRadii,
|
||||
string? additionalArgs = null)
|
||||
{
|
||||
var filter = GetFilter(blastRadii);
|
||||
var args = $"test {testProject} --filter \"{filter}\"";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(additionalArgs))
|
||||
{
|
||||
args += $" {additionalArgs}";
|
||||
}
|
||||
|
||||
return $"dotnet {args}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run tests for specific operational surfaces.
|
||||
/// </summary>
|
||||
/// <param name="testProject">Test project path or solution.</param>
|
||||
/// <param name="blastRadii">Blast radii to run tests for.</param>
|
||||
/// <param name="workingDirectory">Working directory for test execution.</param>
|
||||
/// <param name="timeoutMs">Timeout in milliseconds.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Test run result.</returns>
|
||||
public static async Task<TestRunResult> RunForBlastRadiiAsync(
|
||||
string testProject,
|
||||
string[] blastRadii,
|
||||
string? workingDirectory = null,
|
||||
int timeoutMs = 600000,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var filter = GetFilter(blastRadii);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"test {testProject} --filter \"{filter}\" --logger trx --verbosity normal",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(workingDirectory))
|
||||
{
|
||||
startInfo.WorkingDirectory = workingDirectory;
|
||||
}
|
||||
|
||||
var stdout = new List<string>();
|
||||
var stderr = new List<string>();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
|
||||
process.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
stdout.Add(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
stderr.Add(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(timeoutMs);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore kill errors
|
||||
}
|
||||
|
||||
return new TestRunResult(
|
||||
ExitCode: -1,
|
||||
BlastRadii: [.. blastRadii],
|
||||
Filter: filter,
|
||||
DurationMs: sw.ElapsedMilliseconds,
|
||||
Output: [.. stdout],
|
||||
Errors: [.. stderr],
|
||||
TimedOut: true);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new TestRunResult(
|
||||
ExitCode: process.ExitCode,
|
||||
BlastRadii: [.. blastRadii],
|
||||
Filter: filter,
|
||||
DurationMs: sw.ElapsedMilliseconds,
|
||||
Output: [.. stdout],
|
||||
Errors: [.. stderr],
|
||||
TimedOut: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run tests for a single blast radius.
|
||||
/// </summary>
|
||||
/// <param name="testProject">Test project path or solution.</param>
|
||||
/// <param name="blastRadius">Blast radius to run tests for.</param>
|
||||
/// <param name="workingDirectory">Working directory for test execution.</param>
|
||||
/// <param name="timeoutMs">Timeout in milliseconds.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Test run result.</returns>
|
||||
public static Task<TestRunResult> RunForBlastRadiusAsync(
|
||||
string testProject,
|
||||
string blastRadius,
|
||||
string? workingDirectory = null,
|
||||
int timeoutMs = 600000,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return RunForBlastRadiiAsync(testProject, [blastRadius], workingDirectory, timeoutMs, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse test results from TRX output.
|
||||
/// </summary>
|
||||
/// <param name="result">Test run result.</param>
|
||||
/// <returns>Summary of test results.</returns>
|
||||
public static TestRunSummary ParseSummary(TestRunResult result)
|
||||
{
|
||||
var summary = new TestRunSummary(
|
||||
Passed: 0,
|
||||
Failed: 0,
|
||||
Skipped: 0,
|
||||
Total: 0);
|
||||
|
||||
foreach (var line in result.Output)
|
||||
{
|
||||
// Parse dotnet test output format: "Passed: X" etc.
|
||||
if (line.Contains("Passed:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(line, @"Passed:\s*(\d+)");
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var passed))
|
||||
{
|
||||
summary = summary with { Passed = passed };
|
||||
}
|
||||
}
|
||||
|
||||
if (line.Contains("Failed:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(line, @"Failed:\s*(\d+)");
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var failed))
|
||||
{
|
||||
summary = summary with { Failed = failed };
|
||||
}
|
||||
}
|
||||
|
||||
if (line.Contains("Skipped:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(line, @"Skipped:\s*(\d+)");
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var skipped))
|
||||
{
|
||||
summary = summary with { Skipped = skipped };
|
||||
}
|
||||
}
|
||||
|
||||
if (line.Contains("Total:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(line, @"Total:\s*(\d+)");
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var total))
|
||||
{
|
||||
summary = summary with { Total = total };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of running tests for blast radii.
|
||||
/// </summary>
|
||||
/// <param name="ExitCode">Process exit code (0 = success).</param>
|
||||
/// <param name="BlastRadii">Blast radii that were tested.</param>
|
||||
/// <param name="Filter">xUnit filter that was used.</param>
|
||||
/// <param name="DurationMs">Duration of test run in milliseconds.</param>
|
||||
/// <param name="Output">Standard output lines.</param>
|
||||
/// <param name="Errors">Standard error lines.</param>
|
||||
/// <param name="TimedOut">Whether the test run timed out.</param>
|
||||
public sealed record TestRunResult(
|
||||
int ExitCode,
|
||||
ImmutableArray<string> BlastRadii,
|
||||
string Filter,
|
||||
long DurationMs,
|
||||
ImmutableArray<string> Output,
|
||||
ImmutableArray<string> Errors,
|
||||
bool TimedOut)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the test run was successful.
|
||||
/// </summary>
|
||||
public bool IsSuccess => ExitCode == 0 && !TimedOut;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of test run results.
|
||||
/// </summary>
|
||||
/// <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="Total">Total number of tests.</param>
|
||||
public sealed record TestRunSummary(
|
||||
int Passed,
|
||||
int Failed,
|
||||
int Skipped,
|
||||
int Total);
|
||||
Reference in New Issue
Block a user