Skip to content

Commit 9381ab2

Browse files
rogeralsingclaude
andcommitted
Initial commit: .NET test runner with history tracking
- Wraps dotnet test and captures TRX results - Tracks test history per project and command signature - Shows pass/fail bar charts across runs - Detects regressions and fixes between runs - Supports color and non-color output modes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
0 parents  commit 9381ab2

File tree

12 files changed

+982
-0
lines changed

12 files changed

+982
-0
lines changed

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- name: Setup .NET
15+
uses: actions/setup-dotnet@v4
16+
with:
17+
dotnet-version: '8.0.x'
18+
- name: Restore
19+
run: dotnet restore
20+
- name: Build
21+
run: dotnet build -c Release --nologo

.github/workflows/pack.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Pack
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
jobs:
9+
pack:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- name: Setup .NET
14+
uses: actions/setup-dotnet@v4
15+
with:
16+
dotnet-version: '8.0.x'
17+
- name: Pack
18+
run: |
19+
VERSION="${GITHUB_REF_NAME#v}"
20+
dotnet pack -c Release -o ./nupkg Asynkron.TestRunner.csproj -p:Version="$VERSION"
21+
- name: Publish to NuGet
22+
env:
23+
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
24+
run: dotnet nuget push ./nupkg/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
25+
- name: Upload artifact
26+
uses: actions/upload-artifact@v4
27+
with:
28+
name: nupkg
29+
path: ./nupkg/*.nupkg

.gitignore

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Build outputs
2+
**/bin/
3+
**/obj/
4+
5+
# NuGet
6+
*.nupkg
7+
*.snupkg
8+
9+
# Test results
10+
.testrunner/
11+
12+
# OS
13+
.DS_Store
14+
15+
# IDE
16+
.idea/
17+
.vs/
18+
*.user

Asynkron.TestRunner.csproj

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
9+
<!-- Global Tool Configuration -->
10+
<PackAsTool>true</PackAsTool>
11+
<ToolCommandName>testrunner</ToolCommandName>
12+
<PackageId>Asynkron.TestRunner</PackageId>
13+
<Version>0.1.0</Version>
14+
<Authors>Asynkron</Authors>
15+
<Description>A .NET global tool that wraps dotnet test, captures TRX results, tracks test history, and displays regression charts.</Description>
16+
<PackageLicenseExpression>MIT</PackageLicenseExpression>
17+
<RepositoryUrl>https://github.com/asynkron/Asynkron.TestRunner</RepositoryUrl>
18+
<PackageProjectUrl>https://github.com/asynkron/Asynkron.TestRunner</PackageProjectUrl>
19+
<PackageTags>testing;dotnet;test-runner;tdd;regression;xunit;nunit</PackageTags>
20+
<PackageReadmeFile>README.md</PackageReadmeFile>
21+
</PropertyGroup>
22+
23+
<ItemGroup>
24+
<None Include="README.md" Pack="true" PackagePath="" />
25+
</ItemGroup>
26+
27+
</Project>

ChartRenderer.cs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
using Asynkron.TestRunner.Models;
2+
3+
namespace Asynkron.TestRunner;
4+
5+
public static class ChartRenderer
6+
{
7+
private const int BarWidth = 30;
8+
9+
// Detect if we can use colors
10+
private static readonly bool UseColor = DetectColorSupport();
11+
12+
// ANSI color codes (empty strings if no color support)
13+
private static string Reset => UseColor ? "\x1b[0m" : "";
14+
private static string Green => UseColor ? "\x1b[32m" : "";
15+
private static string Red => UseColor ? "\x1b[31m" : "";
16+
private static string Yellow => UseColor ? "\x1b[33m" : "";
17+
private static string Dim => UseColor ? "\x1b[2m" : "";
18+
private static string Bold => UseColor ? "\x1b[1m" : "";
19+
20+
// Bar characters - use distinct chars when no color
21+
private static char PassedChar => UseColor ? '█' : '=';
22+
private static char FailedChar => UseColor ? '█' : 'X';
23+
private static char SkippedChar => UseColor ? '░' : '-';
24+
private static char EmptyChar => UseColor ? '░' : '.';
25+
26+
private static bool DetectColorSupport()
27+
{
28+
// NO_COLOR is a standard env var to disable colors
29+
if (Environment.GetEnvironmentVariable("NO_COLOR") != null)
30+
return false;
31+
32+
// If output is redirected (piped), don't use colors
33+
if (Console.IsOutputRedirected)
34+
return false;
35+
36+
// Check for dumb terminal
37+
var term = Environment.GetEnvironmentVariable("TERM");
38+
if (term == "dumb")
39+
return false;
40+
41+
// Check CI environments that may not support colors
42+
if (Environment.GetEnvironmentVariable("CI") != null &&
43+
Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == null) // GitHub Actions supports colors
44+
return false;
45+
46+
return true;
47+
}
48+
49+
public static void RenderHistory(List<TestRunResult> results, bool showLatestFirst = true)
50+
{
51+
if (results.Count == 0)
52+
{
53+
Console.WriteLine($"{Dim}No test history found.{Reset}");
54+
return;
55+
}
56+
57+
var ordered = showLatestFirst
58+
? results.OrderByDescending(r => r.Timestamp).ToList()
59+
: results.OrderBy(r => r.Timestamp).ToList();
60+
61+
var maxTotal = ordered.Max(r => r.Total);
62+
63+
Console.WriteLine();
64+
Console.WriteLine($"{Bold}Test History ({results.Count} runs){Reset}");
65+
Console.WriteLine(new string('─', 70));
66+
67+
foreach (var result in ordered)
68+
{
69+
RenderBar(result, maxTotal);
70+
}
71+
72+
Console.WriteLine();
73+
}
74+
75+
public static void RenderSingleResult(TestRunResult result, TestRunResult? previousRun = null)
76+
{
77+
Console.WriteLine();
78+
79+
var statusColor = result.Failed > 0 ? Red : Green;
80+
var statusText = result.Failed > 0 ? "FAILED" : "PASSED";
81+
82+
Console.WriteLine($"{statusColor}{Bold}{statusText}{Reset}");
83+
Console.WriteLine(new string('─', 50));
84+
85+
Console.WriteLine($" {Green}Passed:{Reset} {result.Passed}");
86+
Console.WriteLine($" {Red}Failed:{Reset} {result.Failed}");
87+
Console.WriteLine($" {Yellow}Skipped:{Reset} {result.Skipped}");
88+
Console.WriteLine($" {Dim}Total:{Reset} {result.Total}");
89+
Console.WriteLine($" {Dim}Duration:{Reset} {FormatDuration(result.Duration)}");
90+
Console.WriteLine($" {Dim}Pass Rate:{Reset} {result.PassRate:F1}%");
91+
92+
// Show regressions if we have a previous run to compare
93+
if (previousRun != null)
94+
{
95+
RenderRegressions(result, previousRun);
96+
}
97+
98+
Console.WriteLine();
99+
}
100+
101+
public static void RenderRegressions(TestRunResult current, TestRunResult previous)
102+
{
103+
var regressions = current.GetRegressions(previous);
104+
var fixes = current.GetFixes(previous);
105+
106+
if (regressions.Count > 0)
107+
{
108+
Console.WriteLine();
109+
Console.WriteLine($"{Red}{Bold}Regressions ({regressions.Count}):{Reset}");
110+
foreach (var test in regressions.Take(20))
111+
{
112+
Console.WriteLine($" {Red}{Reset} {test}");
113+
}
114+
if (regressions.Count > 20)
115+
{
116+
Console.WriteLine($" {Dim}... and {regressions.Count - 20} more{Reset}");
117+
}
118+
}
119+
120+
if (fixes.Count > 0)
121+
{
122+
Console.WriteLine();
123+
Console.WriteLine($"{Green}{Bold}Fixed ({fixes.Count}):{Reset}");
124+
foreach (var test in fixes.Take(20))
125+
{
126+
Console.WriteLine($" {Green}{Reset} {test}");
127+
}
128+
if (fixes.Count > 20)
129+
{
130+
Console.WriteLine($" {Dim}... and {fixes.Count - 20} more{Reset}");
131+
}
132+
}
133+
}
134+
135+
private static void RenderBar(TestRunResult result, int maxTotal)
136+
{
137+
var timestamp = result.Timestamp.ToString("yyyy-MM-dd HH:mm");
138+
var passedWidth = maxTotal > 0 ? (int)((double)result.Passed / maxTotal * BarWidth) : 0;
139+
var failedWidth = maxTotal > 0 ? (int)((double)result.Failed / maxTotal * BarWidth) : 0;
140+
var skippedWidth = maxTotal > 0 ? (int)((double)result.Skipped / maxTotal * BarWidth) : 0;
141+
142+
// Ensure at least 1 char for failed if there are failures
143+
if (result.Failed > 0 && failedWidth == 0) failedWidth = 1;
144+
145+
var passedBar = new string(PassedChar, passedWidth);
146+
var failedBar = new string(FailedChar, failedWidth);
147+
var skippedBar = new string(SkippedChar, skippedWidth);
148+
var emptyWidth = Math.Max(0, BarWidth - passedWidth - failedWidth - skippedWidth);
149+
var emptyBar = new string(EmptyChar, emptyWidth);
150+
151+
var failedIndicator = result.Failed > 0
152+
? $"{Red}{result.Failed}{Reset}"
153+
: $"{Green}{Reset}";
154+
155+
Console.Write($"{Dim}{timestamp}{Reset} ");
156+
Console.Write($"{Green}{passedBar}{Reset}");
157+
Console.Write($"{Red}{failedBar}{Reset}");
158+
Console.Write($"{Yellow}{skippedBar}{Reset}");
159+
Console.Write($"{Dim}{emptyBar}{Reset}");
160+
Console.Write($" {result.Passed}/{result.Total} ({result.PassRate:F1}%) ");
161+
Console.WriteLine(failedIndicator);
162+
}
163+
164+
private static string FormatDuration(TimeSpan duration)
165+
{
166+
if (duration.TotalMinutes >= 1)
167+
return $"{duration.TotalMinutes:F1}m";
168+
return $"{duration.TotalSeconds:F1}s";
169+
}
170+
}

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 Asynkron
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Models/TestRunResult.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
namespace Asynkron.TestRunner.Models;
2+
3+
public class TestRunResult
4+
{
5+
public required string Id { get; set; }
6+
public required DateTime Timestamp { get; set; }
7+
public required int Passed { get; set; }
8+
public required int Failed { get; set; }
9+
public required int Skipped { get; set; }
10+
public required TimeSpan Duration { get; set; }
11+
public string? TrxFilePath { get; set; }
12+
13+
/// <summary>
14+
/// Names of tests that failed in this run
15+
/// </summary>
16+
public List<string> FailedTests { get; set; } = [];
17+
18+
/// <summary>
19+
/// Names of tests that passed in this run
20+
/// </summary>
21+
public List<string> PassedTests { get; set; } = [];
22+
23+
public int Total => Passed + Failed + Skipped;
24+
public double PassRate => Total > 0 ? (double)Passed / Total * 100 : 0;
25+
26+
/// <summary>
27+
/// Find tests that regressed (passed before, fail now)
28+
/// </summary>
29+
public List<string> GetRegressions(TestRunResult? previousRun)
30+
{
31+
if (previousRun == null)
32+
return [];
33+
34+
var previousPassed = new HashSet<string>(previousRun.PassedTests);
35+
return FailedTests.Where(t => previousPassed.Contains(t)).ToList();
36+
}
37+
38+
/// <summary>
39+
/// Find tests that were fixed (failed before, pass now)
40+
/// </summary>
41+
public List<string> GetFixes(TestRunResult? previousRun)
42+
{
43+
if (previousRun == null)
44+
return [];
45+
46+
var previousFailed = new HashSet<string>(previousRun.FailedTests);
47+
return PassedTests.Where(t => previousFailed.Contains(t)).ToList();
48+
}
49+
}

0 commit comments

Comments
 (0)