* fix: lots of fixes and name refactoring * feat: Add workbench preferences * fix: MCP bridge status widget and just command fixes * fix(tests): Use the correct mesa-glx package * fix(ci): Add fontconfig to GUI test dependencies FreeCAD GUI was failing to start with: "Fontconfig error: Cannot load default config file: No such file" Added fontconfig and fonts-dejavu-core packages to the GUI test job dependencies to resolve the font configuration issue. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(addon): Extract path utilities into shared module Create path_utils.py module that consolidates duplicated path-finding logic from commands.py and InitGui.py: - get_addon_path(): Find addon directory with caching and fallbacks - get_icon_path(): Get full path to an icon file - get_icons_dir(): Get path to icons directory - get_workbench_icon(): Get path to workbench main icon This removes ~100 lines of duplicated code while preserving the same behavior including _addon_path_cache and all fallback methods. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(addon): Prevent stale plugin state on startup failure The StartMCPBridgeCommand.Activated method could leave _mcp_plugin in a partially initialized state if FreecadMCPPlugin.start() failed after the plugin was instantiated. Changes: - Create plugin in a local variable first - Only assign to _mcp_plugin after start() succeeds - Explicitly clear _mcp_plugin and _running_config in exception handlers to ensure clean state for subsequent retry attempts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Lot of broad improvements * fix(ci): Use blocking headless_server.py for GUI tests The GUI test was using startup_bridge.py which is non-blocking (designed for interactive use). For CI, even in GUI mode, we need the blocking headless_server.py that calls run_forever() to keep FreeCAD running. GUI features are still available since we use the 'freecad' executable instead of 'freecadcmd'. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(addon): Rename headless_server.py to blocking_bridge.py The old name was misleading because: - It works with both GUI (freecad) and headless (freecadcmd) modes - The key characteristic is that it BLOCKS with run_forever() New naming convention clarifies the difference: - blocking_bridge.py: Starts bridge and blocks (for CI, servers) - startup_bridge.py: Starts bridge and returns (for interactive GUI) Updated all references across: - GitHub workflow (macro-test.yaml) - Just commands (freecad.just) - Unit tests (test_addon_structure.py) - Documentation (5 files) - CLAUDE.md Also improved the script to detect GUI mode dynamically using FreeCAD.GuiUp and display the appropriate status message. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(just): Remove erroneous rm of startup_bridge.py on error The startup script is now a permanent source file in the repository, not a generated temporary file. The rm -f would have deleted source code if FreeCAD wasn't found. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: General improvements * fix: Lots of general fixes and only stable to PyPi * fix: small cleanup * fix: Small fixes and hopefully fixes the GUI tests * fix: Add proper library paths for FreeCAD GUI in CI - Create wrapper scripts instead of symlinks for AppImage binaries - Set LD_LIBRARY_PATH, QT_PLUGIN_PATH for GUI mode - Add diagnostic output to identify startup failures * fix: Use apprun for GUI tests in CI * fix: Improving Xvfb tests * fix: GUI tests worlk * chore: remove invalid --no-splash comments * fix: ARM64 architecture support and other fixes * fix: cleanup * test: just commands test suite * test: improve just command tests * fix: more general improvements * fix: more cleanup * fix: more updates * fix: small tweaks --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
281 lines
9.4 KiB
Python
281 lines
9.4 KiB
Python
"""Tests for execution tools module."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from freecad_mcp.bridge.base import ConnectionStatus, ExecutionResult
|
|
|
|
|
|
class TestExecutionTools:
|
|
"""Tests for Python execution tools."""
|
|
|
|
@pytest.fixture
|
|
def mock_mcp(self):
|
|
"""Create a mock MCP server that captures tool registrations."""
|
|
mcp = MagicMock()
|
|
mcp._registered_tools = {}
|
|
|
|
def tool_decorator():
|
|
def wrapper(func):
|
|
mcp._registered_tools[func.__name__] = func
|
|
return func
|
|
|
|
return wrapper
|
|
|
|
mcp.tool = tool_decorator
|
|
return mcp
|
|
|
|
@pytest.fixture
|
|
def mock_bridge(self):
|
|
"""Create a mock FreeCAD bridge."""
|
|
return AsyncMock()
|
|
|
|
@pytest.fixture
|
|
def register_tools(self, mock_mcp, mock_bridge):
|
|
"""Register execution tools and return the registered functions."""
|
|
from freecad_mcp.tools.execution import register_execution_tools
|
|
|
|
async def get_bridge():
|
|
return mock_bridge
|
|
|
|
register_execution_tools(mock_mcp, get_bridge)
|
|
return mock_mcp._registered_tools
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_python_success(self, register_tools, mock_bridge):
|
|
"""execute_python should return success result."""
|
|
mock_bridge.execute_python = AsyncMock(
|
|
return_value=ExecutionResult(
|
|
success=True,
|
|
result={"value": 42, "type": "int"},
|
|
stdout="",
|
|
stderr="",
|
|
execution_time_ms=10.5,
|
|
)
|
|
)
|
|
|
|
execute_python = register_tools["execute_python"]
|
|
result = await execute_python(code="_result_ = {'value': 42, 'type': 'int'}")
|
|
|
|
assert result["success"] is True
|
|
assert result["result"] == {"value": 42, "type": "int"}
|
|
assert result["execution_time_ms"] == 10.5
|
|
mock_bridge.execute_python.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_python_with_timeout(self, register_tools, mock_bridge):
|
|
"""execute_python should pass timeout to bridge."""
|
|
mock_bridge.execute_python = AsyncMock(
|
|
return_value=ExecutionResult(
|
|
success=True,
|
|
result=True,
|
|
stdout="",
|
|
stderr="",
|
|
execution_time_ms=5.0,
|
|
)
|
|
)
|
|
|
|
execute_python = register_tools["execute_python"]
|
|
await execute_python(code="_result_ = True", timeout_ms=60000)
|
|
|
|
mock_bridge.execute_python.assert_called_once()
|
|
call_args = mock_bridge.execute_python.call_args
|
|
assert call_args.kwargs.get("timeout_ms") == 60000 or call_args.args[1] == 60000
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_python_failure(self, register_tools, mock_bridge):
|
|
"""execute_python should return error on failure."""
|
|
mock_bridge.execute_python = AsyncMock(
|
|
return_value=ExecutionResult(
|
|
success=False,
|
|
result=None,
|
|
stdout="",
|
|
stderr="NameError: name 'foo' is not defined",
|
|
execution_time_ms=2.0,
|
|
error_type="NameError",
|
|
error_traceback="Traceback...\nNameError: name 'foo' is not defined",
|
|
)
|
|
)
|
|
|
|
execute_python = register_tools["execute_python"]
|
|
result = await execute_python(code="foo")
|
|
|
|
assert result["success"] is False
|
|
assert result["error_type"] == "NameError"
|
|
assert "foo" in result["error_traceback"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_python_with_stdout(self, register_tools, mock_bridge):
|
|
"""execute_python should capture stdout."""
|
|
mock_bridge.execute_python = AsyncMock(
|
|
return_value=ExecutionResult(
|
|
success=True,
|
|
result=None,
|
|
stdout="Hello, World!\n",
|
|
stderr="",
|
|
execution_time_ms=1.0,
|
|
)
|
|
)
|
|
|
|
execute_python = register_tools["execute_python"]
|
|
result = await execute_python(code="print('Hello, World!')")
|
|
|
|
assert result["success"] is True
|
|
assert result["stdout"] == "Hello, World!\n"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_freecad_version(self, register_tools, mock_bridge):
|
|
"""get_freecad_version should return version info."""
|
|
mock_bridge.get_freecad_version = AsyncMock(
|
|
return_value={
|
|
"version": "1.0.0",
|
|
"version_tuple": [1, 0, 0],
|
|
"build_date": "2024-01-15",
|
|
"python_version": "3.11.6",
|
|
"gui_available": True,
|
|
}
|
|
)
|
|
|
|
get_freecad_version = register_tools["get_freecad_version"]
|
|
result = await get_freecad_version()
|
|
|
|
assert result["version"] == "1.0.0"
|
|
assert result["gui_available"] is True
|
|
mock_bridge.get_freecad_version.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_connection_status_connected(self, register_tools, mock_bridge):
|
|
"""get_connection_status should return connected status."""
|
|
mock_bridge.get_status = AsyncMock(
|
|
return_value=ConnectionStatus(
|
|
connected=True,
|
|
mode="xmlrpc",
|
|
freecad_version="1.0.0",
|
|
gui_available=True,
|
|
last_ping_ms=5.5,
|
|
error=None,
|
|
)
|
|
)
|
|
|
|
get_connection_status = register_tools["get_connection_status"]
|
|
result = await get_connection_status()
|
|
|
|
assert result["connected"] is True
|
|
assert result["mode"] == "xmlrpc"
|
|
assert result["last_ping_ms"] == 5.5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_connection_status_disconnected(
|
|
self, register_tools, mock_bridge
|
|
):
|
|
"""get_connection_status should return disconnected status with error."""
|
|
mock_bridge.get_status = AsyncMock(
|
|
return_value=ConnectionStatus(
|
|
connected=False,
|
|
mode="xmlrpc",
|
|
error="Connection refused",
|
|
)
|
|
)
|
|
|
|
get_connection_status = register_tools["get_connection_status"]
|
|
result = await get_connection_status()
|
|
|
|
assert result["connected"] is False
|
|
assert result["error"] == "Connection refused"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_console_output(self, register_tools, mock_bridge):
|
|
"""get_console_output should return console lines."""
|
|
mock_bridge.get_console_output = AsyncMock(
|
|
return_value=[
|
|
"FreeCAD started",
|
|
"Document created: TestDoc",
|
|
"Box created",
|
|
]
|
|
)
|
|
|
|
get_console_output = register_tools["get_console_output"]
|
|
result = await get_console_output()
|
|
|
|
# Returns a list directly, not a dict
|
|
assert result == [
|
|
"FreeCAD started",
|
|
"Document created: TestDoc",
|
|
"Box created",
|
|
]
|
|
mock_bridge.get_console_output.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_console_output_with_lines_param(
|
|
self, register_tools, mock_bridge
|
|
):
|
|
"""get_console_output should pass lines parameter."""
|
|
mock_bridge.get_console_output = AsyncMock(return_value=["Line 1"])
|
|
|
|
get_console_output = register_tools["get_console_output"]
|
|
await get_console_output(lines=50)
|
|
|
|
mock_bridge.get_console_output.assert_called_once_with(50)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_mcp_server_environment(self, register_tools, mock_bridge):
|
|
"""get_mcp_server_environment should return environment info."""
|
|
mock_bridge.get_status = AsyncMock(
|
|
return_value=ConnectionStatus(
|
|
connected=True,
|
|
mode="xmlrpc",
|
|
freecad_version="1.0.0",
|
|
gui_available=True,
|
|
last_ping_ms=5.0,
|
|
error=None,
|
|
)
|
|
)
|
|
|
|
get_env = register_tools["get_mcp_server_environment"]
|
|
result = await get_env()
|
|
|
|
# Should have standard fields
|
|
assert "instance_id" in result
|
|
assert "hostname" in result
|
|
assert "os_name" in result
|
|
assert "python_version" in result
|
|
assert "platform" in result
|
|
assert "os_version" in result
|
|
|
|
# Verify removed fields are not present (prevent regressions)
|
|
assert "in_docker" not in result
|
|
assert "docker_container_id" not in result
|
|
|
|
# Should have freecad status
|
|
assert "freecad" in result
|
|
assert result["freecad"]["connected"] is True
|
|
assert result["freecad"]["mode"] == "xmlrpc"
|
|
assert result["freecad"]["is_headless"] is False
|
|
|
|
# Should have env vars
|
|
assert "env_vars" in result
|
|
mock_bridge.get_status.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_mcp_server_environment_headless(
|
|
self, register_tools, mock_bridge
|
|
):
|
|
"""get_mcp_server_environment should detect headless mode."""
|
|
mock_bridge.get_status = AsyncMock(
|
|
return_value=ConnectionStatus(
|
|
connected=True,
|
|
mode="embedded",
|
|
freecad_version="1.0.0",
|
|
gui_available=False,
|
|
last_ping_ms=0.0,
|
|
error=None,
|
|
)
|
|
)
|
|
|
|
get_env = register_tools["get_mcp_server_environment"]
|
|
result = await get_env()
|
|
|
|
assert result["freecad"]["gui_available"] is False
|
|
assert result["freecad"]["is_headless"] is True
|