//
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
//
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
// Task: CCUT-002
using System.Collections.Immutable;
using System.Diagnostics;
namespace StellaOps.TestKit.BlastRadius;
///
/// Runs tests filtered by blast radius for incident response.
///
public static class BlastRadiusTestRunner
{
///
/// Get xUnit filter for specific blast radii.
///
/// Blast radii to filter by.
/// xUnit filter string.
/// Thrown when no blast radii provided.
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);
}
///
/// Get xUnit filter for specific blast radii (IEnumerable overload).
///
/// Blast radii to filter by.
/// xUnit filter string.
public static string GetFilter(IEnumerable blastRadii)
{
return GetFilter(blastRadii.ToArray());
}
///
/// Get the dotnet test command for specific blast radii.
///
/// Test project path or solution.
/// Blast radii to filter by.
/// Additional dotnet test arguments.
/// Complete dotnet test command.
public static string GetCommand(
string testProject,
IEnumerable blastRadii,
string? additionalArgs = null)
{
var filter = GetFilter(blastRadii);
var args = $"test {testProject} --filter \"{filter}\"";
if (!string.IsNullOrWhiteSpace(additionalArgs))
{
args += $" {additionalArgs}";
}
return $"dotnet {args}";
}
///
/// Run tests for specific operational surfaces.
///
/// Test project path or solution.
/// Blast radii to run tests for.
/// Working directory for test execution.
/// Timeout in milliseconds.
/// Cancellation token.
/// Test run result.
public static async Task 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();
var stderr = new List();
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);
}
///
/// Run tests for a single blast radius.
///
/// Test project path or solution.
/// Blast radius to run tests for.
/// Working directory for test execution.
/// Timeout in milliseconds.
/// Cancellation token.
/// Test run result.
public static Task RunForBlastRadiusAsync(
string testProject,
string blastRadius,
string? workingDirectory = null,
int timeoutMs = 600000,
CancellationToken ct = default)
{
return RunForBlastRadiiAsync(testProject, [blastRadius], workingDirectory, timeoutMs, ct);
}
///
/// Parse test results from TRX output.
///
/// Test run result.
/// Summary of test results.
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;
}
}
///
/// Result of running tests for blast radii.
///
/// Process exit code (0 = success).
/// Blast radii that were tested.
/// xUnit filter that was used.
/// Duration of test run in milliseconds.
/// Standard output lines.
/// Standard error lines.
/// Whether the test run timed out.
public sealed record TestRunResult(
int ExitCode,
ImmutableArray BlastRadii,
string Filter,
long DurationMs,
ImmutableArray Output,
ImmutableArray Errors,
bool TimedOut)
{
///
/// Gets a value indicating whether the test run was successful.
///
public bool IsSuccess => ExitCode == 0 && !TimedOut;
}
///
/// Summary of test run results.
///
/// Number of passed tests.
/// Number of failed tests.
/// Number of skipped tests.
/// Total number of tests.
public sealed record TestRunSummary(
int Passed,
int Failed,
int Skipped,
int Total);