mcp-arduino/tests/test_esp32_unit_mock.py
Ryan Malloy eb524b8c1d Major project refactor: Rename to mcp-arduino with smart client capabilities
BREAKING CHANGES:
- Package renamed from mcp-arduino-server to mcp-arduino
- Command changed to 'mcp-arduino' (was 'mcp-arduino-server')
- Repository moved to git.supported.systems/MCP/mcp-arduino

NEW FEATURES:
 Smart client capability detection and dual-mode sampling support
 Intelligent WireViz templates with component-specific circuits (LED, motor, sensor, button, display)
 Client debug tools for MCP capability inspection
 Enhanced error handling with progressive enhancement patterns

IMPROVEMENTS:
🧹 Major repository cleanup - removed 14+ experimental files and tests
📝 Consolidated and reorganized documentation
🐛 Fixed import issues and applied comprehensive linting with ruff
📦 Updated author information to Ryan Malloy (ryan@supported.systems)
🔧 Fixed package version references in startup code

TECHNICAL DETAILS:
- Added dual-mode WireViz: AI generation for sampling clients, smart templates for others
- Implemented client capability detection via MCP handshake inspection
- Created progressive enhancement pattern for universal MCP client compatibility
- Organized test files into proper structure (tests/examples/)
- Applied comprehensive code formatting and lint fixes

The server now provides excellent functionality for ALL MCP clients regardless
of their sampling capabilities, while preserving advanced features for clients
that support them.

Version: 2025.09.27.1
2025-09-27 20:16:43 -06:00

415 lines
16 KiB
Python

"""
ESP32 Installation Unit Tests with Proper Mocking
==================================================
This test file validates the ESP32 installation functionality using direct
component testing with comprehensive mocking.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from fastmcp import Context
from src.mcp_arduino_server.components.arduino_board import ArduinoBoard
from src.mcp_arduino_server.config import ArduinoServerConfig
class TestESP32InstallationUnit:
"""Unit tests for ESP32 installation functionality"""
@pytest.fixture
def config(self):
"""Create test configuration"""
return ArduinoServerConfig(
arduino_cli_path="/usr/bin/arduino-cli",
command_timeout=30
)
@pytest.fixture
def arduino_board(self, config):
"""Create ArduinoBoard component instance"""
return ArduinoBoard(config)
@pytest.fixture
def mock_context(self):
"""Create mock FastMCP context"""
ctx = Mock(spec=Context)
ctx.info = AsyncMock()
ctx.debug = AsyncMock()
ctx.error = AsyncMock()
ctx.report_progress = AsyncMock()
return ctx
@pytest.mark.asyncio
async def test_esp32_install_successful(self, arduino_board, mock_context):
"""Test successful ESP32 installation"""
print("\n🔧 Testing successful ESP32 installation...")
# Mock index update process
mock_index_process = AsyncMock()
mock_index_process.returncode = 0
mock_index_process.communicate.return_value = (
b"Updating index: package_index.json downloaded",
b""
)
# Mock core installation process
mock_install_process = AsyncMock()
mock_install_process.returncode = 0
mock_install_process.wait = AsyncMock()
# Create progressive output simulation
stdout_messages = [
b"Downloading esp32:esp32@2.0.11...\n",
b"esp32:esp32@2.0.11 downloaded\n",
b"Downloading xtensa-esp32-elf-gcc@8.4.0+2021r2-patch5...\n",
b"Installing esp32:esp32@2.0.11...\n",
b"Platform esp32:esp32@2.0.11 installed\n",
b"" # End of stream
]
message_index = 0
async def mock_stdout_readline():
nonlocal message_index
if message_index < len(stdout_messages):
msg = stdout_messages[message_index]
message_index += 1
await asyncio.sleep(0.01) # Simulate some delay
return msg
return b""
mock_install_process.stdout = AsyncMock()
mock_install_process.stderr = AsyncMock()
mock_install_process.stdout.readline = mock_stdout_readline
mock_install_process.stderr.readline = AsyncMock(return_value=b"")
# Mock subprocess creation
def create_subprocess_side_effect(*args, **kwargs):
cmd = args if args else kwargs.get('args', [])
if any('update-index' in str(arg) for arg in cmd):
return mock_index_process
else:
return mock_install_process
# Mock the final board list command
mock_run_result = MagicMock()
mock_run_result.returncode = 0
mock_run_result.stdout = "ESP32 Dev Module esp32:esp32:esp32\n"
with patch('asyncio.create_subprocess_exec', side_effect=create_subprocess_side_effect):
with patch('asyncio.wait_for', side_effect=[
(b"Index updated", b""), # Index update result
0 # Installation wait result
]):
with patch('subprocess.run', return_value=mock_run_result):
result = await arduino_board.install_esp32(mock_context)
print(f"📊 Installation result: {result}")
# Verify successful installation
assert result["success"] is True
assert "ESP32 core installed successfully" in result["message"]
assert "next_steps" in result
assert isinstance(result["next_steps"], list)
assert len(result["next_steps"]) > 0
# Verify progress was reported
mock_context.report_progress.assert_called()
assert mock_context.report_progress.call_count >= 4
# Verify context methods were called appropriately
mock_context.info.assert_called()
assert any("Installing ESP32 board support" in str(call)
for call in mock_context.info.call_args_list)
print("✅ ESP32 installation test passed")
@pytest.mark.asyncio
async def test_esp32_already_installed(self, arduino_board, mock_context):
"""Test ESP32 installation when already installed"""
print("\n🔄 Testing ESP32 already installed scenario...")
# Mock index update (successful)
mock_index_process = AsyncMock()
mock_index_process.returncode = 0
mock_index_process.communicate.return_value = (b"Index updated", b"")
# Mock installation process (already installed)
mock_install_process = AsyncMock()
mock_install_process.returncode = 1 # Non-zero for already installed
mock_install_process.wait = AsyncMock()
stderr_messages = [
b"Platform esp32:esp32@2.0.11 already installed\n",
b""
]
stderr_index = 0
async def mock_stderr_readline():
nonlocal stderr_index
if stderr_index < len(stderr_messages):
msg = stderr_messages[stderr_index]
stderr_index += 1
return msg
return b""
mock_install_process.stdout = AsyncMock()
mock_install_process.stderr = AsyncMock()
mock_install_process.stdout.readline = AsyncMock(return_value=b"")
mock_install_process.stderr.readline = mock_stderr_readline
def create_subprocess_side_effect(*args, **kwargs):
cmd = args if args else kwargs.get('args', [])
if any('update-index' in str(arg) for arg in cmd):
return mock_index_process
else:
return mock_install_process
with patch('asyncio.create_subprocess_exec', side_effect=create_subprocess_side_effect):
with patch('asyncio.wait_for', side_effect=[
(b"Index updated", b""),
1 # Installation returns 1 (already installed)
]):
result = await arduino_board.install_esp32(mock_context)
print(f"📊 Already installed result: {result}")
# Should still be successful
assert result["success"] is True
assert "already installed" in result["message"].lower()
print("✅ Already installed test passed")
@pytest.mark.asyncio
async def test_esp32_installation_timeout(self, arduino_board, mock_context):
"""Test ESP32 installation timeout handling"""
print("\n⏱️ Testing ESP32 installation timeout...")
# Mock index update (successful)
mock_index_process = AsyncMock()
mock_index_process.returncode = 0
mock_index_process.communicate.return_value = (b"Index updated", b"")
# Mock installation process that times out
mock_install_process = AsyncMock()
mock_install_process.wait.side_effect = asyncio.TimeoutError()
mock_install_process.kill = AsyncMock()
mock_install_process.stdout = AsyncMock()
mock_install_process.stderr = AsyncMock()
mock_install_process.stdout.readline = AsyncMock(return_value=b"Downloading...\n")
mock_install_process.stderr.readline = AsyncMock(return_value=b"")
def create_subprocess_side_effect(*args, **kwargs):
cmd = args if args else kwargs.get('args', [])
if any('update-index' in str(arg) for arg in cmd):
return mock_index_process
else:
return mock_install_process
with patch('asyncio.create_subprocess_exec', side_effect=create_subprocess_side_effect):
with patch('asyncio.wait_for', side_effect=[
(b"Index updated", b""),
asyncio.TimeoutError() # Installation times out
]):
result = await arduino_board.install_esp32(mock_context)
print(f"📊 Timeout result: {result}")
# Should handle timeout gracefully
assert "error" in result
assert "timed out" in result["error"].lower()
assert "hint" in result
# Verify process was killed
mock_install_process.kill.assert_called_once()
# Verify error was reported
mock_context.error.assert_called()
print("✅ Timeout handling test passed")
@pytest.mark.asyncio
async def test_esp32_index_update_failure(self, arduino_board, mock_context):
"""Test ESP32 installation when index update fails"""
print("\n❌ Testing index update failure...")
# Mock index update failure
mock_index_process = AsyncMock()
mock_index_process.returncode = 1
mock_index_process.communicate.return_value = (
b"",
b"Error updating index: connection failed"
)
with patch('asyncio.create_subprocess_exec', return_value=mock_index_process):
with patch('asyncio.wait_for', return_value=(b"", b"Connection failed")):
result = await arduino_board.install_esp32(mock_context)
print(f"📊 Index failure result: {result}")
# Should handle index update failure
assert "error" in result
assert "Failed to update board index" in result["error"]
# Verify error was reported
mock_context.error.assert_called()
print("✅ Index update failure test passed")
@pytest.mark.asyncio
async def test_esp32_progress_tracking(self, arduino_board, mock_context):
"""Test ESP32 installation progress tracking"""
print("\n📊 Testing progress tracking...")
# Mock index update
mock_index_process = AsyncMock()
mock_index_process.returncode = 0
mock_index_process.communicate.return_value = (b"Index updated", b"")
# Mock installation with detailed progress
mock_install_process = AsyncMock()
mock_install_process.returncode = 0
mock_install_process.wait = AsyncMock()
progress_messages = [
b"Downloading esp32:esp32@2.0.11 (425MB)...\n",
b"Downloading xtensa-esp32-elf-gcc@8.4.0 (566MB)...\n",
b"Downloading esptool_py@1.30300.0 (45MB)...\n",
b"Installing esp32:esp32@2.0.11...\n",
b"Installing xtensa-esp32-elf-gcc@8.4.0...\n",
b"Installing esptool_py@1.30300.0...\n",
b"Platform esp32:esp32@2.0.11 installed\n",
b"" # End of stream
]
message_index = 0
async def mock_stdout_readline():
nonlocal message_index
if message_index < len(progress_messages):
msg = progress_messages[message_index]
message_index += 1
await asyncio.sleep(0.01) # Simulate download time
return msg
return b""
mock_install_process.stdout = AsyncMock()
mock_install_process.stderr = AsyncMock()
mock_install_process.stdout.readline = mock_stdout_readline
mock_install_process.stderr.readline = AsyncMock(return_value=b"")
def create_subprocess_side_effect(*args, **kwargs):
cmd = args if args else kwargs.get('args', [])
if any('update-index' in str(arg) for arg in cmd):
return mock_index_process
else:
return mock_install_process
# Mock board list
mock_run_result = MagicMock()
mock_run_result.returncode = 0
mock_run_result.stdout = "ESP32 boards available\n"
with patch('asyncio.create_subprocess_exec', side_effect=create_subprocess_side_effect):
with patch('asyncio.wait_for', side_effect=[
(b"Index updated", b""),
0 # Installation completes
]):
with patch('subprocess.run', return_value=mock_run_result):
result = await arduino_board.install_esp32(mock_context)
print(f"📊 Progress tracking result: {result}")
# Verify successful installation
assert result["success"] is True
# Verify progress was tracked
progress_calls = mock_context.report_progress.call_args_list
assert len(progress_calls) >= 5 # Multiple progress updates
# Verify progress values are reasonable and increasing
progress_values = [call[0][0] for call in progress_calls]
assert all(0 <= val <= 100 for val in progress_values)
assert progress_values[-1] == 100 # Should end at 100%
# Verify info messages were logged for downloads
info_calls = mock_context.info.call_args_list
download_messages = [call for call in info_calls
if any(word in str(call) for word in ["Downloading", "📦"])]
assert len(download_messages) >= 2 # Should track multiple downloads
print("✅ Progress tracking test passed")
@pytest.mark.asyncio
async def test_esp32_url_configuration(self, arduino_board, mock_context):
"""Test that ESP32 installation uses correct ESP32 board package URL"""
print("\n🔗 Testing ESP32 URL configuration...")
# Mock successful processes
mock_index_process = AsyncMock()
mock_index_process.returncode = 0
mock_index_process.communicate.return_value = (b"Index updated", b"")
mock_install_process = AsyncMock()
mock_install_process.returncode = 0
mock_install_process.wait = AsyncMock()
mock_install_process.stdout = AsyncMock()
mock_install_process.stderr = AsyncMock()
mock_install_process.stdout.readline = AsyncMock(return_value=b"Platform installed\n")
mock_install_process.stderr.readline = AsyncMock(return_value=b"")
captured_commands = []
def capture_subprocess_calls(*args, **kwargs):
captured_commands.append(args)
cmd = args if args else kwargs.get('args', [])
if any('update-index' in str(arg) for arg in cmd):
return mock_index_process
else:
return mock_install_process
mock_run_result = MagicMock()
mock_run_result.returncode = 0
mock_run_result.stdout = "ESP32 boards\n"
with patch('asyncio.create_subprocess_exec', side_effect=capture_subprocess_calls):
with patch('asyncio.wait_for', side_effect=[
(b"Index updated", b""),
0
]):
with patch('subprocess.run', return_value=mock_run_result):
result = await arduino_board.install_esp32(mock_context)
print(f"📊 URL configuration result: {result}")
# Verify successful installation
assert result["success"] is True
# Verify ESP32 URL was used
esp32_url = "https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json"
# Check that ESP32 URL was used in commands
url_used = False
for cmd_args in captured_commands:
cmd_str = " ".join(str(arg) for arg in cmd_args)
if "--additional-urls" in cmd_str and esp32_url in cmd_str:
url_used = True
break
assert url_used, f"ESP32 URL not found in commands: {captured_commands}"
# Verify URL was logged
debug_calls = mock_context.debug.call_args_list
url_logged = any(esp32_url in str(call) for call in debug_calls)
assert url_logged, f"ESP32 URL not logged in debug messages: {debug_calls}"
print("✅ ESP32 URL configuration test passed")
if __name__ == "__main__":
# Run the unit tests
import sys
sys.exit(pytest.main([__file__, "-v", "-s"]))