Files
freecad-robust-mcp-fc111/tests/integration/test_headless_mode.py
T
cd77560297 ci: Add CI and Release workflows and other test fixes (#7)
* ci: add caching and fix workflow failures

- Add UV package caching to all workflows for faster dependency installation
- Add pre-commit hook caching with OS-specific cache keys
- Skip no-commit-to-branch hook in CI (fails on main branch)
- Remove broken apt cache action (doesn't work with PPAs)
- Simplify FreeCAD command detection (PPA installs to standard PATH)
- Add FreeCAD Python version logging for debugging

* ci: Add code Rabbit configuration file

* ci: fix issues in CI workflows

* ci: add uv.lock

* ci: tweaks

* ci: Fix errors and add UUID generation and checking

* ci: Use the GitHub FreeCAD release latest stables

* ci: skip macro test for now, due to headless mode

* feat: Add a multi export macro

* ci: Fix tests

* ci: fix docker build workflow

* ci: skip trufflehog in GitHub Actions due to wasm panic bug

TruffleHog has a known wasm/go-re2 panic bug that causes failures
in GitHub Actions environment. The hook still runs locally during
development for secrets detection.

- Add trufflehog to SKIP env var in pre-commit.yaml
- Update trufflehog to v3.88.7 (latest)
- Add reference to upstream issue #3321

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: use boolean holes instead of PartDesign::Hole in CI

PartDesign::Hole has a CADKernelError bug in FreeCAD AppImage headless
mode on Linux (used in GitHub Actions) where it fails with "Cannot make
face from profile". The SmartCutter class already supports boolean holes
as an alternative.

Changes:
- Modify SmartCutter.execute() to use boolean holes by default
- Update all test assertions for Part::Feature output type
- Update test docstrings and class descriptions
- Remove PartDesign-specific checks (Group, Sketcher::SketchObject)
- Update workflow comment explaining the CI limitation

Boolean holes work reliably in both GUI and headless mode across all
FreeCAD configurations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci: clean up GitHub Actions workflows

* ci: Dependabot improvements

* ci: add CodeQL scanning workflow

* ci: AI review suggested improvements

* ci: add coderabbit updates for intentional decisions

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:29:25 -08:00

627 lines
17 KiB
Python

"""Integration tests for FreeCAD MCP headless mode.
These tests verify that the MCP bridge works correctly when FreeCAD is running
in headless mode (without GUI). They test object creation, manipulation, and
export functionality.
Note: These tests require a running FreeCAD headless server.
Start it with: just run-headless
To run these tests:
pytest tests/integration/test_headless_mode.py -v
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
import xmlrpc.client
from collections.abc import Generator
# Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration
# Note: xmlrpc_proxy fixture is defined in conftest.py
@pytest.fixture(scope="module")
def temp_dir() -> Generator[str, None, None]:
"""Create a temporary directory for test files."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
"""Execute Python code via the MCP bridge and return the result."""
result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment]
assert result.get("success"), f"Execution failed: {result.get('error_traceback')}"
return result
class TestHeadlessConnection:
"""Tests for basic headless mode connectivity."""
def test_ping(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test that the server responds to ping."""
result: dict[str, Any] = xmlrpc_proxy.ping() # type: ignore[assignment]
assert result["pong"] is True
assert "timestamp" in result
def test_headless_mode_detected(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test that FreeCAD is running in headless mode (GuiUp=False)."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
_result_ = {"gui_up": FreeCAD.GuiUp}
""",
)
# In headless mode, GuiUp should be False (0)
assert result["result"]["gui_up"] == 0 or result["result"]["gui_up"] is False
class TestDocumentManagement:
"""Tests for document creation and management in headless mode."""
def test_create_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a new document."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
doc = FreeCAD.newDocument("TestDoc")
_result_ = {"name": doc.Name, "object_count": len(doc.Objects)}
""",
)
assert result["result"]["name"] == "TestDoc"
assert result["result"]["object_count"] == 0
def test_list_documents(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test listing open documents."""
# First create a document
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
if not FreeCAD.listDocuments():
FreeCAD.newDocument("ListTestDoc")
_result_ = True
""",
)
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
docs = list(FreeCAD.listDocuments().keys())
_result_ = {"documents": docs, "count": len(docs)}
""",
)
assert result["result"]["count"] >= 1
def test_close_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test closing a document."""
# Create a document to close
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
doc = FreeCAD.newDocument("ToClose")
_result_ = True
""",
)
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
FreeCAD.closeDocument("ToClose")
_result_ = {"closed": "ToClose" not in FreeCAD.listDocuments()}
""",
)
assert result["result"]["closed"] is True
class TestPrimitiveCreation:
"""Tests for creating primitive shapes in headless mode."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Create a fresh document for each test."""
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
# Close any existing PrimitiveTestDoc
if "PrimitiveTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("PrimitiveTestDoc")
doc = FreeCAD.newDocument("PrimitiveTestDoc")
_result_ = True
""",
)
def test_create_box(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a Part::Box primitive."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
doc = FreeCAD.ActiveDocument
box = Part.makeBox(10, 20, 30)
obj = doc.addObject("Part::Feature", "TestBox")
obj.Shape = box
doc.recompute()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
"valid": obj.Shape.isValid()
}
""",
)
assert result["result"]["name"] == "TestBox"
# Volume should be 10 * 20 * 30 = 6000
assert abs(result["result"]["volume"] - 6000.0) < 0.01
assert result["result"]["valid"] is True
def test_create_cylinder(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a Part::Cylinder primitive."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
import math
doc = FreeCAD.ActiveDocument
cylinder = Part.makeCylinder(5, 20)
obj = doc.addObject("Part::Feature", "TestCylinder")
obj.Shape = cylinder
doc.recompute()
expected_volume = math.pi * 5**2 * 20
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
"expected_volume": expected_volume,
"valid": obj.Shape.isValid()
}
""",
)
assert result["result"]["name"] == "TestCylinder"
# Volume should be π * r² * h = π * 25 * 20 ≈ 1570.8
assert (
abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 0.1
)
assert result["result"]["valid"] is True
def test_create_sphere(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a Part::Sphere primitive."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
import math
doc = FreeCAD.ActiveDocument
sphere = Part.makeSphere(10)
obj = doc.addObject("Part::Feature", "TestSphere")
obj.Shape = sphere
doc.recompute()
expected_volume = (4/3) * math.pi * 10**3
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
"expected_volume": expected_volume,
"valid": obj.Shape.isValid()
}
""",
)
assert result["result"]["name"] == "TestSphere"
# Volume should be (4/3) * π * r³ ≈ 4188.79
assert (
abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 1.0
)
assert result["result"]["valid"] is True
def test_create_cone(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a Part::Cone primitive."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
doc = FreeCAD.ActiveDocument
cone = Part.makeCone(10, 0, 20) # Base radius=10, top radius=0, height=20
obj = doc.addObject("Part::Feature", "TestCone")
obj.Shape = cone
doc.recompute()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
"valid": obj.Shape.isValid()
}
""",
)
assert result["result"]["name"] == "TestCone"
# Volume of a cone = (1/3) * π * r² * h ≈ 2094.4
assert result["result"]["volume"] > 2000
assert result["result"]["valid"] is True
def test_create_torus(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test creating a Part::Torus primitive."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
doc = FreeCAD.ActiveDocument
torus = Part.makeTorus(20, 5) # Major radius=20, minor radius=5
obj = doc.addObject("Part::Feature", "TestTorus")
obj.Shape = torus
doc.recompute()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
"valid": obj.Shape.isValid()
}
""",
)
assert result["result"]["name"] == "TestTorus"
assert result["result"]["volume"] > 0
assert result["result"]["valid"] is True
class TestBooleanOperations:
"""Tests for boolean operations in headless mode."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Create a fresh document for each test."""
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
if "BooleanTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("BooleanTestDoc")
doc = FreeCAD.newDocument("BooleanTestDoc")
_result_ = True
""",
)
def test_boolean_fuse(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test boolean fuse (union) operation."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
doc = FreeCAD.ActiveDocument
# Create two overlapping boxes
box1 = Part.makeBox(10, 10, 10)
box2 = Part.makeBox(10, 10, 10, FreeCAD.Vector(5, 0, 0))
obj1 = doc.addObject("Part::Feature", "Box1")
obj1.Shape = box1
obj2 = doc.addObject("Part::Feature", "Box2")
obj2.Shape = box2
# Fuse them
fused = box1.fuse(box2)
obj_fused = doc.addObject("Part::Feature", "Fused")
obj_fused.Shape = fused
doc.recompute()
# Fused volume should be less than 2000 (two boxes) due to overlap
_result_ = {
"fused_volume": obj_fused.Shape.Volume,
"valid": obj_fused.Shape.isValid()
}
""",
)
# Two 10x10x10 boxes overlapping by 5mm = 2000 - 500 = 1500
assert result["result"]["fused_volume"] == pytest.approx(1500.0, rel=0.01)
assert result["result"]["valid"] is True
def test_boolean_cut(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test boolean cut (subtract) operation."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
doc = FreeCAD.ActiveDocument
# Create a box and a cylinder to cut from it
box = Part.makeBox(20, 20, 20)
cylinder = Part.makeCylinder(5, 30, FreeCAD.Vector(10, 10, -5))
# Cut cylinder from box
cut = box.cut(cylinder)
obj_cut = doc.addObject("Part::Feature", "Cut")
obj_cut.Shape = cut
doc.recompute()
box_volume = 20 * 20 * 20
import math
cylinder_volume_in_box = math.pi * 5**2 * 20 # Only 20mm of cylinder is in box
_result_ = {
"cut_volume": obj_cut.Shape.Volume,
"expected_volume": box_volume - cylinder_volume_in_box,
"valid": obj_cut.Shape.isValid()
}
""",
)
# Volume should be box - cylinder intersection
assert (
abs(result["result"]["cut_volume"] - result["result"]["expected_volume"])
< 10
)
assert result["result"]["valid"] is True
class TestObjectManipulation:
"""Tests for object manipulation in headless mode."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Create a fresh document with a test box."""
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
if "ManipTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("ManipTestDoc")
doc = FreeCAD.newDocument("ManipTestDoc")
box = Part.makeBox(10, 10, 10)
obj = doc.addObject("Part::Feature", "ManipBox")
obj.Shape = box
doc.recompute()
_result_ = True
""",
)
def test_set_placement_position(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test setting object position."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
doc = FreeCAD.ActiveDocument
obj = doc.getObject("ManipBox")
# Move the object
obj.Placement.Base = FreeCAD.Vector(100, 200, 300)
_result_ = {
"x": obj.Placement.Base.x,
"y": obj.Placement.Base.y,
"z": obj.Placement.Base.z
}
""",
)
assert result["result"]["x"] == 100.0
assert result["result"]["y"] == 200.0
assert result["result"]["z"] == 300.0
def test_set_placement_rotation(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test setting object rotation."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
doc = FreeCAD.ActiveDocument
obj = doc.getObject("ManipBox")
# Rotate the object 45 degrees around Z axis
obj.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45)
doc.recompute()
euler = obj.Placement.Rotation.toEuler()
_result_ = {
"rotation_z": euler[0], # Yaw
"valid": obj.Shape.isValid()
}
""",
)
# Check rotation is approximately 45 degrees
assert abs(result["result"]["rotation_z"] - 45.0) < 0.1
assert result["result"]["valid"] is True
class TestExportOperations:
"""Tests for export functionality in headless mode."""
@pytest.fixture(autouse=True)
def setup_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
) -> None:
"""Create a document with objects for export tests."""
execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
if "ExportTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("ExportTestDoc")
doc = FreeCAD.newDocument("ExportTestDoc")
# Create a simple box
box = Part.makeBox(10, 10, 10)
obj = doc.addObject("Part::Feature", "ExportBox")
obj.Shape = box
doc.recompute()
_result_ = True
""",
)
self.temp_dir = temp_dir
def test_export_step(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
) -> None:
"""Test exporting to STEP format."""
step_path = Path(temp_dir) / "test_export.step"
step_path_str = str(step_path)
result = execute_code(
xmlrpc_proxy,
f"""
import FreeCAD
doc = FreeCAD.ActiveDocument
obj = doc.getObject("ExportBox")
obj.Shape.exportStep({step_path_str!r})
import os
_result_ = {{"exported": os.path.exists({step_path_str!r})}}
""",
)
assert result["result"]["exported"] is True
assert step_path.exists()
def test_save_fcstd(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
) -> None:
"""Test saving as FreeCAD native format."""
fcstd_path = Path(temp_dir) / "test_save.FCStd"
fcstd_path_str = str(fcstd_path)
result = execute_code(
xmlrpc_proxy,
f"""
import FreeCAD
doc = FreeCAD.ActiveDocument
doc.saveAs({fcstd_path_str!r})
import os
_result_ = {{
"saved": os.path.exists({fcstd_path_str!r}),
"path": {fcstd_path_str!r}
}}
""",
)
assert result["result"]["saved"] is True
assert fcstd_path.exists()
class TestGUIFeaturesInHeadless:
"""Tests to verify GUI features fail gracefully in headless mode."""
def test_gui_features_not_available(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test that GUI-only features return appropriate errors."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
gui_available = FreeCAD.GuiUp
if not gui_available:
_result_ = {
"success": False,
"error": "GUI not available in headless mode",
"gui_up": False
}
else:
_result_ = {
"success": True,
"gui_up": True
}
""",
)
# In headless mode, GUI should not be available
assert result["result"]["gui_up"] is False
class TestComplexWorkflow:
"""Tests for complex multi-step workflows in headless mode."""
def test_parametric_modeling_workflow(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test a complete parametric modeling workflow."""
result = execute_code(
xmlrpc_proxy,
"""
import FreeCAD
import Part
# Create document
if "WorkflowTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("WorkflowTestDoc")
doc = FreeCAD.newDocument("WorkflowTestDoc")
# Step 1: Create base box
base_box = Part.makeBox(50, 50, 10)
base_obj = doc.addObject("Part::Feature", "Base")
base_obj.Shape = base_box
# Step 2: Create cylinder to cut
cylinder = Part.makeCylinder(5, 20, FreeCAD.Vector(25, 25, -5))
# Step 3: Cut hole in base
result_shape = base_box.cut(cylinder)
final_obj = doc.addObject("Part::Feature", "BaseWithHole")
final_obj.Shape = result_shape
# Step 4: Add fillet (rounded edges) - using Part API
# Select edges and apply fillet
try:
filleted = final_obj.Shape.makeFillet(2, final_obj.Shape.Edges[:4])
fillet_obj = doc.addObject("Part::Feature", "Filleted")
fillet_obj.Shape = filleted
fillet_success = True
except Exception:
fillet_success = False
doc.recompute()
_result_ = {
"objects": [obj.Name for obj in doc.Objects],
"final_volume": final_obj.Shape.Volume,
"final_valid": final_obj.Shape.isValid(),
"fillet_success": fillet_success
}
""",
)
assert "Base" in result["result"]["objects"]
assert "BaseWithHole" in result["result"]["objects"]
assert result["result"]["final_valid"] is True
# Volume should be base - cylinder hole
assert result["result"]["final_volume"] > 0