diff --git a/ra_aid/tools/handle_user_defined_test_cmd_execution.py b/ra_aid/tools/handle_user_defined_test_cmd_execution.py index 993ffd7..35eb7da 100644 --- a/ra_aid/tools/handle_user_defined_test_cmd_execution.py +++ b/ra_aid/tools/handle_user_defined_test_cmd_execution.py @@ -1,6 +1,7 @@ """Utilities for executing and managing user-defined test commands.""" -from typing import Dict, Any, Tuple, Optional +from typing import Dict, Any, Tuple +import subprocess from dataclasses import dataclass from rich.console import Console from rich.markdown import Markdown @@ -20,105 +21,146 @@ class TestState: auto_test: bool should_break: bool = False -def display_test_failure(attempts: int, max_retries: int) -> None: - """Display test failure message. +class TestCommandExecutor: + """Class for executing and managing test commands.""" - Args: - attempts: Current number of attempts - max_retries: Maximum allowed retries - """ - console.print( - Panel( - Markdown(f"Test failed. Attempt number {attempts} of {max_retries}. Retrying and informing of failure output"), - title="🔎 User Defined Test", - border_style="red bold" + def __init__(self, config: Dict[str, Any], original_prompt: str, test_attempts: int = 0, auto_test: bool = False): + """Initialize the test command executor. + + Args: + config: Configuration dictionary containing test settings + original_prompt: The original prompt to append errors to + test_attempts: Current number of test attempts + auto_test: Whether auto-test mode is enabled + """ + self.config = config + self.state = TestState( + prompt=original_prompt, + test_attempts=test_attempts, + auto_test=auto_test, + should_break=False ) - ) + self.max_retries = config.get("max_test_cmd_retries", 5) -def handle_test_failure(state: TestState, original_prompt: str, test_result: Dict[str, Any]) -> TestState: - """Handle test command failure. - - Args: - state: Current test state - original_prompt: Original prompt text - test_result: Test command result - - Returns: - Updated test state - """ - state.prompt = f"{original_prompt}. Previous attempt failed with: {test_result['output']}" - display_test_failure(state.test_attempts, 5) # Default max retries - state.should_break = False - return state + def display_test_failure(self) -> None: + """Display test failure message.""" + console.print( + Panel( + Markdown(f"Test failed. Attempt number {self.state.test_attempts} of {self.max_retries}. Retrying and informing of failure output"), + title="🔎 User Defined Test", + border_style="red bold" + ) + ) -def run_test_command(cmd: str, state: TestState, original_prompt: str) -> TestState: - """Run test command and handle result. - - Args: - cmd: Test command to execute - state: Current test state - original_prompt: Original prompt text + def handle_test_failure(self, original_prompt: str, test_result: Dict[str, Any]) -> None: + """Handle test command failure. - Returns: - Updated test state - """ - try: - test_result = run_shell_command(cmd) - state.test_attempts += 1 + Args: + original_prompt: Original prompt text + test_result: Test command result + """ + self.state.prompt = f"{original_prompt}. Previous attempt failed with: {test_result['output']}" + self.display_test_failure() + self.state.should_break = False + + def run_test_command(self, cmd: str, original_prompt: str) -> None: + """Run test command and handle result. - if not test_result["success"]: - return handle_test_failure(state, original_prompt, test_result) + Args: + cmd: Test command to execute + original_prompt: Original prompt text + """ + timeout = self.config.get('timeout', 30) + try: + logger.info(f"Executing test command: {cmd} with timeout {timeout}s") + test_result = run_shell_command(cmd, timeout=timeout) + self.state.test_attempts += 1 - state.should_break = True - return state - - except Exception as e: - logger.warning(f"Test command execution failed: {str(e)}") - state.test_attempts += 1 - state.should_break = True - return state + if not test_result["success"]: + self.handle_test_failure(original_prompt, test_result) + return + + self.state.should_break = True + logger.info("Test command executed successfully") + + except subprocess.TimeoutExpired as e: + logger.warning(f"Test command timed out after {timeout}s: {cmd}") + self.state.test_attempts += 1 + self.state.prompt = f"{original_prompt}. Previous attempt timed out after {timeout} seconds" + self.display_test_failure() + + except subprocess.CalledProcessError as e: + logger.error(f"Test command failed with exit code {e.returncode}: {cmd}\nOutput: {e.output}") + self.state.test_attempts += 1 + self.state.prompt = f"{original_prompt}. Previous attempt failed with exit code {e.returncode}: {e.output}" + self.display_test_failure() + + except Exception as e: + logger.warning(f"Test command execution failed: {str(e)}") + self.state.test_attempts += 1 + self.state.should_break = True -def handle_user_response(response: str, state: TestState, cmd: str, original_prompt: str) -> TestState: - """Handle user's response to test prompt. - - Args: - response: User's response (y/n/a) - state: Current test state - cmd: Test command - original_prompt: Original prompt text + def handle_user_response(self, response: str, cmd: str, original_prompt: str) -> None: + """Handle user's response to test prompt. + Args: + response: User's response (y/n/a) + cmd: Test command + original_prompt: Original prompt text + """ + response = response.strip().lower() - Returns: - Updated test state - """ - response = response.strip().lower() - - if response == "n": - state.should_break = True - return state - - if response == "a": - state.auto_test = True - return run_test_command(cmd, state, original_prompt) - - if response == "y": - return run_test_command(cmd, state, original_prompt) - - return state + if response == "n": + self.state.should_break = True + return + + if response == "a": + self.state.auto_test = True + self.run_test_command(cmd, original_prompt) + return + + if response == "y": + self.run_test_command(cmd, original_prompt) -def check_max_retries(attempts: int, max_retries: int) -> bool: - """Check if max retries reached. - - Args: - attempts: Current number of attempts - max_retries: Maximum allowed retries + def check_max_retries(self) -> bool: + """Check if max retries reached. - Returns: - True if max retries reached - """ - if attempts >= max_retries: - logger.warning("Max test retries reached") - return True - return False + Returns: + True if max retries reached + """ + if self.state.test_attempts >= self.max_retries: + logger.warning("Max test retries reached") + return True + return False + + def execute(self) -> Tuple[bool, str, bool, int]: + """Execute test command and handle retries. + + Returns: + Tuple containing: + - bool: Whether to break the retry loop + - str: Updated prompt + - bool: Updated auto_test flag + - int: Updated test_attempts count + """ + if not self.config.get("test_cmd"): + self.state.should_break = True + return self.state.should_break, self.state.prompt, self.state.auto_test, self.state.test_attempts + + cmd = self.config["test_cmd"] + + if not self.state.auto_test: + print() + response = ask_human.invoke({"question": "Would you like to run the test command? (y=yes, n=no, a=enable auto-test)"}) + self.handle_user_response(response, cmd, self.state.prompt) + else: + if self.check_max_retries(): + logger.error(f"Maximum number of test retries ({self.max_retries}) reached. Stopping test execution.") + console.print(Panel(f"Maximum retries ({self.max_retries}) reached. Test execution stopped.", title="⚠️ Test Execution", border_style="yellow bold")) + self.state.should_break = True + else: + self.run_test_command(cmd, self.state.prompt) + + return self.state.should_break, self.state.prompt, self.state.auto_test, self.state.test_attempts def execute_test_command( config: Dict[str, Any], @@ -127,13 +169,13 @@ def execute_test_command( auto_test: bool = False, ) -> Tuple[bool, str, bool, int]: """Execute a test command and handle retries. - + Args: config: Configuration dictionary containing test settings original_prompt: The original prompt to append errors to test_attempts: Current number of test attempts auto_test: Whether auto-test mode is enabled - + Returns: Tuple containing: - bool: Whether to break the retry loop @@ -141,27 +183,5 @@ def execute_test_command( - bool: Updated auto_test flag - int: Updated test_attempts count """ - state = TestState( - prompt=original_prompt, - test_attempts=test_attempts, - auto_test=auto_test - ) - - if not config.get("test_cmd"): - state.should_break = True - return state.should_break, state.prompt, state.auto_test, state.test_attempts - - max_retries = config.get("max_test_cmd_retries", 5) - cmd = config["test_cmd"] - - if not auto_test: - print() - response = ask_human.invoke({"question": "Would you like to run the test command? (y=yes, n=no, a=enable auto-test)"}) - state = handle_user_response(response, state, cmd, original_prompt) - else: - if check_max_retries(test_attempts, max_retries): - state.should_break = True - else: - state = run_test_command(cmd, state, original_prompt) - - return state.should_break, state.prompt, state.auto_test, state.test_attempts \ No newline at end of file + executor = TestCommandExecutor(config, original_prompt, test_attempts, auto_test) + return executor.execute() \ No newline at end of file diff --git a/tests/ra_aid/tools/test_execution.py b/tests/ra_aid/tools/test_execution.py index 316ebcd..ee9951c 100644 --- a/tests/ra_aid/tools/test_execution.py +++ b/tests/ra_aid/tools/test_execution.py @@ -190,7 +190,8 @@ def test_execute_test_command( if auto_test and test_attempts < config.get("max_test_cmd_retries", 5): if config.get("test_cmd"): - mock_run_cmd.assert_called_once_with(config["test_cmd"]) + # Verify run_shell_command called with command and default timeout + mock_run_cmd.assert_called_once_with(config["test_cmd"], timeout=config.get('timeout', 30)) # Verify logging for max retries if test_attempts >= config.get("max_test_cmd_retries", 5): diff --git a/tests/ra_aid/tools/test_handle_user_defined_test_cmd_execution.py b/tests/ra_aid/tools/test_handle_user_defined_test_cmd_execution.py index 4f436b5..1915f00 100644 --- a/tests/ra_aid/tools/test_handle_user_defined_test_cmd_execution.py +++ b/tests/ra_aid/tools/test_handle_user_defined_test_cmd_execution.py @@ -2,13 +2,11 @@ import pytest from unittest.mock import patch, Mock +import subprocess from ra_aid.tools.handle_user_defined_test_cmd_execution import ( TestState, - execute_test_command, - handle_test_failure, - run_test_command, - handle_user_response, - check_max_retries + TestCommandExecutor, + execute_test_command ) @pytest.fixture @@ -17,93 +15,170 @@ def test_state(): return TestState( prompt="test prompt", test_attempts=0, - auto_test=False + auto_test=False, + should_break=False ) -def test_check_max_retries(): - """Test max retries check.""" - assert not check_max_retries(2, 3) - assert check_max_retries(3, 3) - assert check_max_retries(4, 3) +@pytest.fixture +def test_executor(): + """Create a test executor fixture.""" + config = {"test_cmd": "test", "max_test_cmd_retries": 3} + return TestCommandExecutor(config, "test prompt") -def test_handle_test_failure(test_state): +def test_check_max_retries(test_executor): + """Test max retries check.""" + test_executor.state.test_attempts = 2 + assert not test_executor.check_max_retries() + + test_executor.state.test_attempts = 3 + assert test_executor.check_max_retries() + + test_executor.state.test_attempts = 4 + assert test_executor.check_max_retries() + +def test_handle_test_failure(test_executor): """Test handling of test failures.""" test_result = {"output": "error message"} - with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.display_test_failure"): - state = handle_test_failure(test_state, "original", test_result) - assert not state.should_break - assert "error message" in state.prompt + with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.console.print"): + test_executor.handle_test_failure("original", test_result) + assert not test_executor.state.should_break + assert "error message" in test_executor.state.prompt -def test_run_test_command_success(test_state): +def test_run_test_command_success(test_executor): """Test successful test command execution.""" with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run: mock_run.return_value = {"success": True, "output": ""} - state = run_test_command("test", test_state, "original") - assert state.should_break - assert state.test_attempts == 1 + test_executor.run_test_command("test", "original") + assert test_executor.state.should_break + assert test_executor.state.test_attempts == 1 -def test_run_test_command_failure(test_state): +def test_run_test_command_failure(test_executor): """Test failed test command execution.""" with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run: mock_run.return_value = {"success": False, "output": "error"} - state = run_test_command("test", test_state, "original") - assert not state.should_break - assert state.test_attempts == 1 - assert "error" in state.prompt + test_executor.run_test_command("test", "original") + assert not test_executor.state.should_break + assert test_executor.state.test_attempts == 1 + assert "error" in test_executor.state.prompt -def test_run_test_command_error(test_state): +def test_run_test_command_error(test_executor): """Test test command execution error.""" with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run: - mock_run.side_effect = Exception("Command failed") - state = run_test_command("test", test_state, "original") - assert state.should_break - assert state.test_attempts == 1 + mock_run.side_effect = Exception("Generic error") + test_executor.run_test_command("test", "original") + assert test_executor.state.should_break + assert test_executor.state.test_attempts == 1 -def test_handle_user_response_no(test_state): +def test_run_test_command_timeout(test_executor): + """Test test command timeout handling.""" + with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run,\ + patch("ra_aid.tools.handle_user_defined_test_cmd_execution.logger.warning") as mock_logger: + + # Create a TimeoutExpired exception + timeout_exc = subprocess.TimeoutExpired(cmd="test", timeout=30) + mock_run.side_effect = timeout_exc + + test_executor.run_test_command("test", "original") + + # Verify state updates + assert not test_executor.state.should_break + assert test_executor.state.test_attempts == 1 + assert "timed out after 30 seconds" in test_executor.state.prompt + + # Verify logging + mock_logger.assert_called_once() + +def test_run_test_command_called_process_error(test_executor): + """Test handling of CalledProcessError exception.""" + with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run,\ + patch("ra_aid.tools.handle_user_defined_test_cmd_execution.logger.error") as mock_logger: + + # Create a CalledProcessError exception + process_error = subprocess.CalledProcessError( + returncode=1, + cmd="test", + output="Command failed output" + ) + mock_run.side_effect = process_error + + test_executor.run_test_command("test", "original") + + # Verify state updates + assert not test_executor.state.should_break + assert test_executor.state.test_attempts == 1 + assert "failed with exit code 1" in test_executor.state.prompt + + # Verify logging + mock_logger.assert_called_once() + +def test_handle_user_response_no(test_executor): """Test handling of 'no' response.""" - state = handle_user_response("n", test_state, "test", "original") - assert state.should_break - assert not state.auto_test + test_executor.handle_user_response("n", "test", "original") + assert test_executor.state.should_break + assert not test_executor.state.auto_test -def test_handle_user_response_auto(test_state): +def test_handle_user_response_auto(test_executor): """Test handling of 'auto' response.""" - with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_test_command") as mock_run: - mock_state = TestState("prompt", 1, True, True) - mock_run.return_value = mock_state - state = handle_user_response("a", test_state, "test", "original") - assert state.auto_test - mock_run.assert_called_once() + with patch.object(test_executor, "run_test_command") as mock_run: + test_executor.handle_user_response("a", "test", "original") + assert test_executor.state.auto_test + mock_run.assert_called_once_with("test", "original") -def test_handle_user_response_yes(test_state): +def test_handle_user_response_yes(test_executor): """Test handling of 'yes' response.""" - with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_test_command") as mock_run: - mock_state = TestState("prompt", 1, False, True) - mock_run.return_value = mock_state - state = handle_user_response("y", test_state, "test", "original") - assert not state.auto_test - mock_run.assert_called_once() + with patch.object(test_executor, "run_test_command") as mock_run: + test_executor.handle_user_response("y", "test", "original") + assert not test_executor.state.auto_test + mock_run.assert_called_once_with("test", "original") -def test_execute_test_command_no_cmd(): +def test_execute_no_cmd(): """Test execution with no test command.""" - result = execute_test_command({}, "prompt") + executor = TestCommandExecutor({}, "prompt") + result = executor.execute() assert result == (True, "prompt", False, 0) -def test_execute_test_command_manual(): +def test_execute_manual(): """Test manual test execution.""" config = {"test_cmd": "test"} + executor = TestCommandExecutor(config, "prompt") + + def mock_handle_response(response, cmd, prompt): + # Simulate the behavior of handle_user_response and run_test_command + executor.state.should_break = True + executor.state.test_attempts = 1 + executor.state.prompt = "new prompt" + with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.ask_human") as mock_ask, \ - patch("ra_aid.tools.handle_user_defined_test_cmd_execution.handle_user_response") as mock_handle: + patch.object(executor, "handle_user_response", side_effect=mock_handle_response) as mock_handle: mock_ask.invoke.return_value = "y" - mock_state = TestState("new prompt", 1, False, True) - mock_handle.return_value = mock_state - result = execute_test_command(config, "prompt") + + result = executor.execute() + mock_handle.assert_called_once_with("y", "test", "prompt") assert result == (True, "new prompt", False, 1) -def test_execute_test_command_auto(): +def test_execute_auto(): """Test auto test execution.""" config = {"test_cmd": "test", "max_test_cmd_retries": 3} - with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_test_command") as mock_run: - mock_state = TestState("new prompt", 1, True, True) - mock_run.return_value = mock_state + executor = TestCommandExecutor(config, "prompt", auto_test=True) + + # Set up state before creating mock + executor.state.test_attempts = 1 + executor.state.should_break = True + + with patch.object(executor, "run_test_command") as mock_run: + result = executor.execute() + assert result == (True, "prompt", True, 1) + mock_run.assert_called_once_with("test", "prompt") + +def test_execute_test_command_function(): + """Test the execute_test_command function.""" + config = {"test_cmd": "test"} + with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.TestCommandExecutor") as mock_executor_class: + mock_executor = Mock() + mock_executor.execute.return_value = (True, "new prompt", True, 1) + mock_executor_class.return_value = mock_executor + result = execute_test_command(config, "prompt", auto_test=True) - assert result == (True, "new prompt", True, 1) \ No newline at end of file + assert result == (True, "new prompt", True, 1) + mock_executor_class.assert_called_once_with(config, "prompt", 0, True) + mock_executor.execute.assert_called_once() \ No newline at end of file