Skip to content

Commit a33647b

Browse files
rogeralsingclaude
andcommitted
Add per-test timeout with blame-hang and timeout detection
- Default 20s per-test timeout using --blame-hang - Configurable via --timeout <seconds> - Detect and display timed out tests separately from assertion failures - Show timeout info at end of run for AI agents 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 8986a38 commit a33647b

File tree

5 files changed

+120
-23
lines changed

5 files changed

+120
-23
lines changed

ChartRenderer.cs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public static class ChartRenderer
1414
private static string Green => UseColor ? "\x1b[32m" : "";
1515
private static string Red => UseColor ? "\x1b[31m" : "";
1616
private static string Yellow => UseColor ? "\x1b[33m" : "";
17+
private static string Magenta => UseColor ? "\x1b[35m" : "";
1718
private static string Dim => UseColor ? "\x1b[2m" : "";
1819
private static string Bold => UseColor ? "\x1b[1m" : "";
1920

@@ -76,19 +77,43 @@ public static void RenderSingleResult(TestRunResult result, TestRunResult? previ
7677
{
7778
Console.WriteLine();
7879

79-
var statusColor = result.Failed > 0 ? Red : Green;
80-
var statusText = result.Failed > 0 ? "FAILED" : "PASSED";
80+
var hasFailures = result.FailedTests.Count > 0 || result.TimedOutTests.Count > 0;
81+
var statusColor = hasFailures ? Red : Green;
82+
var statusText = hasFailures ? "FAILED" : "PASSED";
8183

8284
Console.WriteLine($"{statusColor}{Bold}{statusText}{Reset}");
8385
Console.WriteLine(new string('─', 50));
8486

8587
Console.WriteLine($" {Green}Passed:{Reset} {result.Passed}");
86-
Console.WriteLine($" {Red}Failed:{Reset} {result.Failed}");
88+
if (result.TimedOutTests.Count > 0)
89+
{
90+
Console.WriteLine($" {Magenta}Timeout:{Reset} {result.TimedOutTests.Count}");
91+
Console.WriteLine($" {Red}Failed:{Reset} {result.FailedTests.Count}");
92+
}
93+
else
94+
{
95+
Console.WriteLine($" {Red}Failed:{Reset} {result.Failed}");
96+
}
8797
Console.WriteLine($" {Yellow}Skipped:{Reset} {result.Skipped}");
8898
Console.WriteLine($" {Dim}Total:{Reset} {result.Total}");
8999
Console.WriteLine($" {Dim}Duration:{Reset} {FormatDuration(result.Duration)}");
90100
Console.WriteLine($" {Dim}Pass Rate:{Reset} {result.PassRate:F1}%");
91101

102+
// Show timed out tests
103+
if (result.TimedOutTests.Count > 0)
104+
{
105+
Console.WriteLine();
106+
Console.WriteLine($"{Magenta}{Bold}Timed Out ({result.TimedOutTests.Count}):{Reset}");
107+
foreach (var test in result.TimedOutTests.Take(10))
108+
{
109+
Console.WriteLine($" {Magenta}{Reset} {test}");
110+
}
111+
if (result.TimedOutTests.Count > 10)
112+
{
113+
Console.WriteLine($" {Dim}... and {result.TimedOutTests.Count - 10} more{Reset}");
114+
}
115+
}
116+
92117
// Show regressions if we have a previous run to compare
93118
if (previousRun != null)
94119
{

Models/TestRunResult.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ public class TestRunResult
1111
public string? TrxFilePath { get; set; }
1212

1313
/// <summary>
14-
/// Names of tests that failed in this run
14+
/// Names of tests that failed in this run (assertion failures)
1515
/// </summary>
1616
public List<string> FailedTests { get; set; } = [];
1717

18+
/// <summary>
19+
/// Names of tests that timed out (hung)
20+
/// </summary>
21+
public List<string> TimedOutTests { get; set; } = [];
22+
1823
/// <summary>
1924
/// Names of tests that passed in this run
2025
/// </summary>

Program.cs

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ static async Task<int> RunAsync(string[] args)
3434
return HandleRegressions(store);
3535
}
3636

37+
// Parse --timeout before the -- separator
38+
var timeout = ParseTimeout(args);
39+
3740
// Handle "--" separator for running tests
3841
var separatorIndex = Array.IndexOf(args, "--");
3942
if (separatorIndex >= 0)
@@ -46,7 +49,7 @@ static async Task<int> RunAsync(string[] args)
4649
}
4750

4851
var store = new ResultStore(testArgs);
49-
var runner = new TestRunner(store);
52+
var runner = new TestRunner(store, timeout);
5053
return await runner.RunTestsAsync(testArgs);
5154
}
5255

@@ -59,7 +62,7 @@ static async Task<int> RunAsync(string[] args)
5962
: args;
6063

6164
var store = new ResultStore(testArgs);
62-
var runner = new TestRunner(store);
65+
var runner = new TestRunner(store, timeout);
6366
return await runner.RunTestsAsync(testArgs);
6467
}
6568

@@ -77,6 +80,22 @@ static async Task<int> RunAsync(string[] args)
7780
return null;
7881
}
7982

83+
static int? ParseTimeout(string[] args)
84+
{
85+
for (var i = 0; i < args.Length; i++)
86+
{
87+
if (args[i].Equals("--timeout", StringComparison.OrdinalIgnoreCase) ||
88+
args[i].Equals("-t", StringComparison.OrdinalIgnoreCase))
89+
{
90+
if (i + 1 < args.Length && int.TryParse(args[i + 1], out var seconds))
91+
{
92+
return seconds;
93+
}
94+
}
95+
}
96+
return null; // Use default
97+
}
98+
8099
static int HandleStats(string[] args, ResultStore store, string[]? testArgs)
81100
{
82101
var historyCount = 10; // default
@@ -151,17 +170,21 @@ static void PrintUsage()
151170
testrunner - .NET Test Runner with History
152171
153172
Usage:
154-
testrunner -- dotnet test [options] Run tests and capture results
155-
testrunner stats -- dotnet test [options] Show history for specific command
156-
testrunner stats --history N -- <command> Show last N runs (default: 10)
157-
testrunner regressions -- <command> Show regressions vs previous run
158-
testrunner clear Clear all test history
173+
testrunner [options] -- dotnet test [test-options] Run tests and capture results
174+
testrunner stats -- dotnet test [test-options] Show history for specific command
175+
testrunner stats --history N -- <command> Show last N runs (default: 10)
176+
testrunner regressions -- <command> Show regressions vs previous run
177+
testrunner clear Clear all test history
178+
179+
Options:
180+
--timeout <seconds> Per-test timeout in seconds (default: 20)
181+
Tests exceeding this are killed and marked as failed
159182
160183
Examples:
161184
testrunner -- dotnet test ./tests/MyTests
185+
testrunner --timeout 60 -- dotnet test ./tests/MyTests
162186
testrunner -- dotnet test --filter "Category=Unit"
163187
testrunner stats -- dotnet test ./tests/MyTests
164-
testrunner regressions -- dotnet test --filter "Category=Unit"
165188
166189
History is tracked separately per:
167190
- Project (git repo or current directory)

TestRunner.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ namespace Asynkron.TestRunner;
55

66
public class TestRunner
77
{
8+
private const int DefaultTimeoutSeconds = 20;
9+
810
private readonly ResultStore _store;
11+
private readonly int _timeoutSeconds;
912

10-
public TestRunner(ResultStore store)
13+
public TestRunner(ResultStore store, int? timeoutSeconds = null)
1114
{
1215
_store = store;
16+
_timeoutSeconds = timeoutSeconds ?? DefaultTimeoutSeconds;
1317
}
1418

1519
public async Task<int> RunTestsAsync(string[] args)
@@ -18,8 +22,8 @@ public async Task<int> RunTestsAsync(string[] args)
1822
var resultsDir = Path.Combine(_store.StoreFolder, runId);
1923
Directory.CreateDirectory(resultsDir);
2024

21-
// Build the command with TRX logger injected
22-
var processArgs = BuildArgsWithTrxLogger(args, resultsDir);
25+
// Build the command with TRX logger and blame-hang injected
26+
var processArgs = BuildArgs(args, resultsDir, _timeoutSeconds);
2327

2428
// Find the executable (first arg after --)
2529
var executable = "dotnet";
@@ -91,10 +95,13 @@ public async Task<int> RunTestsAsync(string[] args)
9195
Console.WriteLine("Warning: No TRX results found. Was the test run successful?");
9296
}
9397

98+
// Output timeout info for AI agents and users
99+
Console.WriteLine($"[testrunner] Per-test timeout: {_timeoutSeconds}s (use --timeout <seconds> to change)");
100+
94101
return process.ExitCode;
95102
}
96103

97-
private static string[] BuildArgsWithTrxLogger(string[] args, string resultsDir)
104+
private static string[] BuildArgs(string[] args, string resultsDir, int timeoutSeconds)
98105
{
99106
var argsList = args.ToList();
100107

@@ -121,6 +128,17 @@ private static string[] BuildArgsWithTrxLogger(string[] args, string resultsDir)
121128
argsList.Add(resultsDir);
122129
}
123130

131+
// Add blame-hang for per-test timeout (unless already specified)
132+
var hasBlameHang = argsList.Any(a =>
133+
a.StartsWith("--blame-hang", StringComparison.OrdinalIgnoreCase));
134+
135+
if (!hasBlameHang && timeoutSeconds > 0)
136+
{
137+
argsList.Add("--blame-hang");
138+
argsList.Add("--blame-hang-timeout");
139+
argsList.Add($"{timeoutSeconds}s");
140+
}
141+
124142
return argsList.ToArray();
125143
}
126144
}

TrxParser.cs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public static class TrxParser
3030
var skipped = int.Parse(counters.Attribute("notExecuted")?.Value ?? "0");
3131

3232
// Extract individual test results
33-
var (passedTests, failedTests) = ExtractTestNames(root);
33+
var (passedTests, failedTests, timedOutTests) = ExtractTestNames(root);
3434

3535
// Get timing info
3636
var times = root.Element(TrxNamespace + "Times");
@@ -59,7 +59,8 @@ public static class TrxParser
5959
Duration = duration,
6060
TrxFilePath = filePath,
6161
PassedTests = passedTests,
62-
FailedTests = failedTests
62+
FailedTests = failedTests,
63+
TimedOutTests = timedOutTests
6364
};
6465
}
6566
catch
@@ -68,14 +69,15 @@ public static class TrxParser
6869
}
6970
}
7071

71-
private static (List<string> Passed, List<string> Failed) ExtractTestNames(XElement root)
72+
private static (List<string> Passed, List<string> Failed, List<string> TimedOut) ExtractTestNames(XElement root)
7273
{
7374
var passed = new List<string>();
7475
var failed = new List<string>();
76+
var timedOut = new List<string>();
7577

7678
var results = root.Element(TrxNamespace + "Results");
7779
if (results == null)
78-
return (passed, failed);
80+
return (passed, failed, timedOut);
7981

8082
foreach (var result in results.Elements(TrxNamespace + "UnitTestResult"))
8183
{
@@ -91,12 +93,35 @@ private static (List<string> Passed, List<string> Failed) ExtractTestNames(XElem
9193
passed.Add(testName);
9294
break;
9395
case "failed":
94-
failed.Add(testName);
96+
// Check if it's a timeout failure
97+
var errorMessage = result
98+
.Element(TrxNamespace + "Output")?
99+
.Element(TrxNamespace + "ErrorInfo")?
100+
.Element(TrxNamespace + "Message")?.Value ?? "";
101+
102+
if (IsTimeoutFailure(errorMessage))
103+
{
104+
timedOut.Add(testName);
105+
}
106+
else
107+
{
108+
failed.Add(testName);
109+
}
95110
break;
96111
}
97112
}
98113

99-
return (passed, failed);
114+
return (passed, failed, timedOut);
115+
}
116+
117+
private static bool IsTimeoutFailure(string errorMessage)
118+
{
119+
var lowerMessage = errorMessage.ToLowerInvariant();
120+
return lowerMessage.Contains("timed out") ||
121+
lowerMessage.Contains("timeout") ||
122+
lowerMessage.Contains("hang") ||
123+
lowerMessage.Contains("exceeded") ||
124+
lowerMessage.Contains("did not complete");
100125
}
101126

102127
public static TestRunResult? ParseFromDirectory(string trxDirectory)
@@ -128,7 +153,8 @@ private static (List<string> Passed, List<string> Failed) ExtractTestNames(XElem
128153
Duration = TimeSpan.FromTicks(results.Max(r => r!.Duration.Ticks)),
129154
TrxFilePath = trxDirectory,
130155
PassedTests = results.SelectMany(r => r!.PassedTests).ToList(),
131-
FailedTests = results.SelectMany(r => r!.FailedTests).ToList()
156+
FailedTests = results.SelectMany(r => r!.FailedTests).ToList(),
157+
TimedOutTests = results.SelectMany(r => r!.TimedOutTests).ToList()
132158
};
133159
}
134160
}

0 commit comments

Comments
 (0)