* 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>
334 lines
11 KiB
Python
334 lines
11 KiB
Python
"""Shared fixtures and utilities for just command tests.
|
|
|
|
This module provides:
|
|
- Fixtures for running just commands
|
|
- Helper functions for command validation
|
|
- Markers for test categorization
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator
|
|
|
|
# Get project root (where justfile is located)
|
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class JustResult:
|
|
"""Result of running a just command.
|
|
|
|
This dataclass is frozen (immutable) since results should not be modified
|
|
after creation - they represent a snapshot of command execution.
|
|
"""
|
|
|
|
command: str
|
|
returncode: int
|
|
stdout: str
|
|
stderr: str
|
|
success: bool
|
|
|
|
@property
|
|
def output(self) -> str:
|
|
"""Combined stdout and stderr."""
|
|
return f"{self.stdout}\n{self.stderr}".strip()
|
|
|
|
|
|
def assert_command_executed(result: JustResult, command_name: str) -> None:
|
|
"""Assert that a command actually executed (didn't timeout or have missing deps).
|
|
|
|
This checks for:
|
|
- Timeout (returncode -1)
|
|
- Missing command/dependency (returncode 127)
|
|
- "command not found" in stderr
|
|
|
|
It does NOT check for success - the command may legitimately fail
|
|
due to linting issues, type errors, etc.
|
|
"""
|
|
assert result.returncode != -1, f"{command_name} timed out: {result.stderr}"
|
|
assert result.returncode != 127, (
|
|
f"{command_name} missing dependency (exit 127): {result.stderr}"
|
|
)
|
|
assert "command not found" not in result.output.lower(), (
|
|
f"{command_name} has missing tool: {result.output}"
|
|
)
|
|
|
|
|
|
class JustRunner:
|
|
"""Helper class for running just commands in tests."""
|
|
|
|
def __init__(self, project_root: Path) -> None:
|
|
"""Initialize the runner with project root path."""
|
|
self.project_root = project_root
|
|
just_path = shutil.which("just")
|
|
if not just_path:
|
|
raise RuntimeError("just command not found in PATH")
|
|
self._just_path: str = just_path
|
|
|
|
def run(
|
|
self,
|
|
command: str,
|
|
*args: str,
|
|
timeout: int = 60,
|
|
env: dict[str, str] | None = None,
|
|
input_text: str | None = None,
|
|
check: bool = False,
|
|
) -> JustResult:
|
|
"""Run a just command and return the result.
|
|
|
|
Args:
|
|
command: The just command to run (e.g., "quality::lint")
|
|
*args: Additional arguments to pass to the command
|
|
timeout: Timeout in seconds
|
|
env: Additional environment variables
|
|
input_text: Text to pass to stdin
|
|
check: If True, raise CalledProcessError on non-zero exit
|
|
|
|
Returns:
|
|
JustResult with command output and status
|
|
"""
|
|
cmd: list[str] = [self._just_path, command, *args]
|
|
|
|
# Merge environment
|
|
run_env = os.environ.copy()
|
|
if env:
|
|
run_env.update(env)
|
|
|
|
try:
|
|
# S603: subprocess call is safe here - we're running `just` with
|
|
# controlled arguments in a test context
|
|
result = subprocess.run( # noqa: S603
|
|
cmd,
|
|
cwd=self.project_root,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
env=run_env,
|
|
input=input_text,
|
|
check=check,
|
|
)
|
|
return JustResult(
|
|
command=command,
|
|
returncode=result.returncode,
|
|
stdout=result.stdout,
|
|
stderr=result.stderr,
|
|
success=result.returncode == 0,
|
|
)
|
|
except subprocess.TimeoutExpired as e:
|
|
# subprocess.run with text=True means e.stdout is str|None at runtime,
|
|
# but the type stub says bytes|str|None. Cast to satisfy mypy.
|
|
stdout_val = str(e.stdout) if e.stdout else ""
|
|
return JustResult(
|
|
command=command,
|
|
returncode=-1,
|
|
stdout=stdout_val,
|
|
stderr=f"Command timed out after {timeout}s",
|
|
success=False,
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
return JustResult(
|
|
command=command,
|
|
returncode=e.returncode,
|
|
stdout=e.stdout or "",
|
|
stderr=e.stderr or "",
|
|
success=False,
|
|
)
|
|
|
|
def dry_run(self, command: str, *args: str, timeout: int = 30) -> JustResult:
|
|
"""Run a just command in dry-run mode (syntax check only).
|
|
|
|
This validates that just can parse the command without executing it.
|
|
"""
|
|
cmd: list[str] = [self._just_path, "--dry-run", command, *args]
|
|
|
|
# S603: subprocess call is safe here - we're running `just` with
|
|
# controlled arguments in a test context
|
|
result = subprocess.run( # noqa: S603
|
|
cmd,
|
|
cwd=self.project_root,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
return JustResult(
|
|
command=command,
|
|
returncode=result.returncode,
|
|
stdout=result.stdout,
|
|
stderr=result.stderr,
|
|
success=result.returncode == 0,
|
|
)
|
|
|
|
def list_commands(self, module: str | None = None) -> list[str]:
|
|
"""List available just commands, optionally for a specific module."""
|
|
cmd: list[str] = [self._just_path, "--list"]
|
|
if module:
|
|
cmd.append(module)
|
|
|
|
# S603: subprocess call is safe here - we're running `just` with
|
|
# controlled arguments in a test context
|
|
result = subprocess.run( # noqa: S603
|
|
cmd,
|
|
cwd=self.project_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
# Parse the output to extract command names
|
|
commands = []
|
|
for raw_line in result.stdout.splitlines():
|
|
# Skip empty lines and headers
|
|
stripped_line = raw_line.strip()
|
|
if not stripped_line or stripped_line.startswith("Available"):
|
|
continue
|
|
# Extract command name (first word before any description)
|
|
parts = stripped_line.split()
|
|
if parts:
|
|
commands.append(parts[0])
|
|
|
|
return commands
|
|
|
|
|
|
@pytest.fixture
|
|
def just() -> JustRunner:
|
|
"""Fixture providing a JustRunner instance."""
|
|
return JustRunner(PROJECT_ROOT)
|
|
|
|
|
|
@pytest.fixture
|
|
def project_root() -> Path:
|
|
"""Fixture providing the project root path."""
|
|
return PROJECT_ROOT
|
|
|
|
|
|
# Register custom markers
|
|
def pytest_configure(config: pytest.Config) -> None:
|
|
"""Register custom pytest markers."""
|
|
config.addinivalue_line(
|
|
"markers", "just_syntax: marks tests as just syntax checks (dry-run only)"
|
|
)
|
|
config.addinivalue_line(
|
|
"markers", "just_runtime: marks tests as just runtime tests (actually execute)"
|
|
)
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"just_release: marks tests as release command tests (require special handling)",
|
|
)
|
|
config.addinivalue_line(
|
|
"markers", "requires_freecad: marks tests that require FreeCAD to be installed"
|
|
)
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"requires_docker: marks tests that require Docker to be running",
|
|
)
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"requires_coderabbit: marks tests that require CodeRabbit CLI to be installed",
|
|
)
|
|
config.addinivalue_line(
|
|
"markers",
|
|
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
|
)
|
|
|
|
|
|
# Cleanup fixtures for release tests
|
|
@pytest.fixture
|
|
def git_tag_cleanup() -> Generator[list[str], None, None]:
|
|
"""Fixture that tracks and cleans up git tags created during tests.
|
|
|
|
Usage:
|
|
def test_create_tag(git_tag_cleanup):
|
|
git_tag_cleanup.append("test-tag-v0.0.0")
|
|
# Create the tag...
|
|
# Tag will be deleted after test
|
|
"""
|
|
tags_to_cleanup: list[str] = []
|
|
yield tags_to_cleanup
|
|
|
|
# Cleanup: delete all tracked tags (only test- prefixed tags for safety)
|
|
for tag in tags_to_cleanup:
|
|
# Safety guard: only delete tags starting with "test-" to prevent
|
|
# accidental deletion of real release tags
|
|
if not tag.startswith("test-"):
|
|
# Warn about skipped non-test tags to surface accidental additions
|
|
import warnings
|
|
|
|
warnings.warn(
|
|
f"Skipping cleanup of non-test tag '{tag}' - "
|
|
"only 'test-' prefixed tags are allowed in git_tag_cleanup fixture. "
|
|
"This may indicate a bug in the test.",
|
|
UserWarning,
|
|
stacklevel=1,
|
|
)
|
|
continue
|
|
|
|
# Delete local tag
|
|
# S603, S607: git is a well-known command, safe in test cleanup context
|
|
subprocess.run( # noqa: S603
|
|
["git", "tag", "-d", tag], # noqa: S607
|
|
cwd=PROJECT_ROOT,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
# Delete remote tag (already guarded by the startswith check above)
|
|
subprocess.run( # noqa: S603
|
|
["git", "push", "origin", "--delete", tag], # noqa: S607
|
|
cwd=PROJECT_ROOT,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
|
|
|
|
# Safe image name patterns for docker_image_cleanup fixture
|
|
# Only images matching these patterns will be removed during cleanup
|
|
SAFE_DOCKER_IMAGE_PATTERNS = (
|
|
"freecad-robust-mcp", # Project's Docker image
|
|
"test-", # Any test-prefixed images
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def docker_image_cleanup() -> Generator[list[str], None, None]:
|
|
"""Fixture that tracks and cleans up Docker images created during tests."""
|
|
images_to_cleanup: list[str] = []
|
|
yield images_to_cleanup
|
|
|
|
# Cleanup: delete all tracked images (only safe/known patterns)
|
|
for image in images_to_cleanup:
|
|
# Safety guard: only delete images matching safe patterns
|
|
is_safe = any(
|
|
image == pattern or image.startswith(pattern)
|
|
for pattern in SAFE_DOCKER_IMAGE_PATTERNS
|
|
)
|
|
if not is_safe:
|
|
# Warn about skipped images to surface accidental additions
|
|
import warnings
|
|
|
|
warnings.warn(
|
|
f"Skipping cleanup of Docker image '{image}' - "
|
|
f"not in safe patterns {SAFE_DOCKER_IMAGE_PATTERNS}. "
|
|
"This may indicate a bug in the test.",
|
|
UserWarning,
|
|
stacklevel=1,
|
|
)
|
|
continue
|
|
|
|
# S603, S607: docker is a well-known command, safe in test cleanup context
|
|
subprocess.run( # noqa: S603
|
|
["docker", "rmi", "-f", image], # noqa: S607
|
|
cwd=PROJECT_ROOT,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|