name: Integration Tests on: push: branches: [main, master] paths: - "src/freecad_mcp/**/*.py" - "macros/**/*.FCMacro" - "macros/**/*.py" - "addon/FreecadRobustMCPBridge/**/*.py" - "tests/integration/**/*.py" - ".github/workflows/macro-test.yaml" - ".github/actions/setup-freecad/**" pull_request: branches: [main, master] paths: - "src/freecad_mcp/**/*.py" - "macros/**/*.FCMacro" - "macros/**/*.py" - "addon/FreecadRobustMCPBridge/**/*.py" - "tests/integration/**/*.py" - ".github/workflows/macro-test.yaml" - ".github/actions/setup-freecad/**" workflow_dispatch: workflow_call: # Cancel in-progress runs for the same branch concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test-integration: name: Integration Tests with FreeCAD runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v6 - name: Install mise uses: jdx/mise-action@v3 - name: Setup FreeCAD uses: ./.github/actions/setup-freecad - name: Cache uv dependencies uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "**/uv.lock" - name: Install dependencies run: uv sync --all-extras - name: Validate macro syntax run: | echo "Validating macro Python syntax..." for macro in macros/**/*.FCMacro; do echo "Checking: $macro" python3 -m py_compile "$macro" || { echo "ERROR: Syntax error in $macro" exit 1 } done echo "All macros have valid Python syntax" - name: Start FreeCAD headless with MCP bridge run: | echo "Using FreeCAD: freecadcmd" # Start FreeCAD headless with MCP bridge in background # Uses the workbench addon's blocking bridge script # Use setsid to create a new process group for reliable cleanup setsid freecadcmd addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_bridge.log 2>&1 & FREECAD_PID=$! echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV" # Wait for bridge to be ready (check XML-RPC port) echo "Waiting for MCP bridge to start..." for i in {1..60}; do if curl -s --max-time 2 -X POST \ -H "Content-Type: text/xml" \ -d 'system.listMethods' \ http://localhost:9875 > /dev/null 2>&1; then echo "MCP bridge is ready (took ${i}s)" break fi if [ "$i" -eq 60 ]; then echo "ERROR: MCP bridge did not start within 60s" echo "=== Bridge log ===" cat /tmp/freecad_bridge.log || true kill "$FREECAD_PID" 2>/dev/null || true exit 1 fi sleep 1 done # Log the instance ID for debugging (pytest tests verify bridge via conftest.py fixtures) BRIDGE_INSTANCE_ID=$(grep -o 'FREECAD_MCP_BRIDGE_INSTANCE_ID=[^ ]*' /tmp/freecad_bridge.log | cut -d= -f2 | head -1) if [ -n "$BRIDGE_INSTANCE_ID" ]; then echo "Bridge Instance ID: $BRIDGE_INSTANCE_ID" else echo "Warning: Could not extract bridge instance ID from log" cat /tmp/freecad_bridge.log || true fi - name: Run CutObjectForMagnets macro tests env: FREECAD_MODE: xmlrpc # Note: Tests use boolean holes (not PartDesign::Hole) for CI compatibility. # PartDesign::Hole has a CADKernelError bug in some headless AppImage environments. run: | uv run pytest tests/integration/test_cut_object_for_magnets.py -v --tb=short - name: Run MultiExport macro tests env: FREECAD_MODE: xmlrpc # Export functionality should work in headless mode continue-on-error: true run: | uv run pytest tests/integration/test_multi_export.py -v --tb=short - name: Run headless mode integration tests env: FREECAD_MODE: xmlrpc # Tests for headless FreeCAD operations (primitives, booleans, exports, etc.) run: | uv run pytest tests/integration/test_headless_mode.py -v --tb=short - name: Upload FreeCAD logs on failure if: failure() || cancelled() uses: actions/upload-artifact@v6 with: name: freecad-headless-logs path: /tmp/freecad_bridge.log retention-days: 7 if-no-files-found: ignore - name: Stop FreeCAD if: always() run: | if [ -n "$FREECAD_PID" ]; then # Kill process group (handles child processes spawned by FreeCAD/AppImage) kill -- "-$FREECAD_PID" 2>/dev/null || kill "$FREECAD_PID" 2>/dev/null || true fi test-gui: name: GUI Tests with FreeCAD + Xvfb runs-on: ubuntu-latest timeout-minutes: 30 # FreeCAD GUI requires a window manager to generate expose/configure events. # Without a WM, the GUI binary hangs during Qt initialization. # Solution: Xvfb + openbox window manager + xdotool for synthetic events. steps: - name: Checkout code uses: actions/checkout@v6 - name: Install mise uses: jdx/mise-action@v3 # Note: apt cache removed - caching /var/cache/apt/archives causes permission # issues and is largely negated by the immediate sudo apt-get update - name: Install Xvfb, openbox, and X11 dependencies run: | sudo apt-get update sudo apt-get install -y \ xvfb \ openbox \ xdotool \ libxkbcommon-x11-0 \ libxcb-icccm4 \ libxcb-image0 \ libxcb-keysyms1 \ libxcb-randr0 \ libxcb-render-util0 \ libxcb-xinerama0 \ libxcb-xfixes0 \ libxcb-shape0 \ libxcb-cursor0 \ x11-utils \ libegl1 \ libgl1-mesa-dri \ libgl1 \ mesa-utils \ fontconfig \ fonts-dejavu-core # Rebuild font cache (required after installing fonts, especially from cache) sudo fc-cache -f -v - name: Setup FreeCAD uses: ./.github/actions/setup-freecad - name: Start Xvfb and openbox run: | echo "Starting Xvfb virtual display..." # Use -nolisten tcp to prevent TCP listeners for security Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & XVFB_PID=$! echo "XVFB_PID=$XVFB_PID" >> "$GITHUB_ENV" echo "DISPLAY=:99" >> "$GITHUB_ENV" sleep 2 # Verify Xvfb is running if ! ps -p $XVFB_PID > /dev/null; then echo "ERROR: Xvfb failed to start" exit 1 fi echo "Xvfb started on display :99 (PID: $XVFB_PID)" # Start openbox window manager (required for FreeCAD GUI) # FreeCAD needs a WM to generate expose/configure events echo "Starting openbox window manager..." DISPLAY=:99 openbox & OPENBOX_PID=$! echo "OPENBOX_PID=$OPENBOX_PID" >> "$GITHUB_ENV" sleep 1 if ! ps -p $OPENBOX_PID > /dev/null; then echo "WARNING: openbox may have failed to start" else echo "openbox started (PID: $OPENBOX_PID)" fi - name: Verify X11 display env: DISPLAY: ":99" run: | echo "Checking X11 display..." xdpyinfo | head -10 || echo "xdpyinfo not available" - name: Cache uv dependencies uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "**/uv.lock" - name: Install dependencies run: uv sync --all-extras - name: Verify fontconfig setup run: | echo "Checking fontconfig configuration..." # Verify the system fontconfig config exists if [ -f /etc/fonts/fonts.conf ]; then echo "System fontconfig found at /etc/fonts/fonts.conf" else echo "ERROR: /etc/fonts/fonts.conf not found!" ls -la /etc/fonts/ || echo "/etc/fonts/ directory doesn't exist" fi # Test fontconfig is working echo "Testing fc-list..." fc-list | head -5 || echo "fc-list failed" echo "Fontconfig setup verified." - name: Start FreeCAD GUI with MCP bridge env: DISPLAY: ":99" QT_QPA_PLATFORM: xcb # Use software rendering for OpenGL (more reliable in CI) LIBGL_ALWAYS_SOFTWARE: "1" # Explicit fontconfig paths for AppImage compatibility # The AppImage's bundled fontconfig may not find system config automatically FONTCONFIG_FILE: /etc/fonts/fonts.conf FONTCONFIG_PATH: /etc/fonts # Set XDG_RUNTIME_DIR to avoid Qt warning XDG_RUNTIME_DIR: /tmp/runtime-root run: | # Create XDG_RUNTIME_DIR mkdir -p "$XDG_RUNTIME_DIR" chmod 700 "$XDG_RUNTIME_DIR" echo "Starting FreeCAD GUI with MCP bridge under Xvfb + openbox..." echo "DISPLAY=$DISPLAY" echo "QT_QPA_PLATFORM=$QT_QPA_PLATFORM" echo "LIBGL_ALWAYS_SOFTWARE=$LIBGL_ALWAYS_SOFTWARE" echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" # Verify freecadcmd works (headless mode) echo "=== Testing freecadcmd (headless) ===" freecadcmd --version || echo "freecadcmd failed" # Export fontconfig vars so they're available to backgrounded process export FONTCONFIG_FILE FONTCONFIG_PATH # Start FreeCAD GUI (not headless) with MCP bridge # Uses blocking_bridge.py which blocks with run_forever() to keep process alive # GUI features are available since we're using 'freecad' not 'freecadcmd' # Use setsid to create a new process group for reliable cleanup setsid freecad addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_gui.log 2>&1 & FREECAD_PID=$! echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV" echo "Waiting for MCP bridge to start..." # Send xdotool events to help FreeCAD GUI initialize # FreeCAD needs mouse/keyboard events to process its event queue for i in {1..90}; do # Send synthetic events to help FreeCAD initialize xdotool mousemove $((400 + i*3)) $((300 + i*3)) click 1 key Escape 2>/dev/null || true # Check if process is still running if ! ps -p "$FREECAD_PID" > /dev/null 2>&1; then echo "ERROR: FreeCAD process died (PID $FREECAD_PID)" echo "=== FreeCAD GUI log ===" cat /tmp/freecad_gui.log || echo "Log file is empty or missing" exit 1 fi if curl -s --max-time 2 -X POST \ -H "Content-Type: text/xml" \ -d 'system.listMethods' \ http://localhost:9875 > /dev/null 2>&1; then echo "MCP bridge is ready (took ${i}s)" break fi if [ "$i" -eq 90 ]; then echo "ERROR: MCP bridge did not start within 90s" echo "=== FreeCAD GUI log ===" cat /tmp/freecad_gui.log || echo "Log file is empty or missing" echo "=== Process status ===" pgrep -a freecad || echo "No freecad processes found" kill -- "-$FREECAD_PID" 2>/dev/null || kill "$FREECAD_PID" 2>/dev/null || true exit 1 fi # Show progress every 10 seconds if [ $((i % 10)) -eq 0 ]; then echo "Still waiting... (${i}s)" # Show last few lines of log tail -5 /tmp/freecad_gui.log 2>/dev/null || true fi sleep 1 done # Verify GUI is available sleep 2 BRIDGE_INSTANCE_ID=$(grep -o 'FREECAD_MCP_BRIDGE_INSTANCE_ID=[^ ]*' /tmp/freecad_gui.log | cut -d= -f2 | head -1) echo "Bridge Instance ID: $BRIDGE_INSTANCE_ID" # Check if GUI is up if grep -q "GuiUp.*True\|GUI.*available" /tmp/freecad_gui.log 2>/dev/null; then echo "FreeCAD GUI is available" else echo "Note: Could not confirm GUI status from log (may still work)" fi - name: Run GUI mode integration tests env: FREECAD_MODE: xmlrpc DISPLAY: ":99" # Tests for GUI-only features (screenshots, visibility, colors, camera, etc.) # GUI tests under Xvfb can be flaky; don't fail the workflow during stabilization continue-on-error: true run: | uv run pytest tests/integration/test_gui_mode.py -v --tb=short - name: Upload FreeCAD logs on failure if: failure() || cancelled() uses: actions/upload-artifact@v6 with: name: freecad-gui-logs path: /tmp/freecad_gui.log retention-days: 7 if-no-files-found: ignore - name: Stop FreeCAD, openbox, and Xvfb if: always() run: | if [ -n "$FREECAD_PID" ]; then # Kill process group (handles child processes spawned by FreeCAD/AppImage) kill -- "-$FREECAD_PID" 2>/dev/null || kill "$FREECAD_PID" 2>/dev/null || true fi if [ -n "$OPENBOX_PID" ]; then kill "$OPENBOX_PID" 2>/dev/null || true fi if [ -n "$XVFB_PID" ]; then kill "$XVFB_PID" 2>/dev/null || true fi lint-macros: name: Lint Macro Files runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Install mise uses: jdx/mise-action@v3 - name: Cache uv dependencies uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "**/uv.lock" - name: Install dependencies run: uv sync --all-extras - name: Lint macro files with Ruff run: | # Run ruff on macro files (check only, don't fail on issues specific to FreeCAD macros) uv run ruff check macros/ --select=E,F,W --ignore=F401,E402 || true - name: Check macro documentation run: | # Ensure each macro directory has a README for macro_dir in macros/*/; do if [ -d "$macro_dir" ]; then readme_found=false for readme in "$macro_dir"README*.md; do if [ -f "$readme" ]; then readme_found=true break fi done if [ "$readme_found" = false ]; then echo "WARNING: No README found in $macro_dir" else echo "OK: README found in $macro_dir" fi fi done