diff --git a/.editorconfig b/.editorconfig index 4cab270..3ba6837 100644 --- a/.editorconfig +++ b/.editorconfig @@ -30,6 +30,8 @@ indent_size = 2 # Dotnet code style settings: [*.{cs,vb}] +tab_width = 4 + # Sort using and Import directives with System.* appearing first dotnet_sort_system_directives_first = true # Avoid "this." and "Me." if not necessary @@ -57,6 +59,9 @@ dotnet_style_require_accessibility_modifiers = omit_if_default:error # IDE0040: Add accessibility modifiers dotnet_diagnostic.IDE0040.severity = error +# IDE1100: Error reading content of source file 'Project.TargetFrameworkMoniker' (i.e. from ThisAssembly) +dotnet_diagnostic.IDE1100.severity = none + [*.cs] # Top-level files are definitely OK csharp_using_directive_placement = outside_namespace:silent diff --git a/.github/workflows/includes.yml b/.github/workflows/includes.yml index bcd52a2..b591d40 100644 --- a/.github/workflows/includes.yml +++ b/.github/workflows/includes.yml @@ -43,7 +43,7 @@ jobs: $product = dotnet msbuild $props -getproperty:Product if (-not $product) { - write-error "To use OSMF EULA, ensure the $(Product) property is set in Directory.props" + write-error 'To use OSMF EULA, ensure the $(Product) property is set in Directory.props' exit 1 } @@ -58,7 +58,7 @@ jobs: base: main branch: markdown-includes delete-branch: true - labels: docs + labels: dependencies author: ${{ env.BOT_AUTHOR }} committer: ${{ env.BOT_AUTHOR }} commit-message: +Mᐁ includes diff --git a/.netconfig b/.netconfig index 0bb6f8a..0e08a5a 100644 --- a/.netconfig +++ b/.netconfig @@ -14,8 +14,8 @@ skip [file ".editorconfig"] url = https://github.com/devlooped/oss/blob/main/.editorconfig - sha = e81ab754b366d52d92bd69b24bef1d5b1c610634 - etag = 7298c6450967975a8782b5c74f3071e1910fc59686e48f9c9d5cd7c68213cf59 + sha = a62c45934ac2952f2f5d54d8aea4a7ebc1babaff + etag = b5e919b472a52d4b522f86494f0f2c0ba74a6d9601454e20e4cbaf744317ff62 weak [file ".gitattributes"] url = https://github.com/devlooped/oss/blob/main/.gitattributes @@ -52,8 +52,8 @@ weak [file ".github/workflows/includes.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/includes.yml - sha = 6a6de0580b40e305f7a0f41b406d4aabaa0756fe - etag = 1a5cd7a883700c328105910cc212f5f8c9f3759fc1af023e048a9f486da794c1 + sha = 2d1fb4ed52b63689f2b20b994512ebac28721243 + etag = 34ade86f020dea717c6a27ad7dcd0069c35be2832c58b0ba961278a1efe34089 weak [file ".github/workflows/publish.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/publish.yml @@ -85,8 +85,8 @@ weak [file "src/Directory.Build.props"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.props - sha = c509be4378ff6789df4f66338cb88119453c0975 - etag = cbbdc1a4d3030f353f3e5306a6c380238dd4ed0945aad2d56ba87b49fcfcd66d + sha = 0ff8b7b79a82112678326d1dc5543ed890311366 + etag = 3ebde0a8630d526b80f15801179116e17a857ff880a4442e7db7b075efa4fd63 weak [file "src/Directory.Build.targets"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.targets diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 352da32..29281ee 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,4 +1,4 @@ - + @@ -43,6 +43,10 @@ true + + false + + true @@ -134,6 +138,15 @@ $(_VersionLabel) $(_VersionLabel) + + + true + 42.42.0 + $(VersionSuffix).$(GITHUB_RUN_NUMBER) diff --git a/src/dotnet-retest/Extensions.cs b/src/dotnet-retest/Extensions.cs new file mode 100644 index 0000000..681eef9 --- /dev/null +++ b/src/dotnet-retest/Extensions.cs @@ -0,0 +1,15 @@ +using System; +using System.Text; + +namespace Devlooped; + +static class Extensions +{ + public static StringBuilder AppendLineIndented(this StringBuilder builder, string value, string indent) + { + foreach (var line in value.ReplaceLineEndings().Split(Environment.NewLine)) + builder.Append(indent).AppendLine(line); + + return builder; + } +} diff --git a/src/dotnet-retest/Process.cs b/src/dotnet-retest/Process.cs new file mode 100644 index 0000000..9b863fc --- /dev/null +++ b/src/dotnet-retest/Process.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; + +namespace Devlooped; + +static class Process +{ + public static bool TryExecute(string program, IEnumerable arguments, out string? output) + => TryExecuteCore(program, arguments, null, out output); + + public static bool TryExecute(string program, IEnumerable arguments, string input, out string? output) + => TryExecuteCore(program, arguments, input, out output); + + public static bool TryExecute(string program, string arguments, out string? output) + => TryExecuteCore(program, arguments, null, out output); + + public static bool TryExecute(string program, string arguments, string input, out string? output) + => TryExecuteCore(program, arguments, input, out output); + + static bool TryExecuteCore(string program, IEnumerable arguments, string? input, out string? output) + => TryExecuteCore(new ProcessStartInfo(program, arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = input != null + }, input, out output); + + static bool TryExecuteCore(string program, string arguments, string? input, out string? output) + => TryExecuteCore(new ProcessStartInfo(program, arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = input != null + }, input, out output); + + static bool TryExecuteCore(ProcessStartInfo info, string? input, out string? output) + { + try + { + info.StandardOutputEncoding = Encoding.UTF8; + //if (input != null) + // info.StandardInputEncoding = Encoding.UTF8; + + var proc = System.Diagnostics.Process.Start(info); + if (proc == null) + { + output = null; + return false; + } + + var gotError = false; + proc.ErrorDataReceived += (_, __) => gotError = true; + + if (input != null) + { + // Write the input to the standard input stream + proc.StandardInput.WriteLine(input); + proc.StandardInput.Close(); + } + + output = proc.StandardOutput.ReadToEnd(); + if (!proc.WaitForExit(5000)) + { + proc.Kill(); + output = null; + return false; + } + + var error = proc.StandardError.ReadToEnd(); + gotError |= error.Length > 0; + output = output.Trim(); + if (string.IsNullOrEmpty(output)) + output = null; + + return !gotError && proc.ExitCode == 0; + } + catch (Exception ex) + { + output = ex.Message; + return false; + } + } +} diff --git a/src/dotnet-retest/TrxCommand.cs b/src/dotnet-retest/TrxCommand.cs new file mode 100644 index 0000000..37c27a0 --- /dev/null +++ b/src/dotnet-retest/TrxCommand.cs @@ -0,0 +1,549 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Devlooped.Web; +using Humanizer; +using Spectre.Console; +using Spectre.Console.Cli; +using static Devlooped.Process; +using static Spectre.Console.AnsiConsole; + +namespace Devlooped; + +public partial class TrxCommand : Command +{ + static readonly JsonSerializerOptions indentedJson = new() { WriteIndented = true }; + const string Header = ""; + const string Footer = ""; + + static string Author => + $"from [{ThisAssembly.Project.PackageId}]({ThisAssembly.Project.PackageProjectUrl}) v{ThisAssembly.Project.Version} on {RuntimeInformation.FrameworkDescription} with [:purple_heart:](https://github.com/sponsors/devlooped) by @devlooped"; + + public enum Verbosity + { + [Description("Only failed tests are displayed")] + Quiet, + [Description("Failed and skipped tests are displayed")] + Normal, + [Description("Failed, skipped and passed tests are displayed")] + Verbose, + } + + public class TrxSettings : CommandSettings + { + [Description("Prints version information")] + [CommandOption("--version")] + public bool Version { get; init; } + + [Description("Optional base directory for *.trx files discovery. Defaults to current directory.")] + [CommandOption("-p|--path")] + public string? Path { get; set; } + + [Description("Include test output")] + [CommandOption("-o|--output")] + [DefaultValue(false)] + public bool Output { get; set; } + + [Description("Recursively search for *.trx files")] + [CommandOption("-r|--recursive")] + [DefaultValue(true)] + public bool Recursive { get; set; } = true; + + /// + /// Output verbosity. + /// + [Description( + """ + Output display verbosity: + - quiet: only failed tests are displayed + - normal: failed and skipped tests are displayed + - verbose: failed, skipped and passed tests are displayed + """)] + [CommandOption("-v|--verbosity")] + [DefaultValue(Verbosity.Quiet)] + public Verbosity Verbosity { get; set; } = Verbosity.Quiet; + + /// + /// Whether to include skipped tests in the output. + /// + [Description("Include skipped tests in output")] + [CommandOption("--skipped", IsHidden = true)] + [DefaultValue(true)] + public bool Skipped + { + get => Verbosity != Verbosity.Quiet; + set + { + if (!value) + Verbosity = Verbosity.Quiet; + } + } + + /// + /// Whether to return a -1 exit code on test failures. + /// + [Description("Do not return a -1 exit code on test failures")] + [CommandOption("--no-exit-code")] + public bool NoExitCode { get; set; } + + /// + /// Report as GitHub PR comment. + /// + [Description("Report as GitHub PR comment")] + [CommandOption("--gh-comment")] + [DefaultValue(true)] + public bool GitHubComment { get; set; } = true; + + /// + /// Report as GitHub PR comment. + /// + [Description("Report as GitHub step summary")] + [CommandOption("--gh-summary")] + [DefaultValue(true)] + public bool GitHubSummary { get; set; } = true; + + public override ValidationResult Validate() + { + // Validate, normalize and default path. + var path = Path ?? Directory.GetCurrentDirectory(); + if (!System.IO.Path.IsPathFullyQualified(path)) + path = System.IO.Path.Combine(Directory.GetCurrentDirectory(), path); + + Path = File.Exists(path) ? new FileInfo(path).DirectoryName! : System.IO.Path.GetFullPath(path); + + return base.Validate(); + } + } + + public override int Execute(CommandContext context, TrxSettings settings) + { + if (Environment.GetEnvironmentVariable("RUNNER_DEBUG") == "1") + WriteLine(JsonSerializer.Serialize(new { settings }, indentedJson)); + + // We get this validated by the settings, so it's always non-null. + var path = settings.Path!; + var search = settings.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var testIds = new HashSet(); + var passed = 0; + var failed = 0; + var skipped = 0; + var duration = TimeSpan.Zero; + var failures = new List(); + + // markdown details for gh comment + var details = new StringBuilder().AppendLine( + $""" +
+ + :test_tube: Details on {OS} + + """); + + var results = new List(); + + Status().Start("Discovering test results...", ctx => + { + // Process from newest files to oldest so that newest result we find (by test id) is the one we keep + foreach (var trx in Directory.EnumerateFiles(path, "*.trx", search).OrderByDescending(File.GetLastWriteTime)) + { + ctx.Status($"Discovering test results in {Path.GetFileName(trx).EscapeMarkup()}..."); + using var file = File.OpenRead(trx); + // Clears namespaces + var doc = HtmlDocument.Load(file, new HtmlReaderSettings { CaseFolding = Sgml.CaseFolding.None }); + foreach (var result in doc.CssSelectElements("UnitTestResult")) + { + var id = result.Attribute("testId")!.Value; + // Process only once per test id, this avoids duplicates when multiple trx files are processed + if (testIds.Add(id)) + results.Add(result); + } + } + + ctx.Status("Sorting tests by name..."); + results.Sort(new Comparison((x, y) => x.Attribute("testName")!.Value.CompareTo(y.Attribute("testName")!.Value))); + }); + + foreach (var result in results) + { + var test = result.Attribute("testName")!.Value.EscapeMarkup(); + var elapsed = TimeSpan.Parse(result.Attribute("duration")?.Value ?? "0"); + var output = settings.Output ? result.CssSelectElement("StdOut")?.Value : default; + + switch (result.Attribute("outcome")?.Value) + { + case "Passed": + passed++; + duration += elapsed; + if (settings.Verbosity != Verbosity.Verbose) + break; + + MarkupLine($":check_mark_button: {test}"); + if (output == null) + details.AppendLine($":white_check_mark: {test}"); + else + details.AppendLine( + $""" +
+ + :white_check_mark: {test} + + """) + .AppendLineIndented(output, "> > ") + .AppendLine( + """ + +
+ """); + break; + case "Failed": + failed++; + duration += elapsed; + MarkupLine($":cross_mark: {test}"); + details.AppendLine( + $""" +
+ + :x: {test} + + """); + WriteError(path, failures, result, details); + if (output != null) + details.AppendLineIndented(output, "> > "); + details.AppendLine().AppendLine("
").AppendLine(); + break; + case "NotExecuted": + skipped++; + if (settings.Verbosity == Verbosity.Quiet) + break; + + var reason = result.CssSelectElement("Output > ErrorInfo > Message")?.Value; + Markup($"[dim]:white_question_mark: {test}[/]"); + details.Append($":grey_question: {test}"); + + if (reason != null) + { + Markup($"[dim] => {reason.EscapeMarkup()}[/]"); + details.Append($" => {reason}"); + } + + WriteLine(); + details.AppendLine(); + break; + default: + break; + } + + if (output != null) + { + Write(new Panel($"[dim]{output.ReplaceLineEndings().EscapeMarkup()}[/]") + { + Border = BoxBorder.None, + Padding = new Padding(5, 0, 0, 0), + }); + } + } + + details.AppendLine().AppendLine("
"); + + var summary = new Summary(passed, failed, skipped, duration); + WriteLine(); + MarkupSummary(summary); + WriteLine(); + + if (Environment.GetEnvironmentVariable("CI") == "true" && + (settings.GitHubComment || settings.GitHubSummary)) + { + GitHubReport(settings, summary, details); + if (failures.Count > 0) + { + // Send workflow commands for each failure to be annotated in GH CI + // TODO: somehow the notice does not end up pointing to the right file/line + // TODO: we should do newline replacement with "%0A" here too + //foreach (var failure in failures) + // WriteLine($"::error file={failure.File},line={failure.Line},title={failure.Title}::{failure.Message}"); + } + } + + return settings.NoExitCode || failed == 0 ? 0 : -1; + } + + static void MarkupSummary(Summary summary) + { + Markup($":backhand_index_pointing_right: Run {summary.Total} tests in ~ {summary.Duration.Humanize()}"); + + if (summary.Failed > 0) + MarkupLine($" :cross_mark:"); + else + MarkupLine($" :check_mark_button:"); + + if (summary.Passed > 0) + MarkupLine($" :check_mark_button: {summary.Passed} passed"); + + if (summary.Failed > 0) + MarkupLine($" :cross_mark: {summary.Failed} failed"); + + if (summary.Skipped > 0) + MarkupLine($" :white_question_mark: {summary.Skipped} skipped"); + } + + static void GitHubReport(TrxSettings settings, Summary summary, StringBuilder details) + { + // Don't report anything if there's nothing to report. + if (summary.Total == 0) + return; + + if (Environment.GetEnvironmentVariable("GITHUB_EVENT_NAME") != "pull_request" || + Environment.GetEnvironmentVariable("GITHUB_ACTIONS") != "true") + return; + + if (TryExecute("gh", "--version", out var output) && output?.StartsWith("gh version") != true) + return; + + // See https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables + if (Environment.GetEnvironmentVariable("GITHUB_REF_NAME") is not { } branch || + !branch.EndsWith("/merge") || + !int.TryParse(branch[..^6], out var pr) || + Environment.GetEnvironmentVariable("GITHUB_REPOSITORY") is not { Length: > 0 } repo || + Environment.GetEnvironmentVariable("GITHUB_RUN_ID") is not { Length: > 0 } runId || + Environment.GetEnvironmentVariable("GITHUB_JOB") is not { Length: > 0 } jobName || + Environment.GetEnvironmentVariable("GITHUB_SERVER_URL") is not { Length: > 0 } serverUrl) + return; + + // Some day, it might just show-up and we'd be forwards compatible. + // See https://github.com/orgs/community/discussions/129314 and https://github.com/actions/runner/issues/324 + // Pending PR that introduces this envvar: https://github.com/actions/runner/pull/4053 + var jobId = Environment.GetEnvironmentVariable("JOB_CHECK_RUN_ID"); + + // Provide a mechanism that would work on matrix in the meantime + if (Environment.GetEnvironmentVariable("GH_JOB_NAME") is { Length: > 0 } ghJobName) + jobName = ghJobName; + + string? jobUrl = default; + + if (!string.IsNullOrEmpty(jobId)) + jobUrl = $"{serverUrl}/{repo}/actions/runs/{runId}/jobs/{jobId}?pr={pr}"; + + var sb = new StringBuilder(); + var elapsed = FormatTimeSpan(summary.Duration); + long commentId = 0; + + if (jobUrl == null && TryExecute("gh", + ["api", $"repos/{repo}/actions/runs/{runId}/jobs", "--jq", $"[.jobs[] | select(.name == \"{jobName}\") | .id]"], + out var jobsJson) && jobsJson != null && JsonSerializer.Deserialize(jobsJson) is { Length: 1 } jobIds) + { + jobUrl = $"{serverUrl}/{repo}/actions/runs/{runId}/job/{jobIds[0]}?pr={pr}"; + } + + static string Link(string image, string? url) => url == null ? image + " " : $"[{image}]({url}) "; + + static StringBuilder AppendBadges(Summary summary, StringBuilder builder, string elapsed, string? jobUrl) + { + elapsed = elapsed.Replace(" ", "%20"); + + // ![5 passed](https://img.shields.io/badge/❌-linux%20in%2015m%206s-blue) ![5 passed](https://img.shields.io/badge/os-macOS%20✅-blue) + if (summary.Failed > 0) + builder.Append(Link($"![{summary.Failed} failed](https://img.shields.io/badge/❌-{Runtime}%20in%20{elapsed}-blue)", jobUrl)); + else if (summary.Passed > 0) + builder.Append(Link($"![{summary.Passed} passed](https://img.shields.io/badge/✅-{Runtime}%20in%20{elapsed}-blue)", jobUrl)); + else + builder.Append(Link($"![{summary.Skipped} skipped](https://img.shields.io/badge/⚪-{Runtime}%20in%20{elapsed}-blue)", jobUrl)); + + if (summary.Passed > 0) + builder.Append(Link($"![{summary.Passed} passed](https://img.shields.io/badge/passed-{summary.Passed}-brightgreen)", jobUrl)); + if (summary.Failed > 0) + builder.Append(Link($"![{summary.Failed} failed](https://img.shields.io/badge/failed-{summary.Failed}-red)", jobUrl)); + if (summary.Skipped > 0) + builder.Append(Link($"![{summary.Skipped} skipped](https://img.shields.io/badge/skipped-{summary.Skipped}-silver)", jobUrl)); + + builder.AppendLine(); + return builder; + } + + // Find potentially existing comment to update + if (TryExecute("gh", + ["api", $"repos/{repo}/issues/{pr}/comments", "--jq", "[.[] | { id:.id, body:.body } | select(.body | contains(\""; + + var input = Path.GetTempFileName(); + if (settings.GitHubComment) + { + if (commentId > 0) + { + // API requires a json payload + File.WriteAllText(input, JsonSerializer.Serialize(new { body })); + TryExecute("gh", $"api repos/{repo}/issues/comments/{commentId} -X PATCH --input {input}", out _); + } + else + { + // CLI can use the straight body + File.WriteAllText(input, body); + TryExecute("gh", $"pr comment {pr} --body-file {input}", out _); + } + } + + if (settings.GitHubSummary && + Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY") is { Length: > 0 } summaryPath) + { + File.AppendAllText(summaryPath, + AppendBadges(summary, new(), elapsed, jobUrl) + .AppendLine() + .Append(details) + .AppendLine() + .AppendLine(Author) + .ToString()); + } + } + + void WriteError(string baseDir, List failures, XElement result, StringBuilder details) + { + if (result.CssSelectElement("Message")?.Value is not string message || + result.CssSelectElement("StackTrace")?.Value is not string stackTrace) + return; + + var testName = result.Attribute("testName")!.Value; + var testId = result.Attribute("testId")!.Value; + var method = result.Document!.CssSelectElement($"UnitTest[id={testId}] TestMethod"); + var lines = stackTrace.ReplaceLineEndings().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + + if (method != null) + { + var fullName = $"{method.Attribute("className")?.Value}.{method.Attribute("name")?.Value}"; + var last = Array.FindLastIndex(lines, x => x.Contains(fullName)); + // Stop lines when we find the last one from the test method + if (last != -1) + lines = lines[..(last + 1)]; + } + + Failed? failed = null; + var cli = new StringBuilder(); + details.Append("> ```"); + if (stackTrace.Contains(".vb:line")) + details.AppendLine("vb"); + else + details.AppendLine("csharp"); + + // First line should be the actual error message. + details.AppendLineIndented(message.ReplaceLineEndings(), "> "); + + foreach (var line in lines.Select(x => x.EscapeMarkup())) + { + var match = ParseFile().Match(line); + if (!match.Success) + { + cli.AppendLine(line); + details.AppendLineIndented(line, "> "); + continue; + } + + var file = match.Groups["file"].Value; + var pos = match.Groups["line"].Value; + var relative = file; + if (Path.IsPathRooted(file) && file.StartsWith(baseDir)) + relative = file[baseDir.Length..].TrimStart(Path.DirectorySeparatorChar); + + // NOTE: we replace whichever was last, since we want the annotation on the + // last one with a filename, which will be the test itself (see previous skip from last found). + failed = new Failed(testName, + message.ReplaceLineEndings(), + stackTrace.ReplaceLineEndings(), + relative, int.Parse(pos)); + + cli.AppendLine(line.Replace(file, $"[link={file}][steelblue1_1]{relative}[/][/]")); + // TODO: can we render a useful link in comment details? + details.AppendLineIndented(line.Replace(file, relative), "> "); + } + + var error = new Panel( + $""" + [red]{message.EscapeMarkup()}[/] + [dim]{cli}[/] + """); + error.Padding = new Padding(5, 0, 0, 0); + error.Border = BoxBorder.None; + Write(error); + + // Use a blockquote for the entire error message + + details.AppendLine("> ```"); + + // Add to collected failures we may report to GH CI + if (failed != null) + failures.Add(failed); + } + + static string Runtime => RuntimeInformation.RuntimeIdentifier.Replace("-", "‐"); + static string OS => RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + // Otherwise we end up with this, yuck: Darwin 23.5.0 Darwin Kernel Version 23.5.0: Wed May 1 20:12:39 PDT 2024; root:xnu-10063.121.3~5/RELEASE_ARM64_VMAPPLE + ? $"macOS {Environment.OSVersion.VersionString}" : + RuntimeInformation.OSDescription; + + static string FormatTimeSpan(TimeSpan timeSpan) + { + var parts = new List(); + + if (timeSpan.Hours > 0) + parts.Add($"{timeSpan.Hours}h"); + + if (timeSpan.Minutes > 0) + parts.Add($"{timeSpan.Minutes}m"); + + if (timeSpan.Seconds > 0 || parts.Count == 0) // Always include seconds if no other parts + parts.Add($"{timeSpan.Seconds}s"); + + return string.Join(" ", parts); + } + + // in C:\path\to\file.cs:line 123 + [GeneratedRegex(@" in (?.+):line (?\d+)", RegexOptions.Compiled)] + private static partial Regex ParseFile(); + + [GeneratedRegex(@"")] + private static partial Regex TrxRunId(); + + record Summary(int Passed, int Failed, int Skipped, TimeSpan Duration) + { + public int Total => Passed + Failed + Skipped; + } + + record Failed(string Test, string Title, string Message, string File, int Line); +}