4040INTERESTING_ACTIONS = (PANIC , BUG , FAIL , KNOWN_FAILURE , MISSING , FLAKY , RECOVERED , KNOWN_SKIP )
4141ACTIONS_WITH_ICON = INTERESTING_ACTIONS + (PASS , SKIP )
4242
43+ # Minimum elapsed time for test to be in slowest tests table.
44+ SLOWEST_MIN_MINUTES = 2
45+
46+ # Maximum number of tests in slowest tests table
47+ SLOWEST_MAX_ENTRIES = 50
48+
4349ACTION_MESSAGES = {
4450 "fail" : FAIL ,
4551 "pass" : PASS ,
@@ -277,6 +283,8 @@ def iter_paths(paths):
277283def parse_file (path , filter ):
278284 results = {}
279285 outputs = {}
286+ durations = {} # test_key -> elapsed time in seconds
287+ timestamps = [] # list of all timestamps from this file
280288 for line in path .open ():
281289 if not line .strip ():
282290 continue
@@ -285,6 +293,12 @@ def parse_file(path, filter):
285293 except Exception as ex :
286294 print (f"{ path } : { ex } \n { line !r} \n " )
287295 break
296+
297+ # Collect timestamp
298+ timestamp = data .get ("Time" )
299+ if timestamp :
300+ timestamps .append (timestamp )
301+
288302 testname = data .get ("Test" )
289303 if not testname :
290304 continue
@@ -304,6 +318,11 @@ def parse_file(path, filter):
304318 else :
305319 results [test_key ] = action
306320
321+ # Store elapsed time if available
322+ elapsed = data .get ("Elapsed" )
323+ if elapsed and action == PASS :
324+ durations [test_key ] = elapsed
325+
307326 out = data .get ("Output" )
308327 if out :
309328 outputs .setdefault (test_key , []).append (out .rstrip ())
@@ -316,7 +335,7 @@ def parse_file(path, filter):
316335 else :
317336 results .setdefault (test_key , MISSING )
318337
319- return results , outputs
338+ return results , outputs , durations , timestamps
320339
321340
322341def mark_known_failures (results , known_failures_config ):
@@ -338,6 +357,8 @@ def print_report(filenames, filter, filter_env, show_output, markdown=False, omi
338357 known_failures_config = load_known_failures ()
339358 outputs = {} # test_key -> env -> [output]
340359 per_test_per_env_stats = {} # test_key -> env -> action -> count
360+ durations_by_env = {} # env -> test_key -> duration
361+ timestamps_by_env = {} # env -> list of timestamps
341362 all_test_keys = set ()
342363 all_envs = set ()
343364 count_files = 0
@@ -351,14 +372,17 @@ def print_report(filenames, filter, filter_env, show_output, markdown=False, omi
351372 if filter_env and filter_env not in env :
352373 continue
353374 all_envs .add (env )
354- test_results , test_outputs = parse_file (p , filter )
375+ test_results , test_outputs , test_durations , test_timestamps = parse_file (p , filter )
355376 test_results = mark_known_failures (test_results , known_failures_config )
356377 count_files += 1
357378 count_results += len (test_results )
358379 for test_key , action in test_results .items ():
359380 per_test_per_env_stats .setdefault (test_key , {}).setdefault (env , Counter ())[action ] += 1
360381 for test_key , output in test_outputs .items ():
361382 outputs .setdefault (test_key , {}).setdefault (env , []).extend (output )
383+ for test_key , duration in test_durations .items ():
384+ durations_by_env .setdefault (env , {})[test_key ] = duration
385+ timestamps_by_env .setdefault (env , []).extend (test_timestamps )
362386 all_test_keys .update (test_results )
363387
364388 print (f"Parsed { count_files } files: { count_results } results" , file = sys .stderr , flush = True )
@@ -420,10 +444,32 @@ def is_bug(test_results):
420444 status = action [:2 ]
421445 break
422446
447+ # Find slowest test, mean duration, and time span for this environment
448+ env_durations = durations_by_env .get (env , {})
449+ time_span = ""
450+
451+ # Calculate time span (max timestamp - min timestamp)
452+ env_timestamps = timestamps_by_env .get (env , [])
453+ if env_timestamps :
454+ from datetime import datetime
455+
456+ # Parse timestamps and find span
457+ parsed_timestamps = []
458+ for ts in env_timestamps :
459+ try :
460+ parsed_timestamps .append (datetime .fromisoformat (ts .replace ("Z" , "+00:00" )))
461+ except Exception :
462+ continue
463+
464+ if parsed_timestamps :
465+ time_span_seconds = (max (parsed_timestamps ) - min (parsed_timestamps )).total_seconds ()
466+ time_span = format_duration (time_span_seconds )
467+
423468 table .append (
424469 {
425470 " " : status ,
426471 "Env" : env ,
472+ "Time" : time_span ,
427473 ** stats ,
428474 }
429475 )
@@ -432,10 +478,12 @@ def is_bug(test_results):
432478 def key (column ):
433479 try :
434480 return (ACTIONS_WITH_ICON .index (column ), "" )
435- except :
481+ except Exception :
436482 return (- 1 , str (column ))
437483
438484 columns = sorted (columns , key = key )
485+ columns .append ("Time" )
486+
439487 print (format_table (table , markdown = markdown , columns = columns ))
440488
441489 interesting_envs = set ()
@@ -485,6 +533,28 @@ def key(column):
485533 if table_txt :
486534 print (table_txt )
487535
536+ # Generate slowest tests table (tests slower than 10 minutes)
537+ all_durations = [] # [(env, package, testname, duration), ...]
538+ for env , env_durations in durations_by_env .items ():
539+ for test_key , duration in env_durations .items ():
540+ package_name , testname = test_key
541+ if duration >= SLOWEST_MIN_MINUTES * 60 :
542+ all_durations .append ((env , package_name , testname , duration ))
543+
544+ all_durations .sort (key = lambda x : x [3 ], reverse = True )
545+ top_slowest = all_durations [:SLOWEST_MAX_ENTRIES ]
546+
547+ if top_slowest :
548+ slowest_table = []
549+ for env , package_name , testname , duration in top_slowest :
550+ slowest_table .append ({"duration" : format_duration (duration ), "env" : env , "testname" : testname })
551+
552+ slowest_table_txt = format_table (slowest_table , columns = ["duration" , "env" , "testname" ], markdown = markdown )
553+ slowest_table_txt = wrap_in_details (
554+ slowest_table_txt , f"Top { len (top_slowest )} slowest tests (at least { SLOWEST_MIN_MINUTES } minutes):"
555+ )
556+ print (slowest_table_txt )
557+
488558 if show_output :
489559 for test_key , stats in simplified_results .items ():
490560 package_name , testname = test_key
@@ -584,6 +654,15 @@ def wrap_in_details(txt, summary):
584654 return f"<details><summary>{ summary } </summary>\n \n { txt } \n \n </details>"
585655
586656
657+ def format_duration (seconds ):
658+ """Format duration from seconds to MM:SS format."""
659+ if seconds is None :
660+ return ""
661+ minutes = int (seconds // 60 )
662+ secs = int (seconds % 60 )
663+ return f"{ minutes } :{ secs :02d} "
664+
665+
587666def main ():
588667 parser = argparse .ArgumentParser ()
589668 parser .add_argument ("filenames" , nargs = "+" , help = "Filenames or directories to parse" )
0 commit comments