// // 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);