1+ #!/usr/bin/env python3
2+ """
3+ Test runner hook for Nextflow development.
4+ This hook runs appropriate tests when source files or test files are edited.
5+ """
6+
7+ import json
8+ import os
9+ import re
10+ import subprocess
11+ import sys
12+ from pathlib import Path
13+
14+
15+ def extract_test_info (file_path ):
16+ """Extract module and test class info from file path"""
17+ path = Path (file_path )
18+
19+ # Check if it's in a module directory
20+ module_match = None
21+ for part in path .parts :
22+ if part in ['nextflow' , 'nf-commons' , 'nf-httpfs' , 'nf-lang' , 'nf-lineage' ]:
23+ module_match = part
24+ break
25+ # Handle plugin modules
26+ if part .startswith ('nf-' ) and 'plugins' in path .parts :
27+ module_match = f"plugins:{ part } "
28+ break
29+
30+ if not module_match :
31+ return None , None , None
32+
33+ # Extract the class/package info
34+ src_parts = list (path .parts )
35+
36+ # Find where the package structure starts
37+ package_start_idx = - 1
38+ for i , part in enumerate (src_parts ):
39+ if part in ['groovy' , 'java' ] and i > 0 and src_parts [i - 1 ] in ['main' , 'test' ]:
40+ package_start_idx = i + 1
41+ break
42+
43+ if package_start_idx == - 1 :
44+ return module_match , None , None
45+
46+ # Get package and class name
47+ package_parts = src_parts [package_start_idx :- 1 ]
48+ package = '.' .join (package_parts ) if package_parts else None
49+
50+ class_name = path .stem
51+
52+ return module_match , package , class_name
53+
54+
55+ def determine_test_command (file_path ):
56+ """Determine the appropriate test command based on the file being edited"""
57+ path = Path (file_path )
58+
59+ # Only process Groovy and Java files in modules
60+ if path .suffix not in ['.groovy' , '.java' ]:
61+ return None
62+
63+ # Must be in modules or plugins directory
64+ if 'modules' not in path .parts and 'plugins' not in path .parts :
65+ return None
66+
67+ module , package , class_name = extract_test_info (file_path )
68+ if not module or not class_name :
69+ return None
70+
71+ # If it's already a test file, run it directly
72+ if class_name .endswith ('Test' ):
73+ test_pattern = f"*{ class_name } "
74+ return f"./gradlew :{ module } :test --tests \" { test_pattern } \" "
75+
76+ # If it's a source file, look for corresponding test
77+ test_class = f"{ class_name } Test"
78+ test_pattern = f"*{ test_class } "
79+
80+ return f"./gradlew :{ module } :test --tests \" { test_pattern } \" "
81+
82+
83+ def run_test_command (command ):
84+ """Execute the test command"""
85+ try :
86+ print (f"Running: { command } " )
87+
88+ result = subprocess .run (command , shell = True , capture_output = True ,
89+ text = True , timeout = 180 ) # 3 minute timeout
90+
91+ return result .returncode , result .stdout , result .stderr
92+
93+ except subprocess .TimeoutExpired :
94+ return 1 , "" , "Test execution timed out after 3 minutes"
95+ except Exception as e :
96+ return 1 , "" , f"Error running tests: { str (e )} "
97+
98+
99+ def main ():
100+ try :
101+ input_data = json .load (sys .stdin )
102+ except json .JSONDecodeError as e :
103+ print (f"Error: Invalid JSON input: { e } " , file = sys .stderr )
104+ sys .exit (1 )
105+
106+ hook_event = input_data .get ("hook_event_name" , "" )
107+ tool_name = input_data .get ("tool_name" , "" )
108+ tool_input = input_data .get ("tool_input" , {})
109+
110+ # Only process Edit, Write, MultiEdit tools
111+ if tool_name not in ["Edit" , "Write" , "MultiEdit" ]:
112+ sys .exit (0 )
113+
114+ file_path = tool_input .get ("file_path" , "" )
115+ if not file_path :
116+ sys .exit (0 )
117+
118+ # Determine test command
119+ test_command = determine_test_command (file_path )
120+ if not test_command :
121+ # Not a file we can test
122+ sys .exit (0 )
123+
124+ # Run the tests
125+ returncode , stdout , stderr = run_test_command (test_command )
126+
127+ if returncode == 0 :
128+ # Tests passed
129+ lines = stdout .split ('\n ' )
130+ test_results = [line for line in lines if 'test' in line .lower () and ('passed' in line .lower () or 'success' in line .lower ())]
131+
132+ message = f"✓ Tests passed for { os .path .basename (file_path )} "
133+ if test_results :
134+ # Show a summary of the last few relevant lines
135+ summary = '\n ' .join (test_results [- 3 :]) if len (test_results ) > 3 else '\n ' .join (test_results )
136+ message += f"\n { summary } "
137+
138+ output = {
139+ "suppressOutput" : True ,
140+ "systemMessage" : message
141+ }
142+ print (json .dumps (output ))
143+ sys .exit (0 )
144+ else :
145+ # Tests failed - show error to Claude for potential fixing
146+ error_msg = f"Tests failed for { os .path .basename (file_path )} :\n "
147+
148+ # Extract useful error information
149+ if stderr :
150+ error_msg += f"Error output:\n { stderr [:500 ]} \n "
151+
152+ if stdout :
153+ # Look for test failure information in stdout
154+ lines = stdout .split ('\n ' )
155+ failure_lines = [line for line in lines
156+ if any (keyword in line .lower ()
157+ for keyword in ['failed' , 'error' , 'exception' , 'assertion' ])]
158+
159+ if failure_lines :
160+ error_msg += f"Test failures:\n " + '\n ' .join (failure_lines [- 5 :])
161+
162+ # Use JSON output to provide feedback to Claude
163+ output = {
164+ "decision" : "block" ,
165+ "reason" : error_msg [:1000 ] + ("..." if len (error_msg ) > 1000 else "" )
166+ }
167+ print (json .dumps (output ))
168+ sys .exit (0 )
169+
170+
171+ if __name__ == "__main__" :
172+ main ()
0 commit comments