Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ jobs:
docker network ls

- name: Upload logs, and test results
id: upload-logs
if: always()
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
Expand All @@ -292,3 +293,15 @@ jobs:
testresults/**
artifacts/bin/Aspire.Templates.Tests/Debug/net8.0/logs/**
artifacts/log/test-logs/**

- name: Generate test results summary
if: always()
env:
CI: false
run: >
${{ github.workspace }}/dotnet.sh
run
--project ${{ github.workspace }}/tools/GenerateTestSummary/GenerateTestSummary.csproj
--
${{ github.workspace }}/testresults
-u ${{ steps.upload-logs.outputs.artifact-url }}
21 changes: 18 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ jobs:
name: Final Results
needs: [ integrations_test_lin, integrations_test_win, templates_test_lin, templates_test_win, endtoend_tests ]
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

# get all the test-job-result* artifacts into a single directory
- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
with:
Expand All @@ -159,20 +162,32 @@ jobs:
with:
pattern: logs-*-ubuntu-latest
merge-multiple: true
path: testresults/ubuntu-latest
path: ${{ github.workspace }}/testresults/ubuntu-latest

- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
with:
pattern: logs-*-windows-latest
merge-multiple: true
path: testresults/windows-latest
path: ${{ github.workspace }}/testresults/windows-latest

- name: Upload test results
if: always()
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: All-TestResults
path: testresults/**/*.trx
path: ${{ github.workspace }}/testresults/**/*.trx

- name: Generate test results summary
if: always()
env:
CI: false
run: >
${{ github.workspace }}/dotnet.sh
run
--project ${{ github.workspace }}/tools/GenerateTestSummary/GenerateTestSummary.csproj
--
${{ github.workspace }}/testresults
--combined

# return success if zero result-failed-* files are found
- name: Compute result
Expand Down
15 changes: 15 additions & 0 deletions Aspire.sln
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApplication1", "playgrou
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApplication2", "playground\SqlServerScript\WebApplication2\WebApplication2.csproj", "{554D72B3-F0B0-FB9A-67ED-BBDF55A6DE81}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenerateTestSummary", "tools\GenerateTestSummary\GenerateTestSummary.csproj", "{29950A00-A83A-48D3-8739-EE3D667B5229}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -3976,6 +3978,18 @@ Global
{554D72B3-F0B0-FB9A-67ED-BBDF55A6DE81}.Release|x64.Build.0 = Release|Any CPU
{554D72B3-F0B0-FB9A-67ED-BBDF55A6DE81}.Release|x86.ActiveCfg = Release|Any CPU
{554D72B3-F0B0-FB9A-67ED-BBDF55A6DE81}.Release|x86.Build.0 = Release|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Debug|Any CPU.Build.0 = Debug|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Debug|x64.ActiveCfg = Debug|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Debug|x64.Build.0 = Debug|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Debug|x86.ActiveCfg = Debug|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Debug|x86.Build.0 = Debug|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Release|Any CPU.ActiveCfg = Release|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Release|Any CPU.Build.0 = Release|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Release|x64.ActiveCfg = Release|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Release|x64.Build.0 = Release|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Release|x86.ActiveCfg = Release|Any CPU
{29950A00-A83A-48D3-8739-EE3D667B5229}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -4302,6 +4316,7 @@ Global
{3928CF69-B803-43A2-8AE5-5E29CB3E8D24} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{E79A95EA-08D9-9947-377D-6F2213B36E1B} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{554D72B3-F0B0-FB9A-67ED-BBDF55A6DE81} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{29950A00-A83A-48D3-8739-EE3D667B5229} = {2136E31D-2CBB-41BB-8618-716FF8E46E9E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {47DCFECF-5631-4BDE-A1EC-BE41E90F60C4}
Expand Down
14 changes: 14 additions & 0 deletions tools/GenerateTestSummary/GenerateTestSummary.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="System.CommandLine" />
</ItemGroup>

</Project>
76 changes: 76 additions & 0 deletions tools/GenerateTestSummary/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text;
using System.CommandLine;
using Aspire.TestTools;

// Usage: dotnet tools run GenerateTestSummary --dirPathOrTrxFilePath <path> [--output <output>] [--combined]
// Generate a summary report from trx files.
// And write to $GITHUB_STEP_SUMMARY if running in GitHub Actions.

var dirPathOrTrxFilePathArgument = new Argument<string>("dirPathOrTrxFilePath");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm struggling to understand all use-cases for this argument; more comments would be really helpful.

I understand when this points to a folder where *.trx files are, but what does it mean when it is a file? Why would I pass a file?

What does the following combination means?

 -- D:\a\aspire\aspire/testresults -u https://github.com/dotnet/aspire/actions/runs/14966104757/artifacts/3103892024

var outputOption = new Option<string>("--output", "-o") { Description = "Output file path" };
var combinedSummaryOption = new Option<bool>("--combined", "-c") { Description = "Generate combined summary report" };
var urlOption = new Option<string>("--url", "-u") { Description = "URL for test links" };

var rootCommand = new RootCommand
{
dirPathOrTrxFilePathArgument,
outputOption,
combinedSummaryOption,
urlOption
};

rootCommand.SetAction(result =>
{
var dirPathOrTrxFilePath = result.GetValue<string>(dirPathOrTrxFilePathArgument);
if (string.IsNullOrEmpty(dirPathOrTrxFilePath))
{
Console.WriteLine("Please provide a directory path with trx files or a trx file path.");
return;
}

var combinedSummary = result.GetValue<bool>(combinedSummaryOption);

string report;
if (combinedSummary)
{
report = TestSummaryGenerator.CreateCombinedTestSummaryReport(dirPathOrTrxFilePath);
}
else
{
var reportBuilder = new StringBuilder();
if (Directory.Exists(dirPathOrTrxFilePath))
{
var trxFiles = Directory.EnumerateFiles(dirPathOrTrxFilePath, "*.trx", SearchOption.AllDirectories);
foreach (var trxFile in trxFiles)
{
TestSummaryGenerator.CreateSingleTestSummaryReport(trxFile, reportBuilder);
}
}
else
{
TestSummaryGenerator.CreateSingleTestSummaryReport(dirPathOrTrxFilePath, reportBuilder, result.GetValue<string>(urlOption));
}

report = reportBuilder.ToString();
}

var outputFilePath = result.GetValue<string>(outputOption);
if (outputFilePath is not null)
{
File.WriteAllText(outputFilePath, report);
Console.WriteLine($"Report written to {outputFilePath}");
}

if (report.Length > 0
&& Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true"
&& Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY") is string summaryPath
&& !string.IsNullOrEmpty(summaryPath))
{
File.WriteAllText(summaryPath, report);
}
});

return rootCommand.Parse(args).Invoke();
171 changes: 171 additions & 0 deletions tools/GenerateTestSummary/TestSummaryGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text;
using System.Globalization;
using System.Text.RegularExpressions;

namespace Aspire.TestTools;

sealed partial class TestSummaryGenerator
{
public static string CreateCombinedTestSummaryReport(string basePath)
{
if (!Directory.Exists(basePath))
{
throw new DirectoryNotFoundException($"The directory '{basePath}' does not exist.");
}

var trxFiles = System.IO.Directory.EnumerateFiles(basePath, "*.trx", System.IO.SearchOption.AllDirectories);

int overallTotalTestCount = 0;
int overallPassedTestCount = 0;
int overallFailedTestCount = 0;
int overallSkippedTestCount = 0;

// Update to use markdown tables instead of HTML
var tableBuilder = new StringBuilder();
tableBuilder.AppendLine("| Name | Passed | Failed | Skipped | Total |");
tableBuilder.AppendLine("|------|--------|--------|---------|-------|");

foreach (var file in trxFiles.OrderBy(f => Path.GetFileName(f)))
{
TestRun? testRun;
try
{
testRun = TrxReader.DeserializeTrxFile(file);
if (testRun == null || testRun.ResultSummary?.Counters == null)
{
Console.WriteLine($"Failed to deserialize or find results in file: {file}, tr: {testRun}");
continue;
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to deserialize file: {file}, exception: {ex}");
continue;
}

// emit row for each trx file
var counters = testRun.ResultSummary.Counters;
int total = counters.Total;
int passed = counters.Passed;
int failed = counters.Failed;
int skipped = counters.NotExecuted;

overallTotalTestCount += total;
overallPassedTestCount += passed;
overallFailedTestCount += failed;
overallSkippedTestCount += skipped;

tableBuilder.AppendLine(CultureInfo.InvariantCulture, $"| {(failed > 0 ? "❌" : "✅")} {GetTestTitle(file)} | {passed} | {failed} | {skipped} | {total} |");
}

var overallTableBuilder = new StringBuilder();
overallTableBuilder.AppendLine("## Overall Summary");

overallTableBuilder.AppendLine("| Passed | Failed | Skipped | Total |");
overallTableBuilder.AppendLine("|--------|--------|---------|-------|");
overallTableBuilder.AppendLine(CultureInfo.InvariantCulture, $"| {overallPassedTestCount} | {overallFailedTestCount} | {overallSkippedTestCount} | {overallTotalTestCount} |");

overallTableBuilder.AppendLine();
overallTableBuilder.Append(tableBuilder);

return overallTableBuilder.ToString();
}

public static void CreateSingleTestSummaryReport(string trxFilePath, StringBuilder reportBuilder, string? url = null)
{
if (!File.Exists(trxFilePath))
{
throw new FileNotFoundException($"The file '{trxFilePath}' does not exist.");
}

TestRun? testRun;
try
{
testRun = TrxReader.DeserializeTrxFile(trxFilePath);
if (testRun == null || testRun.ResultSummary?.Counters == null)
{
throw new InvalidOperationException($"Failed to deserialize or find results in file: {trxFilePath}");
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to process file: {trxFilePath}", ex);
}

var counters = testRun.ResultSummary.Counters;
var failed = counters.Failed;
if (failed == 0)
{
return;
}

var total = counters.Total;
var passed = counters.Passed;
var skipped = counters.NotExecuted;

reportBuilder.AppendLine(CultureInfo.InvariantCulture, $"### {GetTestTitle(trxFilePath)}");
reportBuilder.AppendLine("| Passed | Failed | Skipped | Total |");
reportBuilder.AppendLine("|--------|--------|---------|-------|");
reportBuilder.AppendLine(CultureInfo.InvariantCulture, $"| {passed} | {failed} | {skipped} | {total} |");

reportBuilder.AppendLine();
if (testRun.Results?.UnitTestResults is null)
{
return;
}

var failedTests = testRun.Results.UnitTestResults.Where(r => r.Outcome == "Failed");
if (failedTests.Any())
{
foreach (var test in failedTests)
{
var title = string.IsNullOrEmpty(url)
? $"🔴 <b>{test.TestName}</b>"
: $"🔴 <a href=\"{url}\">{test.TestName}</a>";

reportBuilder.AppendLine("<div>");
reportBuilder.AppendLine(CultureInfo.InvariantCulture, $"""
<details><summary>{title}</summary>
""");

var errorMsgBuilder = new StringBuilder();
errorMsgBuilder.AppendLine(test.Output?.ErrorInfo?.InnerText ?? string.Empty);
errorMsgBuilder.AppendLine(test.Output?.StdOut ?? string.Empty);

// Truncate long error messages for readability
var errorMsgTruncated = TruncateTheStart(errorMsgBuilder.ToString(), 50_000);

reportBuilder.AppendLine();
reportBuilder.AppendLine("```yml");
reportBuilder.AppendLine(errorMsgTruncated);
reportBuilder.AppendLine("```");
reportBuilder.AppendLine();
reportBuilder.AppendLine("</div>");
}
}
reportBuilder.AppendLine();
}

public static string GetTestTitle(string trxFileName)
{
var filename = Path.GetFileNameWithoutExtension(trxFileName);
var match = TestNameFromTrxFileNameRegex().Match(filename);
if (match.Success)
{
return $"{match.Groups["testName"].Value} ({match.Groups["tfm"].Value})";
}

return filename;
}

[GeneratedRegex(@"(?<testName>.*)_(?<tfm>net\d+\.0)_.*")]
private static partial Regex TestNameFromTrxFileNameRegex();

private static string? TruncateTheStart(string? s, int maxLength)
=> s is null || s.Length <= maxLength
? s
: "... (truncated) " + s[^maxLength..];
}
Loading