@@ -31,8 +31,11 @@ public async Task<TestRunResult> RunAsync(
3131 IReporter reporter ,
3232 string ? a11yMode ,
3333 bool enforcePerfBudget ,
34- IReadOnlyList < ICoverageReporter > ? coverageReporters )
34+ IReadOnlyList < ICoverageReporter > ? coverageReporters ,
35+ int maxRetries = 0 )
3536 {
37+ if ( maxRetries < 0 )
38+ maxRetries = 0 ;
3639 var suiteName = tests . Count > 0
3740 ? tests [ 0 ] . TestClass . Assembly . GetName ( ) . Name ?? "Motus Tests"
3841 : "Motus Tests" ;
@@ -78,20 +81,42 @@ public async Task<TestRunResult> RunAsync(
7881 var testInfo = new TestInfo ( test . FullName , suiteName ) ;
7982 await reporter . OnTestStartAsync ( testInfo ) ;
8083
81- AccessibilityViolationSink . Begin ( ) ;
82- PerformanceMetricsSink . Begin ( ) ;
83- CoverageSink . Begin ( ) ;
84-
8584 CliTestResult result ;
8685
8786 if ( failedAssemblies . Contains ( test . TestClass . Assembly ) )
8887 {
88+ AccessibilityViolationSink . Begin ( ) ;
89+ PerformanceMetricsSink . Begin ( ) ;
90+ CoverageSink . Begin ( ) ;
8991 result = new CliTestResult ( test . FullName , false , TimeSpan . Zero ,
9092 "AssemblyInitialize failed" , null ) ;
9193 }
9294 else
9395 {
94- result = await ExecuteTestAsync ( test ) ;
96+ var totalAttempts = maxRetries + 1 ;
97+ var attempt = 0 ;
98+ Exception ? failure ;
99+ while ( true )
100+ {
101+ attempt ++ ;
102+
103+ AccessibilityViolationSink . Begin ( ) ;
104+ PerformanceMetricsSink . Begin ( ) ;
105+ CoverageSink . Begin ( ) ;
106+
107+ ( result , failure ) = await ExecuteTestAsync ( test ) ;
108+
109+ if ( result . Passed || attempt >= totalAttempts || ! IsTransientCdpFailure ( failure ) )
110+ break ;
111+
112+ // Discard sink data captured during the failed attempt; a clean
113+ // Begin() runs at the top of the next iteration.
114+ AccessibilityViolationSink . End ( ) ;
115+ PerformanceMetricsSink . End ( ) ;
116+ CoverageSink . End ( ) ;
117+
118+ WriteRetryNotice ( test . FullName , attempt + 1 , totalAttempts ) ;
119+ }
95120 }
96121
97122 var violations = AccessibilityViolationSink . End ( ) ;
@@ -274,7 +299,47 @@ public async Task<TestRunResult> RunAsync(
274299 return runResult ;
275300 }
276301
277- private async Task < CliTestResult > ExecuteTestAsync ( DiscoveredTest test )
302+ /// <summary>
303+ /// Emits a [RETRY] notice in the same shape (and color, when stderr is a TTY)
304+ /// as the [PASS]/[FAIL] lines emitted by ConsoleReporter, so the retry log
305+ /// reads as part of the test stream instead of free-form debug output.
306+ /// </summary>
307+ private static void WriteRetryNotice ( string testName , int attempt , int totalAttempts )
308+ {
309+ const string Yellow = "\x1b [33m" ;
310+ const string Gray = "\x1b [90m" ;
311+ const string Reset = "\x1b [0m" ;
312+
313+ var useColor = ! Console . IsErrorRedirected ;
314+ if ( useColor )
315+ {
316+ Console . Error . WriteLine (
317+ $ " { Yellow } [RETRY]{ Reset } { testName } { Gray } (CDP disconnect, attempt { attempt } /{ totalAttempts } ){ Reset } ") ;
318+ }
319+ else
320+ {
321+ Console . Error . WriteLine (
322+ $ " [RETRY] { testName } (CDP disconnect, attempt { attempt } /{ totalAttempts } )") ;
323+ }
324+ }
325+
326+ /// <summary>
327+ /// Returns true when the exception chain contains a transient CDP failure that
328+ /// is safe to recover from by re-running the entire test (which rebuilds a fresh
329+ /// browser context and WebSocket). Used to drive --retries.
330+ /// </summary>
331+ private static bool IsTransientCdpFailure ( Exception ? ex )
332+ {
333+ while ( ex is not null )
334+ {
335+ if ( ex is CdpDisconnectedException || ex is Abstractions . MotusTargetClosedException )
336+ return true ;
337+ ex = ex . InnerException ;
338+ }
339+ return false ;
340+ }
341+
342+ private async Task < ( CliTestResult Result , Exception ? Failure ) > ExecuteTestAsync ( DiscoveredTest test )
278343 {
279344 var testSw = Stopwatch . StartNew ( ) ;
280345 object ? instance = null ;
@@ -299,17 +364,21 @@ private async Task<CliTestResult> ExecuteTestAsync(DiscoveredTest test)
299364 await task ;
300365
301366 testSw . Stop ( ) ;
302- return new CliTestResult ( test . FullName , true , testSw . Elapsed , null , null ) ;
367+ return ( new CliTestResult ( test . FullName , true , testSw . Elapsed , null , null ) , null ) ;
303368 }
304369 catch ( TargetInvocationException ex ) when ( ex . InnerException is not null )
305370 {
306371 testSw . Stop ( ) ;
307- return new CliTestResult ( test . FullName , false , testSw . Elapsed , ex . InnerException . Message , ex . InnerException . StackTrace ) ;
372+ return (
373+ new CliTestResult ( test . FullName , false , testSw . Elapsed , ex . InnerException . Message , ex . InnerException . StackTrace ) ,
374+ ex . InnerException ) ;
308375 }
309376 catch ( Exception ex )
310377 {
311378 testSw . Stop ( ) ;
312- return new CliTestResult ( test . FullName , false , testSw . Elapsed , ex . Message , ex . StackTrace ) ;
379+ return (
380+ new CliTestResult ( test . FullName , false , testSw . Elapsed , ex . Message , ex . StackTrace ) ,
381+ ex ) ;
313382 }
314383 finally
315384 {
0 commit comments