Files
freecad-robust-mcp-fc111/tests/unit/test_tools_execution.py
T
8c338f6da7 feat: MCP Bridge Workbench, just command cleanup, testing, etc. (#24)
* 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>
2026-01-10 15:26:33 -08:00

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