280 lines
9.2 KiB
C#
280 lines
9.2 KiB
C#
// <copyright file="BlastRadiusTestRunner.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
|
// </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);
|