66import subprocess
77import sys
88import time
9- import time
109import xml .etree .ElementTree as ET
10+ import itertools
1111from enum import Enum
1212from tqdm import tqdm
1313
14- class ErrorType (Enum ):
15- NO_ERROR = 1
14+ class ReturnCode (Enum ):
15+ SUCCESS = 0
16+ FAILURE = 1
1617 TIMEOUT = 2
17- UNEXPECTED_RETURN = 3
18+ UNKNOWN = 3
19+
20+ def classify_returncode (returncode : int ) -> ReturnCode :
21+ match returncode :
22+ case 0 :
23+ return ReturnCode .SUCCESS
24+ case 1 :
25+ return ReturnCode .FAILURE
26+ case 124 | 137 :
27+ return ReturnCode .TIMEOUT
28+ case _:
29+ return ReturnCode .UNKNOWN
1830
1931# data needed for junit output
2032class JunitData :
@@ -24,11 +36,14 @@ class JunitData:
2436 execution_time = 0.0
2537 test_cases = []
2638
27- def append_data (self , stdout : str , stderr : str , error_type : ErrorType , test_name : str ):
39+ def append_data (self , stdout : str , stderr : str , returncode : int , test_name : str ):
40+ return_class = classify_returncode (returncode )
41+
2842 # If a process times out we do not want to parse the result. It is just a failure.
29- if error_type != ErrorType . NO_ERROR :
30- self .failures += 1
43+ if return_class not in [ ReturnCode . SUCCESS , ReturnCode . FAILURE ] :
44+ self .errors += 1
3145 return
46+
3247 assert stdout is not None
3348 tree = ET .fromstring (stdout )
3449 test_suite = tree .find ('testsuite' )
@@ -62,25 +77,37 @@ class TestData:
6277 total_assertions = 0
6378 passed_assertions = 0
6479 failed_assertions = 0
80+
6581 total_test_cases = 0
6682 passed_test_cases = 0
6783 failed_test_cases = 0
68- timeouts = 0
84+
6985 execution_time = 0.0
86+
7087 error_msgs = []
7188 timeouted_tests = []
72- is_error = False
89+ unknown_test_failures = []
90+ has_test_failure = False
91+
92+ def append_data (self , stdout : str , stderr : str , returncode : int , test_name : str ):
93+ return_class = classify_returncode (returncode )
7394
74- def append_data (self , stdout : str , stderr : str , error_type : ErrorType , test_name : str ):
7595 # If a process times out we do not want to parse the result. It is just a failure.
76- if error_type == ErrorType .TIMEOUT :
77- self .timeouts += 1
96+ if return_class == ReturnCode .TIMEOUT :
7897 self .total_test_cases += 1
7998 self .failed_test_cases += 1
80- self .is_error = True
99+ self .has_test_failure = True
81100 self .timeouted_tests .append (test_name )
82101 return
83102
103+ # If a process fails enexpectedly, we do not want to parse the result
104+ if return_class == ReturnCode .UNKNOWN :
105+ self .total_test_cases += 1
106+ self .failed_test_cases += 1
107+ self .has_test_failure = True
108+ self .unknown_test_failures .append ((test_name , returncode ))
109+ return
110+
84111 current_failed_tests = 0
85112 assert stdout is not None
86113 assert stderr is not None
@@ -117,35 +144,37 @@ def append_data(self, stdout: str, stderr: str, error_type: ErrorType, test_name
117144 if current_failed_tests > 0 :
118145 self .error_msgs .append (stdout )
119146 self .error_msgs .append (stderr )
120- self .is_error = True
147+ self .has_test_failure = True
121148 elif stderr != '' :
122149 self .error_msgs .append (stderr )
123150
124151 def dump (self , _ ):
125152 for msg in self .error_msgs :
126153 print (msg )
127154
128- if ( self .is_error ) :
155+ if self .has_test_failure :
129156 digits_total = max (len (str (self .total_assertions )), len (str (self .total_test_cases )))
130157 digits_passed = max (len (str (self .passed_assertions )), len (str (self .passed_test_cases )))
131158 digits_failed = max (len (str (self .failed_assertions )), len (str (self .failed_test_cases )))
132159 print (f'\u001b [31;1m===============================================================================\u001b [39;0m' )
133160 print (f'test cases: { self .total_test_cases :>{digits_total }} | \u001b [32;1m{ self .passed_test_cases :>{digits_passed }} passed\u001b [39;0m | \u001b [31;1m{ self .failed_test_cases :>{digits_failed }} failed\u001b [39;0m' )
134- print (f'assertions: { self .total_assertions :>{digits_total }} | \u001b [32;1m{ self .passed_assertions :>{digits_passed }} passed\u001b [39;0m | \u001b [31;1m{ self .failed_assertions :>{digits_failed }} failed\u001b [39;0m\n ' )
135- if self .timeouts > 0 :
136- print (f'\u001b [31;1mTimeouts: { self .timeouts } \u001b [39;0m\n ' )
137- print ('Tests that timed out:' )
138- for timeout_test in self .timeouted_tests :
139- print (timeout_test )
140-
141- print (f'Execution time: { self .execution_time } s\n ' )
161+ print (f'assertions: { self .total_assertions :>{digits_total }} | \u001b [32;1m{ self .passed_assertions :>{digits_passed }} passed\u001b [39;0m | \u001b [31;1m{ self .failed_assertions :>{digits_failed }} failed\u001b [39;0m' )
162+
163+ if len (self .timeouted_tests ) > 0 :
164+ print (f'\n \u001b [31;1mTimeouts: { len (self .timeouted_tests )} \u001b [39;0m' )
165+ print ('\n ' .join (map (lambda test : f' { test } ' , self .timeouted_tests )))
166+
167+ if len (self .unknown_test_failures ) > 0 :
168+ print (f'\n \u001b [31;1mUnknown test failures: { len (self .unknown_test_failures )} \u001b [39;0m' )
169+ print ('\n ' .join (itertools .starmap (lambda test , ret : f' { test } (return code { ret } )' , self .unknown_test_failures )))
142170 else :
143171 print (f'\u001b [32;1m===============================================================================\u001b [39;0m' )
144172 print (f'\u001b [32;1mAll tests passed\u001b [39;0m ({ self .passed_assertions } assertions in { self .passed_test_cases } test cases)\n ' )
145- print (f'Execution time: { self .execution_time } s\n ' )
173+
174+ print (f'\n Execution time: { self .execution_time } s' )
146175
147176 def is_failure (self ):
148- if ( self .failed_assertions > 0 or self .failed_test_cases > 0 ) :
177+ if self .failed_assertions > 0 or self .failed_test_cases > 0 :
149178 return True
150179 else :
151180 return False
@@ -174,17 +203,37 @@ def handle_returncode(stdout: str, stderr: str, returncode: int, test_name: str)
174203 match returncode :
175204 # no error
176205 case 0 | 1 :
177- data .append_data (stdout , stderr , ErrorType . NO_ERROR , test_name )
206+ data .append_data (stdout , stderr , ReturnCode . NONE , test_name )
178207
179208 # timout with SIGTERM or SIGKILL
180209 case 124 | 137 :
181- data .append_data (stdout , stderr , ErrorType .TIMEOUT , test_name )
210+ data .append_data (stdout , stderr , ReturnCode .TIMEOUT , test_name )
182211
183212 # unexpected return code
184213 case _:
185- data .append_data (stdout , stderr , ErrorType .UNEXPECTED_RETURN , test_name )
214+ data .append_data (stdout , stderr , ReturnCode .UNEXPECTED_RETURN , test_name )
215+
216+ running_processes : map (int , tuple (subprocess .Popen , str )) = {}
217+
218+ def wait_for_child () -> int :
219+ pid , status = os .wait () # wait for *any* child to exit
220+
221+ assert pid in running_processes , 'os.wait() returned unexpected child PID'
222+
223+ process , p_test_name = running_processes [pid ]
224+ assert process .pid == pid , 'PID mismatch'
225+
226+ returncode = os .waitstatus_to_exitcode (status ) # get return status from process; don't use Popen.returncode
227+ assert returncode is not None
228+
229+ progress_bar .update (1 )
230+ del running_processes [pid ]
231+ stdout , stderr = process .communicate ()
232+
233+ data .append_data (stdout , stderr , returncode , test_name )
234+
235+ return returncode
186236
187- running_processes = {}
188237 # execute tests in parallel until we have max_processes running
189238 for test_name in test_names :
190239 test_name = test_name .replace (',' , '\\ ,' )
@@ -195,29 +244,26 @@ def handle_returncode(stdout: str, stderr: str, returncode: int, test_name: str)
195244 # if we have max_processes number of processes running, wait for a process to finish and remove the process from
196245 # the running processes dictionary
197246 if len (running_processes ) >= max_processes :
198- pid , status = os .wait () # wait for *any* child to exit
199- assert pid in running_processes , 'os.wait() returned unexpected child PID'
200- process , p_test_name = running_processes [pid ]
201- assert process .pid == pid , 'PID mismatch'
202- returncode = os .waitstatus_to_exitcode (status ) # get return status from process; don't use Popen.returncode
203- assert returncode is not None
204- progress_bar .update (1 )
205- del running_processes [pid ]
206- stdout , stderr = process .communicate ()
207- handle_returncode (stdout , stderr , returncode , p_test_name )
247+ returncode = wait_for_child ()
208248 if args .stop_fail and returncode != 0 : # stop on first failure
209249 failed = True
210250 break
211251
212252 # wait for the remaining running processes to finish
213- for pid , (process , p_test_name ) in running_processes .items ():
214- stdout , stderr = process .communicate () # wait for process to terminate; consume stdout/stderr to avoid deadlock
215- progress_bar .update (1 )
216- handle_returncode (stdout , stderr , process .returncode , p_test_name )
217- if process .returncode != 0 and args .stop_fail :
253+ while len (running_processes ) != 0 :
254+ returncode = wait_for_child ()
255+ if args .stop_fail and returncode != 0 : # stop on first failure
218256 failed = True
219257 break
220258
259+ # for pid, (process, p_test_name) in running_processes.items():
260+ # stdout, stderr = process.communicate() # wait for process to terminate; consume stdout/stderr to avoid deadlock
261+ # progress_bar.update(1)
262+ # handle_returncode(stdout, stderr, process.returncode, p_test_name)
263+ # if process.returncode != 0 and args.stop_fail:
264+ # failed = True
265+ # break
266+
221267 if failed :
222268 # send SIGTERM to all processes
223269 for process , test_name in running_processes .values ():
@@ -236,7 +282,7 @@ def handle_returncode(stdout: str, stderr: str, returncode: int, test_name: str)
236282 execution_time = time_end - time_start
237283 data .execution_time = execution_time
238284
239- return data
285+ return data , failed
240286
241287
242288if __name__ == '__main__' :
@@ -268,10 +314,10 @@ def handle_returncode(stdout: str, stderr: str, returncode: int, test_name: str)
268314 output = subprocess .run (list_tests_command , stdout = subprocess .PIPE )
269315 test_names = output .stdout .decode ().strip ().split ('\n ' )
270316
271- data = run_tests (args , test_names , args .binary_path , is_interactive )
317+ data , failed = run_tests (args , test_names , args .binary_path , is_interactive )
272318
273319 data .dump (args .out )
274- if data .is_failure ():
320+ if failed or data .is_failure ():
275321 exit (1 )
276322 else :
277323 exit (0 )
0 commit comments