| 1 | using System.Text.RegularExpressions; |
| 2 | |
| 3 | namespace DotNetBuildTestParsers; |
| 4 | |
| 5 | /// <summary>Результат одного теста: имя и при падении — сообщение и опционально время.</summary> |
| 6 | public sealed record TestResultItem(string Name, bool Passed, string? Message = null, int? DurationMs = null); |
| 7 | |
| 8 | /// <summary>Результат разбора вывода dotnet test (console logger).</summary> |
| 9 | public sealed record TestParseResult( |
| 10 | int Total, |
| 11 | int Passed, |
| 12 | int Failed, |
| 13 | int Skipped, |
| 14 | IReadOnlyList<TestResultItem> FailedTests) |
| 15 | { |
| 16 | public bool Success => Failed == 0; |
| 17 | } |
| 18 | |
| 19 | public static class TestOutputParser |
| 20 | { |
| 21 | private static readonly Regex ResultLineRegex = new( |
| 22 | @"^\s*(Passed|Failed|Skipped)\s+(.+?)(?:\s+\[(\d+)\s*ms\])?\s*$", |
| 23 | RegexOptions.Compiled); |
| 24 | |
| 25 | private static readonly Regex MessageLineRegex = new( |
| 26 | @"^\s*(?:Error Message|Message|Stack Trace):\s*(.*)$", |
| 27 | RegexOptions.Compiled); |
| 28 | |
| 29 | /// <summary>Разбирает вывод dotnet test (logger console;verbosity=normal или detailed).</summary> |
| 30 | public static TestParseResult Parse(string output) |
| 31 | { |
| 32 | if (string.IsNullOrEmpty(output)) |
| 33 | return new TestParseResult(0, 0, 0, 0, []); |
| 34 | |
| 35 | var passed = 0; |
| 36 | var failed = 0; |
| 37 | var skipped = 0; |
| 38 | var failedTests = new List<TestResultItem>(); |
| 39 | var lines = output.Split('\n'); |
| 40 | string? lastFailedName = null; |
| 41 | int? lastFailedDuration = null; |
| 42 | var messageLines = new List<string>(); |
| 43 | |
| 44 | void FlushFailed() |
| 45 | { |
| 46 | if (lastFailedName is null) return; |
| 47 | var msg = messageLines.Count > 0 ? string.Join(" ", messageLines).Trim() : null; |
| 48 | if (string.IsNullOrWhiteSpace(msg)) msg = null; |
| 49 | failedTests.Add(new TestResultItem(lastFailedName, false, msg, lastFailedDuration)); |
| 50 | lastFailedName = null; |
| 51 | lastFailedDuration = null; |
| 52 | messageLines.Clear(); |
| 53 | } |
| 54 | |
| 55 | foreach (var line in lines) |
| 56 | { |
| 57 | var m = ResultLineRegex.Match(line.Trim()); |
| 58 | if (m.Success) |
| 59 | { |
| 60 | FlushFailed(); |
| 61 | var outcome = m.Groups[1].Value; |
| 62 | var name = m.Groups[2].Value.Trim(); |
| 63 | var duration = m.Groups[3].Success && m.Groups[3].Value.Length > 0 ? int.Parse(m.Groups[3].ValueSpan) : (int?)null; |
| 64 | |
| 65 | switch (outcome) |
| 66 | { |
| 67 | case "Passed": |
| 68 | passed++; |
| 69 | break; |
| 70 | case "Failed": |
| 71 | failed++; |
| 72 | lastFailedName = name; |
| 73 | lastFailedDuration = duration; |
| 74 | break; |
| 75 | case "Skipped": |
| 76 | skipped++; |
| 77 | break; |
| 78 | } |
| 79 | continue; |
| 80 | } |
| 81 | |
| 82 | var msgMatch = MessageLineRegex.Match(line); |
| 83 | if (msgMatch.Success && lastFailedName is not null) |
| 84 | messageLines.Add(msgMatch.Groups[1].Value.Trim()); |
| 85 | } |
| 86 | |
| 87 | FlushFailed(); |
| 88 | |
| 89 | var summaryMatch = Regex.Match(output, @"(?:Failed|Passed)!\s*-\s*Failed:\s*(\d+),\s*Passed:\s*(\d+),\s*Skipped:\s*(\d+)", RegexOptions.IgnoreCase); |
| 90 | if (summaryMatch.Success) |
| 91 | { |
| 92 | failed = int.Parse(summaryMatch.Groups[1].ValueSpan); |
| 93 | passed = int.Parse(summaryMatch.Groups[2].ValueSpan); |
| 94 | skipped = int.Parse(summaryMatch.Groups[3].ValueSpan); |
| 95 | } |
| 96 | else |
| 97 | { |
| 98 | if (passed + failed + skipped == 0 && failedTests.Count > 0) |
| 99 | failed = failedTests.Count; |
| 100 | } |
| 101 | |
| 102 | var totalCount = passed + failed + skipped; |
| 103 | return new TestParseResult(totalCount, passed, failed, skipped, failedTests); |
| 104 | } |
| 105 | } |
| 106 | |