"""Comprehensive Test Suite for App Control Tools - Following Proven MCP Tool Test Pattern.
import logging
logging.basicConfig(level=logging.DEBUG)
This test suite validates the App Control Tools functionality using the systematic
testing approach that achieved 100% success rate across 10 tool suites.
Test Coverage:
- Application control functionality with comprehensive validation (launch, quit, activate, menu_select, get_state)
- Application identifier parsing and security validation
- Menu path validation and automation testing
- Operation timeout and configuration handling
- Security validation for application operations
- Property-based testing for robust input validation
- Integration testing with mocked application controller
- Error handling for all failure scenarios
- Performance testing for application operation limits
Testing Strategy:
- Property-based testing with Hypothesis for comprehensive input coverage
- Mock-based testing for AppController and application operations
- Security validation for application control prevention
- Integration testing scenarios with realistic application operations
- Performance and timeout testing with operation limits
Key Mocking Pattern:
- AppController: Mock all async methods with Either.success() pattern
- AppIdentifier: Mock constructor and display methods
- Duration: Mock from_seconds method to prevent comparison errors
- LaunchConfiguration: Mock constructor to prevent validation errors
- MenuPath: Mock constructor for menu operations
"""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, Mock, patch
import pytest
from fastmcp import Context
from hypothesis import HealthCheck, assume, given, settings
from hypothesis import strategies as st
from hypothesis.strategies import composite
# Import application types and errors
from src.applications.app_controller import (
AppController,
AppState,
)
from src.core.errors import SecurityViolationError, ValidationError
from src.core.types import Duration
# Import the tools we're testing
from src.server.tools.app_control_tools import (
_execute_get_state_operation,
_execute_launch_operation,
_execute_menu_select_operation,
_execute_quit_operation,
_get_state_description,
km_app_control,
)
if TYPE_CHECKING:
from collections.abc import Callable
# Test fixtures following proven pattern
@pytest.fixture
def mock_context() -> Mock:
"""Create mock FastMCP context following successful pattern."""
context = Mock(spec=Context)
context.info = AsyncMock()
context.warn = AsyncMock()
context.error = AsyncMock()
context.report_progress = AsyncMock()
context.read_resource = AsyncMock()
context.sample = AsyncMock()
return context
@pytest.fixture
def mock_app_controller() -> Mock:
"""Create mock AppController with standard interface."""
controller = Mock(spec=AppController)
controller.launch_application = AsyncMock()
controller.quit_application = AsyncMock()
controller.activate_application = AsyncMock()
controller.select_menu_item = AsyncMock()
controller.get_application_state = AsyncMock()
# Setup standard success response using Either.success() pattern
mock_result = Mock()
mock_result.is_right.return_value = True
mock_result.is_left.return_value = False
# Create proper mock for operation result
mock_operation_result = Mock()
mock_operation_result.app_state = AppState.RUNNING
mock_operation_result.operation_time = timedelta(seconds=0.5)
mock_operation_result.details = "Operation completed successfully"
mock_result.get_right.return_value = mock_operation_result
# Apply to all methods
controller.launch_application.return_value = mock_result
controller.quit_application.return_value = mock_result
controller.activate_application.return_value = mock_result
controller.select_menu_item.return_value = mock_result
# get_application_state returns a direct AppState value wrapped in Either
mock_state_result = Mock()
mock_state_result.is_right.return_value = True
mock_state_result.is_left.return_value = False
mock_state_result.get_right.return_value = AppState.RUNNING
controller.get_application_state.return_value = mock_state_result
return controller
@pytest.fixture
def sample_app_identifiers() -> Mock:
"""Sample application identifiers for testing."""
return {
"bundle_id": "com.apple.TextEdit",
"app_name": "TextEdit",
"complex_bundle": "com.microsoft.VSCode",
"complex_name": "Visual Studio Code",
"system_app": "com.apple.finder",
}
@pytest.fixture
def sample_menu_paths() -> Mock:
"""Sample menu paths for testing."""
return {
"simple": ["File", "New"],
"complex": ["Edit", "Find", "Advanced Find"],
"deep": ["View", "Toolbar", "Customize", "Add Item"],
"single": ["Help"],
}
class TestKMAppControl:
"""Test app control functionality following proven pattern."""
@pytest.mark.asyncio
async def test_launch_operation_success(
self,
mock_context: Any,
mock_app_controller: Any,
sample_app_identifiers: str,
) -> None:
"""Test successful application launch operation."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
patch("src.server.tools.app_control_tools.Duration") as mock_duration_class,
patch(
"src.server.tools.app_control_tools.LaunchConfiguration",
) as mock_config_class,
):
# Setup AppIdentifier mock
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TextEdit"
mock_app_id.primary_identifier.return_value = "com.apple.TextEdit"
mock_app_id_class.return_value = mock_app_id
# Setup Duration mock
mock_duration = Mock()
mock_duration_class.from_seconds.return_value = mock_duration
# Setup LaunchConfiguration mock
mock_config = Mock()
mock_config_class.return_value = mock_config
# Execute
result = await km_app_control(
operation="launch",
app_identifier=sample_app_identifiers["bundle_id"],
wait_for_completion=True,
timeout_seconds=30,
hide_on_launch=False,
ctx=mock_context,
)
# Verify success response structure
assert result["success"] is True
assert result["data"]["app_state"] == AppState.RUNNING.value
assert result["data"]["app_name"] == "TextEdit"
assert result["data"]["app_identifier"] == "com.apple.TextEdit"
assert result["data"]["waited_for_completion"] is True
assert result["data"]["hidden"] is False
assert "metadata" in result
assert "correlation_id" in result["metadata"]
assert "timestamp" in result["metadata"]
assert "execution_time" in result["metadata"]
@pytest.mark.asyncio
async def test_quit_operation_success(
self,
mock_context: Any,
mock_app_controller: Any,
sample_app_identifiers: str,
) -> None:
"""Test successful application quit operation."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
patch("src.server.tools.app_control_tools.Duration") as mock_duration_class,
patch(
"src.server.tools.app_control_tools.LaunchConfiguration",
) as mock_config_class,
):
# Setup AppIdentifier mock (same pattern as launch test)
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TextEdit"
mock_app_id.primary_identifier.return_value = "com.apple.TextEdit"
mock_app_id_class.return_value = mock_app_id
# Setup Duration mock (same pattern as launch test)
mock_duration = Mock()
mock_duration_class.from_seconds.return_value = mock_duration
# Setup LaunchConfiguration mock (same pattern as launch test)
mock_config = Mock()
mock_config_class.return_value = mock_config
# Execute (using the mock_app_controller fixture directly)
result = await km_app_control(
operation="quit",
app_identifier=sample_app_identifiers["app_name"],
force_quit=True,
timeout_seconds=15,
ctx=mock_context,
)
# Verify
assert result["success"] is True
assert (
result["data"]["app_state"] == AppState.RUNNING.value
) # Use fixture's default state
assert result["data"]["force_quit_used"] is True
@pytest.mark.asyncio
async def test_activate_operation_success(
self,
mock_context: Any,
mock_app_controller: Any,
sample_app_identifiers: str,
) -> None:
"""Test successful application activation operation."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
patch("src.server.tools.app_control_tools.Duration") as mock_duration_class,
patch(
"src.server.tools.app_control_tools.LaunchConfiguration",
) as mock_config_class,
):
# Setup AppIdentifier mock (same pattern as launch test)
mock_app_id = Mock()
mock_app_id.display_name.return_value = "Visual Studio Code"
mock_app_id.primary_identifier.return_value = "com.microsoft.VSCode"
mock_app_id_class.return_value = mock_app_id
# Setup Duration mock (same pattern as launch test)
mock_duration = Mock()
mock_duration_class.from_seconds.return_value = mock_duration
# Setup LaunchConfiguration mock (same pattern as launch test)
mock_config = Mock()
mock_config_class.return_value = mock_config
# Execute (using the mock_app_controller fixture directly)
result = await km_app_control(
operation="activate",
app_identifier=sample_app_identifiers["complex_bundle"],
ctx=mock_context,
)
# Verify
assert result["success"] is True
assert (
result["data"]["app_state"] == AppState.RUNNING.value
) # Use fixture's default state
assert result["data"]["activated"] is True
@pytest.mark.asyncio
async def test_menu_select_operation_success(
self,
mock_context: Any,
mock_app_controller: Any,
sample_app_identifiers: str,
sample_menu_paths: Any,
) -> None:
"""Test successful menu selection operation."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
patch(
"src.server.tools.app_control_tools.MenuPath",
) as mock_menu_path_class,
patch("src.server.tools.app_control_tools.Duration") as mock_duration_class,
patch(
"src.server.tools.app_control_tools.LaunchConfiguration",
) as mock_config_class,
):
# Setup AppIdentifier mock (same pattern as launch test)
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TextEdit"
mock_app_id.primary_identifier.return_value = "com.apple.TextEdit"
mock_app_id_class.return_value = mock_app_id
# Setup Duration mock (same pattern as launch test)
mock_duration = Mock()
mock_duration_class.from_seconds.return_value = mock_duration
# Setup LaunchConfiguration mock (same pattern as launch test)
mock_config = Mock()
mock_config_class.return_value = mock_config
# Setup MenuPath mock
mock_menu_path = Mock()
mock_menu_path_class.return_value = mock_menu_path
# Execute (using the mock_app_controller fixture directly)
result = await km_app_control(
operation="menu_select",
app_identifier=sample_app_identifiers["app_name"],
menu_path=sample_menu_paths["simple"],
timeout_seconds=20,
ctx=mock_context,
)
# Verify
assert result["success"] is True
assert result["data"]["menu_path"] == sample_menu_paths["simple"]
assert result["data"]["menu_depth"] == 2
assert result["data"]["menu_selected"] is True
@pytest.mark.asyncio
async def test_get_state_operation_success(
self,
mock_context: Any,
mock_app_controller: Any,
sample_app_identifiers: str,
) -> None:
"""Test successful application state query operation."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
patch("src.server.tools.app_control_tools.Duration") as mock_duration_class,
patch(
"src.server.tools.app_control_tools.LaunchConfiguration",
) as mock_config_class,
):
# Setup AppIdentifier mock (same pattern as launch test)
mock_app_id = Mock()
mock_app_id.display_name.return_value = "Finder"
mock_app_id.primary_identifier.return_value = "com.apple.finder"
mock_app_id_class.return_value = mock_app_id
# Setup Duration mock (same pattern as launch test)
mock_duration = Mock()
mock_duration_class.from_seconds.return_value = mock_duration
# Setup LaunchConfiguration mock (same pattern as launch test)
mock_config = Mock()
mock_config_class.return_value = mock_config
# Execute (using the mock_app_controller fixture directly)
result = await km_app_control(
operation="get_state",
app_identifier=sample_app_identifiers["system_app"],
ctx=mock_context,
)
# Verify (get_state has different result structure)
assert result["success"] is True
assert (
result["data"]["app_state"] == AppState.RUNNING.value
) # Use fixture's default state
assert result["data"]["is_running"] is True
assert "state_description" in result["data"]
@pytest.mark.asyncio
async def test_invalid_app_identifier_handling(self, mock_context: Any) -> None:
"""Test handling of invalid application identifier."""
with patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class:
# Setup AppIdentifier to raise ValueError
mock_app_id_class.side_effect = ValueError(
"Invalid application identifier format",
)
# Execute
result = await km_app_control(
operation="launch",
app_identifier="invalid@app#identifier!",
ctx=mock_context,
)
# Verify error response
assert result["success"] is False
assert result["error"]["code"] == "INVALID_IDENTIFIER"
assert "Invalid application identifier format" in result["error"]["message"]
assert "recovery_suggestion" in result["error"]
assert result["metadata"]["validation_stage"] == "identifier_parsing"
@pytest.mark.asyncio
async def test_menu_select_missing_path(
self,
mock_context: Any,
sample_app_identifiers: str,
) -> None:
"""Test menu select operation without menu path."""
# Execute
result = await km_app_control(
operation="menu_select",
app_identifier=sample_app_identifiers["app_name"],
menu_path=None,
ctx=mock_context,
)
# Verify error response
assert result["success"] is False
assert result["error"]["code"] == "MISSING_MENU_PATH"
assert "Menu path required" in result["error"]["message"]
assert "recovery_suggestion" in result["error"]
@pytest.mark.asyncio
async def test_unsupported_operation(
self,
mock_context: Any,
sample_app_identifiers: str,
) -> None:
"""Test handling of unsupported operation."""
# Execute with invalid operation (would be caught by pydantic in real scenario)
with patch("src.server.tools.app_control_tools.AppController"):
# Manually test the logic path
result = await km_app_control(
operation="invalid_operation",
app_identifier=sample_app_identifiers["app_name"],
ctx=mock_context,
)
# Verify error response
assert result["success"] is False
assert result["error"]["code"] == "UNSUPPORTED_OPERATION"
assert "Operation not supported" in result["error"]["message"]
@pytest.mark.asyncio
async def test_validation_error_handling(
self,
mock_context: Any,
sample_app_identifiers: str,
) -> None:
"""Test ValidationError handling."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
) as mock_controller_class,
patch("src.server.tools.app_control_tools.Duration") as mock_duration_class,
patch(
"src.server.tools.app_control_tools.LaunchConfiguration",
) as mock_config_class,
):
# Setup comprehensive mocking
mock_duration_class.from_seconds.return_value = Mock()
mock_config_class.return_value = Mock()
# Setup controller to raise ValidationError (with required parameters)
mock_controller_class.side_effect = ValidationError(
"Invalid input parameters",
"test_value",
"test_constraint",
)
# Execute
result = await km_app_control(
operation="launch",
app_identifier=sample_app_identifiers["app_name"],
ctx=mock_context,
)
# Verify error response
assert result["success"] is False
assert result["error"]["code"] == "VALIDATION_ERROR"
assert "Invalid input parameters" in result["error"]["message"]
assert result["metadata"]["failure_stage"] == "input_validation"
@pytest.mark.asyncio
async def test_security_violation_handling(
self,
mock_context: Any,
sample_app_identifiers: str,
) -> None:
"""Test SecurityViolationError handling."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
) as mock_controller_class,
patch("src.server.tools.app_control_tools.Duration") as mock_duration_class,
patch(
"src.server.tools.app_control_tools.LaunchConfiguration",
) as mock_config_class,
):
# Setup comprehensive mocking
mock_duration_class.from_seconds.return_value = Mock()
mock_config_class.return_value = Mock()
# Setup controller to raise SecurityViolationError (with required parameters)
mock_controller_class.side_effect = SecurityViolationError(
"Access denied to system application",
{"app": "system"},
)
# Execute
result = await km_app_control(
operation="quit",
app_identifier=sample_app_identifiers["system_app"],
force_quit=True,
ctx=mock_context,
)
# Verify error response
assert result["success"] is False
assert result["error"]["code"] == "SECURITY_VIOLATION"
assert "Security validation failed" in result["error"]["message"]
assert result["metadata"]["failure_stage"] == "security_validation"
@pytest.mark.asyncio
async def test_unexpected_error_handling(
self,
mock_context: Any,
sample_app_identifiers: str,
) -> None:
"""Test unexpected error handling."""
with patch(
"src.server.tools.app_control_tools.AppController",
) as mock_controller_class:
# Setup controller to raise unexpected error
mock_controller_class.side_effect = RuntimeError("Unexpected system error")
# Execute
result = await km_app_control(
operation="activate",
app_identifier=sample_app_identifiers["app_name"],
ctx=mock_context,
)
# Verify error response
assert result["success"] is False
assert result["error"]["code"] == "SYSTEM_ERROR"
assert "Unexpected system error" in result["error"]["message"]
assert result["metadata"]["failure_stage"] == "system_error"
class TestAppControlHelperFunctions:
"""Test helper functions and operation handlers."""
@pytest.mark.asyncio
async def test_execute_launch_operation_success(
self,
mock_app_controller: Any,
) -> None:
"""Test launch operation helper function."""
with patch(
"src.server.tools.app_control_tools.LaunchConfiguration",
) as mock_config_class:
# Setup mocks
mock_config = Mock()
mock_config_class.return_value = mock_config
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TestApp"
mock_app_id.primary_identifier.return_value = "com.test.app"
# Execute
result = await _execute_launch_operation(
mock_app_controller,
mock_app_id,
True,
Duration.from_seconds(30),
False,
None,
)
# Verify
assert result["success"] is True
assert result["data"]["app_name"] == "TestApp"
assert result["data"]["waited_for_completion"] is True
assert result["data"]["hidden"] is False
@pytest.mark.asyncio
async def test_execute_launch_operation_failure(
self,
mock_app_controller: Any,
) -> None:
"""Test launch operation failure handling."""
# Setup failure response
mock_error_result = Mock()
mock_error_result.is_right.return_value = False
mock_error = Mock()
mock_error.code = "APP_NOT_FOUND"
mock_error.message = "Application not found"
mock_error.details = "Could not locate application"
mock_error.recovery_suggestion = "Check application name"
mock_error_result.get_left.return_value = mock_error
mock_app_controller.launch_application.return_value = mock_error_result
mock_app_id = Mock()
# Execute
result = await _execute_launch_operation(
mock_app_controller,
mock_app_id,
True,
Duration.from_seconds(30),
False,
None,
)
# Verify
assert result["success"] is False
assert result["error"]["code"] == "APP_NOT_FOUND"
assert result["error"]["message"] == "Application not found"
@pytest.mark.asyncio
async def test_execute_quit_operation_success(
self,
mock_app_controller: Any,
) -> None:
"""Test quit operation helper function."""
with patch(
"src.server.tools.app_control_tools.Duration",
) as mock_duration_class:
# Setup Duration mock
mock_duration = Mock()
mock_duration_class.from_seconds.return_value = mock_duration
# Setup quit-specific response (override the fixture response)
mock_quit_result = Mock()
mock_quit_result.is_right.return_value = True
mock_quit_result.is_left.return_value = False
mock_quit_result.get_right.return_value = Mock(
app_state=AppState.NOT_RUNNING,
operation_time=timedelta(seconds=1.5),
details="Application terminated",
)
# Override the specific method for this test
mock_app_controller.quit_application.return_value = mock_quit_result
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TestApp"
mock_app_id.primary_identifier.return_value = "com.test.app"
# Execute
result = await _execute_quit_operation(
mock_app_controller,
mock_app_id,
True,
mock_duration,
None,
)
# Verify
assert result["success"] is True
assert result["data"]["force_quit_used"] is True
assert result["data"]["final_state"] == AppState.NOT_RUNNING.value
@pytest.mark.asyncio
async def test_execute_menu_select_operation_success(
self,
mock_app_controller: Any,
) -> None:
"""Test menu selection operation helper function."""
with (
patch(
"src.server.tools.app_control_tools.MenuPath",
) as mock_menu_path_class,
patch("src.server.tools.app_control_tools.Duration") as mock_duration_class,
):
# Setup mocks
mock_menu_path = Mock()
mock_menu_path_class.return_value = mock_menu_path
# Setup Duration mock
mock_duration = Mock()
mock_duration_class.from_seconds.return_value = mock_duration
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TestApp"
mock_app_id.primary_identifier.return_value = "com.test.app"
# Execute
result = await _execute_menu_select_operation(
mock_app_controller,
mock_app_id,
["File", "New"],
mock_duration,
None,
)
# Verify
assert result["success"] is True
assert result["data"]["menu_path"] == ["File", "New"]
assert result["data"]["menu_depth"] == 2
assert result["data"]["menu_selected"] is True
@pytest.mark.asyncio
async def test_execute_menu_select_invalid_path(
self,
mock_app_controller: Any,
) -> None:
"""Test menu selection with invalid path."""
with (
patch(
"src.server.tools.app_control_tools.MenuPath",
) as mock_menu_path_class,
patch("src.server.tools.app_control_tools.Duration") as mock_duration_class,
):
# Setup MenuPath to raise ValueError
mock_menu_path_class.side_effect = ValueError("Invalid menu path format")
# Setup Duration mock
mock_duration = Mock()
mock_duration_class.from_seconds.return_value = mock_duration
mock_app_id = Mock()
# Execute
result = await _execute_menu_select_operation(
mock_app_controller,
mock_app_id,
["Invalid", "Path"],
mock_duration,
None,
)
# Verify
assert result["success"] is False
assert result["error"]["code"] == "INVALID_MENU_PATH"
assert "Invalid menu path format" in result["error"]["message"]
@pytest.mark.asyncio
async def test_execute_get_state_operation_success(
self,
mock_app_controller: Any,
) -> None:
"""Test state query operation helper function."""
# Setup state response (override the fixture response)
mock_state_result = Mock()
mock_state_result.is_right.return_value = True
mock_state_result.is_left.return_value = False
mock_state_result.get_right.return_value = AppState.FOREGROUND
# Override the specific method for this test
mock_app_controller.get_application_state.return_value = mock_state_result
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TestApp"
mock_app_id.primary_identifier.return_value = "com.test.app"
# Execute
result = await _execute_get_state_operation(
mock_app_controller,
mock_app_id,
None,
)
# Verify
assert result["success"] is True
assert result["data"]["app_state"] == AppState.FOREGROUND.value
assert result["data"]["is_running"] is True
assert result["data"]["is_foreground"] is True
assert result["data"]["is_background"] is False
def test_get_state_description(self) -> None:
"""Test state description helper function."""
# Test all app states
state_tests = [
(AppState.NOT_RUNNING, "Application is not currently running"),
(AppState.LAUNCHING, "Application is in the process of launching"),
(AppState.RUNNING, "Application is running but not in foreground"),
(AppState.FOREGROUND, "Application is running and in the foreground"),
(AppState.BACKGROUND, "Application is running in the background"),
(AppState.TERMINATING, "Application is in the process of terminating"),
(AppState.CRASHED, "Application has crashed or is unresponsive"),
(AppState.UNKNOWN, "Application state cannot be determined"),
]
for state, expected_description in state_tests:
actual_description = _get_state_description(state)
assert actual_description == expected_description
class TestAppControlIntegration:
"""Test integration scenarios across app control operations."""
@pytest.mark.asyncio
async def test_app_identifier_parsing_bundle_id(
self,
mock_context: Any,
mock_app_controller: Any,
) -> None:
"""Test bundle ID parsing for app identifier."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
):
# Setup AppIdentifier mock
mock_app_id = Mock()
mock_app_id.display_name.return_value = "VSCode"
mock_app_id.primary_identifier.return_value = "com.microsoft.VSCode"
mock_app_id_class.return_value = mock_app_id
# Execute with bundle ID format
result = await km_app_control(
operation="get_state",
app_identifier="com.microsoft.VSCode",
ctx=mock_context,
)
# Verify bundle ID was processed correctly
assert result["success"] is True
# Verify AppIdentifier was called with bundle_id parameter
mock_app_id_class.assert_called_once_with(bundle_id="com.microsoft.VSCode")
@pytest.mark.asyncio
async def test_app_identifier_parsing_app_name(
self,
mock_context: Any,
mock_app_controller: Any,
) -> None:
"""Test app name parsing for app identifier."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
):
# Setup AppIdentifier mock
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TextEdit"
mock_app_id.primary_identifier.return_value = "TextEdit"
mock_app_id_class.return_value = mock_app_id
# Execute with app name format
result = await km_app_control(
operation="activate",
app_identifier="TextEdit",
ctx=mock_context,
)
# Verify app name was processed correctly
assert result["success"] is True
# Verify AppIdentifier was called with app_name parameter
mock_app_id_class.assert_called_once_with(app_name="TextEdit")
@pytest.mark.asyncio
async def test_timeout_configuration(
self,
mock_context: Any,
mock_app_controller: Any,
) -> None:
"""Test timeout configuration handling."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
patch("src.server.tools.app_control_tools.Duration") as mock_duration_class,
patch(
"src.server.tools.app_control_tools.LaunchConfiguration",
) as mock_config_class,
):
# Setup comprehensive mocking
mock_config_class.return_value = Mock()
# Setup mocks
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TestApp"
mock_app_id.primary_identifier.return_value = "com.test.app"
mock_app_id_class.return_value = mock_app_id
mock_duration = Mock()
mock_duration_class.from_seconds.return_value = mock_duration
# Execute with custom timeout
result = await km_app_control(
operation="launch",
app_identifier="TestApp",
timeout_seconds=60,
ctx=mock_context,
)
# Verify timeout was configured
assert result["success"] is True
mock_duration_class.from_seconds.assert_called_with(60)
@pytest.mark.asyncio
async def test_progress_reporting(
self,
mock_context: Any,
mock_app_controller: Any,
) -> None:
"""Test progress reporting during operations."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
):
# Setup mocks
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TestApp"
mock_app_id.primary_identifier.return_value = "com.test.app"
mock_app_id_class.return_value = mock_app_id
# Execute
result = await km_app_control(
operation="launch",
app_identifier="TestApp",
ctx=mock_context,
)
# Verify progress was reported
assert result["success"] is True
assert mock_context.report_progress.call_count >= 2
progress_calls = mock_context.report_progress.call_args_list
# Check progress sequence
assert progress_calls[0][0][0] == 25 # First progress
assert progress_calls[-1][0][0] == 100 # Final progress
class TestAppControlSecurity:
"""Test security validation and prevention measures."""
@pytest.mark.asyncio
async def test_app_identifier_validation(self, mock_context: Any) -> None:
"""Test application identifier security validation."""
dangerous_identifiers = [
"../../evil.app",
"com.evil.malware",
"../system/app",
"rm -rf /",
"'; DROP TABLE apps; --",
]
for dangerous_id in dangerous_identifiers:
with patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class:
# Setup validation to fail for dangerous identifiers
mock_app_id_class.side_effect = ValueError("Security validation failed")
result = await km_app_control(
operation="launch",
app_identifier=dangerous_id,
ctx=mock_context,
)
# Should fail validation
assert result["success"] is False
assert result["error"]["code"] == "INVALID_IDENTIFIER"
@pytest.mark.asyncio
async def test_menu_path_validation(
self,
mock_context: Any,
mock_app_controller: Any,
) -> None:
"""Test menu path security validation."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
patch(
"src.server.tools.app_control_tools.MenuPath",
) as mock_menu_path_class,
):
# Setup mocks
mock_app_id = Mock()
mock_app_id_class.return_value = mock_app_id
# Test with dangerous menu path
mock_menu_path_class.side_effect = ValueError(
"Invalid menu path contains dangerous characters",
)
result = await km_app_control(
operation="menu_select",
app_identifier="TestApp",
menu_path=["File", "../../../evil"],
ctx=mock_context,
)
# Should fail validation
assert result["success"] is False
assert result["error"]["code"] == "INVALID_MENU_PATH"
class TestAppControlPropertyBased:
"""Property-based testing for app control operations."""
@composite
def valid_app_identifier_strategy(draw: Callable[..., Any]) -> Mock:
"""Generate valid application identifiers for testing."""
# Generate realistic app identifiers
identifier_type = draw(st.sampled_from(["bundle_id", "app_name"]))
if identifier_type == "bundle_id":
# Generate bundle ID format: com.company.app
company = draw(
st.text(
alphabet=st.characters(whitelist_categories=("Lu", "Ll")),
min_size=2,
max_size=10,
),
)
app = draw(
st.text(
alphabet=st.characters(whitelist_categories=("Lu", "Ll")),
min_size=2,
max_size=15,
),
)
identifier = f"com.{company}.{app}"
else:
# Generate app name
identifier = draw(
st.text(
alphabet=st.characters(
whitelist_categories=("Lu", "Ll", "Nd", "Pc"),
),
min_size=1,
max_size=30,
),
)
# Ensure identifier length is reasonable
assume(len(identifier) <= 255)
assume(identifier.strip() == identifier) # No leading/trailing whitespace
return identifier
@given(valid_app_identifier_strategy())
@settings(
max_examples=20,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_app_identifier_properties(self, identifier: str) -> None:
"""Property: Valid app identifiers should pass basic validation checks."""
# Test basic identifier properties
assert len(identifier) > 0
assert len(identifier) <= 255
assert identifier.strip() == identifier
# Bundle IDs should contain dots, app names typically don't end with .app
if "." in identifier and not identifier.endswith(".app"):
# Looks like bundle ID
assert "com." in identifier or "org." in identifier or "." in identifier
else:
# Looks like app name
assert not identifier.startswith(".")
@given(st.sampled_from(["launch", "quit", "activate", "menu_select", "get_state"]))
@settings(max_examples=5)
def test_operation_validation_properties(self, operation: str) -> None:
"""Property: All valid operations should be properly defined."""
valid_operations = ["launch", "quit", "activate", "menu_select", "get_state"]
assert operation in valid_operations
@given(st.integers(min_value=1, max_value=120))
@settings(max_examples=10)
def test_timeout_validation_properties(self, timeout_seconds: Any) -> None:
"""Property: Valid timeout values should be within safe limits."""
assert 1 <= timeout_seconds <= 120
assert isinstance(timeout_seconds, int)
@given(st.lists(st.text(min_size=1, max_size=20), min_size=1, max_size=10))
@settings(max_examples=15)
def test_menu_path_properties(self, menu_path: list[Any] | str) -> None:
"""Property: Valid menu paths should have reasonable structure."""
# Test menu path properties
assert len(menu_path) >= 1
assert len(menu_path) <= 10
assert all(len(item) > 0 for item in menu_path)
assert all(len(item) <= 20 for item in menu_path)
class TestAppControlPerformance:
"""Test performance and limits for app control operations."""
@pytest.mark.asyncio
async def test_operation_timing_tracking(
self,
mock_context: Any,
mock_app_controller: Any,
) -> None:
"""Test operation execution time tracking."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
):
# Setup mocks
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TestApp"
mock_app_id.primary_identifier.return_value = "com.test.app"
mock_app_id_class.return_value = mock_app_id
# Execute
result = await km_app_control(
operation="activate",
app_identifier="TestApp",
ctx=mock_context,
)
# Verify timing is tracked
assert result["success"] is True
assert "execution_time" in result["metadata"]
assert isinstance(result["metadata"]["execution_time"], int | float)
assert result["metadata"]["execution_time"] >= 0
@pytest.mark.asyncio
async def test_correlation_id_tracking(
self,
mock_context: Any,
mock_app_controller: Any,
) -> None:
"""Test correlation ID tracking for operations."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
):
# Setup mocks
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TestApp"
mock_app_id.primary_identifier.return_value = "com.test.app"
mock_app_id_class.return_value = mock_app_id
# Execute
result = await km_app_control(
operation="get_state",
app_identifier="TestApp",
ctx=mock_context,
)
# Verify correlation ID is present and valid
assert result["success"] is True
assert "correlation_id" in result["metadata"]
correlation_id = result["metadata"]["correlation_id"]
# Should be valid UUID format
assert len(correlation_id) == 36
assert correlation_id.count("-") == 4
class TestAppControlEdgeCases:
"""Test edge cases and boundary conditions."""
@pytest.mark.asyncio
async def test_empty_menu_path_list(self, mock_context: Any) -> None:
"""Test handling of empty menu path list."""
result = await km_app_control(
operation="menu_select",
app_identifier="TestApp",
menu_path=[],
ctx=mock_context,
)
# Empty list should be treated as None
assert result["success"] is False
assert result["error"]["code"] == "MISSING_MENU_PATH"
@pytest.mark.asyncio
async def test_maximum_timeout_value(
self,
mock_context: Any,
mock_app_controller: Any,
) -> None:
"""Test maximum timeout boundary."""
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
):
# Setup mocks
mock_app_id = Mock()
mock_app_id.display_name.return_value = "TestApp"
mock_app_id.primary_identifier.return_value = "com.test.app"
mock_app_id_class.return_value = mock_app_id
# Execute with maximum timeout
result = await km_app_control(
operation="launch",
app_identifier="TestApp",
timeout_seconds=120, # Maximum allowed
ctx=mock_context,
)
# Should succeed with maximum timeout
assert result["success"] is True
@pytest.mark.asyncio
async def test_complex_app_name_handling(
self,
mock_context: Any,
mock_app_controller: Any,
) -> None:
"""Test handling of complex application names."""
complex_names = [
"Visual Studio Code",
"Microsoft Word",
"Adobe Photoshop 2024",
"Final Cut Pro",
]
for app_name in complex_names:
with (
patch(
"src.server.tools.app_control_tools.AppController",
return_value=mock_app_controller,
),
patch(
"src.server.tools.app_control_tools.AppIdentifier",
) as mock_app_id_class,
):
# Setup mocks
mock_app_id = Mock()
mock_app_id.display_name.return_value = app_name
mock_app_id.primary_identifier.return_value = app_name
mock_app_id_class.return_value = mock_app_id
result = await km_app_control(
operation="get_state",
app_identifier=app_name,
ctx=mock_context,
)
# Should handle complex names properly
assert result["success"] is True
assert result["data"]["app_name"] == app_name
if __name__ == "__main__":
pytest.main([__file__, "-v"])