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
252 lines
6.8 KiB
Python
252 lines
6.8 KiB
Python
"""
|
|
Pytest configuration and fixtures for mcp-arduino-server tests
|
|
"""
|
|
import tempfile
|
|
from collections.abc import Generator
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
from fastmcp import Context
|
|
|
|
from mcp_arduino_server.components import (
|
|
ArduinoBoard,
|
|
ArduinoDebug,
|
|
ArduinoLibrary,
|
|
ArduinoSketch,
|
|
WireViz,
|
|
)
|
|
from mcp_arduino_server.config import ArduinoServerConfig
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_dir() -> Generator[Path, None, None]:
|
|
"""Create a temporary directory for test files"""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
yield Path(tmpdir)
|
|
|
|
|
|
@pytest.fixture
|
|
def test_config(temp_dir: Path) -> ArduinoServerConfig:
|
|
"""Create a test configuration with temporary directories"""
|
|
config = ArduinoServerConfig(
|
|
sketches_base_dir=temp_dir / "sketches",
|
|
build_temp_dir=temp_dir / "build",
|
|
arduino_cli_path="arduino-cli", # Will be mocked
|
|
wireviz_path="wireviz", # Will be mocked
|
|
enable_client_sampling=True
|
|
)
|
|
# Ensure directories exist
|
|
config.sketches_base_dir.mkdir(parents=True, exist_ok=True)
|
|
config.build_temp_dir.mkdir(parents=True, exist_ok=True)
|
|
return config
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_arduino_cli():
|
|
"""Mock arduino-cli subprocess calls"""
|
|
with patch('subprocess.run') as mock_run:
|
|
# Default successful response
|
|
mock_run.return_value.returncode = 0
|
|
mock_run.return_value.stdout = '{"success": true}'
|
|
mock_run.return_value.stderr = ''
|
|
yield mock_run
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_async_subprocess():
|
|
"""Mock async subprocess for components that use it"""
|
|
with patch('asyncio.create_subprocess_exec') as mock_exec:
|
|
mock_process = AsyncMock()
|
|
mock_process.returncode = 0
|
|
mock_process.stdout = AsyncMock()
|
|
mock_process.stderr = AsyncMock()
|
|
mock_process.stdin = AsyncMock()
|
|
|
|
# Mock readline for progress monitoring
|
|
mock_process.stdout.readline = AsyncMock(
|
|
side_effect=[
|
|
b'Downloading core...\n',
|
|
b'Installing core...\n',
|
|
b'Core installed successfully\n',
|
|
b'' # End of stream
|
|
]
|
|
)
|
|
mock_process.stderr.readline = AsyncMock(return_value=b'')
|
|
mock_process.wait = AsyncMock(return_value=0)
|
|
mock_process.communicate = AsyncMock(return_value=(b'Success', b''))
|
|
|
|
mock_exec.return_value = mock_process
|
|
yield mock_exec
|
|
|
|
|
|
@pytest.fixture
|
|
def test_context():
|
|
"""Create a test context with mocked elicitation support"""
|
|
# Create a mock context object
|
|
ctx = Mock(spec=Context)
|
|
|
|
# Add elicitation methods for interactive debugging tests
|
|
ctx.ask_user = AsyncMock(return_value="Continue to next breakpoint")
|
|
ctx.ask_confirmation = AsyncMock(return_value=True)
|
|
|
|
# Track progress and log calls for assertions
|
|
ctx.report_progress = AsyncMock()
|
|
ctx.info = AsyncMock()
|
|
ctx.debug = AsyncMock()
|
|
ctx.warning = AsyncMock()
|
|
ctx.error = AsyncMock()
|
|
|
|
# Add sampling support for AI features
|
|
ctx.sample = AsyncMock(return_value=Mock(
|
|
choices=[Mock(
|
|
message=Mock(
|
|
content="Generated YAML content"
|
|
)
|
|
)]
|
|
))
|
|
|
|
return ctx
|
|
|
|
|
|
@pytest.fixture
|
|
def sketch_component(test_config: ArduinoServerConfig) -> ArduinoSketch:
|
|
"""Create ArduinoSketch component instance"""
|
|
return ArduinoSketch(test_config)
|
|
|
|
|
|
@pytest.fixture
|
|
def library_component(test_config: ArduinoServerConfig) -> ArduinoLibrary:
|
|
"""Create ArduinoLibrary component instance"""
|
|
return ArduinoLibrary(test_config)
|
|
|
|
|
|
@pytest.fixture
|
|
def board_component(test_config: ArduinoServerConfig) -> ArduinoBoard:
|
|
"""Create ArduinoBoard component instance"""
|
|
return ArduinoBoard(test_config)
|
|
|
|
|
|
@pytest.fixture
|
|
def debug_component(test_config: ArduinoServerConfig) -> ArduinoDebug:
|
|
"""Create ArduinoDebug component instance"""
|
|
return ArduinoDebug(test_config)
|
|
|
|
|
|
@pytest.fixture
|
|
def wireviz_component(test_config: ArduinoServerConfig) -> WireViz:
|
|
"""Create WireViz component instance"""
|
|
return WireViz(test_config)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_sketch_content() -> str:
|
|
"""Sample Arduino sketch code"""
|
|
return """// Blink LED
|
|
void setup() {
|
|
pinMode(LED_BUILTIN, OUTPUT);
|
|
}
|
|
|
|
void loop() {
|
|
digitalWrite(LED_BUILTIN, HIGH);
|
|
delay(1000);
|
|
digitalWrite(LED_BUILTIN, LOW);
|
|
delay(1000);
|
|
}
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_wireviz_yaml() -> str:
|
|
"""Sample WireViz YAML configuration"""
|
|
return """
|
|
connectors:
|
|
Arduino:
|
|
type: Arduino Uno
|
|
pins: [GND, D13]
|
|
LED:
|
|
type: LED
|
|
pins: [cathode, anode]
|
|
cables:
|
|
jumper:
|
|
colors: [BK, RD]
|
|
connections:
|
|
- Arduino: [GND]
|
|
cable: [1]
|
|
LED: [cathode]
|
|
- Arduino: [D13]
|
|
cable: [2]
|
|
LED: [anode]
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_board_list_response() -> str:
|
|
"""Mock JSON response for board list"""
|
|
return """{
|
|
"detected_ports": [
|
|
{
|
|
"port": {
|
|
"address": "/dev/ttyUSB0",
|
|
"protocol": "serial",
|
|
"label": "Arduino Uno"
|
|
},
|
|
"matching_boards": [
|
|
{
|
|
"name": "Arduino Uno",
|
|
"fqbn": "arduino:avr:uno"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}"""
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_library_search_response() -> str:
|
|
"""Mock JSON response for library search"""
|
|
return """{
|
|
"libraries": [
|
|
{
|
|
"name": "Servo",
|
|
"author": "Arduino",
|
|
"sentence": "Allows Arduino boards to control servo motors",
|
|
"paragraph": "This library can control a great number of servos.",
|
|
"category": "Device Control",
|
|
"architectures": ["*"],
|
|
"latest": {
|
|
"version": "1.1.8"
|
|
}
|
|
}
|
|
]
|
|
}"""
|
|
|
|
|
|
# Helper functions for testing
|
|
|
|
def create_sketch_directory(base_dir: Path, sketch_name: str, content: str = None) -> Path:
|
|
"""Helper to create a sketch directory with .ino file"""
|
|
sketch_dir = base_dir / sketch_name
|
|
sketch_dir.mkdir(parents=True, exist_ok=True)
|
|
ino_file = sketch_dir / f"{sketch_name}.ino"
|
|
|
|
if content is None:
|
|
content = f"// {sketch_name}\nvoid setup() {{}}\nvoid loop() {{}}"
|
|
|
|
ino_file.write_text(content)
|
|
return sketch_dir
|
|
|
|
|
|
def assert_progress_reported(ctx: Mock, min_calls: int = 1):
|
|
"""Assert that progress was reported at least min_calls times"""
|
|
assert ctx.report_progress.call_count >= min_calls, \
|
|
f"Expected at least {min_calls} progress reports, got {ctx.report_progress.call_count}"
|
|
|
|
|
|
def assert_logged_info(ctx: Mock, message_fragment: str):
|
|
"""Assert that an info message containing the fragment was logged"""
|
|
for call in ctx.info.call_args_list:
|
|
if message_fragment in str(call):
|
|
return
|
|
assert False, f"Info message containing '{message_fragment}' not found in logs"
|