自动同步: 2026-03-12 03:00

This commit is contained in:
2026-03-12 03:00:29 +08:00
parent a078c575b9
commit deff5877b4
70 changed files with 8684 additions and 367 deletions
@@ -0,0 +1,394 @@
"""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