This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
openclaw/skills/gsd-openclaw/tests/test_command_executor.py
T

395 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for command execution and output capture.
This module tests the CommandExecutor and OutputCapture classes which provide
the foundational I/O layer for all GSD command bridging.
"""
import pytest
import time
from unittest.mock import MagicMock, patch, PropertyMock
from dataclasses import fields
from gsd_openclaw.command_executor import (
CommandResult,
OutputCapture,
CommandExecutor,
)
class TestOutputCaptureIsPrompt:
"""Tests for OutputCapture._is_prompt method."""
def test_is_prompt_detects_dollar_prompt(self):
"""_is_prompt should return True for '$ ' prompt."""
capture = OutputCapture()
assert capture._is_prompt("$ ") is True
def test_is_prompt_detects_dollar_with_space_only(self):
"""_is_prompt should return True for '$' followed by only whitespace."""
capture = OutputCapture()
assert capture._is_prompt("$ ") is True
def test_is_prompt_detects_unicode_arrow_prompt(self):
"""_is_prompt should return True for Unicode arrow prompts like '❯ '."""
capture = OutputCapture()
assert capture._is_prompt("❯ ") is True
def test_is_prompt_detects_triangle_arrow_prompt(self):
"""_is_prompt should return True for triangle arrow prompts like '➜ '."""
capture = OutputCapture()
assert capture._is_prompt("➜ ") is True
def test_is_prompt_returns_false_for_non_prompt_lines(self):
"""_is_prompt should return False for non-prompt lines."""
capture = OutputCapture()
assert capture._is_prompt("some command output") is False
assert capture._is_prompt("$ not a prompt (has content after)") is False
assert capture._is_prompt("") is False
assert capture._is_prompt("error: something failed") is False
class TestOutputCaptureCaptureUntilComplete:
"""Tests for OutputCapture.capture_until_complete method."""
@pytest.fixture
def mock_pane(self):
"""Create a mock tmux pane."""
pane = MagicMock()
pane.pane_id = "%100"
return pane
def test_capture_until_complete_returns_output_and_true_when_prompt_detected(self, mock_pane):
"""capture_until_complete should return (output, True) when prompt is detected."""
capture = OutputCapture()
# Simulate output that ends with prompt
mock_pane.capture_pane.return_value = [
"Running command...",
"Output line 1",
"Output line 2",
"$ ", # Prompt appears
]
output, completed = capture.capture_until_complete(mock_pane, timeout=1.0)
assert completed is True
assert "Running command..." in output
assert "Output line 1" in output
def test_capture_until_complete_returns_true_when_output_stabilizes(self, mock_pane):
"""capture_until_complete should return (output, True) when output stabilizes."""
capture = OutputCapture()
# Simulate stable output (same for 3 consecutive polls)
stable_output = ["Line 1", "Line 2", "Line 3"]
mock_pane.capture_pane.return_value = stable_output
output, completed = capture.capture_until_complete(
mock_pane, timeout=1.0, poll_interval=0.01
)
assert completed is True
assert "Line 1" in output
assert "Line 2" in output
def test_capture_until_complete_returns_false_on_timeout(self, mock_pane):
"""capture_until_complete should return (output, False) on timeout."""
capture = OutputCapture()
# Simulate output that keeps changing (never stabilizes, no prompt)
call_count = [0]
def changing_output():
call_count[0] += 1
return [f"Line {call_count[0]}"]
mock_pane.capture_pane.side_effect = changing_output
output, completed = capture.capture_until_complete(
mock_pane, timeout=0.1, poll_interval=0.02
)
assert completed is False
class TestCommandExecutorDetectErrors:
"""Tests for CommandExecutor._detect_errors method."""
def test_detect_errors_returns_true_for_error_keyword(self):
"""_detect_errors should return (True, 'error') for 'Error: something'."""
executor = CommandExecutor()
has_error, error_type = executor._detect_errors("Error: something went wrong")
assert has_error is True
assert error_type == "error"
def test_detect_errors_returns_true_for_failed_keyword(self):
"""_detect_errors should return (True, 'failed') for 'Process failed'."""
executor = CommandExecutor()
has_error, error_type = executor._detect_errors("Process failed to start")
assert has_error is True
assert error_type == "failed"
def test_detect_errors_returns_true_for_exception_keyword(self):
"""_detect_errors should return (True, 'exception') for exception in output."""
executor = CommandExecutor()
has_error, error_type = executor._detect_errors("ValueError: An exception occurred")
assert has_error is True
assert error_type == "exception"
def test_detect_errors_returns_true_for_traceback_keyword(self):
"""_detect_errors should return (True, 'traceback') for traceback in output."""
executor = CommandExecutor()
has_error, error_type = executor._detect_errors("Traceback (most recent call last):")
assert has_error is True
assert error_type == "traceback"
def test_detect_errors_returns_true_for_chinese_error_keywords(self):
"""_detect_errors should detect Chinese error keywords."""
executor = CommandExecutor()
# Test Chinese error
has_error, error_type = executor._detect_errors("发生错误:无法连接")
assert has_error is True
assert error_type == "错误"
# Test Chinese failure
has_error, error_type = executor._detect_errors("操作失败")
assert has_error is True
assert error_type == "失败"
def test_detect_errors_returns_false_for_normal_output(self):
"""_detect_errors should return (False, None) for normal output."""
executor = CommandExecutor()
normal_outputs = [
"Command completed successfully",
"All tests passed",
"Files created: 5",
"Done!",
]
for output in normal_outputs:
has_error, error_type = executor._detect_errors(output)
assert has_error is False
assert error_type is None
class TestCommandExecutorExecute:
"""Tests for CommandExecutor.execute method."""
@pytest.fixture
def mock_session(self):
"""Create a mock TmuxSession with active_pane."""
session = MagicMock()
session.session_id = "test-123"
session.project_name = "test-project"
session.tmux_session_name = "gsd-test-project"
# Mock the tmux_session.active_pane
mock_pane = MagicMock()
mock_pane.pane_id = "%100"
mock_pane.send_keys = MagicMock()
mock_tmux_session = MagicMock()
mock_tmux_session.active_pane = mock_pane
session.tmux_session = mock_tmux_session
return session
def test_execute_returns_command_result_with_all_fields(self, mock_session):
"""execute should return CommandResult with all required fields."""
executor = CommandExecutor()
# Mock capture to return immediately with prompt
mock_session.tmux_session.active_pane.capture_pane.return_value = [
"Command output",
"$ ",
]
result = executor.execute(mock_session, "echo hello", timeout=1.0)
# Verify CommandResult has all required fields
assert hasattr(result, 'output')
assert hasattr(result, 'completed')
assert hasattr(result, 'has_error')
assert hasattr(result, 'error_type')
def test_execute_calls_send_keys_with_enter(self, mock_session):
"""execute should call send_keys with the command."""
executor = CommandExecutor()
# Mock capture to return immediately
mock_session.tmux_session.active_pane.capture_pane.return_value = ["$ "]
executor.execute(mock_session, "echo hello", timeout=1.0)
# Verify send_keys was called
mock_session.tmux_session.active_pane.send_keys.assert_called_once()
def test_execute_sets_has_error_true_when_error_keywords_detected(self, mock_session):
"""execute should set has_error=True when error keywords detected."""
executor = CommandExecutor()
# Mock output with error
mock_session.tmux_session.active_pane.capture_pane.return_value = [
"Error: command failed",
"$ ",
]
result = executor.execute(mock_session, "bad-command", timeout=1.0)
assert result.has_error is True
assert result.error_type == "error"
def test_execute_respects_timeout_parameter(self, mock_session):
"""execute should respect the timeout parameter."""
executor = CommandExecutor()
# Mock output that never stabilizes
call_count = [0]
def changing_output():
call_count[0] += 1
return [f"Line {call_count[0]}"]
mock_session.tmux_session.active_pane.capture_pane.side_effect = changing_output
start_time = time.time()
result = executor.execute(mock_session, "slow-command", timeout=0.1, poll_interval=0.02)
elapsed = time.time() - start_time
assert result.completed is False
assert elapsed < 0.3 # Should timeout around 0.1s, not much longer
def test_execute_uses_default_timeout_of_60_seconds(self, mock_session):
"""execute should use default timeout of 60 seconds."""
executor = CommandExecutor()
# Verify DEFAULT_TIMEOUT is set
assert executor.DEFAULT_TIMEOUT == 60.0
class TestCommandResultDataclass:
"""Tests for CommandResult dataclass."""
def test_command_result_stores_all_fields(self):
"""CommandResult should store all required fields."""
result = CommandResult(
output="test output",
completed=True,
has_error=False,
error_type=None,
)
assert result.output == "test output"
assert result.completed is True
assert result.has_error is False
assert result.error_type is None
def test_command_result_with_error(self):
"""CommandResult should store error information correctly."""
result = CommandResult(
output="Error: something failed",
completed=True,
has_error=True,
error_type="error",
)
assert result.has_error is True
assert result.error_type == "error"
def test_command_result_incomplete_execution(self):
"""CommandResult should handle incomplete execution."""
result = CommandResult(
output="Partial output...",
completed=False,
has_error=False,
error_type=None,
)
assert result.completed is False
class TestCommandExecutorSendCommand:
"""Tests for CommandExecutor.send_command method."""
def test_send_command_calls_pane_send_keys(self):
"""send_command should call pane.send_keys with the command."""
executor = CommandExecutor()
mock_pane = MagicMock()
mock_pane.send_keys = MagicMock()
executor.send_command(mock_pane, "echo hello")
mock_pane.send_keys.assert_called_once()
def test_send_command_passes_enter_true(self):
"""send_command should send keys with enter=True to execute the command."""
executor = CommandExecutor()
mock_pane = MagicMock()
mock_pane.send_keys = MagicMock()
executor.send_command(mock_pane, "echo hello")
# Verify it was called - the exact implementation should include enter
call_args = mock_pane.send_keys.call_args
assert call_args is not None
class TestLongCommands:
"""Tests for long command detection in CommandExecutor."""
def test_long_commands_set_contains_expected_commands(self):
"""LONG_COMMANDS should contain execute-phase, plan-phase, discuss-phase, new-project."""
executor = CommandExecutor()
assert 'execute-phase' in executor.LONG_COMMANDS
assert 'plan-phase' in executor.LONG_COMMANDS
assert 'discuss-phase' in executor.LONG_COMMANDS
assert 'new-project' in executor.LONG_COMMANDS
def test_is_long_command_returns_true_for_long_commands(self):
"""_is_long_command should return True for commands in LONG_COMMANDS."""
executor = CommandExecutor()
assert executor._is_long_command('/gsd:execute-phase') is True
assert executor._is_long_command('/gsd:plan-phase') is True
assert executor._is_long_command('/gsd:discuss-phase') is True
assert executor._is_long_command('/gsd:new-project') is True
def test_is_long_command_returns_false_for_regular_commands(self):
"""_is_long_command should return False for commands not in LONG_COMMANDS."""
executor = CommandExecutor()
assert executor._is_long_command('/gsd:status') is False
assert executor._is_long_command('/gsd:list') is False
assert executor._is_long_command('/gsd:help') is False
assert executor._is_long_command('/gsd:use') is False
def test_is_long_command_handles_commands_with_arguments(self):
"""_is_long_command should handle commands with arguments correctly."""
executor = CommandExecutor()
# Long commands with args should still be detected
assert executor._is_long_command('/gsd:execute-phase 01-core') is True
assert executor._is_long_command('/gsd:plan-phase feature-x') is True
assert executor._is_long_command('/gsd:discuss-phase about architecture') is True
assert executor._is_long_command('/gsd:new-project my-project') is True
# Regular commands with args should not be detected as long
assert executor._is_long_command('/gsd:status --verbose') is False
assert executor._is_long_command('/gsd:use my-project') is False
def test_is_long_command_returns_false_for_non_gsd_commands(self):
"""_is_long_command should return False for non-/gsd: commands."""
executor = CommandExecutor()
assert executor._is_long_command('status') is False
assert executor._is_long_command('/help') is False
assert executor._is_long_command('regular message') is False