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
378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""
|
|
Tests for ArduinoBoard component
|
|
"""
|
|
import json
|
|
import subprocess
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import assert_logged_info, assert_progress_reported
|
|
|
|
|
|
class TestArduinoBoard:
|
|
"""Test suite for ArduinoBoard component"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_boards_found(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test listing connected boards with successful detection"""
|
|
mock_response = {
|
|
"detected_ports": [
|
|
{
|
|
"port": {
|
|
"address": "/dev/ttyUSB0",
|
|
"protocol": "serial",
|
|
"label": "Arduino Uno"
|
|
},
|
|
"matching_boards": [
|
|
{
|
|
"name": "Arduino Uno",
|
|
"fqbn": "arduino:avr:uno"
|
|
}
|
|
],
|
|
"hardware_id": "USB\\VID_2341&PID_0043"
|
|
},
|
|
{
|
|
"port": {
|
|
"address": "/dev/ttyACM0",
|
|
"protocol": "serial",
|
|
"label": "Arduino Nano"
|
|
},
|
|
"matching_boards": [
|
|
{
|
|
"name": "Arduino Nano",
|
|
"fqbn": "arduino:avr:nano"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
mock_arduino_cli.return_value.stdout = json.dumps(mock_response)
|
|
mock_arduino_cli.return_value.returncode = 0
|
|
|
|
result = await board_component.list_boards(test_context)
|
|
|
|
assert "Found 2 connected board(s)" in result
|
|
assert "/dev/ttyUSB0" in result
|
|
assert "/dev/ttyACM0" in result
|
|
assert "Arduino Uno" in result
|
|
assert "arduino:avr:uno" in result
|
|
|
|
# Verify arduino-cli was called correctly
|
|
mock_arduino_cli.assert_called_once()
|
|
call_args = mock_arduino_cli.call_args[0][0]
|
|
assert "board" in call_args
|
|
assert "list" in call_args
|
|
assert "--format" in call_args
|
|
assert "json" in call_args
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_boards_empty(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test listing boards when none are connected"""
|
|
mock_arduino_cli.return_value.stdout = '{"detected_ports": []}'
|
|
mock_arduino_cli.return_value.returncode = 0
|
|
|
|
result = await board_component.list_boards(test_context)
|
|
|
|
assert "No Arduino boards detected" in result
|
|
assert "troubleshooting steps" in result
|
|
assert "USB cable connection" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_boards_no_matching(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test listing boards with detected ports but no matching board"""
|
|
mock_response = {
|
|
"detected_ports": [
|
|
{
|
|
"port": {
|
|
"address": "/dev/ttyUSB0",
|
|
"protocol": "serial",
|
|
"label": "Unknown Device"
|
|
},
|
|
"matching_boards": []
|
|
}
|
|
]
|
|
}
|
|
mock_arduino_cli.return_value.stdout = json.dumps(mock_response)
|
|
mock_arduino_cli.return_value.returncode = 0
|
|
|
|
result = await board_component.list_boards(test_context)
|
|
|
|
assert "/dev/ttyUSB0" in result
|
|
assert "No matching board found" in result
|
|
assert "install core" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_boards_success(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test successful board search"""
|
|
mock_response = {
|
|
"boards": [
|
|
{
|
|
"name": "Arduino Uno",
|
|
"fqbn": "arduino:avr:uno",
|
|
"platform": {
|
|
"id": "arduino:avr",
|
|
"maintainer": "Arduino"
|
|
}
|
|
},
|
|
{
|
|
"name": "Arduino Nano",
|
|
"fqbn": "arduino:avr:nano",
|
|
"platform": {
|
|
"id": "arduino:avr",
|
|
"maintainer": "Arduino"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
mock_arduino_cli.return_value.stdout = json.dumps(mock_response)
|
|
mock_arduino_cli.return_value.returncode = 0
|
|
|
|
result = await board_component.search_boards(
|
|
test_context,
|
|
"uno"
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["count"] == 2
|
|
assert len(result["boards"]) == 2
|
|
assert result["boards"][0]["name"] == "Arduino Uno"
|
|
assert result["boards"][0]["fqbn"] == "arduino:avr:uno"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_boards_empty(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test board search with no results"""
|
|
mock_arduino_cli.return_value.stdout = '{"boards": []}'
|
|
mock_arduino_cli.return_value.returncode = 0
|
|
|
|
result = await board_component.search_boards(
|
|
test_context,
|
|
"nonexistent"
|
|
)
|
|
|
|
assert result["count"] == 0
|
|
assert result["boards"] == []
|
|
assert "No board definitions found" in result["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_core_success(self, board_component, test_context, mock_async_subprocess):
|
|
"""Test successful core installation with progress"""
|
|
mock_process = mock_async_subprocess.return_value
|
|
mock_process.returncode = 0
|
|
|
|
# Simulate progress output
|
|
mock_process.stdout.readline = AsyncMock(side_effect=[
|
|
b'Downloading arduino:avr@1.8.5...\n',
|
|
b'Installing arduino:avr@1.8.5...\n',
|
|
b'Platform arduino:avr@1.8.5 installed\n',
|
|
b''
|
|
])
|
|
mock_process.stderr.readline = AsyncMock(return_value=b'')
|
|
mock_process.wait = AsyncMock(return_value=0)
|
|
|
|
result = await board_component.install_core(
|
|
test_context,
|
|
"arduino:avr"
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert "installed successfully" in result["message"]
|
|
|
|
# Verify progress was reported
|
|
assert_progress_reported(test_context, min_calls=2)
|
|
assert_logged_info(test_context, "Starting installation")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_core_already_installed(self, board_component, test_context, mock_async_subprocess):
|
|
"""Test installing core that's already installed"""
|
|
mock_process = mock_async_subprocess.return_value
|
|
mock_process.returncode = 1
|
|
|
|
# Simulate stderr for already installed
|
|
mock_process.stderr.readline = AsyncMock(side_effect=[
|
|
b'Platform arduino:avr already installed\n',
|
|
b''
|
|
])
|
|
mock_process.stdout.readline = AsyncMock(return_value=b'')
|
|
mock_process.wait = AsyncMock(return_value=1)
|
|
|
|
result = await board_component.install_core(
|
|
test_context,
|
|
"arduino:avr"
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert "already installed" in result["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_core_failure(self, board_component, test_context, mock_async_subprocess):
|
|
"""Test core installation failure"""
|
|
mock_process = mock_async_subprocess.return_value
|
|
mock_process.returncode = 1
|
|
|
|
mock_process.stderr.readline = AsyncMock(side_effect=[
|
|
b'Error: invalid platform specification\n',
|
|
b''
|
|
])
|
|
mock_process.stdout.readline = AsyncMock(return_value=b'')
|
|
mock_process.wait = AsyncMock(return_value=1)
|
|
|
|
result = await board_component.install_core(
|
|
test_context,
|
|
"invalid:core"
|
|
)
|
|
|
|
assert "error" in result
|
|
assert "installation failed" in result["error"]
|
|
assert "invalid platform" in result["stderr"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_cores_success(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test listing installed cores"""
|
|
mock_response = {
|
|
"platforms": [
|
|
{
|
|
"id": "arduino:avr",
|
|
"installed": "1.8.5",
|
|
"latest": "1.8.6",
|
|
"name": "Arduino AVR Boards",
|
|
"maintainer": "Arduino",
|
|
"website": "http://www.arduino.cc/",
|
|
"boards": [
|
|
{"name": "Arduino Uno"},
|
|
{"name": "Arduino Nano"}
|
|
]
|
|
},
|
|
{
|
|
"id": "esp32:esp32",
|
|
"installed": "2.0.9",
|
|
"latest": "2.0.11",
|
|
"name": "ESP32 Arduino",
|
|
"maintainer": "Espressif Systems"
|
|
}
|
|
]
|
|
}
|
|
mock_arduino_cli.return_value.stdout = json.dumps(mock_response)
|
|
mock_arduino_cli.return_value.returncode = 0
|
|
|
|
result = await board_component.list_cores(test_context)
|
|
|
|
assert result["success"] is True
|
|
assert result["count"] == 2
|
|
assert len(result["cores"]) == 2
|
|
assert result["cores"][0]["id"] == "arduino:avr"
|
|
assert result["cores"][0]["installed"] == "1.8.5"
|
|
assert len(result["cores"][0]["boards"]) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_cores_empty(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test listing cores when none are installed"""
|
|
mock_arduino_cli.return_value.stdout = '{"platforms": []}'
|
|
mock_arduino_cli.return_value.returncode = 0
|
|
|
|
result = await board_component.list_cores(test_context)
|
|
|
|
assert result["count"] == 0
|
|
assert result["cores"] == []
|
|
assert "No cores installed" in result["message"]
|
|
assert "arduino_install_core" in result["hint"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_cores_success(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test successful core update"""
|
|
# Mock two calls: update-index and upgrade
|
|
mock_arduino_cli.side_effect = [
|
|
# First call: core update-index
|
|
Mock(returncode=0, stdout="Updated package index"),
|
|
# Second call: core upgrade
|
|
Mock(returncode=0, stdout="All platforms upgraded")
|
|
]
|
|
|
|
result = await board_component.update_cores(test_context)
|
|
|
|
assert result["success"] is True
|
|
assert "updated successfully" in result["message"]
|
|
|
|
# Verify both commands were called
|
|
assert mock_arduino_cli.call_count == 2
|
|
|
|
# Check first call (update-index)
|
|
first_call = mock_arduino_cli.call_args_list[0][0][0]
|
|
assert "core" in first_call
|
|
assert "update-index" in first_call
|
|
|
|
# Check second call (upgrade)
|
|
second_call = mock_arduino_cli.call_args_list[1][0][0]
|
|
assert "core" in second_call
|
|
assert "upgrade" in second_call
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_cores_already_updated(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test core update when already up to date"""
|
|
mock_arduino_cli.side_effect = [
|
|
# First call: update-index
|
|
Mock(returncode=0, stdout="Updated package index"),
|
|
# Second call: upgrade (already up to date)
|
|
Mock(returncode=1, stderr="All platforms are already up to date")
|
|
]
|
|
|
|
result = await board_component.update_cores(test_context)
|
|
|
|
assert result["success"] is True
|
|
assert "already up to date" in result["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_cores_index_failure(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test core update with index update failure"""
|
|
mock_arduino_cli.return_value.returncode = 1
|
|
mock_arduino_cli.return_value.stderr = "Network error"
|
|
|
|
result = await board_component.update_cores(test_context)
|
|
|
|
assert "error" in result
|
|
assert "Failed to update core index" in result["error"]
|
|
assert "Network error" in result["stderr"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_connected_boards_resource(self, board_component):
|
|
"""Test the MCP resource for listing boards"""
|
|
with patch.object(board_component, 'list_boards') as mock_list:
|
|
mock_list.return_value = "Found 1 connected board(s):\n\n🔌 Port: /dev/ttyUSB0"
|
|
|
|
result = await board_component.list_connected_boards()
|
|
|
|
assert "Found 1 connected board" in result
|
|
mock_list.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_board_operations_timeout(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test timeout handling in board operations"""
|
|
# Mock timeout for list_boards
|
|
mock_arduino_cli.side_effect = subprocess.TimeoutExpired("arduino-cli", 30)
|
|
|
|
result = await board_component.list_boards(test_context)
|
|
|
|
assert "timed out" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_board_operations_json_parse_error(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test JSON parsing error handling"""
|
|
mock_arduino_cli.return_value.returncode = 0
|
|
mock_arduino_cli.return_value.stdout = "invalid json"
|
|
|
|
result = await board_component.list_boards(test_context)
|
|
|
|
assert "Failed to parse board list" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_boards_error(self, board_component, test_context, mock_arduino_cli):
|
|
"""Test board search command error"""
|
|
mock_arduino_cli.return_value.returncode = 1
|
|
mock_arduino_cli.return_value.stderr = "Invalid search term"
|
|
|
|
result = await board_component.search_boards(test_context, "")
|
|
|
|
assert "error" in result
|
|
assert "Board search failed" in result["error"]
|
|
assert "Invalid search term" in result["stderr"]
|