From 610654ae380fef15e2662191d83f86fe33118a32 Mon Sep 17 00:00:00 2001 From: "Sean P. Kane" Date: Sun, 18 Jan 2026 23:17:07 -0800 Subject: [PATCH] fix: Ensure auto-start works correctly (#40) * fix: Ensure auto-start works correctly * fix: Correct auto-start functionality for Bridge * fix: general improvements --- CLAUDE.md | 137 ++++++- addon/FreecadRobustMCPBridge/Init.py | 90 ++++- addon/FreecadRobustMCPBridge/InitGui.py | 66 ++++ addon/FreecadRobustMCPBridge/RELEASE_NOTES.md | 7 +- .../freecad_mcp_bridge/startup_bridge.py | 62 +++- tests/unit/addon/test_addon_autostart.py | 340 ++++++++++++++++++ 6 files changed, 665 insertions(+), 37 deletions(-) create mode 100644 tests/unit/addon/test_addon_autostart.py diff --git a/CLAUDE.md b/CLAUDE.md index e0c2a76..c67f9dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1009,6 +1009,43 @@ FreeCAD can run in two modes: 1. **GUI mode**: Full graphical interface with 3D view, accessed via `FreeCAD.app` or `freecad` 1. **Headless mode**: Console-only, no GUI, accessed via `FreeCADCmd` or `freecadcmd` +### FreeCAD Workbench Addon Loading (Critical) + +**CRITICAL**: For FreeCAD **workbench addons**, `Init.py` does **NOT** run at FreeCAD startup. Only `InitGui.py` module-level code runs when FreeCAD GUI starts. + +| File | When It Runs | Use Case | +| ------------ | -------------------------------------------------- | ---------------------------------------- | +| `Init.py` | Only when workbench is **selected** by user | Workbench-specific initialization | +| `InitGui.py` | **Module-level code** runs at FreeCAD GUI startup | Auto-start features, status bar setup | +| `InitGui.py` | `Initialize()` method runs when workbench selected | Toolbar/menu setup, command registration | + +**Why this matters for auto-start:** + +If you put auto-start logic in `Init.py`, it will only run when the user manually selects the workbench - NOT when FreeCAD starts. To auto-start the MCP bridge at FreeCAD startup: + +1. Put auto-start code in `InitGui.py` **module-level code** (outside any class/method) +2. Use `QTimer.singleShot()` to defer execution until GUI is fully ready +3. The `Initialize()` method is too late - it only runs when workbench is selected + +**Example pattern in InitGui.py:** + +```python +# Module-level code - runs at FreeCAD GUI startup +try: + from PySide2 import QtCore +except ImportError: + from PySide6 import QtCore + +def _deferred_auto_start() -> None: + """Auto-start bridge after GUI is fully ready.""" + # ... auto-start logic here ... + +# Schedule auto-start after GUI initializes (3 second delay) +QtCore.QTimer.singleShot(3000, _deferred_auto_start) +``` + +**This is different from regular Python packages** where `__init__.py` runs on import. FreeCAD workbenches have special loading behavior. + ### Detecting GUI Availability **CRITICAL**: Always use `FreeCAD.GuiUp` to check if the GUI is available. **Never** check for Qt/PySide availability as a proxy for GUI mode. @@ -1031,7 +1068,7 @@ else: When FreeCAD starts in GUI mode, there's a timing window where: -1. `Init.py` runs when `FreeCAD.GuiUp = False` (GUI not yet initialized) +1. `InitGui.py` module-level code runs when `FreeCAD.GuiUp = False` (GUI not yet initialized) 2. Qt/PySide is available (can import successfully) 3. The bridge starts, sees `GuiUp = False`, and starts a background thread for queue processing 4. FreeCAD GUI finishes initializing (`GuiUp` becomes `True`) @@ -1044,28 +1081,59 @@ When FreeCAD starts in GUI mode, there's a timing window where: - Integration tests pass initial connection, then crash on first document operation - Thread check shows `is_main_thread: False` with `thread_name: 'MCP-QueueProcessor'` even when `gui_up: True` -**The Fix (in `Init.py`):** +**The Fix (in `InitGui.py` or `startup_bridge.py`):** -When Qt is available but `FreeCAD.GuiUp` is `False`, use a repeating timer to wait for `GuiUp` to become `True` before starting the bridge: +Use `QTimer.singleShot()` to defer bridge startup until after GUI is ready: ```python -# WRONG - starts bridge immediately, will use background thread -elif QtCore is not None: - _auto_start_bridge() +# WRONG - starts bridge immediately, GUI may not be ready +_auto_start_bridge() -# CORRECT - wait for GUI to be ready -elif QtCore is not None: - _auto_start_timer = QtCore.QTimer() - _auto_start_timer.setSingleShot(False) # Repeating - _auto_start_timer.timeout.connect(_wait_for_gui_and_start) - _auto_start_timer.start(100) # Check every 100ms +# CORRECT - wait for GUI to be ready (use sufficient delay) +QtCore.QTimer.singleShot(3000, _deferred_auto_start) ``` -**Note:** This QTimer wait pattern is **only for GUI startup scenarios**. In headless mode (`freecadcmd`), the bridge starts directly without waiting because there is no Qt event loop to wait for. The three startup paths are: +For `startup_bridge.py`, use `GuiWaiter` to poll for `FreeCAD.GuiUp`: -1. **GUI already up** (`FreeCAD.GuiUp = True`): Start bridge immediately -2. **GUI starting** (Qt available, `GuiUp = False`): Use QTimer to wait for GUI -3. **Headless** (no Qt): Start bridge immediately with background thread +```python +# Wait for GuiUp to become True before starting +from bridge_utils import GuiWaiter +_gui_waiter = GuiWaiter(callback=_start_bridge, log_prefix="Startup Bridge") +_gui_waiter.start() +``` + +**Note:** This QTimer wait pattern is **only for GUI startup scenarios**. In headless mode (`freecadcmd`), the bridge starts directly without waiting because there is no Qt event loop to wait for. The four startup paths are: + +1. **GUI already up** (`FreeCAD.GuiUp = True`): Start bridge immediately with Qt timer +2. **True headless** (`QCoreApplication` exists but is NOT a `QApplication`): Start directly with background thread +3. **GUI starting** (Qt available but no app yet, or `QApplication` initializing): Use GuiWaiter to wait for GUI +4. **No Qt available** (unusual state): Start directly + +**IMPORTANT - Detecting True Headless vs Early GUI Startup:** + +Simply checking `QApplication.instance() is None` is NOT sufficient because: + +- In true headless mode (`freecadcmd`): `QCoreApplication` exists but NOT `QApplication` +- In early GUI startup: No application exists yet, but GUI will be available soon + +The correct detection uses `isinstance` to distinguish these cases: + +```python +# Check for QApplication first +qapp = QtWidgets.QApplication.instance() +if qapp is not None: + # QApplication exists - GUI is available or starting + _has_qapp = True +else: + # No QApplication - check if QCoreApplication exists + qcore_app = QtCore.QCoreApplication.instance() + if qcore_app is not None and not isinstance(qcore_app, QtWidgets.QApplication): + # QCoreApplication exists but is NOT a QApplication = true headless + _is_true_headless = True + # If no app at all, assume early GUI startup (will use GuiWaiter) +``` + +This logic is implemented in both `Init.py` and `startup_bridge.py` and MUST be kept in sync. **Testing:** @@ -1077,6 +1145,43 @@ An integration test in `tests/integration/test_thread_safety.py` verifies that: **Key Lesson:** Never assume Qt availability means GUI is ready. Always check `FreeCAD.GuiUp` before doing operations that depend on the Qt event loop running on the main thread. +### Keeping Startup Scripts in Sync (Critical) + +**CRITICAL**: The MCP bridge can be started from THREE different entry points. They must use compatible detection logic: + +| File | Purpose | +| ------------------------------------------------ | ------------------------------------------------ | +| `addon/FreecadRobustMCPBridge/InitGui.py` | Auto-start at FreeCAD GUI startup (if enabled) | +| `addon/FreecadRobustMCPBridge/Init.py` | Fallback auto-start when workbench selected | +| `addon/.../freecad_mcp_bridge/startup_bridge.py` | Manual start via `just freecad::run-gui` | + +**When modifying startup logic in one file, you MUST update the others to match.** + +All files must have compatible logic for: + +1. **QApplication detection** - Use `QApplication.instance()` to detect GUI vs headless +2. **GuiWaiter usage** - Wait for `GuiUp` before starting in GUI mode +3. **Headless detection** - Start directly when no QApplication exists +4. **Diagnostic logging** - Log `GuiUp`, `QtCore`, and `QApp` state +5. **Already running check** - Skip start if bridge already running + +**Why these files exist:** + +- `InitGui.py` module-level code runs at FreeCAD GUI startup (primary auto-start location) +- `Init.py` runs when workbench is selected (fallback if InitGui didn't auto-start) +- `startup_bridge.py` is passed as a command-line argument for `just freecad::run-gui` +- All check if a bridge is already running to avoid conflicts + +**Test command to verify both work:** + +```bash +# Test headless mode (uses blocking_bridge.py) +just testing::integration-headless-release + +# Test GUI mode (uses startup_bridge.py, InitGui.py auto-start may also be active) +just testing::integration-gui-release +``` + ### Why NOT to Check for PySide/Qt **WRONG** - Do not use Qt availability to detect GUI mode: diff --git a/addon/FreecadRobustMCPBridge/Init.py b/addon/FreecadRobustMCPBridge/Init.py index 4aad62d..649f79c 100644 --- a/addon/FreecadRobustMCPBridge/Init.py +++ b/addon/FreecadRobustMCPBridge/Init.py @@ -13,13 +13,16 @@ must run on the main thread. from __future__ import annotations -from typing import TYPE_CHECKING, Any +# Import FreeCAD first so we can log early +import FreeCAD + +FreeCAD.Console.PrintMessage("Robust MCP Bridge: Init.py loaded\n") + +from typing import TYPE_CHECKING, Any # noqa: E402 if TYPE_CHECKING: from freecad_mcp_bridge.bridge_utils import GuiWaiter -import FreeCAD - FreeCAD.Console.PrintMessage("Robust MCP Bridge: Init loaded\n") # Global reference to GuiWaiter and auto-start timer to prevent garbage collection @@ -83,15 +86,22 @@ def _auto_start_bridge() -> None: except Exception as e: FreeCAD.Console.PrintError(f"Failed to auto-start MCP Bridge: {e}\n") + import traceback + + FreeCAD.Console.PrintError(f"Traceback: {traceback.format_exc()}\n") # Schedule auto-start after FreeCAD finishes loading # Strategy: # - If FreeCAD.GuiUp is True: Qt event loop is running, use timer for deferred start -# - If FreeCAD.GuiUp is False but Qt is available: FreeCAD GUI is initializing. +# - If FreeCAD.GuiUp is False but QApplication exists: FreeCAD GUI is initializing. # Use GuiWaiter to wait for GuiUp to become True before starting. # This ensures the bridge uses Qt timer (not background thread) for queue processing. -# - If Qt is not available: Pure headless mode, start bridge directly +# - If no QApplication: True headless mode, start bridge directly +# +# IMPORTANT: We check for QApplication.instance() rather than just QtCore availability +# because FreeCAD bundles PySide even in headless mode (freecadcmd), but there's no +# Qt event loop running. Without a QApplication, Qt timers will never fire. # # CRITICAL: We must wait for FreeCAD.GuiUp to be True before starting the bridge # in GUI mode. If we start when GuiUp is False, the bridge's _start_queue_processor() @@ -100,19 +110,59 @@ def _auto_start_bridge() -> None: try: from preferences import get_auto_start - if get_auto_start(): - # Try to import Qt + _auto_start_enabled = get_auto_start() + FreeCAD.Console.PrintMessage( + f"Robust MCP Bridge: Auto-start preference = {_auto_start_enabled}\n" + ) + + if _auto_start_enabled: + # Try to import Qt and check for running QApplication import contextlib QtCore = None + QtWidgets = None + _has_qapp = False + _is_true_headless = False + try: - from PySide2 import QtCore # type: ignore[assignment, no-redef] + from PySide2 import QtCore, QtWidgets # type: ignore[assignment, no-redef] except ImportError: with contextlib.suppress(ImportError): - from PySide6 import QtCore # type: ignore[assignment, no-redef] + from PySide6 import ( # type: ignore[assignment, no-redef] + QtCore, + QtWidgets, + ) + + # Detect GUI mode vs true headless mode + # - True headless (freecadcmd): QCoreApplication exists but NOT QApplication + # - GUI mode early startup: No app yet, or QApplication being initialized + # - GUI mode ready: FreeCAD.GuiUp is True + if QtWidgets is not None and QtCore is not None: + qapp = QtWidgets.QApplication.instance() + if qapp is not None: + _has_qapp = True + else: + # No QApplication - check if QCoreApplication exists + # If QCoreApplication exists but is NOT a QApplication, it's true headless + qcore_app = QtCore.QCoreApplication.instance() + if qcore_app is not None and not isinstance( + qcore_app, QtWidgets.QApplication + ): + _is_true_headless = True + # If no app at all, assume early GUI startup (will use GuiWaiter) + + FreeCAD.Console.PrintMessage( + f"Robust MCP Bridge: GuiUp={FreeCAD.GuiUp}, " + f"QtCore={'available' if QtCore else 'unavailable'}, " + f"QApp={'running' if _has_qapp else 'none'}, " + f"headless={_is_true_headless}\n" + ) if FreeCAD.GuiUp: # GUI is already up - use timer for deferred start + FreeCAD.Console.PrintMessage( + "Robust MCP Bridge: GUI already up, scheduling deferred start...\n" + ) if QtCore is not None: _auto_start_timer = QtCore.QTimer() _auto_start_timer.setSingleShot(True) @@ -121,9 +171,21 @@ try: else: # GUI is up but Qt import failed - start directly _auto_start_bridge() + elif _is_true_headless: + # True headless mode - QCoreApplication exists but not QApplication + # No Qt event loop for GUI, so start bridge directly with background thread + FreeCAD.Console.PrintMessage( + "Robust MCP Bridge: True headless mode (QCoreApplication only), " + "starting directly...\n" + ) + _auto_start_bridge() elif QtCore is not None: - # GUI not ready yet, but Qt is available (FreeCAD starting in GUI mode) + # GUI not ready yet (either QApplication exists or no app yet) # Use GuiWaiter to wait for GuiUp to become True before starting + # This ensures the bridge uses Qt timer (not background thread) for queue + FreeCAD.Console.PrintMessage( + "Robust MCP Bridge: GUI not ready, using GuiWaiter...\n" + ) from freecad_mcp_bridge.bridge_utils import GuiWaiter _gui_waiter = GuiWaiter( @@ -136,7 +198,13 @@ try: ) _gui_waiter.start() else: - # True headless mode - no Qt, no GUI + # No Qt available at all - unusual state, start directly + FreeCAD.Console.PrintMessage( + "Robust MCP Bridge: No Qt available, starting directly...\n" + ) _auto_start_bridge() except Exception as e: FreeCAD.Console.PrintWarning(f"Could not set up auto-start: {e}\n") + import traceback + + FreeCAD.Console.PrintWarning(f"Traceback: {traceback.format_exc()}\n") diff --git a/addon/FreecadRobustMCPBridge/InitGui.py b/addon/FreecadRobustMCPBridge/InitGui.py index a115ed9..097dd57 100644 --- a/addon/FreecadRobustMCPBridge/InitGui.py +++ b/addon/FreecadRobustMCPBridge/InitGui.py @@ -178,6 +178,72 @@ try: FreeCAD.Console.PrintMessage( "Robust MCP Bridge: Status bar sync scheduled from InitGui (2s)\n" ) + + # Auto-start the MCP bridge if configured in preferences + # This runs at FreeCAD GUI startup (InitGui.py module-level code) + # Note: Init.py does NOT run at startup for workbench addons, so auto-start + # must be triggered from here instead. + def _deferred_auto_start() -> None: + """Auto-start bridge after GUI is fully ready.""" + try: + from preferences import get_auto_start + + if not get_auto_start(): + return + + # Check if bridge is already running + from commands import is_bridge_running + + if is_bridge_running(): + FreeCAD.Console.PrintMessage( + "Robust MCP Bridge: Bridge already running, skipping auto-start\n" + ) + return + + FreeCAD.Console.PrintMessage( + "Robust MCP Bridge: Auto-starting from InitGui...\n" + ) + + # Import and start the bridge + from freecad_mcp_bridge.bridge_utils import register_mcp_plugin + from freecad_mcp_bridge.server import FreecadMCPPlugin + from preferences import get_socket_port, get_xmlrpc_port + + xmlrpc_port = get_xmlrpc_port() + socket_port = get_socket_port() + + plugin = FreecadMCPPlugin( + host="localhost", + port=socket_port, + xmlrpc_port=xmlrpc_port, + enable_xmlrpc=True, + ) + plugin.start() + + # Register plugin with commands module + register_mcp_plugin(plugin, xmlrpc_port, socket_port) + + # Sync status bar now that bridge is running + try: + from status_widget import sync_status_with_bridge + + sync_status_with_bridge() + except Exception as status_err: + FreeCAD.Console.PrintWarning( + f"Could not sync status bar after auto-start: {status_err}\n" + ) + except Exception as e: + FreeCAD.Console.PrintError(f"Robust MCP Bridge: Auto-start failed: {e}\n") + import traceback + + FreeCAD.Console.PrintError(traceback.format_exc()) + + # Schedule auto-start after a short delay to ensure GUI is fully ready + # Use a longer delay than status bar sync to avoid race conditions + QtCore.QTimer.singleShot(3000, _deferred_auto_start) + FreeCAD.Console.PrintMessage( + "Robust MCP Bridge: Auto-start scheduled from InitGui (3s)\n" + ) except Exception as e: FreeCAD.Console.PrintWarning( f"Robust MCP Bridge: Could not schedule status bar sync: {e}\n" diff --git a/addon/FreecadRobustMCPBridge/RELEASE_NOTES.md b/addon/FreecadRobustMCPBridge/RELEASE_NOTES.md index 594a9b0..f8472ff 100644 --- a/addon/FreecadRobustMCPBridge/RELEASE_NOTES.md +++ b/addon/FreecadRobustMCPBridge/RELEASE_NOTES.md @@ -2,7 +2,7 @@ ## Version 0.6.2 (2026-01-18) -No changes to the workbench code itself. This release reflects the repository restructuring. +This release fixes some auto-start issues and improves the overall startup experience across all supported modes. ### Added @@ -11,10 +11,13 @@ No changes to the workbench code itself. This release reflects the repository re ### Changed - **Repository restructured**: This addon now focuses solely on the MCP Bridge Workbench. Standalone macros have been moved to dedicated repositories for independent release cycles. +- **Cleaner startup messages**: Removed duplicate success messages when bridge auto-starts. ### Fixed -- No bug fixes in this release. +- **Auto-start not working at FreeCAD startup**: Fixed bug where auto-start only worked when manually selecting the workbench. For FreeCAD workbench addons, `Init.py` does NOT run at startup - only `InitGui.py` module-level code runs. Auto-start logic has been moved to `InitGui.py`. +- **Status bar not appearing after auto-start**: The status bar widget now syncs immediately after the bridge starts, instead of on a timer that ran before the bridge was ready. +- **Integration test crashes**: Fixed race condition where the bridge could start before `FreeCAD.GuiUp` was `True`, causing Qt operations to run on a background thread and crash FreeCAD. ### Note diff --git a/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/startup_bridge.py b/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/startup_bridge.py index d388a1e..c2920ab 100755 --- a/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/startup_bridge.py +++ b/addon/FreecadRobustMCPBridge/freecad_mcp_bridge/startup_bridge.py @@ -128,31 +128,76 @@ def _start_bridge() -> None: # Schedule bridge start after FreeCAD finishes loading # Strategy: # - If FreeCAD.GuiUp is True: Qt event loop is running, start bridge directly -# - If FreeCAD.GuiUp is False but Qt is available: FreeCAD GUI is initializing. +# - If FreeCAD.GuiUp is False but QApplication exists: FreeCAD GUI is initializing. # Use GuiWaiter to wait for GuiUp to become True before starting. # This ensures the bridge uses Qt timer (not background thread) for queue processing. -# - If Qt is not available: Pure headless mode, start bridge directly +# - If no QApplication: True headless mode, start bridge directly +# +# IMPORTANT: We check for QApplication.instance() rather than just QtCore availability +# because FreeCAD bundles PySide even in headless mode (freecadcmd), but there's no +# Qt event loop running. Without a QApplication, Qt timers will never fire. # # CRITICAL: We must wait for FreeCAD.GuiUp to be True before starting the bridge # in GUI mode. If we start when GuiUp is False, the bridge's _start_queue_processor() # will see GuiUp=False and use a background thread. Later, code executed on that # thread will try to do Qt operations, causing crashes (SIGABRT in QCocoaWindow). try: - # Try to import Qt + # Try to import Qt and check for running QApplication QtCore = None + QtWidgets = None + _has_qapp = False + _is_true_headless = False + try: - from PySide2 import QtCore # type: ignore[assignment, no-redef] + from PySide2 import QtCore, QtWidgets # type: ignore[assignment, no-redef] except ImportError: with contextlib.suppress(ImportError): - from PySide6 import QtCore # type: ignore[assignment, no-redef] + from PySide6 import QtCore, QtWidgets # type: ignore[assignment, no-redef] + + # Detect GUI mode vs true headless mode + # - True headless (freecadcmd): QCoreApplication exists but NOT QApplication + # - GUI mode early startup: No app yet, or QApplication being initialized + # - GUI mode ready: FreeCAD.GuiUp is True + if QtWidgets is not None and QtCore is not None: + qapp = QtWidgets.QApplication.instance() + if qapp is not None: + _has_qapp = True + else: + # No QApplication - check if QCoreApplication exists + # If QCoreApplication exists but is NOT a QApplication, it's true headless + qcore_app = QtCore.QCoreApplication.instance() + if qcore_app is not None and not isinstance( + qcore_app, QtWidgets.QApplication + ): + _is_true_headless = True + # If no app at all, assume early GUI startup (will use GuiWaiter) + + FreeCAD.Console.PrintMessage( + f"Startup Bridge: GuiUp={FreeCAD.GuiUp}, " + f"QtCore={'available' if QtCore else 'unavailable'}, " + f"QApp={'running' if _has_qapp else 'none'}, " + f"headless={_is_true_headless}\n" + ) if FreeCAD.GuiUp: # GUI is already up - start bridge directly FreeCAD.Console.PrintMessage("Startup Bridge: GUI already up, starting...\n") _start_bridge() + elif _is_true_headless: + # True headless mode - QCoreApplication exists but not QApplication + # No Qt event loop for GUI, so start bridge directly with background thread + FreeCAD.Console.PrintMessage( + "Startup Bridge: True headless mode (QCoreApplication only), " + "starting directly...\n" + ) + _start_bridge() elif QtCore is not None: - # GUI not ready yet, but Qt is available (FreeCAD starting in GUI mode) + # GUI not ready yet (either QApplication exists or no app yet) # Use GuiWaiter to wait for GuiUp to become True before starting + # This ensures the bridge uses Qt timer (not background thread) for queue + FreeCAD.Console.PrintMessage( + "Startup Bridge: GUI not ready, using GuiWaiter...\n" + ) from bridge_utils import GuiWaiter _gui_waiter = GuiWaiter( @@ -165,10 +210,11 @@ try: ) _gui_waiter.start() else: - # True headless mode - no Qt, no GUI + # No Qt available at all - unusual state, start directly FreeCAD.Console.PrintMessage( - "Startup Bridge: Headless mode, starting directly...\n" + "Startup Bridge: No Qt available, starting directly...\n" ) _start_bridge() except Exception as e: FreeCAD.Console.PrintError(f"Startup Bridge: Failed to initialize: {e}\n") + FreeCAD.Console.PrintError(traceback.format_exc()) diff --git a/tests/unit/addon/test_addon_autostart.py b/tests/unit/addon/test_addon_autostart.py new file mode 100644 index 0000000..206f71d --- /dev/null +++ b/tests/unit/addon/test_addon_autostart.py @@ -0,0 +1,340 @@ +"""Tests for the FreeCAD Robust MCP workbench auto-start logic. + +These tests verify that the auto-start logic in Init.py is correctly +implemented with proper diagnostic logging and all code paths covered. + +This test suite was added after discovering a bug where auto-start wasn't +working because the code flow wasn't properly logging which path was taken, +making it difficult to debug. +""" + +import ast +from pathlib import Path + +import pytest + +# Get the addon directory path +ADDON_DIR = ( + Path(__file__).parent.parent.parent.parent / "addon" / "FreecadRobustMCPBridge" +) + + +class TestAutoStartPreferences: + """Tests for auto-start preference functions.""" + + @pytest.fixture + def preferences_code(self) -> str: + """Load preferences.py content.""" + return (ADDON_DIR / "preferences.py").read_text() + + def test_get_auto_start_function_exists(self, preferences_code: str) -> None: + """preferences.py should have get_auto_start function.""" + assert "def get_auto_start" in preferences_code + + def test_set_auto_start_function_exists(self, preferences_code: str) -> None: + """preferences.py should have set_auto_start function.""" + assert "def set_auto_start" in preferences_code + + def test_auto_start_default_is_false(self, preferences_code: str) -> None: + """Default auto-start should be False for safety.""" + assert "DEFAULT_AUTO_START = False" in preferences_code + + def test_auto_start_uses_param_path(self, preferences_code: str) -> None: + """Auto-start should use FreeCAD parameter system.""" + assert "PARAM_PATH" in preferences_code + assert "RobustMCPBridge" in preferences_code + + def test_get_auto_start_returns_bool(self, preferences_code: str) -> None: + """get_auto_start should return a bool.""" + # Check for GetBool call + assert "GetBool" in preferences_code + # Use AST to verify return type annotation + tree = ast.parse(preferences_code) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "get_auto_start": + assert node.returns is not None, ( + "get_auto_start should have return annotation" + ) + # Check if return annotation is 'bool' + if isinstance(node.returns, ast.Name): + assert node.returns.id == "bool", "Return type should be bool" + elif isinstance(node.returns, ast.Constant): + # Python 3.9+ may use Constant for some annotations + assert node.returns.value == "bool", "Return type should be bool" + else: + pytest.fail( + f"Unexpected return annotation type: {type(node.returns)}" + ) + return + pytest.fail("get_auto_start function not found in preferences.py") + + +class TestAutoStartInitLogic: + """Tests for auto-start logic in Init.py.""" + + @pytest.fixture + def init_code(self) -> str: + """Load Init.py content.""" + return (ADDON_DIR / "Init.py").read_text() + + def test_imports_get_auto_start(self, init_code: str) -> None: + """Init.py should import get_auto_start from preferences.""" + assert "from preferences import get_auto_start" in init_code + + def test_checks_auto_start_preference(self, init_code: str) -> None: + """Init.py should check the auto_start preference value.""" + # Should call get_auto_start() and store/check the result + assert "get_auto_start()" in init_code + + def test_logs_auto_start_preference_value(self, init_code: str) -> None: + """Init.py should log whether auto-start is enabled. + + This diagnostic logging helps debug auto-start issues by showing + what value was read from preferences. + """ + # Should log the actual preference value + assert "Auto-start preference" in init_code + + def test_logs_gui_state(self, init_code: str) -> None: + """Init.py should log GuiUp, QtCore, and QApp availability. + + This diagnostic logging helps debug which code path is taken. + """ + assert "GuiUp=" in init_code + assert "QtCore=" in init_code + assert "QApp=" in init_code + + def test_handles_gui_already_up_path(self, init_code: str) -> None: + """Init.py should handle the case when GUI is already up.""" + # Should check FreeCAD.GuiUp and log accordingly + assert "GUI already up" in init_code + + def test_handles_gui_not_ready_path(self, init_code: str) -> None: + """Init.py should handle the case when GUI is not yet ready.""" + # Should use GuiWaiter when GUI is not ready but Qt is available + assert "GUI not ready" in init_code + assert "GuiWaiter" in init_code + + def test_handles_headless_path(self, init_code: str) -> None: + """Init.py should handle true headless mode (QCoreApplication only).""" + # Should detect and handle true headless mode + assert "True headless mode" in init_code or "headless" in init_code.lower() + # Should check QCoreApplication to detect true headless + assert "QCoreApplication" in init_code + # Should use isinstance to distinguish QCoreApplication from QApplication + assert "isinstance" in init_code + + def test_imports_gui_waiter(self, init_code: str) -> None: + """Init.py should import GuiWaiter for waiting on GUI.""" + assert "from freecad_mcp_bridge.bridge_utils import GuiWaiter" in init_code + + def test_uses_single_shot_timer_for_gui_up(self, init_code: str) -> None: + """When GUI is up, should use single-shot timer for deferred start.""" + assert "setSingleShot(True)" in init_code + + def test_exception_handling_with_traceback(self, init_code: str) -> None: + """Auto-start setup should catch exceptions and log traceback. + + Without traceback logging, failures are silent and hard to debug. + """ + assert "except Exception" in init_code + # Should log the traceback, not just the exception message + assert "traceback" in init_code.lower() + + @pytest.mark.parametrize( + "var_name", + ["_auto_start_timer", "_gui_waiter"], + ids=["timer_reference", "gui_waiter_reference"], + ) + def test_global_references_prevent_gc(self, init_code: str, var_name: str) -> None: + """Global references should be stored at module level to prevent GC. + + Both _auto_start_timer and _gui_waiter must be defined at module level + (not indented) to prevent garbage collection of Qt timers and callbacks. + """ + assert var_name in init_code, f"{var_name} should be present in Init.py" + # Verify module-level definition (line starts with var name, not indented) + lines = init_code.split("\n") + for line in lines: + if line.startswith(var_name): + # Found module-level definition (not indented) + return + pytest.fail(f"{var_name} should be defined at module level (not indented)") + + +class TestAutoStartBridgeFunction: + """Tests for the _auto_start_bridge function.""" + + @pytest.fixture + def init_code(self) -> str: + """Load Init.py content.""" + return (ADDON_DIR / "Init.py").read_text() + + def test_auto_start_bridge_function_exists(self, init_code: str) -> None: + """_auto_start_bridge function should exist.""" + assert "def _auto_start_bridge" in init_code + + def test_auto_start_bridge_checks_preference_again(self, init_code: str) -> None: + """_auto_start_bridge should re-check preference before starting. + + This handles the case where preference changed between Init.py load + and the deferred timer firing. + """ + # Parse the function body + tree = ast.parse(init_code) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "_auto_start_bridge": + # Convert function body back to source for analysis + func_code = ast.unparse(node) + assert "get_auto_start" in func_code + return + pytest.fail("_auto_start_bridge function not found") + + def test_auto_start_bridge_checks_if_already_running(self, init_code: str) -> None: + """_auto_start_bridge should check if bridge is already running.""" + # Should check _mcp_plugin.is_running or similar + assert "is_running" in init_code + + def test_auto_start_bridge_logs_start_message(self, init_code: str) -> None: + """_auto_start_bridge should log when auto-starting.""" + assert "Auto-starting MCP Bridge" in init_code + + def test_auto_start_bridge_creates_plugin(self, init_code: str) -> None: + """_auto_start_bridge should create a FreecadMCPPlugin instance.""" + assert "FreecadMCPPlugin" in init_code + + def test_auto_start_bridge_registers_plugin(self, init_code: str) -> None: + """_auto_start_bridge should register plugin with commands module.""" + assert "register_mcp_plugin" in init_code + + def test_auto_start_bridge_handles_exceptions(self, init_code: str) -> None: + """_auto_start_bridge should catch and log exceptions.""" + # Parse the function and check for try/except + tree = ast.parse(init_code) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "_auto_start_bridge": + # Check if function has try/except + for child in ast.walk(node): + if isinstance(child, ast.Try): + return # Found try/except + pytest.fail("_auto_start_bridge should have try/except for error handling") + + +class TestGuiWaiterUsage: + """Tests for GuiWaiter class usage in auto-start.""" + + @pytest.fixture + def bridge_utils_code(self) -> str: + """Load bridge_utils.py content.""" + return (ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py").read_text() + + def test_gui_waiter_class_exists(self, bridge_utils_code: str) -> None: + """GuiWaiter class should exist in bridge_utils.py.""" + assert "class GuiWaiter" in bridge_utils_code + + def test_gui_waiter_has_start_method(self, bridge_utils_code: str) -> None: + """GuiWaiter should have a start method.""" + assert "def start(self)" in bridge_utils_code + + def test_gui_waiter_checks_gui_up(self, bridge_utils_code: str) -> None: + """GuiWaiter should check FreeCAD.GuiUp.""" + assert "GuiUp" in bridge_utils_code + + def test_gui_waiter_uses_repeating_timer(self, bridge_utils_code: str) -> None: + """GuiWaiter should use a repeating timer to poll GUI state.""" + # setSingleShot(False) means repeating + assert "setSingleShot(False)" in bridge_utils_code + + def test_gui_waiter_has_timeout(self, bridge_utils_code: str) -> None: + """GuiWaiter should have a timeout to prevent infinite waiting.""" + assert "max_retries" in bridge_utils_code or "timeout" in bridge_utils_code + + def test_gui_waiter_defers_callback(self, bridge_utils_code: str) -> None: + """GuiWaiter should defer callback after GUI is ready. + + This prevents starting the bridge too early when FreeCAD is still + initializing, which could cause race conditions. + """ + # Check for specific defer-related patterns to avoid false positives + # Look for the defer timer, defer_ms parameter, or deferring log message + has_defer_timer = "_defer_timer" in bridge_utils_code + has_defer_ms = "defer_ms" in bridge_utils_code + has_deferring_message = "deferring" in bridge_utils_code.lower() + assert any([has_defer_timer, has_defer_ms, has_deferring_message]), ( + "GuiWaiter should have defer functionality " + "(expected _defer_timer, defer_ms, or 'deferring' in code)" + ) + + def test_gui_waiter_logs_waiting_message(self, bridge_utils_code: str) -> None: + """GuiWaiter should log when it starts waiting.""" + assert "Waiting for GUI" in bridge_utils_code + + def test_gui_waiter_logs_ready_message(self, bridge_utils_code: str) -> None: + """GuiWaiter should log when GUI becomes ready.""" + assert "GUI ready" in bridge_utils_code + + def test_gui_waiter_logs_timeout_error(self, bridge_utils_code: str) -> None: + """GuiWaiter should log error on timeout.""" + assert "did not become ready" in bridge_utils_code + + +class TestAutoStartCodePaths: + """Tests to verify all auto-start code paths are properly handled.""" + + @pytest.fixture + def init_code(self) -> str: + """Load Init.py content.""" + return (ADDON_DIR / "Init.py").read_text() + + def test_four_startup_scenarios_documented(self, init_code: str) -> None: + """Init.py should document the four startup scenarios. + + 1. GUI already up - use timer for deferred start + 2. True headless (QCoreApplication only) - start directly with background thread + 3. GUI not ready but Qt available - use GuiWaiter + 4. No Qt available - start directly (unusual state) + """ + # Check for documentation of scenarios + assert "GuiUp is True" in init_code or "GUI is already up" in init_code + assert "QCoreApplication" in init_code # True headless detection + assert "GUI not ready" in init_code + assert "No Qt available" in init_code or "headless" in init_code.lower() + + def test_all_paths_have_logging(self, init_code: str) -> None: + """Each code path should have diagnostic logging.""" + # Count distinct log messages for different paths + log_patterns = [ + "GUI already up", + "True headless mode", + "GUI not ready", + "No Qt available", + ] + found = sum(1 for pattern in log_patterns if pattern in init_code) + assert found >= 4, f"Expected 4 code paths with logging, found {found}" + + def test_pyside_fallback_from_2_to_6(self, init_code: str) -> None: + """Should try PySide2 first, then fall back to PySide6.""" + # Check for both imports + assert "PySide2" in init_code + assert "PySide6" in init_code + # PySide2 should be tried first (common pattern) + pyside2_pos = init_code.find("PySide2") + pyside6_pos = init_code.find("PySide6") + assert pyside2_pos < pyside6_pos, "Should try PySide2 before PySide6" + + def test_true_headless_detection_via_isinstance(self, init_code: str) -> None: + """Should use isinstance to detect true headless mode. + + True headless mode is when QCoreApplication exists but is NOT a + QApplication. This happens with freecadcmd (headless FreeCAD). + + In GUI mode during early startup, there may be no application yet, + or a QApplication that's being initialized. We need to wait for + FreeCAD.GuiUp in those cases. + """ + # Should check QCoreApplication.instance() + assert "QCoreApplication.instance()" in init_code + # Should use isinstance to check if it's a QApplication + assert "isinstance" in init_code + # Should have a variable tracking true headless state + assert "_is_true_headless" in init_code