@@ -19,7 +19,7 @@ class Colors:
1919 CYAN = '\033 [0;36m'
2020 BOLD = '\033 [1m'
2121 NC = '\033 [0m' # No Color
22-
22+
2323 @classmethod
2424 def disable (cls ):
2525 """Disable colors for CI environments"""
@@ -34,18 +34,51 @@ def __init__(self, args=None, no_color=False):
3434 self .log_dir = None
3535 self .errors = []
3636 self .warnings = []
37-
37+ self .current_log_file = None
38+ self .dumped_log_files = set ()
39+
3840 if no_color or not sys .stdout .isatty ():
3941 Colors .disable ()
40-
42+
4143 def extract_log_dir (self , line ):
4244 """Extract log directory from test output"""
43- match = re .search (r'Logs will be aggregated in (/tmp/unified_identity_test_\d+)' , line )
45+ match = re .search (r'Logs will be aggregated in (/tmp/unified_identity_test_\d+_\d+ )' , line )
4446 if match :
4547 self .log_dir = match .group (1 )
4648 return True
4749 return False
50+
51+ def track_log_file (self , line ):
52+ """Track the currently active log file from output"""
53+ # Matches "Log: /tmp/.../foo.log"
54+ match = re .search (r'Log:\s+([^ ]+\.log)' , line )
55+ if match :
56+ self .current_log_file = match .group (1 ).strip ()
57+ return True
58+ return False
59+
60+ def dump_current_log_tail (self ):
61+ """Immediately dump the tail of the current log file if not already dumped"""
62+ if not self .current_log_file or self .current_log_file in self .dumped_log_files :
63+ return
4864
65+ log_path = Path (self .current_log_file )
66+ if not log_path .exists ():
67+ return
68+
69+ self .dumped_log_files .add (self .current_log_file )
70+ print (f"\n { Colors .YELLOW } --- IMMEDIATE LOG DUMP: { log_path .name } ---{ Colors .NC } " )
71+ try :
72+ with open (log_path , 'r' ) as f :
73+ # Read all lines efficiently
74+ lines = f .readlines ()
75+ # Show last 50 lines to keep it focused
76+ tail_lines = lines [- 50 :] if len (lines ) > 50 else lines
77+ print ('' .join (tail_lines ))
78+ except Exception as e :
79+ print (f"Error reading log file: { e } " )
80+ print (f"{ Colors .YELLOW } --- END LOG DUMP ---\n { Colors .NC } " )
81+
4982 def detect_error (self , line ):
5083 """Detect error patterns in output"""
5184 # Ignore expected errors
@@ -58,29 +91,30 @@ def detect_error(self, line):
5891 for pattern in ignore_patterns :
5992 if re .search (pattern , line , re .IGNORECASE ):
6093 return False
61-
94+
6295 # Real error patterns
6396 error_patterns = [
6497 r'CRITICAL ERROR' ,
6598 r'FAILED.*test' ,
6699 r'cannot.*connect' ,
67100 r'Unable to start' ,
68101 r'Exit.*code.*[1-9]' , # Non-zero exit from subprocess
102+ r'Error:' , # Catch generic Error: lines
69103 ]
70104 for pattern in error_patterns :
71105 if re .search (pattern , line , re .IGNORECASE ):
72106 self .errors .append (line .strip ())
73107 return True
74-
108+
75109 # Check for ✗ symbol (failure indicator) but not in cleanup-related messages
76110 if '✗' in line :
77111 if any (pattern in line .lower () for pattern in ['cleanup' , 'stopping' , 'cleaning up' ]):
78112 return False # Ignore cleanup failures
79113 self .errors .append (line .strip ())
80114 return True
81-
115+
82116 return False
83-
117+
84118 def detect_warning (self , line ):
85119 """Detect warning patterns in output"""
86120 # Ignore very noisy warnings
@@ -91,7 +125,7 @@ def detect_warning(self, line):
91125 for pattern in ignore_patterns :
92126 if re .search (pattern , line , re .IGNORECASE ):
93127 return False
94-
128+
95129 warning_patterns = [
96130 r'WARNING' ,
97131 r'⚠.*(?!Agent services cleanup)' , # Warnings except cleanup
@@ -101,7 +135,7 @@ def detect_warning(self, line):
101135 self .warnings .append (line .strip ())
102136 return True
103137 return False
104-
138+
105139 def print_header (self ):
106140 """Print CI run header"""
107141 print (f"{ Colors .BOLD } { '=' * 80 } { Colors .NC } " )
@@ -111,32 +145,32 @@ def print_header(self):
111145 print (f"Command: ./test_integration.sh { ' ' .join (self .args )} " )
112146 print (f"{ Colors .BOLD } { '=' * 80 } { Colors .NC } " )
113147 print ()
114-
148+
115149 def parse_log_files (self ):
116150 """Parse log files after completion to find failure details"""
117151 if not self .log_dir or not Path (self .log_dir ).exists ():
118152 return
119-
153+
120154 log_files = list (Path (self .log_dir ).glob ('*.log' ))
121155 if not log_files :
122156 return
123-
157+
124158 # Parse master.log first for overall failure
125159 master_log = Path (self .log_dir ) / 'master.log'
126160 if master_log .exists ():
127161 self ._parse_master_log (master_log )
128-
162+
129163 # Parse individual script logs for specific failures
130164 for log_file in sorted (log_files ):
131165 if log_file .name != 'master.log' :
132166 self ._parse_script_log (log_file )
133-
167+
134168 def _parse_master_log (self , log_path ):
135169 """Parse master.log to find failure point"""
136170 try :
137171 with open (log_path , 'r' ) as f :
138172 lines = f .readlines ()
139-
173+
140174 # Find the last error before exit
141175 for i in range (len (lines ) - 1 , max (0 , len (lines ) - 50 ), - 1 ):
142176 line = lines [i ]
@@ -145,77 +179,121 @@ def _parse_master_log(self, log_path):
145179 context_start = max (0 , i - 5 )
146180 context_end = min (len (lines ), i + 6 )
147181 context = '' .join (lines [context_start :context_end ])
148-
182+
149183 if line .strip () not in [e .strip () for e in self .errors ]:
150184 self .errors .append (f"[master.log:{ i + 1 } ] { line .strip ()} " )
151185 break
152186 except Exception as e :
153187 pass # Ignore parsing errors
154-
188+
155189 def _parse_script_log (self , log_path ):
156190 """Parse individual script log to find failure"""
157191 try :
158192 with open (log_path , 'r' ) as f :
159193 lines = f .readlines ()
160-
194+
161195 # Look for explicit error markers
162196 for i , line in enumerate (lines ):
163197 if any (pattern in line for pattern in ['✗' , 'failed' , 'CRITICAL ERROR' ]):
164198 if line .strip () not in [e .strip () for e in self .errors ]:
165199 self .errors .append (f"[{ log_path .name } :{ i + 1 } ] { line .strip ()} " )
166200 except Exception as e :
167201 pass # Ignore parsing errors
168-
202+
203+ def dump_failure_logs (self ):
204+ """Dump log file contents when tests fail - visible in GitHub Actions"""
205+ if not self .log_dir or not Path (self .log_dir ).exists ():
206+ return
207+
208+ print (f"\n { Colors .RED } { '=' * 80 } { Colors .NC } " )
209+ print (f"{ Colors .RED } FAILURE LOG DUMP (for GitHub Actions debugging){ Colors .NC } " )
210+ print (f"{ Colors .RED } { '=' * 80 } { Colors .NC } " )
211+
212+ log_files = [
213+ 'test_control_plane.log' ,
214+ 'test_agents.log' ,
215+ 'test_onprem.log' ,
216+ 'test_mtls_client.log' ,
217+ 'master.log'
218+ ]
219+
220+ for log_name in log_files :
221+ log_path = Path (self .log_dir ) / log_name
222+ if log_path .exists ():
223+ try :
224+ with open (log_path , 'r' ) as f :
225+ content = f .read ()
226+
227+ # Only show logs that have content
228+ if content .strip ():
229+ # Check if this log has any errors
230+ has_errors = any (pattern in content for pattern in ['✗' , 'FAILED' , 'CRITICAL' , 'Error:' , 'Traceback' ])
231+
232+ if has_errors :
233+ print (f"\n { Colors .YELLOW } --- { log_name } (CONTAINS ERRORS) ---{ Colors .NC } " )
234+ # Show last 100 lines for error logs
235+ lines = content .strip ().split ('\n ' )
236+ if len (lines ) > 100 :
237+ print (f"... (showing last 100 of { len (lines )} lines) ..." )
238+ print ('\n ' .join (lines [- 100 :]))
239+ else :
240+ print (content )
241+ print (f"{ Colors .YELLOW } --- END { log_name } ---{ Colors .NC } \n " )
242+ except Exception as e :
243+ print (f" Could not read { log_name } : { e } " )
244+
245+ print (f"{ Colors .RED } { '=' * 80 } { Colors .NC } " )
246+
169247 def print_summary (self ):
170248 """Print test run summary"""
171249 duration = (self .end_time - self .start_time ).total_seconds ()
172-
250+
173251 print ()
174252 print (f"{ Colors .BOLD } { '=' * 80 } { Colors .NC } " )
175253 print (f"{ Colors .BOLD } Test Run Summary{ Colors .NC } " )
176254 print (f"{ Colors .BOLD } { '=' * 80 } { Colors .NC } " )
177255 print (f"Duration: { duration :.1f} seconds" )
178256 print (f"Exit Code: { self .exit_code } " )
179-
257+
180258 if self .log_dir :
181259 print (f"Logs: { self .log_dir } " )
182-
260+
183261 if self .warnings :
184262 print (f"\n { Colors .YELLOW } Warnings ({ len (self .warnings )} ):{ Colors .NC } " )
185263 for warning in self .warnings [:5 ]: # Show first 5
186264 print (f" • { warning } " )
187265 if len (self .warnings ) > 5 :
188266 print (f" ... and { len (self .warnings ) - 5 } more" )
189-
267+
190268 if self .errors :
191269 print (f"\n { Colors .RED } Errors ({ len (self .errors )} ):{ Colors .NC } " )
192270 for error in self .errors [:10 ]: # Show first 10
193271 print (f" • { error } " )
194272 if len (self .errors ) > 10 :
195273 print (f" ... and { len (self .errors ) - 10 } more" )
196-
274+
197275 print (f"\n { Colors .BOLD } { '=' * 80 } { Colors .NC } " )
198276 if self .exit_code == 0 :
199277 print (f"{ Colors .GREEN } { Colors .BOLD } ✓ TESTS PASSED{ Colors .NC } " )
200278 else :
201279 print (f"{ Colors .RED } { Colors .BOLD } ✗ TESTS FAILED{ Colors .NC } " )
202- if self . log_dir :
203- print ( f" \n { Colors . YELLOW } Check logs for details: { Colors . NC } " )
204- print ( f" master.log: { self .log_dir } /master.log" )
280+ # Dump failure logs directly to GitHub Actions output ( if not already dumped)
281+ # We still dump here to be safe and comprehensive at the end
282+ self .dump_failure_logs ( )
205283 print (f"{ Colors .BOLD } { '=' * 80 } { Colors .NC } " )
206-
284+
207285 def run (self ):
208286 """Run the integration tests"""
209287 self .print_header ()
210288 self .start_time = datetime .now ()
211-
289+
212290 script_path = Path (__file__ ).parent / 'test_integration.sh'
213291 if not script_path .exists ():
214292 print (f"{ Colors .RED } Error: test_integration.sh not found at { script_path } { Colors .NC } " )
215293 return 1
216-
294+
217295 cmd = [str (script_path )] + self .args
218-
296+
219297 try :
220298 # Run test_integration.sh with real-time output streaming
221299 process = subprocess .Popen (
@@ -225,23 +303,29 @@ def run(self):
225303 universal_newlines = True ,
226304 bufsize = 1
227305 )
228-
306+
229307 # Stream output line by line
230308 for line in process .stdout :
231309 # Print to stdout for real-time monitoring
232310 print (line , end = '' )
233-
311+
234312 # Extract log directory
235313 self .extract_log_dir (line )
236-
314+
315+ # Track current log file
316+ self .track_log_file (line )
317+
237318 # Detect errors and warnings
238- self .detect_error (line )
319+ if self .detect_error (line ):
320+ # Immediate failure feedback!
321+ self .dump_current_log_tail ()
322+
239323 self .detect_warning (line )
240-
324+
241325 # Wait for completion
242326 process .wait ()
243327 self .exit_code = process .returncode
244-
328+
245329 except KeyboardInterrupt :
246330 print (f"\n { Colors .YELLOW } Test interrupted by user{ Colors .NC } " )
247331 if process :
@@ -251,21 +335,21 @@ def run(self):
251335 except Exception as e :
252336 print (f"{ Colors .RED } Error running tests: { e } { Colors .NC } " )
253337 self .exit_code = 1
254-
338+
255339 self .end_time = datetime .now ()
256-
340+
257341 # Parse log files to extract failure details
258342 if self .exit_code != 0 :
259343 self .parse_log_files ()
260-
344+
261345 self .print_summary ()
262-
346+
263347 return self .exit_code
264348
265349def main ():
266350 """Main entry point"""
267351 import argparse
268-
352+
269353 parser = argparse .ArgumentParser (
270354 description = 'CI Test Runner for Unified Identity Integration Tests' ,
271355 formatter_class = argparse .RawDescriptionHelpFormatter ,
@@ -287,22 +371,22 @@ def main():
287371 ./ci_test_runner.py -- --no-pause --no-build
288372 """
289373 )
290-
374+
291375 parser .add_argument ('--no-color' , action = 'store_true' ,
292376 help = 'Disable color output (for CI)' )
293377 parser .add_argument ('test_args' , nargs = '*' ,
294378 help = 'Arguments to pass to test_integration.sh (use -- to separate)' )
295-
379+
296380 # Parse only known args, let the rest pass through
297381 args , unknown = parser .parse_known_args ()
298-
382+
299383 # Combine test_args and unknown args
300384 all_test_args = args .test_args + unknown
301-
385+
302386 # Add --no-pause by default for CI usage (unless already present)
303387 if '--no-pause' not in all_test_args and '--pause' not in ' ' .join (all_test_args ):
304388 all_test_args .append ('--no-pause' )
305-
389+
306390 runner = TestRunner (args = all_test_args , no_color = args .no_color )
307391 exit_code = runner .run ()
308392 sys .exit (exit_code )
0 commit comments