From 2220769851a5814ada2ca2325c15ebd0e56c6311 Mon Sep 17 00:00:00 2001 From: tmat Date: Mon, 2 Mar 2026 17:13:30 -0800 Subject: [PATCH 1/2] Add a script that takes output of dotnet-watch tests and formats it to HTML that's easier to reason about --- scripts/format-watch-test-output.cs | 435 ++++++++++++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 scripts/format-watch-test-output.cs diff --git a/scripts/format-watch-test-output.cs b/scripts/format-watch-test-output.cs new file mode 100644 index 000000000000..f824f8e88c46 --- /dev/null +++ b/scripts/format-watch-test-output.cs @@ -0,0 +1,435 @@ +// Usage: dotnet run analyze-watch-test-output.cs [output-html-file] +// Parses xUnit test output from dotnet watch, groups interleaved lines by test name, +// and generates an HTML page with collapsible test output sections. +// The first argument can be a local file path or a URL. If a URL is provided, +// the file is downloaded to a temp directory before processing. + +using System.Text; +using System.Text.RegularExpressions; + +if (args.Length < 1) +{ + Console.Error.WriteLine("Usage: dotnet run analyze-watch-test-output.cs [output-html-file]"); + return 1; +} + +string inputFile; +string? tempFile = null; + +if (Uri.TryCreate(args[0], UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) +{ + try + { + using var httpClient = new HttpClient(); + Console.Error.WriteLine($"Downloading {args[0]}..."); + var bytes = httpClient.GetByteArrayAsync(uri).GetAwaiter().GetResult(); + var fileName = Path.GetFileName(uri.LocalPath); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "downloaded-log.txt"; + tempFile = Path.Combine(Path.GetTempPath(), fileName); + File.WriteAllBytes(tempFile, bytes); + inputFile = tempFile; + Console.Error.WriteLine($"Downloaded to {tempFile}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: Failed to download {args[0]}: {ex.Message}"); + return 1; + } +} +else +{ + inputFile = args[0]; +} + +if (!File.Exists(inputFile)) +{ + Console.Error.WriteLine($"Error: File not found: {inputFile}"); + return 1; +} + +var outputFile = args.Length > 1 ? args[1] : Path.ChangeExtension(inputFile, ".html"); + +try +{ + var lines = File.ReadAllLines(inputFile); + var tests = ParseTestOutput(lines); + var html = GenerateHtml(tests, inputFile); + File.WriteAllText(outputFile, html, Encoding.UTF8); + Console.WriteLine($"Wrote {outputFile} ({tests.Count} tests, {lines.Length} input lines)"); + return 0; +} +finally +{ + if (tempFile != null && File.Exists(tempFile)) + File.Delete(tempFile); +} + +// --- Parsing --- + +List ParseTestOutput(string[] allLines) +{ + // [xUnit.net HH:MM:SS.ss] TestName [TAG] content + var testLineRegex = new Regex( + @"^\[xUnit\.net\s+([^\]]+)\]\s{4,}(.+?)\s+\[(\w+)\]\s?(.*)?$"); + + // [xUnit.net HH:MM:SS.ss] continuation (6+ spaces, no tag — e.g. skip reason) + var continuationRegex = new Regex( + @"^\[xUnit\.net\s+([^\]]+)\]\s{6,}(.+)$"); + + // [xUnit.net HH:MM:SS.ss] assembly: [Long Running Test] 'TestName', Elapsed: ... + var longRunningRegex = new Regex( + @"^\[xUnit\.net\s+([^\]]+)\]\s+\S+:\s+\[Long Running Test\]\s+'([^']+)'"); + + // Non-xunit result lines: Passed/Failed/Skipped TestName [time] + var resultRegex = new Regex( + @"^\s+(Passed|Failed|Skipped)\s+(.+?)\s+\["); + + var testMap = new Dictionary(StringComparer.Ordinal); + string? lastTestName = null; + + TestInfo GetOrCreate(string name) + { + if (!testMap.TryGetValue(name, out var info)) + { + info = new TestInfo(name); + testMap[name] = info; + } + return info; + } + + for (int i = 0; i < allLines.Length; i++) + { + var line = allLines[i]; + + // 1. Test-specific xUnit line with tag + var m = testLineRegex.Match(line); + if (m.Success) + { + var timestamp = m.Groups[1].Value; + var testName = m.Groups[2].Value; + var tag = m.Groups[3].Value; + var content = m.Groups[4].Value; + + var info = GetOrCreate(testName); + lastTestName = testName; + + if (tag.Equals("PASS", StringComparison.OrdinalIgnoreCase)) + { + info.Status = TestStatus.Passed; + info.Lines.Add("[PASS]"); + } + else if (tag.Equals("FAIL", StringComparison.OrdinalIgnoreCase)) + { + info.Status = TestStatus.Failed; + info.Lines.Add("[FAIL]"); + } + else if (tag.Equals("SKIP", StringComparison.OrdinalIgnoreCase)) + { + info.Status = TestStatus.Skipped; + info.Lines.Add("[SKIP]"); + } + else + { + // [OUTPUT] and other tags — show just the content + info.Lines.Add(content); + } + continue; + } + + // 2. Continuation line (e.g. skip reason) + m = continuationRegex.Match(line); + if (m.Success && lastTestName != null) + { + var info = GetOrCreate(lastTestName); + info.Lines.Add(m.Groups[2].Value); + continue; + } + + // 3. Long Running Test line — skip entirely + if (longRunningRegex.IsMatch(line)) + continue; + + // 4. Non-xunit result line (Passed/Failed/Skipped) + m = resultRegex.Match(line); + if (m.Success) + { + var result = m.Groups[1].Value; + var testName = m.Groups[2].Value; + var info = GetOrCreate(testName); + // Extract duration from e.g. " Passed TestName [67 ms]" + var durationMatch = Regex.Match(line, @"\[([^\]]+)\]\s*$"); + var duration = durationMatch.Success ? durationMatch.Groups[1].Value : ""; + info.Lines.Add($"{result} [{duration}]"); + + if (result.Equals("Passed", StringComparison.OrdinalIgnoreCase) && info.Status == TestStatus.Unknown) + info.Status = TestStatus.Passed; + else if (result.Equals("Failed", StringComparison.OrdinalIgnoreCase)) + info.Status = TestStatus.Failed; + else if (result.Equals("Skipped", StringComparison.OrdinalIgnoreCase) && info.Status == TestStatus.Unknown) + info.Status = TestStatus.Skipped; + continue; + } + } + + // Sort: failed first, then skipped, then unknown/running, then passed + var sorted = testMap.Values.ToList(); + sorted.Sort((a, b) => + { + int Order(TestStatus s) => s switch + { + TestStatus.Failed => 0, + TestStatus.Skipped => 1, + TestStatus.Unknown => 2, + TestStatus.Passed => 3, + _ => 4 + }; + int cmp = Order(a.Status).CompareTo(Order(b.Status)); + return cmp != 0 ? cmp : string.Compare(a.Name, b.Name, StringComparison.Ordinal); + }); + + return sorted; +} + +// --- HTML Generation --- + +string GenerateHtml(List tests, string sourceFile) +{ + int passed = tests.Count(t => t.Status == TestStatus.Passed); + int failed = tests.Count(t => t.Status == TestStatus.Failed); + int skipped = tests.Count(t => t.Status == TestStatus.Skipped); + int unknown = tests.Count(t => t.Status == TestStatus.Unknown); + + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"Test Output: {HtmlEncode(Path.GetFileName(sourceFile))}"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"

Test Output: {HtmlEncode(Path.GetFileName(sourceFile))}

"); + + sb.AppendLine("
"); + sb.AppendLine($" {tests.Count} tests"); + if (passed > 0) sb.AppendLine($" ✅ {passed} passed"); + if (failed > 0) sb.AppendLine($" ❌ {failed} failed"); + if (skipped > 0) sb.AppendLine($" ⏭ {skipped} skipped"); + if (unknown > 0) sb.AppendLine($" ⏳ {unknown} unknown"); + sb.AppendLine("
"); + + sb.AppendLine("
"); + + sb.AppendLine("
"); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine("
"); + + sb.AppendLine("
"); + foreach (var test in tests) + { + var badgeClass = test.Status switch + { + TestStatus.Passed => "b-passed", + TestStatus.Failed => "b-failed", + TestStatus.Skipped => "b-skipped", + _ => "b-unknown" + }; + var badgeText = test.Status switch + { + TestStatus.Passed => "PASS", + TestStatus.Failed => "FAIL", + TestStatus.Skipped => "SKIP", + _ => "???" + }; + var openAttr = test.Status == TestStatus.Failed ? " open" : ""; + var arrow = test.Status == TestStatus.Failed ? "▼" : "▶"; + + sb.AppendLine($"
"); + sb.AppendLine($" {arrow}{badgeText}{HtmlEncode(test.Name)}({test.Lines.Count} lines)"); + sb.AppendLine("
"); + RenderTestLines(sb, test.Lines); + sb.AppendLine("
"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + return sb.ToString(); +} + +void RenderTestLines(StringBuilder sb, List lines) +{ + var watchingRegex = new Regex(@"Watching \d+ file\(s\) for changes"); + var fileLineRegex = new Regex(@">\s+\S"); + var solutionRegex = new Regex(@"Solution after (project|document) update:"); + var solutionChildRegex = new Regex(@"(Project:|Document:|Additional:|Config:)\s"); + + int i = 0; + while (i < lines.Count) + { + if (watchingRegex.IsMatch(lines[i])) + { + i = RenderCollapsibleGroup(sb, lines, i, fileLineRegex); + } + else if (solutionRegex.IsMatch(lines[i])) + { + i = RenderCollapsibleGroup(sb, lines, i, solutionChildRegex); + } + else + { + // Collapse runs of more than 3 identical consecutive lines + int runLength = 1; + while (i + runLength < lines.Count && lines[i + runLength] == lines[i]) + runLength++; + + if (runLength > 3) + { + sb.AppendLine($"
{HtmlEncode(lines[i])} (×{runLength} repeated)
"); + for (int k = 0; k < runLength; k++) + sb.AppendLine(RenderLine(lines[i])); + sb.AppendLine("
"); + i += runLength; + } + else + { + sb.AppendLine(RenderLine(lines[i])); + i++; + } + } + } +} + +int RenderCollapsibleGroup(StringBuilder sb, List lines, int i, Regex childRegex) +{ + var headerLine = lines[i]; + var children = new List(); + int j = i + 1; + while (j < lines.Count && childRegex.IsMatch(lines[j])) + { + children.Add(lines[j]); + j++; + } + + if (children.Count > 0) + { + sb.AppendLine($"
{HtmlEncode(headerLine)}
"); + foreach (var child in children) + sb.AppendLine(RenderLine(child)); + sb.AppendLine("
"); + return j; + } + + sb.AppendLine(RenderLine(lines[i])); + return i + 1; +} + +static string HtmlEncode(string s) => s + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """); + +static string HtmlAttrEncode(string s) => HtmlEncode(s).Replace("'", "'"); + +static bool HasEmoji(string s) +{ + foreach (var rune in s.EnumerateRunes()) + { + int v = rune.Value; + if ((v >= 0x2300 && v <= 0x27BF) || (v >= 0x2900 && v <= 0x2BFF) || + (v >= 0x2600 && v <= 0x26FF) || (v >= 0x1F300 && v <= 0x1F9FF)) + return true; + } + return false; +} + +static string RenderLine(string line) +{ + if (Regex.IsMatch(line, @"^\[TEST [^\]]+:\d+\]")) + return $"
{HtmlEncode(line)}
"; + var cls = HasEmoji(line) ? "line" : "line no-emoji"; + return $"
{HtmlEncode(line)}
"; +} + +// --- Types --- + +enum TestStatus { Unknown, Passed, Failed, Skipped } + +class TestInfo(string name) +{ + public string Name { get; } = name; + public List Lines { get; } = []; + public TestStatus Status { get; set; } = TestStatus.Unknown; +} From 61d128482dfa4960a2e7ad6c385ed929beb771a4 Mon Sep 17 00:00:00 2001 From: tmat Date: Tue, 3 Mar 2026 10:24:20 -0800 Subject: [PATCH 2/2] Fix --- scripts/format-watch-test-output.cs | 30 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/scripts/format-watch-test-output.cs b/scripts/format-watch-test-output.cs index f824f8e88c46..5e677ac38b3d 100644 --- a/scripts/format-watch-test-output.cs +++ b/scripts/format-watch-test-output.cs @@ -71,7 +71,7 @@ List ParseTestOutput(string[] allLines) { // [xUnit.net HH:MM:SS.ss] TestName [TAG] content var testLineRegex = new Regex( - @"^\[xUnit\.net\s+([^\]]+)\]\s{4,}(.+?)\s+\[(\w+)\]\s?(.*)?$"); + @"^\[xUnit\.net\s+([^\]]+)\]\s{4,}(.+?)\s+\[(PASS|FAIL|SKIP|OUTPUT)\]\s?(.*)?$"); // [xUnit.net HH:MM:SS.ss] continuation (6+ spaces, no tag — e.g. skip reason) var continuationRegex = new Regex( @@ -102,8 +102,21 @@ TestInfo GetOrCreate(string name) { var line = allLines[i]; - // 1. Test-specific xUnit line with tag - var m = testLineRegex.Match(line); + // 1. Continuation line (6+ spaces) — check before test-specific to avoid + // capturing indented content (e.g. "[OUTPUT] ...") as a bogus test name. + var m = continuationRegex.Match(line); + if (m.Success) + { + if (lastTestName != null) + { + var info = GetOrCreate(lastTestName); + info.Lines.Add(m.Groups[2].Value); + } + continue; + } + + // 2. Test-specific xUnit line with tag + m = testLineRegex.Match(line); if (m.Success) { var timestamp = m.Groups[1].Value; @@ -137,16 +150,7 @@ TestInfo GetOrCreate(string name) continue; } - // 2. Continuation line (e.g. skip reason) - m = continuationRegex.Match(line); - if (m.Success && lastTestName != null) - { - var info = GetOrCreate(lastTestName); - info.Lines.Add(m.Groups[2].Value); - continue; - } - - // 3. Long Running Test line — skip entirely + // 3. Long Running Test line — skip if (longRunningRegex.IsMatch(line)) continue;