Skip to content

Commit 4ff9052

Browse files
rogeralsingclaude
andcommitted
Add streaming console mode and improved filtering
New Features: - Added --console (-c) flag for AI-agent friendly streaming output - Real-time test status updates with colorized labels - [PASSED] (green), [FAILED] (red), [CRASHED] (red), [HANGING] (yellow), [SKIPPED] (yellow) - No interactive UI, just clean streaming output - Generates markdown summary report after run - Improved test filtering with simple substring matching - --filter "ForOf" now matches anywhere in the full FQN - Old syntax still works: --filter "Class=Foo", --filter "Method=Bar" - More intuitive and user-friendly - Fixed HttpServer.cs to match TestRunner constructor signature Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent bef4785 commit 4ff9052

File tree

5 files changed

+189
-14
lines changed

5 files changed

+189
-14
lines changed

src/Asynkron.TestRunner.Worker/XUnitFramework.cs

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,25 @@ public async IAsyncEnumerable<TestResult> RunAsync(
8181
discoverySink.Finished.WaitOne();
8282

8383
// Filter tests if specified
84-
var testCases = discoverySink.TestCases.AsEnumerable();
84+
List<ITestCase> testCasesList;
8585
if (testFqns != null)
8686
{
8787
var fqnSet = new HashSet<string>(testFqns, StringComparer.OrdinalIgnoreCase);
88-
testCases = testCases.Where(tc =>
89-
fqnSet.Contains(tc.TestMethod.TestClass.Class.Name + "." + tc.TestMethod.Method.Name) ||
90-
fqnSet.Contains(tc.TestMethod.TestClass.Class.Name.Split('.').Last() + "." + tc.TestMethod.Method.Name));
88+
testCasesList = new List<ITestCase>();
89+
foreach (var tc in discoverySink.TestCases)
90+
{
91+
var fullFqn = tc.TestMethod.TestClass.Class.Name + "." + tc.TestMethod.Method.Name;
92+
var shortFqn = tc.TestMethod.TestClass.Class.Name.Split('.').Last() + "." + tc.TestMethod.Method.Name;
93+
if (fqnSet.Contains(fullFqn) || fqnSet.Contains(shortFqn))
94+
{
95+
testCasesList.Add(tc);
96+
}
97+
}
98+
}
99+
else
100+
{
101+
testCasesList = new List<ITestCase>(discoverySink.TestCases);
91102
}
92-
93-
var testCasesList = testCases.ToList();
94103
if (testCasesList.Count == 0)
95104
{
96105
channel.Writer.Complete();
@@ -99,10 +108,17 @@ public async IAsyncEnumerable<TestResult> RunAsync(
99108

100109
// Run tests
101110
var executionOptions = TestFrameworkOptions.ForExecution();
102-
var executionSink = new ExecutionSink(channel.Writer, ct);
111+
var executionSink = new ExecutionSink(channel.Writer, ct, testCasesList);
103112

104113
controller.RunTests(testCasesList, executionSink, executionOptions);
105-
executionSink.Finished.WaitOne();
114+
115+
// Wait for completion with timeout (10 minutes max to handle slow tests + overhead)
116+
var waitHandle = WaitHandle.WaitAny(new[] { executionSink.Finished, ct.WaitHandle }, TimeSpan.FromMinutes(10));
117+
if (waitHandle == WaitHandle.WaitTimeout)
118+
{
119+
channel.Writer.TryWrite(new TestFailed("", "xUnit Timeout", TimeSpan.Zero,
120+
"xUnit test execution did not complete within 10 minutes", ""));
121+
}
106122
}
107123
catch (Exception ex)
108124
{
@@ -160,13 +176,16 @@ private sealed class ExecutionSink : IMessageSink
160176
{
161177
private readonly ChannelWriter<TestResult> _writer;
162178
private readonly CancellationToken _ct;
179+
private readonly HashSet<string> _expectedTests;
180+
private readonly HashSet<string> _finishedTests = new();
163181

164182
public ManualResetEvent Finished { get; } = new(false);
165183

166-
public ExecutionSink(ChannelWriter<TestResult> writer, CancellationToken ct)
184+
public ExecutionSink(ChannelWriter<TestResult> writer, CancellationToken ct, IEnumerable<ITestCase> testCases)
167185
{
168186
_writer = writer;
169187
_ct = ct;
188+
_expectedTests = testCases.Select(tc => $"{tc.TestMethod.TestClass.Class.Name}.{tc.TestMethod.Method.Name}").ToHashSet(StringComparer.OrdinalIgnoreCase);
170189
}
171190

172191
public bool OnMessage(IMessageSinkMessage message)
@@ -190,6 +209,7 @@ public bool OnMessage(IMessageSinkMessage message)
190209
passFqn,
191210
passed.Test.DisplayName,
192211
TimeSpan.FromSeconds((double)passed.ExecutionTime)));
212+
CheckFinished(passFqn);
193213
break;
194214

195215
case ITestFailed failed:
@@ -200,6 +220,7 @@ public bool OnMessage(IMessageSinkMessage message)
200220
TimeSpan.FromSeconds((double)failed.ExecutionTime),
201221
string.Join(Environment.NewLine, failed.Messages),
202222
string.Join(Environment.NewLine, failed.StackTraces)));
223+
CheckFinished(failFqn);
203224
break;
204225

205226
case ITestSkipped skipped:
@@ -208,6 +229,7 @@ public bool OnMessage(IMessageSinkMessage message)
208229
skipFqn,
209230
skipped.Test.DisplayName,
210231
skipped.Reason));
232+
CheckFinished(skipFqn);
211233
break;
212234

213235
case ITestOutput output:
@@ -222,6 +244,21 @@ public bool OnMessage(IMessageSinkMessage message)
222244

223245
return true;
224246
}
247+
248+
private void CheckFinished(string testFqn)
249+
{
250+
if (_expectedTests.Contains(testFqn))
251+
{
252+
lock (_finishedTests)
253+
{
254+
_finishedTests.Add(testFqn);
255+
if (_finishedTests.Count >= _expectedTests.Count)
256+
{
257+
Finished.Set();
258+
}
259+
}
260+
}
261+
}
225262
}
226263

227264
/// <summary>

src/Asynkron.TestRunner/HttpServer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ private async Task<object> HandleRunAsync(HttpListenerRequest request)
175175
hangTimeoutSeconds: null, // Use default (same as timeout)
176176
req.Filter,
177177
quiet: false, // Show UI!
178+
streamingConsole: false,
178179
req.Workers ?? 1,
179180
resultCallback: result => _testResults[result.FullyQualifiedName] = result
180181
);

src/Asynkron.TestRunner/Program.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ static async Task<int> HandleRunAsync(string[] args)
8686
var timeout = ParseTimeout(args);
8787
var hangTimeout = ParseHangTimeout(args);
8888
var quiet = ParseQuiet(args);
89+
var streamingConsole = ParseConsole(args);
8990
var workers = ParseWorkers(args) ?? 1;
9091
var verbose = ParseVerbose(args);
9192
var logFile = ParseLogFile(args);
@@ -99,7 +100,7 @@ static async Task<int> HandleRunAsync(string[] args)
99100
}
100101

101102
var store = new ResultStore();
102-
var runner = new TestRunner(store, timeout, hangTimeout, filter, quiet, workers, verbose, logFile, resumeEnabled ? resumeFile : null);
103+
var runner = new TestRunner(store, timeout, hangTimeout, filter, quiet, streamingConsole, workers, verbose, logFile, resumeEnabled ? resumeFile : null);
103104
return await runner.RunTestsAsync(assemblyPaths.ToArray());
104105
}
105106

@@ -389,6 +390,11 @@ static bool ParseQuiet(string[] args)
389390
return HasOption(args, "--quiet", "-q");
390391
}
391392

393+
static bool ParseConsole(string[] args)
394+
{
395+
return HasOption(args, "--console", "-c");
396+
}
397+
392398
static string? ParseFilter(string[] args)
393399
{
394400
return GetOptionValue(args, "--filter", "-f");
@@ -463,6 +469,7 @@ testrunner clear Clear test history
463469
-t, --timeout <seconds> Per-test timeout (default: 30s)
464470
-w, --workers [N] Run N worker processes in parallel (default: 1)
465471
-q, --quiet Suppress verbose output
472+
-c, --console Streaming console mode (no interactive UI)
466473
-v, --verbose Show diagnostic logs on stderr
467474
--log <file> Write diagnostic logs to file
468475
--resume [file] Resume from checkpoint (default: .testrunner/resume.jsonl)

src/Asynkron.TestRunner/TestDiscovery.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public class TestFilter
1212
public string? Class { get; set; }
1313
public string? Method { get; set; }
1414
public string? DisplayName { get; set; }
15+
public string? FullFqnContains { get; set; }
1516

1617
public static TestFilter Parse(string? filterString)
1718
{
@@ -46,20 +47,27 @@ public static TestFilter Parse(string? filterString)
4647
filter.DisplayName = value;
4748
break;
4849
default:
49-
filter.Class = filterString;
50+
filter.FullFqnContains = filterString;
5051
break;
5152
}
5253
}
5354
else
5455
{
55-
filter.Class = filterString;
56+
// Simple string without '=' - match against full FQN
57+
filter.FullFqnContains = filterString;
5658
}
5759

5860
return filter;
5961
}
6062

6163
public bool Matches(DiscoveredTest test)
6264
{
65+
// If simple FQN contains filter is set, just check the full FQN
66+
if (FullFqnContains != null)
67+
{
68+
return test.FullyQualifiedName.Contains(FullFqnContains, StringComparison.OrdinalIgnoreCase);
69+
}
70+
6371
if (Namespace != null && !test.Namespace.Contains(Namespace, StringComparison.OrdinalIgnoreCase))
6472
{
6573
return false;
@@ -88,6 +96,12 @@ public bool Matches(DiscoveredTest test)
8896
/// </summary>
8997
public bool Matches(string fullyQualifiedName, string displayName)
9098
{
99+
// If simple FQN contains filter is set, just check the full FQN
100+
if (FullFqnContains != null)
101+
{
102+
return fullyQualifiedName.Contains(FullFqnContains, StringComparison.OrdinalIgnoreCase);
103+
}
104+
91105
// FQN format: Namespace.Class.Method or Namespace.Class.Method(args)
92106
var (ns, className, methodName) = ParseFqn(fullyQualifiedName);
93107

src/Asynkron.TestRunner/TestRunner.cs

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,22 @@ public class TestRunner
3939
private readonly int _hangTimeoutSeconds;
4040
private readonly string? _filter;
4141
private readonly bool _quiet;
42+
private readonly bool _streamingConsole;
4243
private readonly int _workerCount;
4344
private readonly bool _verbose;
4445
private readonly string? _logFile;
4546
private readonly string? _resumeFilePath;
4647
private readonly object _logLock = new();
4748
private readonly Action<TestResultDetail>? _resultCallback;
4849

49-
public TestRunner(ResultStore store, int? timeoutSeconds = null, int? hangTimeoutSeconds = null, string? filter = null, bool quiet = false, int workerCount = 1, bool verbose = false, string? logFile = null, string? resumeFilePath = null, Action<TestResultDetail>? resultCallback = null)
50+
public TestRunner(ResultStore store, int? timeoutSeconds = null, int? hangTimeoutSeconds = null, string? filter = null, bool quiet = false, bool streamingConsole = false, int workerCount = 1, bool verbose = false, string? logFile = null, string? resumeFilePath = null, Action<TestResultDetail>? resultCallback = null)
5051
{
5152
_store = store;
5253
_testTimeoutSeconds = timeoutSeconds ?? 30;
5354
_hangTimeoutSeconds = hangTimeoutSeconds ?? _testTimeoutSeconds; // Default to same as test timeout
5455
_filter = filter;
5556
_quiet = quiet;
57+
_streamingConsole = streamingConsole;
5658
_workerCount = Math.Max(1, workerCount);
5759
_verbose = verbose;
5860
_logFile = logFile;
@@ -199,7 +201,11 @@ public async Task<int> RunTestsAsync(string[] assemblyPaths, CancellationToken c
199201
AnsiConsole.MarkupLine($"[dim]Found {allTests.Count} tests[/]");
200202

201203
// Run with resilient recovery
202-
if (_quiet)
204+
if (_streamingConsole)
205+
{
206+
await RunWithRecoveryStreamingAsync(assemblyPath, allTests, results, ct);
207+
}
208+
else if (_quiet)
203209
{
204210
await RunWithRecoveryQuietAsync(assemblyPath, allTests, results, ct);
205211
}
@@ -745,6 +751,116 @@ private async Task RunWithRecoveryQuietAsync(string assemblyPath, List<string> a
745751
}
746752
}
747753

754+
private async Task RunWithRecoveryStreamingAsync(string assemblyPath, List<string> allTests, TestResults results, CancellationToken ct)
755+
{
756+
var resumeTracker = ResumeTracker.TryLoad(GetResumeFilePath(), assemblyPath, allTests);
757+
if (resumeTracker != null)
758+
{
759+
SeedResultsFromResume(resumeTracker, results, null);
760+
}
761+
762+
var pendingTests = resumeTracker?.FilterPending() ?? allTests;
763+
var pending = new HashSet<string>(pendingTests);
764+
var running = new HashSet<string>();
765+
var attempts = 0;
766+
const int maxAttempts = 100;
767+
768+
while (pending.Count > 0 && attempts < maxAttempts)
769+
{
770+
if (ct.IsCancellationRequested)
771+
{
772+
return;
773+
}
774+
775+
attempts++;
776+
running.Clear();
777+
778+
await using var worker = WorkerProcess.Spawn();
779+
780+
try
781+
{
782+
using var cts = new CancellationTokenSource();
783+
var testsToRun = pending.ToList();
784+
785+
await foreach (var msg in worker.RunAsync(assemblyPath, testsToRun, _testTimeoutSeconds, cts.Token)
786+
.WithTimeout(TimeSpan.FromSeconds(_testTimeoutSeconds), cts))
787+
{
788+
switch (msg)
789+
{
790+
case TestStartedEvent started:
791+
running.Add(started.FullyQualifiedName);
792+
break;
793+
794+
case TestPassedEvent testPassed:
795+
running.Remove(testPassed.FullyQualifiedName);
796+
pending.Remove(testPassed.FullyQualifiedName);
797+
results.Passed.Add(testPassed.FullyQualifiedName);
798+
MarkResume(resumeTracker, "passed", testPassed.FullyQualifiedName, testPassed.DisplayName);
799+
Console.WriteLine($"\x1b[92m{"[PASSED]",-10}\x1b[0m {testPassed.DisplayName} ({testPassed.DurationMs:F0}ms)");
800+
break;
801+
802+
case TestFailedEvent testFailed:
803+
running.Remove(testFailed.FullyQualifiedName);
804+
pending.Remove(testFailed.FullyQualifiedName);
805+
results.Failed.Add(testFailed.FullyQualifiedName);
806+
MarkResume(resumeTracker, "failed", testFailed.FullyQualifiedName, testFailed.DisplayName);
807+
Console.WriteLine($"\x1b[91m{"[FAILED]",-10}\x1b[0m {testFailed.DisplayName}");
808+
break;
809+
810+
case TestSkippedEvent testSkipped:
811+
running.Remove(testSkipped.FullyQualifiedName);
812+
pending.Remove(testSkipped.FullyQualifiedName);
813+
results.Skipped.Add(testSkipped.FullyQualifiedName);
814+
MarkResume(resumeTracker, "skipped", testSkipped.FullyQualifiedName, testSkipped.DisplayName);
815+
Console.WriteLine($"\x1b[93m{"[SKIPPED]",-10}\x1b[0m {testSkipped.DisplayName}");
816+
break;
817+
818+
case RunCompletedEvent:
819+
// Mark any still-running tests as crashed
820+
foreach (var fqn in running.ToList())
821+
{
822+
running.Remove(fqn);
823+
pending.Remove(fqn);
824+
results.Crashed.Add(fqn);
825+
MarkResume(resumeTracker, "crashed", fqn, fqn);
826+
Console.WriteLine($"\x1b[91m{"[CRASHED]",-10}\x1b[0m {fqn}");
827+
}
828+
break;
829+
830+
case ErrorEvent:
831+
break;
832+
}
833+
}
834+
}
835+
catch (TimeoutException)
836+
{
837+
foreach (var fqn in running)
838+
{
839+
pending.Remove(fqn);
840+
results.Hanging.Add(fqn);
841+
MarkResume(resumeTracker, "hanging", fqn, fqn);
842+
Console.WriteLine($"\x1b[93m{"[HANGING]",-10}\x1b[0m {fqn}");
843+
}
844+
worker.Kill();
845+
}
846+
catch (WorkerCrashedException)
847+
{
848+
foreach (var fqn in running)
849+
{
850+
pending.Remove(fqn);
851+
results.Crashed.Add(fqn);
852+
MarkResume(resumeTracker, "crashed", fqn, fqn);
853+
Console.WriteLine($"\x1b[91m{"[CRASHED]",-10}\x1b[0m {fqn}");
854+
}
855+
}
856+
catch (Exception)
857+
{
858+
worker.Kill();
859+
break;
860+
}
861+
}
862+
}
863+
748864
private static void PrintSummary(TestResults results, TimeSpan elapsed)
749865
{
750866
Console.WriteLine();

0 commit comments

Comments
 (0)