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>
This commit is contained in:
Sean P. Kane
2026-01-10 15:26:33 -08:00
committed by GitHub
co-authored by Claude Opus 4.5
parent bcc3048876
commit 8c338f6da7
110 changed files with 7821 additions and 1276 deletions
+89
View File
@@ -0,0 +1,89 @@
# syntax=docker/dockerfile:1.7
#
# Dockerfile for testing FreeCAD GUI with Xvfb
# Replicates the GitHub Actions CI environment for local debugging
#
# Build:
# docker build -f tests/ci-test/Dockerfile.gui-test -t freecad-gui-test .
#
# Run interactively:
# docker run --rm -it freecad-gui-test
#
# Run with local code mounted:
# docker run --rm -it -v $(pwd):/workspace freecad-gui-test
FROM ubuntu:22.04
# Avoid interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install X11, Xvfb, and dependencies (matching CI workflow)
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
# Xvfb and X11
xvfb \
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 \
# Window manager and X11 tools (required for FreeCAD GUI)
openbox \
xdotool \
# Tools for debugging
curl \
jq \
procps \
strace \
psmisc \
file \
# Python (for uv/pytest)
python3 \
python3-pip \
python3-venv \
# For downloading FreeCAD
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& fc-cache -f -v
# Install uv for Python package management
# hadolint ignore=DL3013
RUN pip3 install --no-cache-dir uv
# Set up working directory
WORKDIR /workspace
# Environment variables for GUI testing
ENV DISPLAY=:99 \
QT_QPA_PLATFORM=xcb \
LIBGL_ALWAYS_SOFTWARE=1 \
FONTCONFIG_FILE=/etc/fonts/fonts.conf \
FONTCONFIG_PATH=/etc/fonts \
FREECAD_TAG=1.0.2
# Script to setup FreeCAD AppImage
COPY tests/ci-test/setup-freecad.sh /usr/local/bin/setup-freecad.sh
RUN chmod +x /usr/local/bin/setup-freecad.sh
# Script to run GUI tests
COPY tests/ci-test/run-gui-test.sh /usr/local/bin/run-gui-test.sh
RUN chmod +x /usr/local/bin/run-gui-test.sh
# Download and setup FreeCAD during build (for faster iteration)
# hadolint ignore=DL3001,DL3059
RUN /usr/local/bin/setup-freecad.sh
# Default: interactive shell for debugging
CMD ["/bin/bash"]
+290
View File
@@ -0,0 +1,290 @@
#!/bin/bash
# Run GUI tests with Xvfb + openbox - for testing FreeCAD GUI functionality
#
# FreeCAD GUI requires a window manager to generate expose/configure events.
# Without a WM, the GUI binary hangs during Qt initialization.
#
# Solution: Xvfb + openbox + xdotool events
#
set -e
echo "========================================"
echo "FreeCAD GUI Test Runner"
echo "========================================"
# Setup XDG_RUNTIME_DIR to avoid Qt warnings
export XDG_RUNTIME_DIR=/tmp/runtime-root
mkdir -p "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
# Start Xvfb if not already running
if ! pgrep -x Xvfb > /dev/null; then
echo "Starting Xvfb..."
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
XVFB_PID=$!
sleep 2
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)"
else
echo "Xvfb already running"
fi
export DISPLAY=:99
export QT_QPA_PLATFORM=xcb
export LIBGL_ALWAYS_SOFTWARE=1
# Start openbox window manager (required for FreeCAD GUI)
if ! pgrep -x openbox > /dev/null; then
echo "Starting openbox window manager..."
openbox &
sleep 1
echo "openbox started"
else
echo "openbox already running"
fi
echo ""
echo "=== Environment ==="
echo "DISPLAY=$DISPLAY"
echo "QT_QPA_PLATFORM=$QT_QPA_PLATFORM"
echo "LIBGL_ALWAYS_SOFTWARE=$LIBGL_ALWAYS_SOFTWARE"
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
echo ""
echo "=== Test 1: Verify X11 display ==="
xdpyinfo -display :99 | head -5 || echo "Cannot connect to display :99"
echo ""
echo "=== Test 2: freecadcmd --version (headless) ==="
timeout 30 freecadcmd --version 2>&1 || echo "freecadcmd version check failed"
echo ""
echo "=== Test 3: FreeCAD GUI mode with openbox ==="
# Helper function to run FreeCAD GUI with xdotool events
run_freecad_gui() {
local SCRIPT="$1"
local TIMEOUT="${2:-30}"
# Start FreeCAD
if [ -n "$SCRIPT" ]; then
freecad "$SCRIPT" > /tmp/fc_stdout.log 2>&1 &
else
freecad --version > /tmp/fc_stdout.log 2>&1 &
fi
local FC_PID
FC_PID=$!
# Send xdotool events to help initialization
local START
START=$(date +%s)
while ps -p "$FC_PID" > /dev/null 2>&1; do
local NOW
NOW=$(date +%s)
local ELAPSED=$((NOW - START))
if [ "$ELAPSED" -gt "$TIMEOUT" ]; then
echo "Timeout after ${TIMEOUT}s"
kill "$FC_PID" 2>/dev/null || true
return 124
fi
# Send synthetic events
xdotool mousemove $((400 + ELAPSED*5)) $((300 + ELAPSED*5)) click 1 key Escape 2>/dev/null || true
sleep 0.5
done
wait "$FC_PID" 2>/dev/null
return $?
}
# Test basic GUI initialization
cat > /tmp/gui_test.py << 'PYEOF'
import FreeCAD
import sys
with open("/tmp/gui_result.txt", "w") as f:
f.write(f"GuiUp: {FreeCAD.GuiUp}\n")
f.write(f"Version: {FreeCAD.Version()}\n")
if FreeCAD.GuiUp:
import FreeCADGui
f.write("GUI is available!\n")
f.write(f"Workbenches: {len(FreeCADGui.listWorkbenches())}\n")
else:
f.write("GUI is NOT available\n")
sys.exit(0)
PYEOF
echo "Running GUI initialization test..."
run_freecad_gui /tmp/gui_test.py 30 || true # FreeCAD may crash on exit, check result file
echo ""
echo "=== GUI Test Result ==="
if [ -f /tmp/gui_result.txt ]; then
cat /tmp/gui_result.txt
if grep -q "GUI is available" /tmp/gui_result.txt; then
echo "GUI initialization test PASSED"
else
echo "GUI initialization test FAILED - GUI not available"
fi
else
echo "No result file (test failed to run)"
fi
echo ""
echo "=== Test 4: Full GUI functionality (screenshot, objects, etc.) ==="
cat > /tmp/full_gui_test.py << 'PYEOF'
import FreeCAD
import sys
results = []
try:
results.append(f"GuiUp: {FreeCAD.GuiUp}")
if not FreeCAD.GuiUp:
results.append("ERROR: GUI not available")
with open("/tmp/full_gui_result.txt", "w") as f:
f.write("\n".join(results))
sys.exit(1)
import FreeCADGui
import Part
# Create a document with objects
doc = FreeCAD.newDocument("GUITest")
results.append(f"Document: {doc.Name}")
# Create objects
box = doc.addObject("Part::Box", "TestBox")
box.Length = 50
box.Width = 30
box.Height = 20
doc.recompute()
results.append(f"Created: {box.Name} ({box.Length}x{box.Width}x{box.Height})")
# Test view operations
view = FreeCADGui.ActiveDocument.ActiveView
results.append(f"ActiveView: {type(view).__name__}")
FreeCADGui.SendMsgToActiveView("ViewFit")
view.viewIsometric()
results.append("ViewFit + Isometric: OK")
# Screenshot
view.saveImage("/tmp/screenshot.png", 800, 600, "Current")
results.append("Screenshot: /tmp/screenshot.png")
# ViewObject manipulation
box_view = FreeCADGui.ActiveDocument.getObject("TestBox")
if box_view:
box_view.ShapeColor = (1.0, 0.0, 0.0)
results.append("ShapeColor set to red: OK")
# Save document
doc.saveAs("/tmp/gui_test.FCStd")
results.append("Document saved: /tmp/gui_test.FCStd")
results.append("ALL TESTS PASSED")
except Exception as e:
results.append(f"ERROR: {e}")
import traceback
results.append(traceback.format_exc())
with open("/tmp/full_gui_result.txt", "w") as f:
f.write("\n".join(results))
sys.exit(0)
PYEOF
echo "Running full GUI test..."
run_freecad_gui /tmp/full_gui_test.py 45 || true # FreeCAD may crash on exit, check result file
echo ""
echo "=== Full GUI Test Result ==="
if [ -f /tmp/full_gui_result.txt ]; then
cat /tmp/full_gui_result.txt
# Check if test passed
if grep -q "ALL TESTS PASSED" /tmp/full_gui_result.txt; then
echo "Full GUI test PASSED"
else
echo "Full GUI test FAILED"
fi
else
echo "No result file (test failed to run)"
fi
echo ""
echo "=== Files Created ==="
ls -la /tmp/screenshot.png /tmp/gui_test.FCStd 2>/dev/null || echo "Files not created"
echo ""
echo "=== Test 5: Start FreeCAD GUI with MCP bridge ==="
if [ -f "/workspace/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py" ]; then
echo "Starting FreeCAD with MCP bridge..."
freecad /workspace/addon/FreecadRobustMCP/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_bridge.log 2>&1 &
FREECAD_PID=$!
echo "FreeCAD PID: $FREECAD_PID"
# Start xdotool events in background for this test
(
for j in {1..120}; do
xdotool mousemove $((400 + j*3)) $((300 + j*3)) click 1 key Escape 2>/dev/null
sleep 0.5
done
) &
XDOT_PID=$!
# Wait for bridge to start
BRIDGE_READY=0
for i in {1..60}; do
if ! ps -p "$FREECAD_PID" > /dev/null 2>&1; then
echo "FreeCAD process died after ${i}s"
break
fi
# Check for bridge
if curl -s --max-time 1 -X POST \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' \
http://localhost:9875 > /dev/null 2>&1; then
echo "MCP bridge is ready after ${i}s!"
BRIDGE_READY=1
break
fi
if [ $((i % 10)) -eq 0 ]; then
echo "Waiting... (${i}s)"
fi
sleep 1
done
if [ "$BRIDGE_READY" -eq 1 ]; then
echo "=== Bridge Test: Execute Python ==="
curl -s -X POST \
-H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>execute</methodName><params><param><value><string>_result_ = {"version": str(FreeCAD.Version()), "gui_up": FreeCAD.GuiUp}</string></value></param></params></methodCall>' \
http://localhost:9875 | head -50
echo ""
fi
echo ""
echo "=== Bridge Log ==="
cat /tmp/freecad_bridge.log 2>/dev/null | tail -30
# Cleanup
kill "$XDOT_PID" 2>/dev/null || true
kill "$FREECAD_PID" 2>/dev/null || true
else
echo "Bridge script not found - mount workspace with: docker run -v \$(pwd):/workspace ..."
fi
echo ""
echo "========================================"
echo "Tests complete"
echo "========================================"
+292
View File
@@ -0,0 +1,292 @@
#!/bin/bash
# Setup FreeCAD AppImage - replicates .github/actions/setup-freecad/action.yaml
set -euo pipefail
# Validate required variables have safe defaults
FREECAD_TAG="${FREECAD_TAG:-1.0.2}"
APPIMAGE_DIR="${APPIMAGE_DIR:-$HOME/freecad-appimage}"
# Optional SHA256 checksum for verification (if provided, download will be verified)
APPIMAGE_SHA256="${APPIMAGE_SHA256:-}"
# Validate APPIMAGE_DIR is set and non-empty after defaults
if [[ -z "$APPIMAGE_DIR" ]]; then
echo "ERROR: APPIMAGE_DIR is empty - cannot determine installation path"
exit 1
fi
# Marker file to indicate complete installation
MARKER_FILE="$APPIMAGE_DIR/.freecad_installed"
# Lock file for atomic operations (prevents race conditions in parallel CI)
LOCK_FILE="$APPIMAGE_DIR/.freecad_install.lock"
# Derive APPIMAGE_PATH early for cleanup function
APPIMAGE_PATH="$APPIMAGE_DIR/FreeCAD.AppImage"
# Track whether installation completed successfully
INSTALL_SUCCESSFUL=false
# Cleanup function to remove partial artifacts on failure
cleanup_on_error() {
local exit_code=$?
if [[ $exit_code -ne 0 ]] && [[ "$INSTALL_SUCCESSFUL" != "true" ]]; then
echo "ERROR: Installation failed (exit code $exit_code), cleaning up partial artifacts..."
rm -f "$MARKER_FILE" 2>/dev/null || true
rm -f "$APPIMAGE_PATH" 2>/dev/null || true
rm -rf "$APPIMAGE_DIR/squashfs-root" 2>/dev/null || true
rm -f "$LOCK_FILE" 2>/dev/null || true
fi
}
trap cleanup_on_error EXIT
# Detect if running in CI environment
is_ci_environment() {
# Check common CI environment variables
[[ -n "${CI:-}" ]] || \
[[ -n "${GITHUB_ACTIONS:-}" ]] || \
[[ -n "${GITLAB_CI:-}" ]] || \
[[ -n "${TRAVIS:-}" ]] || \
[[ -n "${CIRCLECI:-}" ]] || \
[[ -n "${JENKINS_URL:-}" ]] || \
[[ -n "${BUILDKITE:-}" ]]
}
# In CI, require SHA256 checksum for security
if is_ci_environment && [[ -z "$APPIMAGE_SHA256" ]]; then
echo "ERROR: APPIMAGE_SHA256 is required in CI environments for security verification"
echo "Set the APPIMAGE_SHA256 environment variable to the expected checksum"
exit 1
fi
echo "=== Setting up FreeCAD $FREECAD_TAG ==="
# Create directory for lock file
mkdir -p "$APPIMAGE_DIR"
# Use flock for atomic marker file operations (prevents race conditions in parallel CI)
# The lock is held for the entire installation process
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
echo "Another installation is in progress, waiting for lock..."
flock 200
# Re-check marker file after acquiring lock (another process may have completed)
if [[ -f "$MARKER_FILE" ]] && grep -q "^${FREECAD_TAG}$" "$MARKER_FILE" 2>/dev/null; then
echo "FreeCAD $FREECAD_TAG already set up by another process, skipping"
exit 0
fi
fi
# Check for complete setup using marker file
if [[ -f "$MARKER_FILE" ]]; then
# Verify marker file contains expected version
if grep -q "^${FREECAD_TAG}$" "$MARKER_FILE" 2>/dev/null; then
echo "FreeCAD $FREECAD_TAG already set up (marker file present), skipping"
exit 0
else
echo "Different FreeCAD version detected, will reinstall"
fi
fi
# Check for and clean up partial installations
PARTIAL_INSTALL=false
if [[ -f "/usr/local/bin/freecad" ]] && [[ ! -f "/usr/local/bin/freecadcmd" ]]; then
echo "Warning: Partial installation detected (freecad exists but freecadcmd missing)"
PARTIAL_INSTALL=true
elif [[ ! -f "/usr/local/bin/freecad" ]] && [[ -f "/usr/local/bin/freecadcmd" ]]; then
echo "Warning: Partial installation detected (freecadcmd exists but freecad missing)"
PARTIAL_INSTALL=true
fi
if [[ "$PARTIAL_INSTALL" == "true" ]]; then
echo "Cleaning up partial installation..."
# Remove wrapper scripts if they exist (use sudo if needed)
if [[ $EUID -ne 0 ]] && command -v sudo &>/dev/null; then
sudo rm -f /usr/local/bin/freecad /usr/local/bin/freecadcmd 2>/dev/null || true
else
rm -f /usr/local/bin/freecad /usr/local/bin/freecadcmd 2>/dev/null || true
fi
# Remove marker file to force full reinstall
rm -f "$MARKER_FILE" 2>/dev/null || true
fi
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
ARCH_SUFFIX="x86_64"
;;
aarch64|arm64)
ARCH_SUFFIX="aarch64"
;;
*)
echo "ERROR: Unsupported architecture: $ARCH"
exit 1
;;
esac
echo "Detected architecture: $ARCH -> Linux-$ARCH_SUFFIX"
# Use direct download URL to avoid GitHub API rate limits
# Format: FreeCAD_1.0.2-conda-Linux-aarch64-py311.AppImage
APPIMAGE_URL="https://github.com/FreeCAD/FreeCAD/releases/download/${FREECAD_TAG}/FreeCAD_${FREECAD_TAG}-conda-Linux-${ARCH_SUFFIX}-py311.AppImage"
APPIMAGE_NAME="FreeCAD_${FREECAD_TAG}-conda-Linux-${ARCH_SUFFIX}-py311.AppImage"
# APPIMAGE_PATH is defined earlier for use in cleanup_on_error trap
echo "FreeCAD release: $FREECAD_TAG"
echo "AppImage URL: $APPIMAGE_URL"
echo "AppImage name: $APPIMAGE_NAME"
# Download
mkdir -p "$APPIMAGE_DIR"
if [ ! -f "$APPIMAGE_PATH" ]; then
echo "Downloading FreeCAD AppImage..."
curl -L --retry 3 --retry-delay 5 --retry-all-errors --connect-timeout 30 --max-time 600 \
-f -o "$APPIMAGE_PATH" \
"$APPIMAGE_URL"
# Verify download succeeded and file exists
if [[ ! -f "$APPIMAGE_PATH" ]]; then
echo "ERROR: Download failed - file not found at $APPIMAGE_PATH"
exit 1
fi
# Verify SHA256 checksum if provided
if [[ -n "$APPIMAGE_SHA256" ]]; then
echo "Verifying SHA256 checksum..."
COMPUTED_SHA256=$(sha256sum "$APPIMAGE_PATH" | awk '{print $1}')
if [[ "$COMPUTED_SHA256" != "$APPIMAGE_SHA256" ]]; then
echo "ERROR: SHA256 checksum mismatch!"
echo " Expected: $APPIMAGE_SHA256"
echo " Computed: $COMPUTED_SHA256"
echo "Removing corrupted/tampered download..."
rm -f "$APPIMAGE_PATH"
exit 1
fi
echo "SHA256 checksum verified successfully"
else
echo "Note: No APPIMAGE_SHA256 provided, skipping checksum verification"
fi
fi
chmod +x "$APPIMAGE_PATH"
# Extract with proper error handling
cd "$APPIMAGE_DIR"
if [ ! -d "squashfs-root" ]; then
echo "Extracting AppImage..."
EXTRACTION_ERR=$(mktemp)
# Capture exit code directly to avoid shell negation issues with $?
EXTRACTION_EXIT_CODE=0
./FreeCAD.AppImage --appimage-extract > /dev/null 2>"$EXTRACTION_ERR" || EXTRACTION_EXIT_CODE=$?
if [[ $EXTRACTION_EXIT_CODE -ne 0 ]]; then
echo "ERROR: AppImage extraction failed with exit code $EXTRACTION_EXIT_CODE"
if [[ -s "$EXTRACTION_ERR" ]]; then
echo "Extraction stderr:"
cat "$EXTRACTION_ERR"
fi
rm -f "$EXTRACTION_ERR"
exit 1
fi
# Check for any stderr output even on success
if [[ -s "$EXTRACTION_ERR" ]]; then
echo "Warning: Extraction produced stderr output:"
cat "$EXTRACTION_ERR"
fi
rm -f "$EXTRACTION_ERR"
fi
# Verify extraction produced expected structure
if [ ! -d "squashfs-root/usr/bin" ]; then
echo "ERROR: Extracted AppImage missing expected structure (squashfs-root/usr/bin not found)"
exit 1
fi
echo "Checking AppImage structure..."
# Display directory contents for diagnostic purposes (not parsed programmatically)
# shellcheck disable=SC2012 # ls output piped to head for display only, not parsed
ls -la "$APPIMAGE_DIR/squashfs-root/" 2>/dev/null | head -20
# Create wrapper scripts using AppRun
echo "Creating wrapper scripts..."
# Derive APPDIR_PATH from APPIMAGE_DIR for consistency
APPDIR_PATH="$APPIMAGE_DIR/squashfs-root"
# Helper function to install wrapper script
# Uses sudo only if necessary (not root and sudo exists)
install_wrapper() {
local wrapper_name="$1"
local wrapper_content="$2"
local wrapper_path="/usr/local/bin/$wrapper_name"
local temp_file
temp_file=$(mktemp)
echo "$wrapper_content" > "$temp_file"
chmod +x "$temp_file"
# Install with sudo if not root and sudo is available
if [[ $EUID -ne 0 ]]; then
if command -v sudo &>/dev/null; then
sudo mv "$temp_file" "$wrapper_path"
sudo chmod +x "$wrapper_path"
else
echo "ERROR: Not running as root and sudo not available, cannot install to $wrapper_path"
rm -f "$temp_file"
exit 1
fi
else
mv "$temp_file" "$wrapper_path"
chmod +x "$wrapper_path"
fi
}
# freecadcmd wrapper - avoid trailing colon in LD_LIBRARY_PATH
FREECADCMD_WRAPPER="#!/bin/bash
export APPDIR=\"$APPDIR_PATH\"
export LD_LIBRARY_PATH=\"\$APPDIR/usr/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
exec \"\$APPDIR/usr/bin/freecadcmd\" \"\$@\""
install_wrapper "freecadcmd" "$FREECADCMD_WRAPPER"
# freecad (GUI) wrapper - avoid trailing colon in LD_LIBRARY_PATH
FREECAD_WRAPPER="#!/bin/bash
export APPDIR=\"$APPDIR_PATH\"
export LD_LIBRARY_PATH=\"\$APPDIR/usr/lib\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}\"
exec \"\$APPDIR/usr/bin/freecad\" \"\$@\""
install_wrapper "freecad" "$FREECAD_WRAPPER"
echo "Wrapper scripts created at /usr/local/bin/freecad{,cmd}"
# Verify installation - fail the script if verification fails
# Use explicit paths to avoid PATH resolution issues
echo "=== Verifying FreeCAD installation ==="
# Check wrapper exists before running
if [[ ! -x /usr/local/bin/freecadcmd ]]; then
echo "ERROR: freecadcmd wrapper not found or not executable at /usr/local/bin/freecadcmd"
exit 1
fi
echo "--- freecadcmd --version ---"
if ! /usr/local/bin/freecadcmd --version; then
echo "ERROR: freecadcmd version check failed"
exit 1
fi
echo "--- freecadcmd Python test ---"
if ! /usr/local/bin/freecadcmd -c "import sys; print(f'FreeCAD Python: {sys.version}')"; then
echo "ERROR: FreeCAD Python test failed"
exit 1
fi
echo "--- FreeCAD module import test ---"
if ! /usr/local/bin/freecadcmd -c "import FreeCAD; print(f'FreeCAD version: {FreeCAD.Version()}')"; then
echo "ERROR: FreeCAD module import failed - FreeCAD bindings may be missing or corrupted"
exit 1
fi
# Create marker file to indicate successful installation
echo "$FREECAD_TAG" > "$MARKER_FILE"
echo "Created marker file: $MARKER_FILE"
# Mark installation as successful (prevents cleanup_on_error from removing artifacts)
INSTALL_SUCCESSFUL=true
echo "=== FreeCAD setup complete ==="
+115 -29
View File
@@ -1,7 +1,8 @@
"""Pytest configuration for integration tests.
This module handles connection checking and provides consolidated skip behavior
when the FreeCAD MCP bridge is not available.
This module handles connection checking and provides hard error behavior
when the FreeCAD MCP bridge is not available. Connection failures are
treated as test errors, not skips.
Instance ID Verification:
The FreeCAD MCP bridge generates a unique instance ID at startup which is
@@ -13,7 +14,6 @@ Instance ID Verification:
from __future__ import annotations
import os
import warnings
import xmlrpc.client
from typing import Any
@@ -24,7 +24,7 @@ _bridge_available: bool | None = None
_bridge_error: str | None = None
_bridge_instance_id: str | None = None
_gui_available: bool | None = None
_warning_emitted: bool = False
_connection_checked: bool = False
def _check_bridge_connection() -> tuple[bool, str | None, str | None]:
@@ -47,12 +47,21 @@ def _check_bridge_connection() -> tuple[bool, str | None, str | None]:
# The ping response includes instance_id
_bridge_instance_id = result.get("instance_id")
# Check if GUI is available via get_status
# Check if GUI is available by executing code to check FreeCAD.GuiUp
try:
status: dict[str, Any] = proxy.get_status() # type: ignore[assignment]
_gui_available = status.get("gui_available", False)
gui_check: dict[str, Any] = proxy.execute( # type: ignore[assignment]
"""
import FreeCAD
_result_ = {"gui_up": bool(FreeCAD.GuiUp)}
"""
)
if gui_check.get("success") and gui_check.get("result"):
_gui_available = gui_check["result"].get("gui_up", False)
else:
# Execution failed, assume headless
_gui_available = False
except Exception:
# If get_status fails, assume headless
# If execute fails, assume headless
_gui_available = False
else:
_bridge_available = False
@@ -77,7 +86,12 @@ def is_gui_available() -> bool:
"""Check if FreeCAD GUI is available.
Returns:
True if running in GUI mode, False if headless.
True if running in GUI mode, False if headless or bridge unavailable.
Note:
When the bridge is unavailable, this returns False. The
pytest_collection_modifyitems hook will raise a hard error in this case,
so tests won't actually run with an incorrect skip condition.
"""
# Ensure bridge check has been performed
_check_bridge_connection()
@@ -89,27 +103,69 @@ def is_headless_mode() -> bool:
Returns:
True if running in headless mode, False if GUI is available.
Note:
When the bridge is unavailable, this returns True (assumes headless).
The pytest_collection_modifyitems hook will raise a hard error before
any tests run, so this assumption doesn't affect actual test execution.
"""
return not is_gui_available()
# Skip marker for GUI-only tests
def _should_skip_for_gui_requirement() -> bool:
"""Return True if test should be skipped due to requiring GUI mode.
Returns False when:
- Bridge is available and in GUI mode
Returns True when:
- Bridge is unavailable (will fail anyway, skip is irrelevant)
- Bridge is available and in headless mode
"""
_check_bridge_connection()
return _gui_available is not True
def _should_skip_for_headless_requirement() -> bool:
"""Return True if test should be skipped due to requiring headless mode.
Returns False when:
- Bridge is unavailable (collection will fail anyway)
- Bridge is available and in headless mode
Returns True when:
- Bridge is available and in GUI mode
"""
_check_bridge_connection()
if not _bridge_available:
return False # Don't skip, let collection error handle it
return _gui_available is True # Skip if in GUI mode
# Skip markers for mode-specific tests
# These markers handle the bridge unavailable case by deferring to
# pytest_collection_modifyitems which raises a hard error.
requires_gui = pytest.mark.skipif(
is_headless_mode(),
_should_skip_for_gui_requirement(),
reason="Test requires FreeCAD GUI mode (running in headless mode)",
)
requires_headless = pytest.mark.skipif(
_should_skip_for_headless_requirement(),
reason="Test requires FreeCAD headless mode (running in GUI mode)",
)
def pytest_collection_modifyitems(
config: pytest.Config, # noqa: ARG001
items: list[pytest.Item],
) -> None:
"""Skip all integration tests if the bridge is not available.
"""Verify bridge connection for integration tests.
This runs once during test collection and emits a single warning instead of
per-test skip messages.
This runs once during test collection. If the bridge is not available,
this raises a hard error instead of skipping tests.
"""
global _warning_emitted
global _connection_checked
# Filter to only integration tests in this directory
integration_tests = [
@@ -122,21 +178,51 @@ def pytest_collection_modifyitems(
# Check bridge connection once
is_available, error, _instance_id = _check_bridge_connection()
if not is_available:
# Apply skip marker to all integration tests
skip_marker = pytest.mark.skip(reason="FreeCAD MCP bridge unavailable")
for item in integration_tests:
item.add_marker(skip_marker)
if not is_available and not _connection_checked:
_connection_checked = True
pytest.fail(
f"\n\n{'=' * 60}\n"
f"INTEGRATION TEST ERROR: FreeCAD MCP bridge not available\n"
f"{'=' * 60}\n\n"
f"Error: {error}\n\n"
f"To run integration tests, start FreeCAD with the MCP bridge:\n"
f" • GUI mode: just freecad::run-gui\n"
f" • Headless mode: just freecad::run-headless\n\n"
f"Then run the tests again.\n"
f"{'=' * 60}\n",
pytrace=False,
)
# Emit a single warning (only once)
if not _warning_emitted:
_warning_emitted = True
warnings.warn(
f"Skipping {len(integration_tests)} integration tests: {error}. "
f"Start the bridge with 'just run-gui' or 'just run-headless'.",
pytest.PytestWarning,
stacklevel=1,
)
def pytest_terminal_summary(
terminalreporter: Any,
exitstatus: int, # noqa: ARG001
config: pytest.Config, # noqa: ARG001
) -> None:
"""Print a summary of FreeCAD connection status at the end of test run.
This provides clear visibility into which mode was used and confirms
successful connection.
"""
# Only show summary if we ran integration tests
if _bridge_available is None:
return
# Build the summary message
terminalreporter.write_sep("=", "FreeCAD MCP Bridge Status")
if _bridge_available:
mode = "GUI" if _gui_available else "Headless"
terminalreporter.write_line(" Connection: SUCCESS")
terminalreporter.write_line(f" Mode: {mode}")
if _bridge_instance_id:
terminalreporter.write_line(f" Instance: {_bridge_instance_id}")
else:
terminalreporter.write_line(" Connection: FAILED")
if _bridge_error:
terminalreporter.write_line(f" Error: {_bridge_error}")
terminalreporter.write_line("")
@pytest.fixture(scope="module")
+4 -2
View File
@@ -19,12 +19,14 @@ from typing import TYPE_CHECKING, Any
import pytest
from tests.integration.conftest import requires_gui
if TYPE_CHECKING:
import xmlrpc.client
from collections.abc import Generator
# Mark all tests in this module as integration tests and gui tests
pytestmark = [pytest.mark.integration, pytest.mark.gui]
# Mark all tests in this module as integration tests, gui tests, and require GUI mode
pytestmark = [pytest.mark.integration, pytest.mark.gui, requires_gui]
# Note: xmlrpc_proxy fixture is defined in conftest.py
+259 -56
View File
@@ -1,11 +1,12 @@
"""Integration tests for FreeCAD MCP headless mode.
"""Integration tests for FreeCAD MCP bridge functionality.
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.
These tests verify that the MCP bridge works correctly with FreeCAD, including
object creation, manipulation, and export functionality. Most tests work in
both GUI and headless modes, with specific tests marked as headless-only.
Note: These tests require a running FreeCAD headless server.
Start it with: just freecad::run-headless
Note: These tests require a running FreeCAD server.
Start with: just freecad::run-gui (GUI mode)
or: just freecad::run-headless (headless mode)
To run these tests:
pytest tests/integration/test_headless_mode.py -v
@@ -14,11 +15,14 @@ To run these tests:
from __future__ import annotations
import tempfile
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from tests.integration.conftest import requires_headless
if TYPE_CHECKING:
import xmlrpc.client
from collections.abc import Generator
@@ -29,6 +33,11 @@ pytestmark = pytest.mark.integration
# Note: xmlrpc_proxy fixture is defined in conftest.py
def _unique_suffix() -> str:
"""Generate a unique suffix using timestamp."""
return time.strftime("%Y%m%d%H%M%S")
@pytest.fixture(scope="module")
def temp_dir() -> Generator[str, None, None]:
"""Create a temporary directory for test files."""
@@ -36,6 +45,15 @@ def temp_dir() -> Generator[str, None, None]:
yield tmpdir
@pytest.fixture(scope="module")
def unique_suffix() -> str:
"""Generate a unique suffix for document names in this test session.
Uses timestamp format YYYYMMDDHHMMSS to ensure unique names across runs.
"""
return _unique_suffix()
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]
@@ -43,6 +61,32 @@ def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
return result
# Common code snippet for centering viewport in GUI mode
CENTER_VIEWPORT_CODE = """
# Center viewport for GUI mode recording
if FreeCAD.GuiUp:
import FreeCADGui
if FreeCADGui.ActiveDocument and FreeCADGui.ActiveDocument.ActiveView:
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
"""
def center_viewport(proxy: xmlrpc.client.ServerProxy) -> None:
"""Center all objects in the viewport if GUI is available.
Call this after creating/modifying objects to keep them centered.
Safe to call in headless mode - it will simply do nothing.
"""
proxy.execute( # type: ignore[union-attr]
f"""
import FreeCAD
{CENTER_VIEWPORT_CODE}
_result_ = True
"""
)
class TestHeadlessConnection:
"""Tests for basic headless mode connectivity."""
@@ -52,10 +96,14 @@ class TestHeadlessConnection:
assert result["pong"] is True
assert "timestamp" in result
@requires_headless
def test_headless_mode_detected(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test that FreeCAD is running in headless mode (GuiUp=False)."""
"""Test that FreeCAD is running in headless mode (GuiUp=False).
This test is skipped when FreeCAD is running in GUI mode.
"""
result = execute_code(
xmlrpc_proxy,
"""
@@ -70,28 +118,40 @@ _result_ = {"gui_up": FreeCAD.GuiUp}
class TestDocumentManagement:
"""Tests for document creation and management in headless mode."""
def test_create_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def test_create_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Test creating a new document."""
doc_name = f"TestDoc_{unique_suffix}"
result = execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
doc = FreeCAD.newDocument("TestDoc")
_result_ = {"name": doc.Name, "object_count": len(doc.Objects)}
doc_name = {doc_name!r}
# Close if it already exists (from a previous failed run)
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
_result_ = {{"name": doc.Name, "object_count": len(doc.Objects)}}
""",
)
assert result["result"]["name"] == "TestDoc"
assert result["result"]["name"] == doc_name
assert result["result"]["object_count"] == 0
def test_list_documents(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def test_list_documents(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Test listing open documents."""
doc_name = f"ListTestDoc_{unique_suffix}"
# First create a document
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
if not FreeCAD.listDocuments():
FreeCAD.newDocument("ListTestDoc")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
FreeCAD.newDocument(doc_name)
_result_ = True
""",
)
@@ -106,43 +166,60 @@ _result_ = {"documents": docs, "count": len(docs)}
)
assert result["result"]["count"] >= 1
def test_close_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def test_close_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Test closing a document."""
doc_name = f"ToClose_{unique_suffix}"
# Create a document to close
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
doc = FreeCAD.newDocument("ToClose")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
_result_ = True
""",
)
result = execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
FreeCAD.closeDocument("ToClose")
_result_ = {"closed": "ToClose" not in FreeCAD.listDocuments()}
doc_name = {doc_name!r}
FreeCAD.closeDocument(doc_name)
_result_ = {{"closed": doc_name not in FreeCAD.listDocuments()}}
""",
)
assert result["result"]["closed"] is True
class TestPrimitiveCreation:
"""Tests for creating primitive shapes in headless mode."""
"""Tests for creating primitive shapes."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def setup_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Create a fresh document for each test."""
doc_name = f"PrimitiveTestDoc_{unique_suffix}"
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
# Close any existing PrimitiveTestDoc
if "PrimitiveTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("PrimitiveTestDoc")
doc = FreeCAD.newDocument("PrimitiveTestDoc")
doc_name = {doc_name!r}
# Close any existing document with this name
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
# Set up initial view for GUI mode
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
_result_ = True
""",
)
@@ -159,8 +236,18 @@ doc = FreeCAD.ActiveDocument
box = Part.makeBox(10, 20, 30)
obj = doc.addObject("Part::Feature", "TestBox")
obj.Shape = box
# Center viewport immediately after adding object (before it renders off-center)
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
@@ -186,8 +273,18 @@ doc = FreeCAD.ActiveDocument
cylinder = Part.makeCylinder(5, 20)
obj = doc.addObject("Part::Feature", "TestCylinder")
obj.Shape = cylinder
# Center viewport immediately after adding object
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
expected_volume = math.pi * 5**2 * 20
_result_ = {
@@ -218,8 +315,18 @@ doc = FreeCAD.ActiveDocument
sphere = Part.makeSphere(10)
obj = doc.addObject("Part::Feature", "TestSphere")
obj.Shape = sphere
# Center viewport immediately after adding object
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
expected_volume = (4/3) * math.pi * 10**3
_result_ = {
@@ -249,8 +356,18 @@ 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
# Center viewport immediately after adding object
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
@@ -275,8 +392,18 @@ doc = FreeCAD.ActiveDocument
torus = Part.makeTorus(20, 5) # Major radius=20, minor radius=5
obj = doc.addObject("Part::Feature", "TestTorus")
obj.Shape = torus
# Center viewport immediately after adding object
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = {
"name": obj.Name,
"volume": obj.Shape.Volume,
@@ -290,18 +417,28 @@ _result_ = {
class TestBooleanOperations:
"""Tests for boolean operations in headless mode."""
"""Tests for boolean operations."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def setup_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Create a fresh document for each test."""
doc_name = f"BooleanTestDoc_{unique_suffix}"
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
if "BooleanTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("BooleanTestDoc")
doc = FreeCAD.newDocument("BooleanTestDoc")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
# Set up initial view for GUI mode
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
_result_ = True
""",
)
@@ -325,12 +462,21 @@ obj1.Shape = box1
obj2 = doc.addObject("Part::Feature", "Box2")
obj2.Shape = box2
# Center viewport after adding initial objects
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
# Fuse them
fused = box1.fuse(box2)
obj_fused = doc.addObject("Part::Feature", "Fused")
obj_fused.Shape = fused
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
# Fused volume should be less than 2000 (two boxes) due to overlap
_result_ = {
"fused_volume": obj_fused.Shape.Volume,
@@ -360,8 +506,18 @@ cylinder = Part.makeCylinder(5, 30, FreeCAD.Vector(10, 10, -5))
cut = box.cut(cylinder)
obj_cut = doc.addObject("Part::Feature", "Cut")
obj_cut.Shape = cut
# Center viewport immediately after adding object
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
# Final viewport adjustment
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
box_volume = 20 * 20 * 20
import math
cylinder_volume_in_box = math.pi * 5**2 * 20 # Only 20mm of cylinder is in box
@@ -382,25 +538,36 @@ _result_ = {
class TestObjectManipulation:
"""Tests for object manipulation in headless mode."""
"""Tests for object manipulation."""
@pytest.fixture(autouse=True)
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
def setup_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Create a fresh document with a test box."""
doc_name = f"ManipTestDoc_{unique_suffix}"
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
import Part
if "ManipTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("ManipTestDoc")
doc = FreeCAD.newDocument("ManipTestDoc")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
box = Part.makeBox(10, 10, 10)
obj = doc.addObject("Part::Feature", "ManipBox")
obj.Shape = box
doc.recompute()
# Center viewport for GUI mode recording
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = True
""",
)
@@ -420,6 +587,12 @@ obj = doc.getObject("ManipBox")
# Move the object
obj.Placement.Base = FreeCAD.Vector(100, 200, 300)
# Center viewport for GUI mode recording
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = {
"x": obj.Placement.Base.x,
"y": obj.Placement.Base.y,
@@ -447,6 +620,12 @@ obj = doc.getObject("ManipBox")
obj.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45)
doc.recompute()
# Center viewport for GUI mode recording
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
euler = obj.Placement.Rotation.toEuler()
_result_ = {
"rotation_z": euler[0], # Yaw
@@ -460,22 +639,24 @@ _result_ = {
class TestExportOperations:
"""Tests for export functionality in headless mode."""
"""Tests for export functionality."""
@pytest.fixture(autouse=True)
def setup_document(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str, unique_suffix: str
) -> None:
"""Create a document with objects for export tests."""
doc_name = f"ExportTestDoc_{unique_suffix}"
execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
import Part
if "ExportTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("ExportTestDoc")
doc = FreeCAD.newDocument("ExportTestDoc")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
# Create a simple box
box = Part.makeBox(10, 10, 10)
@@ -483,6 +664,12 @@ obj = doc.addObject("Part::Feature", "ExportBox")
obj.Shape = box
doc.recompute()
# Center viewport for GUI mode recording
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_result_ = True
""",
)
@@ -537,8 +724,12 @@ _result_ = {{
assert fcstd_path.exists()
@requires_headless
class TestGUIFeaturesInHeadless:
"""Tests to verify GUI features fail gracefully in headless mode."""
"""Tests to verify GUI features fail gracefully in headless mode.
These tests are skipped when FreeCAD is running in GUI mode.
"""
def test_gui_features_not_available(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
@@ -569,22 +760,24 @@ else:
class TestComplexWorkflow:
"""Tests for complex multi-step workflows in headless mode."""
"""Tests for complex multi-step workflows."""
def test_parametric_modeling_workflow(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
) -> None:
"""Test a complete parametric modeling workflow."""
doc_name = f"WorkflowTestDoc_{unique_suffix}"
result = execute_code(
xmlrpc_proxy,
"""
f"""
import FreeCAD
import Part
# Create document
if "WorkflowTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("WorkflowTestDoc")
doc = FreeCAD.newDocument("WorkflowTestDoc")
doc_name = {doc_name!r}
if doc_name in FreeCAD.listDocuments():
FreeCAD.closeDocument(doc_name)
doc = FreeCAD.newDocument(doc_name)
# Step 1: Create base box
base_box = Part.makeBox(50, 50, 10)
@@ -609,14 +802,24 @@ try:
except Exception:
fillet_success = False
# Center viewport immediately after adding objects (before recompute)
if FreeCAD.GuiUp:
import FreeCADGui
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
FreeCADGui.ActiveDocument.ActiveView.fitAll()
doc.recompute()
_result_ = {
# Final viewport adjustment after recompute
if FreeCAD.GuiUp:
FreeCADGui.ActiveDocument.ActiveView.fitAll()
_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"]
+20
View File
@@ -0,0 +1,20 @@
"""Just command test suite.
This test suite validates all just commands in the project to catch
syntax errors, missing dependencies, and runtime failures early.
Test Categories:
- Syntax tests: Verify just can parse the command (--dry-run)
- Runtime tests: Actually execute commands and verify behavior
- Release tests: Special tests for release commands with cleanup
Usage:
uv run pytest tests/just_commands/ # Run all just command tests
uv run pytest tests/just_commands/ -m just_syntax # Only syntax checks
uv run pytest tests/just_commands/ -m just_runtime # Only runtime tests
uv run pytest tests/just_commands/ -m just_release # Only release tests (with cleanup)
Maintenance:
When adding or modifying just commands, update the corresponding test file.
See CLAUDE.md section "Just Command Testing" for details.
"""
+333
View File
@@ -0,0 +1,333 @@
"""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,
)
+76
View File
@@ -0,0 +1,76 @@
"""Tests for coderabbit module just commands.
These tests verify that CodeRabbit CLI commands work correctly.
Note: Most commands require CodeRabbit CLI to be installed and authenticated.
"""
from __future__ import annotations
import shutil
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
def coderabbit_available() -> bool:
"""Check if CodeRabbit CLI is available."""
return shutil.which("coderabbit") is not None
class TestCoderabbitSyntax:
"""Syntax validation tests for CodeRabbit commands."""
CODERABBIT_COMMANDS: ClassVar[list[str]] = [
"coderabbit::install",
"coderabbit::check-installed",
"coderabbit::login",
"coderabbit::logout",
"coderabbit::auth-status",
"coderabbit::review",
"coderabbit::review-fix",
"coderabbit::review-all",
"coderabbit::review-last",
"coderabbit::review-since",
"coderabbit::review-branch",
"coderabbit::prompt-only",
"coderabbit::review-json",
"coderabbit::help",
"coderabbit::version",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", CODERABBIT_COMMANDS)
def test_coderabbit_command_syntax(self, just: JustRunner, command: str) -> None:
"""CodeRabbit command should have valid syntax."""
# Commands with required arguments
if command == "coderabbit::review-since":
result = just.dry_run(command, "HEAD~5")
else:
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestCoderabbitRuntime:
"""Runtime tests for CodeRabbit commands."""
@pytest.mark.just_runtime
def test_check_installed_reports_status(self, just: JustRunner) -> None:
"""check-installed should report installation status."""
result = just.run("coderabbit::check-installed", timeout=10)
if coderabbit_available():
assert result.success
else:
# Should fail with helpful message
assert not result.success
assert "not installed" in result.output.lower()
@pytest.mark.just_runtime
def test_version_runs(self, just: JustRunner) -> None:
"""version command should run if CodeRabbit is installed."""
if not coderabbit_available():
pytest.skip("CodeRabbit CLI not installed")
result = just.run("coderabbit::version", timeout=10)
assert result.success, f"Version failed: {result.stderr}"
+63
View File
@@ -0,0 +1,63 @@
"""Tests for dev module just commands.
These tests verify that development utility commands work correctly.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestDevSyntax:
"""Syntax validation tests for dev commands."""
DEV_COMMANDS: ClassVar[list[str]] = [
"dev::install-deps",
"dev::install-pre-commit",
"dev::update-deps",
"dev::clean",
"dev::repl",
"dev::tree",
"dev::validate",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", DEV_COMMANDS)
def test_dev_command_syntax(self, just: JustRunner, command: str) -> None:
"""Dev command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestDevRuntime:
"""Runtime tests for dev commands."""
@pytest.mark.just_runtime
def test_clean_runs(self, just: JustRunner) -> None:
"""Clean command should run successfully."""
result = just.run("dev::clean", timeout=30)
assert result.success, f"Clean failed: {result.stderr}"
@pytest.mark.just_runtime
def test_validate_runs(self, just: JustRunner) -> None:
"""Validate command should run successfully."""
result = just.run("dev::validate", timeout=30)
assert result.success, f"Validate failed: {result.stderr}"
@pytest.mark.just_runtime
@pytest.mark.slow
def test_install_deps_runs(self, just: JustRunner) -> None:
"""Install-deps command should run (may take a while)."""
result = just.run("dev::install-deps", timeout=300)
assert result.success, f"Install-deps failed: {result.stderr}"
@pytest.mark.just_runtime
def test_install_pre_commit_runs(self, just: JustRunner) -> None:
"""Install-pre-commit should run successfully."""
result = just.run("dev::install-pre-commit", timeout=60)
assert result.success, f"Install-pre-commit failed: {result.stderr}"
+127
View File
@@ -0,0 +1,127 @@
"""Tests for docker module just commands.
These tests verify that Docker-related commands work correctly.
Some tests require Docker to be running.
"""
from __future__ import annotations
import shutil
import subprocess
from typing import TYPE_CHECKING, ClassVar
import pytest
from tests.just_commands.conftest import assert_command_executed
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
# Docker image name used in tests - defined once for maintainability
DOCKER_IMAGE_NAME = "freecad-robust-mcp"
# Mapping of commands to their required arguments for dry-run testing
COMMAND_ARG_MAP: dict[str, tuple[str, ...]] = {
"docker::build-tag": ("test",),
"docker::build-push": ("test",),
"docker::scan-sarif": ("test.sarif",),
"docker::run-env": ("-e", "TEST=1"),
"docker::gui-test-cmd": ("echo", "test"),
}
def docker_available() -> bool:
"""Check if Docker is available and running."""
if not shutil.which("docker"):
return False
try:
# S603, S607: docker is a well-known command, safe in test context
result = subprocess.run(
["docker", "info"], # noqa: S607
capture_output=True,
timeout=10,
check=False,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
class TestDockerSyntax:
"""Syntax validation tests for docker commands."""
DOCKER_COMMANDS: ClassVar[list[str]] = [
"docker::build",
"docker::build-tag",
"docker::build-multi",
"docker::build-push",
"docker::build-load",
"docker::run",
"docker::run-env",
"docker::shell",
"docker::inspect",
"docker::clean",
"docker::clean-all",
"docker::scan",
"docker::scan-strict",
"docker::scan-sarif",
"docker::setup-buildx",
"docker::test",
"docker::build-gui-test",
"docker::gui-test-shell",
"docker::gui-test-run",
"docker::gui-test-cmd",
"docker::gui-test",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", DOCKER_COMMANDS)
def test_docker_command_syntax(self, just: JustRunner, command: str) -> None:
"""Docker command should have valid syntax."""
# Get args from mapping, or empty tuple for commands without required args
args = COMMAND_ARG_MAP.get(command, ())
result = just.dry_run(command, *args)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
@pytest.mark.requires_docker
class TestDockerRuntime:
"""Runtime tests for docker commands (require Docker)."""
@pytest.fixture(autouse=True)
def skip_if_no_docker(self) -> None:
"""Skip tests if Docker is not available."""
if not docker_available():
pytest.skip("Docker not available")
@pytest.mark.just_runtime
@pytest.mark.slow
def test_build_runs(
self, just: JustRunner, docker_image_cleanup: list[str]
) -> None:
"""Docker build should run successfully."""
docker_image_cleanup.append(DOCKER_IMAGE_NAME)
# 10 minute timeout: Docker builds can be slow, especially on first run
# when base images need to be pulled and dependencies compiled
result = just.run("docker::build", timeout=600)
assert result.success, f"Docker build failed: {result.stderr}"
@pytest.mark.just_runtime
def test_inspect_runs(self, just: JustRunner) -> None:
"""Docker inspect should run (may fail if no image)."""
result = just.run("docker::inspect", timeout=30)
# May fail if image doesn't exist, but should run without missing deps
assert_command_executed(result, "docker::inspect")
@pytest.mark.just_runtime
def test_clean_runs(self, just: JustRunner) -> None:
"""Docker clean should run."""
result = just.run("docker::clean", timeout=60)
# May fail if no images to clean, but should run without missing deps
assert_command_executed(result, "docker::clean")
@pytest.mark.just_runtime
def test_setup_buildx_runs(self, just: JustRunner) -> None:
"""Setup buildx should run."""
result = just.run("docker::setup-buildx", timeout=120)
assert result.success, f"Setup buildx failed: {result.stderr}"
+47
View File
@@ -0,0 +1,47 @@
"""Tests for documentation module just commands.
These tests verify that documentation commands work correctly.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestDocumentationSyntax:
"""Syntax validation tests for documentation commands."""
DOC_COMMANDS: ClassVar[list[str]] = [
"documentation::build",
"documentation::build-strict",
"documentation::serve",
"documentation::open",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", DOC_COMMANDS)
def test_documentation_command_syntax(self, just: JustRunner, command: str) -> None:
"""Documentation command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestDocumentationRuntime:
"""Runtime tests for documentation commands."""
@pytest.mark.just_runtime
def test_build_runs(self, just: JustRunner) -> None:
"""Documentation build should run successfully."""
result = just.run("documentation::build", timeout=120)
assert result.success, f"Documentation build failed: {result.stderr}"
@pytest.mark.just_runtime
def test_build_strict_runs(self, just: JustRunner) -> None:
"""Documentation build-strict should run successfully."""
result = just.run("documentation::build-strict", timeout=120)
assert result.success, f"Documentation build-strict failed: {result.stderr}"
+84
View File
@@ -0,0 +1,84 @@
"""Tests for freecad module just commands.
These tests verify that FreeCAD-related commands work correctly.
Note: Most FreeCAD commands require FreeCAD to be installed.
"""
from __future__ import annotations
import shutil
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
def freecad_available() -> bool:
"""Check if FreeCAD is available."""
# Check for common FreeCAD command locations
if shutil.which("freecad") or shutil.which("FreeCAD"):
return True
return bool(shutil.which("freecadcmd") or shutil.which("FreeCADCmd"))
class TestFreecadSyntax:
"""Syntax validation tests for FreeCAD commands."""
FREECAD_COMMANDS: ClassVar[list[str]] = [
"freecad::run-headless",
"freecad::run-headless-custom",
"freecad::run-gui",
"freecad::run-gui-custom",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", FREECAD_COMMANDS)
def test_freecad_command_syntax(self, just: JustRunner, command: str) -> None:
"""FreeCAD command should have valid syntax."""
# Commands with required arguments
if command == "freecad::run-headless-custom":
result = just.dry_run(command, "/path/to/freecadcmd")
elif command == "freecad::run-gui-custom":
result = just.dry_run(command, "/path/to/freecad")
else:
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
@pytest.mark.requires_freecad
class TestFreecadRuntime:
"""Runtime tests for FreeCAD commands (require FreeCAD)."""
@pytest.fixture(autouse=True)
def skip_if_no_freecad(self) -> None:
"""Skip tests if FreeCAD is not available."""
if not freecad_available():
pytest.skip("FreeCAD not available")
@pytest.mark.just_runtime
def test_run_headless_starts(self, just: JustRunner) -> None:
"""run-headless should start FreeCAD (we just verify it doesn't crash immediately).
Note: This test starts FreeCAD headless and lets it run briefly.
In CI, this should work with Xvfb.
"""
# Start headless and kill after short timeout
result = just.run("freecad::run-headless", timeout=10)
# Check for missing executable first (exit code 127)
if result.returncode == 127:
pytest.fail(
"FreeCAD executable not found (exit code 127). "
"Ensure FreeCAD is installed and in PATH. "
f"Output: {result.output}"
)
# Will timeout (expected) - we just want to verify it started
# returncode -1 means timeout, which is expected
# returncode 0 means it exited cleanly
# Any other exit code indicates a problem
assert result.returncode in (-1, 0), (
f"FreeCAD failed unexpectedly (exit {result.returncode}): {result.output}"
)
+79
View File
@@ -0,0 +1,79 @@
"""Tests for install module just commands.
These tests verify that installation commands work correctly.
Note: Some tests may modify local installations.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestInstallSyntax:
"""Syntax validation tests for install commands."""
INSTALL_COMMANDS: ClassVar[list[str]] = [
"install::mcp-server",
"install::mcp-server-clean",
"install::uninstall-mcp-server",
"install::mcp-bridge-workbench",
"install::uninstall-mcp-bridge-workbench",
"install::macro-cut",
"install::uninstall-macro-cut",
"install::macro-export",
"install::uninstall-macro-export",
"install::macro-all",
"install::uninstall-macro-all",
"install::status",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", INSTALL_COMMANDS)
def test_install_command_syntax(self, just: JustRunner, command: str) -> None:
"""Install command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestInstallRuntime:
"""Runtime tests for install commands."""
@pytest.mark.just_runtime
def test_status_runs(self, just: JustRunner) -> None:
"""Status command should run and show installation status."""
result = just.run("install::status", timeout=60)
assert result.success, f"Status failed: {result.stderr}"
# Should contain some expected output
assert "Installation Status" in result.stdout
@pytest.mark.just_runtime
def test_freecad_dirs_helper_runs(self, just: JustRunner) -> None:
"""Private _freecad-dirs helper should work."""
result = just.run("install::_freecad-dirs", timeout=10)
assert result.success, f"_freecad-dirs failed: {result.stderr}"
# Should output shell code to set directories
assert "MOD_DIR" in result.stdout
assert "MACRO_DIR" in result.stdout
@pytest.mark.just_runtime
@pytest.mark.slow
def test_mcp_server_install_uninstall(self, just: JustRunner) -> None:
"""MCP server install and uninstall should work."""
# Install
result = just.run("install::mcp-server", timeout=300)
assert result.success, f"MCP server install failed: {result.stderr}"
# Verify installation
status_result = just.run("install::status", timeout=60)
assert "Robust MCP Server" in status_result.stdout
# Uninstall
uninstall_result = just.run("install::uninstall-mcp-server", timeout=60)
assert uninstall_result.success, (
f"MCP server uninstall failed: {uninstall_result.stderr}"
)
+156
View File
@@ -0,0 +1,156 @@
"""Tests for just command listing functionality.
These tests verify that all listing commands work correctly and
that all expected modules are accessible.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestListCommands:
"""Test just command listing functionality."""
@pytest.mark.just_syntax
def test_default_lists_commands(self, just: JustRunner) -> None:
"""Default command should list available commands."""
result = just.run("default", timeout=10)
assert result.success, f"Failed: {result.stderr}"
# Should show at least some commands
assert "setup" in result.stdout or "all" in result.stdout
@pytest.mark.just_syntax
def test_list_all_shows_all_modules(self, just: JustRunner) -> None:
"""list-all should show commands from all modules."""
result = just.run("list-all", timeout=10)
assert result.success, f"Failed: {result.stderr}"
# Should include module headers (e.g., "quality:" or "testing:")
assert "quality:" in result.stdout or "testing:" in result.stdout
@pytest.mark.just_syntax
@pytest.mark.parametrize(
"module",
[
"coderabbit",
"dev",
"docker",
"documentation",
"freecad",
"install",
"mcp",
"quality",
"release",
"testing",
],
)
def test_list_module_commands(self, just: JustRunner, module: str) -> None:
"""Each module listing command should work."""
result = just.run(f"list-{module}", timeout=10)
assert result.success, f"list-{module} failed: {result.stderr}"
# Output should not be empty
assert result.stdout.strip(), f"list-{module} returned empty output"
class TestModulesExist:
"""Test that all expected modules and commands exist."""
# Map of modules to their expected commands (subset for validation)
EXPECTED_COMMANDS: ClassVar[dict[str, list[str]]] = {
"quality": ["check", "format", "lint", "typecheck", "security"],
"testing": ["unit", "cov", "fast", "integration"],
"dev": ["install-deps", "install-pre-commit", "clean"],
"docker": ["build", "run", "clean"],
"documentation": ["build", "serve", "open"],
"install": ["mcp-server", "mcp-bridge-workbench", "status"],
"mcp": ["run", "check"],
"freecad": ["run-gui", "run-headless"],
"release": ["status", "list-tags", "latest-versions"],
"coderabbit": ["install", "review"],
}
@pytest.mark.just_syntax
@pytest.mark.parametrize("module,commands", EXPECTED_COMMANDS.items())
def test_module_has_expected_commands(
self, just: JustRunner, module: str, commands: list[str]
) -> None:
"""Each module should have its expected commands."""
# Use list_commands to get parsed command names
available_commands = just.list_commands(module)
assert available_commands, f"No commands found in {module} module"
for cmd in commands:
assert cmd in available_commands, (
f"Command '{cmd}' not found in {module} module. "
f"Available: {available_commands}"
)
class TestSyntaxValidation:
"""Test that all commands have valid syntax (dry-run)."""
# Commands that can be safely dry-run tested
SAFE_DRY_RUN_COMMANDS: ClassVar[list[str]] = [
# Main justfile
"setup",
"all",
# Quality commands
"quality::check",
"quality::format",
"quality::lint",
"quality::typecheck",
"quality::security",
"quality::scan",
"quality::markdown-lint",
# Testing commands
"testing::unit",
"testing::cov",
"testing::fast",
"testing::verbose",
# Dev commands
"dev::install-deps",
"dev::install-pre-commit",
"dev::clean",
"dev::validate",
# Documentation commands
"documentation::build",
"documentation::build-strict",
"documentation::serve",
"documentation::open",
# Docker commands (dry-run safe)
"docker::build",
"docker::run",
"docker::shell",
"docker::inspect",
"docker::clean",
# Install commands
"install::mcp-server",
"install::mcp-bridge-workbench",
"install::macro-cut",
"install::macro-export",
"install::status",
# MCP commands
"mcp::check",
"mcp::run",
# FreeCAD commands
"freecad::run-gui",
"freecad::run-headless",
# Release commands (read-only ones)
"release::status",
"release::list-tags",
"release::latest-versions",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", SAFE_DRY_RUN_COMMANDS)
def test_command_syntax_valid(self, just: JustRunner, command: str) -> None:
"""Command should have valid syntax (just --dry-run succeeds)."""
result = just.dry_run(command)
assert result.success, (
f"Syntax error in '{command}': {result.stderr}\n{result.stdout}"
)
+87
View File
@@ -0,0 +1,87 @@
"""Tests for main justfile commands.
These tests verify that the top-level commands in the main justfile work correctly.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestMainSyntax:
"""Syntax validation tests for main justfile commands."""
MAIN_COMMANDS: ClassVar[list[str]] = [
"default",
"setup",
"all",
"all-with-integration",
"list-all",
"list-coderabbit",
"list-dev",
"list-docker",
"list-documentation",
"list-freecad",
"list-install",
"list-mcp",
"list-quality",
"list-release",
"list-testing",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", MAIN_COMMANDS)
def test_main_command_syntax(self, just: JustRunner, command: str) -> None:
"""Main justfile command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestMainRuntime:
"""Runtime tests for main justfile commands."""
@pytest.mark.just_runtime
def test_default_shows_help(self, just: JustRunner) -> None:
"""Default command should show available commands."""
result = just.run("default", timeout=10)
assert result.success, f"Default failed: {result.stderr}"
# Should show some commands
assert "setup" in result.stdout or "all" in result.stdout
@pytest.mark.just_runtime
@pytest.mark.parametrize(
"module",
[
"coderabbit",
"dev",
"docker",
"documentation",
"freecad",
"install",
"mcp",
"quality",
"release",
"testing",
],
)
def test_list_module_shows_commands(self, just: JustRunner, module: str) -> None:
"""Each list-<module> command should show module commands."""
result = just.run(f"list-{module}", timeout=10)
assert result.success, f"list-{module} failed: {result.stderr}"
# Should have some output
assert result.stdout.strip()
@pytest.mark.just_runtime
def test_list_all_comprehensive(self, just: JustRunner) -> None:
"""list-all should show commands from multiple modules."""
result = just.run("list-all", timeout=10)
assert result.success, f"list-all failed: {result.stderr}"
# Should include module headers (using : separator for headers)
module_headers = ["quality:", "testing:", "dev:", "docker:"]
found_modules = sum(1 for header in module_headers if header in result.stdout)
assert found_modules >= 2, "list-all should show commands from multiple modules"
+59
View File
@@ -0,0 +1,59 @@
"""Tests for mcp module just commands.
These tests verify that MCP server commands work correctly.
Note: Most MCP commands require FreeCAD to be running.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
from tests.just_commands.conftest import assert_command_executed
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestMCPSyntax:
"""Syntax validation tests for MCP commands."""
MCP_COMMANDS: ClassVar[list[str]] = [
"mcp::check",
"mcp::run",
"mcp::run-debug",
"mcp::run-http",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", MCP_COMMANDS)
def test_mcp_command_syntax(self, just: JustRunner, command: str) -> None:
"""MCP command should have valid syntax."""
# run-http can take an optional port argument
if command == "mcp::run-http":
result = just.dry_run(command, "8080")
else:
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestMCPRuntime:
"""Runtime tests for MCP commands.
Note: Most MCP commands require FreeCAD MCP bridge to be running.
The check command can run without FreeCAD and will fail gracefully.
"""
@pytest.mark.just_runtime
def test_check_runs_and_reports_status(self, just: JustRunner) -> None:
"""Check command should run and report bridge status.
This will likely fail (no bridge running) but should not crash.
"""
result = just.run("mcp::check", timeout=30)
# Command may fail if no bridge running, but should run without missing deps
assert_command_executed(result, "mcp::check")
# Should produce some output (verifies the command actually ran and
# reported something, without being brittle to specific wording)
assert result.output.strip(), "mcp::check produced no output"
+172
View File
@@ -0,0 +1,172 @@
"""Post-execution checks for just commands.
These tests verify that just commands don't have unintended side effects,
such as creating files in the wrong directories.
"""
from __future__ import annotations
import fnmatch
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
# Get project root (where justfile is located)
PROJECT_ROOT = Path(__file__).parent.parent.parent
JUST_DIR = PROJECT_ROOT / "just"
class TestNoUnexpectedFilesInJustDir:
"""Test that no unexpected files are created in the just/ directory.
The just/ directory should only contain .just module files.
Any other files (like .coverage, __pycache__, etc.) indicate that
a just recipe is running from the wrong directory.
"""
# Files that are expected to exist in the just/ directory
EXPECTED_FILES: ClassVar[set[str]] = {
"coderabbit.just",
"dev.just",
"docker.just",
"documentation.just",
"freecad.just",
"install.just",
"mcp.just",
"quality.just",
"release.just",
"testing.just",
}
# Patterns for files that should NEVER be in the just/ directory
FORBIDDEN_PATTERNS: ClassVar[list[str]] = [
".coverage", # pytest-cov coverage data
".pytest_cache", # pytest cache
"__pycache__", # Python bytecode cache
"*.pyc", # Compiled Python files
"htmlcov", # Coverage HTML reports
".mypy_cache", # mypy cache
".ruff_cache", # ruff cache
"node_modules", # Node.js modules
".env", # Environment files
"*.log", # Log files
"*.tmp", # Temporary files
"*.bak", # Backup files
]
@pytest.mark.just_syntax
def test_just_dir_contains_only_expected_files(self) -> None:
"""The just/ directory should only contain .just module files."""
if not JUST_DIR.exists():
pytest.skip("just/ directory does not exist")
actual_files = set()
for item in JUST_DIR.iterdir():
# Skip hidden files that git might create
if item.name.startswith(".git"):
continue
actual_files.add(item.name)
unexpected_files = actual_files - self.EXPECTED_FILES
assert not unexpected_files, (
f"Unexpected files found in just/ directory: {unexpected_files}\n"
f"This usually means a just recipe is running from the wrong directory.\n"
f"Expected only: {self.EXPECTED_FILES}"
)
@pytest.mark.just_syntax
def test_no_forbidden_files_in_just_dir(self) -> None:
"""The just/ directory should not contain any forbidden file patterns."""
if not JUST_DIR.exists():
pytest.skip("just/ directory does not exist")
forbidden_found: list[str] = []
for item in JUST_DIR.iterdir():
name = item.name
# Check each pattern using fnmatch for proper glob support
for pattern in self.FORBIDDEN_PATTERNS:
if fnmatch.fnmatch(name, pattern):
forbidden_found.append(name)
break
assert not forbidden_found, (
f"Forbidden files found in just/ directory: {forbidden_found}\n"
f"This indicates just recipes are running from the wrong directory.\n"
f"Fix the recipe to use 'cd {{{{project_root}}}}' or absolute paths."
)
@pytest.mark.just_runtime
def test_cov_command_does_not_pollute_just_dir(self, just: JustRunner) -> None:
"""Running coverage should not create files in the just/ directory."""
# Record files before running command
files_before = set(JUST_DIR.iterdir()) if JUST_DIR.exists() else set()
# Run the coverage command (with minimal test collection to speed up)
just.run(
"testing::cov",
timeout=120,
env={"PYTEST_ADDOPTS": "--collect-only -q"},
)
# Check for new files
files_after = set(JUST_DIR.iterdir()) if JUST_DIR.exists() else set()
new_files = files_after - files_before
# Filter out any git-related files
new_files = {f for f in new_files if not f.name.startswith(".git")}
assert not new_files, (
f"New files created in just/ directory after running 'testing::cov': "
f"{[f.name for f in new_files]}\n"
f"This indicates the cov recipe is running from the wrong directory."
)
class TestProjectRootIntegrity:
"""Test that commands create files in the correct locations."""
@pytest.mark.just_runtime
def test_coverage_file_location(self, just: JustRunner) -> None:
"""Coverage file should be created in project root, not elsewhere."""
# Clean up any existing coverage file first
coverage_file = PROJECT_ROOT / ".coverage"
coverage_in_just = JUST_DIR / ".coverage"
# Remove existing files to ensure clean state
if coverage_file.exists():
coverage_file.unlink()
if coverage_in_just.exists():
coverage_in_just.unlink()
try:
# Run coverage with minimal test collection
result = just.run(
"testing::cov",
timeout=120,
env={"PYTEST_ADDOPTS": "--collect-only -q"},
)
# Check results
# Coverage file should NOT be in just/ directory
assert not coverage_in_just.exists(), (
".coverage file was created in just/ directory instead of "
"project root.\n"
"Fix the testing::cov recipe to run from the correct directory."
)
# If the command succeeded, coverage file should be in project root
# (It may not exist if --collect-only was used, so we only check on
# success)
if result.success and coverage_file.exists():
# This is the expected location - test passes
pass
finally:
# Clean up coverage files created during test
if coverage_file.exists():
coverage_file.unlink()
if coverage_in_just.exists():
coverage_in_just.unlink()
+98
View File
@@ -0,0 +1,98 @@
"""Tests for quality module just commands.
These tests verify that code quality commands work correctly.
Note: Some tests may be slow as they run actual linters.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
from tests.just_commands.conftest import assert_command_executed
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestQualitySyntax:
"""Syntax validation tests for quality commands."""
QUALITY_COMMANDS: ClassVar[list[str]] = [
"quality::check",
"quality::format",
"quality::lint",
"quality::typecheck",
"quality::security",
"quality::spellcheck",
"quality::scan",
"quality::scan-gitleaks",
"quality::scan-gitleaks-history",
"quality::scan-detect",
"quality::scan-audit",
"quality::scan-baseline-update",
"quality::scan-trufflehog",
"quality::markdown-lint",
"quality::markdown-fix",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", QUALITY_COMMANDS)
def test_quality_command_syntax(self, just: JustRunner, command: str) -> None:
"""Quality command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestQualityRuntime:
"""Runtime tests for quality commands."""
@pytest.mark.just_runtime
def test_lint_runs(self, just: JustRunner) -> None:
"""Lint command should run successfully on the codebase."""
result = just.run("quality::lint", timeout=120)
# Lint may find issues, so we don't require success
# But it should at least run without crashing or missing deps
assert_command_executed(result, "quality::lint")
@pytest.mark.just_runtime
def test_typecheck_runs(self, just: JustRunner) -> None:
"""Typecheck command should run successfully."""
result = just.run("quality::typecheck", timeout=180)
# Type checking may find issues, but should run without missing deps
assert_command_executed(result, "quality::typecheck")
@pytest.mark.just_runtime
def test_spellcheck_runs(self, just: JustRunner) -> None:
"""Spellcheck command should run."""
result = just.run("quality::spellcheck", timeout=60)
assert_command_executed(result, "quality::spellcheck")
@pytest.mark.just_runtime
def test_scan_gitleaks_runs(self, just: JustRunner) -> None:
"""Gitleaks scanner should run."""
result = just.run("quality::scan-gitleaks", timeout=120)
# May find false positives, but should run without missing deps
assert_command_executed(result, "quality::scan-gitleaks")
@pytest.mark.just_runtime
def test_scan_detect_runs(self, just: JustRunner) -> None:
"""detect-secrets scanner should run."""
result = just.run("quality::scan-detect", timeout=60)
assert_command_executed(result, "quality::scan-detect")
@pytest.mark.just_runtime
def test_markdown_lint_runs(self, just: JustRunner) -> None:
"""Markdown linter should run."""
result = just.run("quality::markdown-lint", timeout=60)
# May find issues, but should run without missing deps
assert_command_executed(result, "quality::markdown-lint")
@pytest.mark.just_runtime
@pytest.mark.slow
def test_full_check_runs(self, just: JustRunner) -> None:
"""Full quality check should run (may take a while)."""
result = just.run("quality::check", timeout=600)
# This runs all pre-commit hooks - should run without missing deps
assert_command_executed(result, "quality::check")
+390
View File
@@ -0,0 +1,390 @@
"""Tests for release module just commands.
These tests verify that release commands work correctly.
IMPORTANT: Release commands are tested carefully to avoid:
- Pushing to PyPI/TestPyPI
- Pushing to Docker Hub
- Creating GitHub releases
- Creating unwanted git tags
Testing Strategy:
1. Syntax tests: Use --dry-run for all commands
2. Read-only tests: Safe commands like status, list-tags, latest-versions
3. Version bump tests: Test bump commands (they only modify local files)
4. Tag tests: Use TEST-RELEASE versions and clean up after
5. Skip push tests: Commands that push to remote are only syntax-tested
For integration testing of actual releases, use:
- TestPyPI with `-alpha` suffix
- Docker with `:test-release` tag
- Cleanup verification
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING, ClassVar
import pytest
from tests.just_commands.conftest import PROJECT_ROOT
if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
from tests.just_commands.conftest import JustRunner
class TestReleaseSyntax:
"""Syntax validation tests for release commands."""
# All release commands for syntax testing
RELEASE_COMMANDS: ClassVar[list[tuple[str, list[str]]]] = [
# Version bump commands
("bump-workbench", ["0.0.1-test"]),
("bump-macro-magnets", ["0.0.1-test"]),
("bump-macro-export", ["0.0.1-test"]),
# Tag commands (require version argument)
("tag-mcp-server", ["0.0.1-test"]),
("tag-workbench", ["0.0.1-test"]),
("tag-macro-magnets", ["0.0.1-test"]),
("tag-macro-export", ["0.0.1-test"]),
# Info commands
("list-tags", []),
("latest-versions", []),
("status", []),
("changes-since", ["mcp-server"]),
("draft-notes", ["mcp-server"]),
("extract-changelog", ["mcp-server", "1.0.0"]),
# Dry-run command
("dry-run-tag", ["mcp-server", "0.0.1-test"]),
# Tag management
("delete-tag", ["test-tag-v0.0.1"]),
# Wiki commands
("wiki-update", ["magnets"]),
("wiki-show", ["magnets"]),
("wiki-diff", ["magnets"]),
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command,args", RELEASE_COMMANDS)
def test_release_command_syntax(
self, just: JustRunner, command: str, args: list[str]
) -> None:
"""Release command should have valid syntax."""
result = just.dry_run(f"release::{command}", *args)
assert result.success, f"Syntax error in 'release::{command}': {result.stderr}"
class TestReleaseReadOnly:
"""Runtime tests for read-only release commands (safe to run)."""
@pytest.mark.just_runtime
def test_status_shows_release_info(self, just: JustRunner) -> None:
"""Status command should show release information."""
result = just.run("release::status", timeout=30)
assert result.success, f"Status failed: {result.stderr}"
assert "Release Status" in result.stdout
@pytest.mark.just_runtime
def test_list_tags_works(self, just: JustRunner) -> None:
"""list-tags should show release tags."""
result = just.run("release::list-tags", timeout=30)
assert result.success, f"list-tags failed: {result.stderr}"
# Should have section headers
assert "MCP Server" in result.stdout or "Workbench" in result.stdout
@pytest.mark.just_runtime
def test_latest_versions_works(self, just: JustRunner) -> None:
"""latest-versions should show version information."""
result = just.run("release::latest-versions", timeout=30)
assert result.success, f"latest-versions failed: {result.stderr}"
assert "Latest versions" in result.stdout
@pytest.mark.just_runtime
@pytest.mark.parametrize(
"component",
["mcp-server", "workbench", "macro-magnets", "macro-export"],
)
def test_changes_since_works(self, just: JustRunner, component: str) -> None:
"""changes-since should work for each component."""
result = just.run("release::changes-since", component, timeout=30)
# Command should succeed (exit 0) even if there are no previous releases
# The "No previous releases" message is informational, not an error
assert result.success, (
f"changes-since failed for {component}: "
f"exit code {result.returncode}, output: {result.output}"
)
@pytest.mark.just_runtime
@pytest.mark.parametrize(
"component",
["mcp-server", "workbench", "macro-magnets", "macro-export"],
)
def test_draft_notes_works(self, just: JustRunner, component: str) -> None:
"""draft-notes should work for each component."""
result = just.run("release::draft-notes", component, timeout=30)
assert result.success, f"draft-notes failed: {result.stderr}"
assert "Draft Release Notes" in result.stdout
@pytest.mark.just_runtime
@pytest.mark.parametrize(
"component,version",
[
("mcp-server", "1.0.0"),
("workbench", "1.0.0"),
("macro-magnets", "1.0.0"),
("macro-export", "1.0.0"),
],
)
def test_dry_run_tag_shows_info(
self, just: JustRunner, component: str, version: str
) -> None:
"""dry-run-tag should show what would be created."""
result = just.run("release::dry-run-tag", component, version, timeout=10)
assert result.success, f"dry-run-tag failed: {result.stderr}"
assert "Would create tag" in result.stdout
@pytest.mark.just_runtime
@pytest.mark.parametrize("macro", ["magnets", "export"])
def test_wiki_show_works(self, just: JustRunner, macro: str) -> None:
"""wiki-show should display wiki source content."""
result = just.run("release::wiki-show", macro, timeout=10)
assert result.success, f"wiki-show failed: {result.stderr}"
assert "Wiki Source" in result.stdout
class TestReleaseBumpCommands:
"""Tests for version bump commands.
These commands modify local files but don't push anywhere.
We test them but restore files afterward.
"""
@pytest.fixture
def backup_and_restore_files(
self,
) -> Generator[None, None, None]:
"""Backup files before bump tests and restore after."""
# Files that bump commands modify
files_to_backup = [
PROJECT_ROOT / "addon/FreecadRobustMCP/freecad_mcp_bridge/__init__.py",
PROJECT_ROOT / "package.xml",
PROJECT_ROOT / "macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro",
PROJECT_ROOT
/ "macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md",
PROJECT_ROOT / "macros/Cut_Object_for_Magnets/wiki-source.txt",
PROJECT_ROOT / "macros/Multi_Export/MultiExport.FCMacro",
PROJECT_ROOT / "macros/Multi_Export/README-MultiExport.md",
PROJECT_ROOT / "macros/Multi_Export/wiki-source.txt",
]
backups: dict[Path, str] = {}
for file_path in files_to_backup:
if file_path.exists():
backups[file_path] = file_path.read_text()
yield
# Restore all files
for file_path, content in backups.items():
file_path.write_text(content)
@pytest.mark.just_runtime
@pytest.mark.just_release
def test_bump_workbench_modifies_files(
self, just: JustRunner, backup_and_restore_files: None
) -> None:
"""bump-workbench should modify version files."""
result = just.run("release::bump-workbench", "99.99.99-test", timeout=30)
assert result.success, f"bump-workbench failed: {result.stderr}"
assert "Version bump complete" in result.stdout
# Verify version was updated
init_file = (
PROJECT_ROOT / "addon/FreecadRobustMCP/freecad_mcp_bridge/__init__.py"
)
content = init_file.read_text()
assert "99.99.99-test" in content
@pytest.mark.just_runtime
@pytest.mark.just_release
def test_bump_macro_magnets_modifies_files(
self, just: JustRunner, backup_and_restore_files: None
) -> None:
"""bump-macro-magnets should modify version files."""
result = just.run("release::bump-macro-magnets", "99.99.99-test", timeout=30)
assert result.success, f"bump-macro-magnets failed: {result.stderr}"
assert "Version bump complete" in result.stdout
# Verify version was updated
macro_file = (
PROJECT_ROOT / "macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro"
)
content = macro_file.read_text()
assert "99.99.99-test" in content
@pytest.mark.just_runtime
@pytest.mark.just_release
def test_bump_macro_export_modifies_files(
self, just: JustRunner, backup_and_restore_files: None
) -> None:
"""bump-macro-export should modify version files."""
result = just.run("release::bump-macro-export", "99.99.99-test", timeout=30)
assert result.success, f"bump-macro-export failed: {result.stderr}"
assert "Version bump complete" in result.stdout
# Verify version was updated
macro_file = PROJECT_ROOT / "macros/Multi_Export/MultiExport.FCMacro"
content = macro_file.read_text()
assert "99.99.99-test" in content
class TestReleaseTagCommands:
"""Tests for tag creation commands.
IMPORTANT: These tests create and clean up test tags.
They use special test versions to avoid conflicts with real releases.
Tag naming: *-test-release-v0.0.0-test-XXXXXX
Where XXXXXX is a random suffix to avoid conflicts.
"""
@pytest.fixture
def test_tag_prefix(self) -> str:
"""Generate a unique test tag prefix."""
import random
import string
# S311: Random is fine for test fixture naming, not crypto
suffix = "".join(
random.choices(string.ascii_lowercase + string.digits, k=6) # noqa: S311
)
return f"test-release-{suffix}"
@pytest.mark.just_runtime
@pytest.mark.just_release
@pytest.mark.slow
def test_tag_commands_require_clean_working_tree(self, just: JustRunner) -> None:
"""Tag commands should fail if working tree is dirty.
We don't actually create tags here, just verify the validation.
"""
# Create a temporary dirty file
test_file = PROJECT_ROOT / ".test-dirty-file"
try:
test_file.write_text("test")
# S603, S607: git is a well-known command, safe in test context
subprocess.run( # noqa: S603
["git", "add", str(test_file)], # noqa: S607
cwd=PROJECT_ROOT,
capture_output=True,
check=False,
)
# Tag commands should fail due to uncommitted changes
# Use input to respond 'n' to confirmation prompt
result = just.run(
"release::tag-mcp-server",
"0.0.1-test",
timeout=10,
input_text="n\n",
)
# Should fail before even asking for confirmation
assert "uncommitted changes" in result.output.lower()
finally:
# Cleanup - S603, S607: git is a well-known command, safe in test context
subprocess.run( # noqa: S603
["git", "reset", "HEAD", str(test_file)], # noqa: S607
cwd=PROJECT_ROOT,
capture_output=True,
check=False,
)
test_file.unlink(missing_ok=True)
@pytest.mark.just_runtime
@pytest.mark.just_release
def test_tag_commands_validate_version_format(self, just: JustRunner) -> None:
"""Tag commands should validate semver format."""
# Invalid version format
result = just.run(
"release::tag-mcp-server",
"invalid-version",
timeout=10,
input_text="n\n",
)
assert not result.success
assert "not valid semver" in result.output.lower()
class TestReleaseValidation:
"""Tests to validate release command behavior without side effects."""
@pytest.mark.just_runtime
def test_delete_tag_requires_confirmation(self, just: JustRunner) -> None:
"""delete-tag should require confirmation."""
result = just.run(
"release::delete-tag",
"nonexistent-tag-12345",
timeout=10,
input_text="n\n",
)
# Should abort when user says 'n'
assert "Aborted" in result.output or "not found" in result.output.lower()
@pytest.mark.just_runtime
@pytest.mark.parametrize(
"component",
[
"mcp-server",
"server",
"workbench",
"macro-magnets",
"magnets",
"macro-export",
"export",
],
)
def test_changes_since_component_aliases(
self, just: JustRunner, component: str
) -> None:
"""changes-since should accept various component aliases."""
result = just.run("release::changes-since", component, timeout=30)
# Should not fail with "Unknown component"
assert "Unknown component" not in result.output
@pytest.mark.just_runtime
@pytest.mark.parametrize(
"version",
[
"1.0.0",
"1.0.0-alpha",
"1.0.0-alpha.1",
"1.0.0-beta",
"1.0.0-beta.2",
"1.0.0-rc.1",
],
)
def test_dry_run_accepts_valid_versions(
self, just: JustRunner, version: str
) -> None:
"""dry-run-tag should accept valid semver versions."""
result = just.run("release::dry-run-tag", "mcp-server", version, timeout=10)
assert result.success, f"dry-run-tag rejected valid version {version}"
assert "Would create tag" in result.stdout
@pytest.mark.just_runtime
@pytest.mark.parametrize(
"version",
["invalid", "1.0", "1", "v1.0.0", "1.0.0.0"],
)
def test_dry_run_rejects_invalid_versions(
self, just: JustRunner, version: str
) -> None:
"""dry-run-tag should reject invalid versions."""
result = just.run("release::dry-run-tag", "mcp-server", version, timeout=10)
assert not result.success
assert "Invalid version format" in result.output
+76
View File
@@ -0,0 +1,76 @@
"""Tests for testing module just commands.
These tests verify that test commands work correctly.
Note: We avoid running integration tests here to prevent recursion.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import pytest
from tests.just_commands.conftest import assert_command_executed
if TYPE_CHECKING:
from tests.just_commands.conftest import JustRunner
class TestTestingSyntax:
"""Syntax validation tests for testing commands."""
TESTING_COMMANDS: ClassVar[list[str]] = [
"testing::unit",
"testing::cov",
"testing::fast",
"testing::integration",
"testing::verbose",
"testing::all",
"testing::watch",
"testing::integration-freecad-auto",
"testing::kill-bridge",
]
@pytest.mark.just_syntax
@pytest.mark.parametrize("command", TESTING_COMMANDS)
def test_testing_command_syntax(self, just: JustRunner, command: str) -> None:
"""Testing command should have valid syntax."""
result = just.dry_run(command)
assert result.success, f"Syntax error in '{command}': {result.stderr}"
class TestTestingRuntime:
"""Runtime tests for testing commands.
Note: We use --collect-only or run minimal tests to avoid
long execution times and recursion issues.
"""
@pytest.mark.just_runtime
def test_kill_bridge_runs(self, just: JustRunner) -> None:
"""Kill-bridge command should run (even if nothing to kill)."""
result = just.run("testing::kill-bridge", timeout=30)
# Should succeed even if no processes to kill
assert result.success, f"Kill-bridge failed: {result.stderr}"
@pytest.mark.just_runtime
def test_unit_command_recognizes_pytest(self, just: JustRunner) -> None:
"""Unit test command should at least recognize pytest."""
# Run with --collect-only to just validate pytest setup
result = just.run(
"testing::unit",
timeout=60,
env={"PYTEST_ADDOPTS": "--collect-only -q"},
)
# Should at least find some tests or run without error
assert_command_executed(result, "testing::unit")
@pytest.mark.just_runtime
def test_fast_command_recognizes_markers(self, just: JustRunner) -> None:
"""Fast test command should recognize the 'not slow' marker."""
result = just.run(
"testing::fast",
timeout=60,
env={"PYTEST_ADDOPTS": "--collect-only -q"},
)
assert_command_executed(result, "testing::fast")
+33 -16
View File
@@ -52,10 +52,15 @@ class TestAddonFileStructure:
server_file = ADDON_DIR / "freecad_mcp_bridge" / "server.py"
assert server_file.exists(), f"Bridge server.py not found: {server_file}"
def test_headless_server_exists(self):
"""The headless_server.py should exist for headless mode support."""
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
assert headless_file.exists(), f"headless_server.py not found: {headless_file}"
def test_blocking_bridge_exists(self):
"""The blocking_bridge.py should exist for blocking server mode."""
blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
assert blocking_file.exists(), f"blocking_bridge.py not found: {blocking_file}"
def test_bridge_utils_exists(self):
"""The bridge_utils.py should exist for shared utilities."""
utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py"
assert utils_file.exists(), f"bridge_utils.py not found: {utils_file}"
class TestAddonPythonSyntax:
@@ -87,10 +92,16 @@ class TestAddonPythonSyntax:
code = server_file.read_text()
ast.parse(code)
def test_headless_server_valid_syntax(self):
"""headless_server.py should have valid Python syntax."""
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
code = headless_file.read_text()
def test_blocking_bridge_valid_syntax(self):
"""blocking_bridge.py should have valid Python syntax."""
blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
code = blocking_file.read_text()
ast.parse(code)
def test_bridge_utils_valid_syntax(self):
"""bridge_utils.py should have valid Python syntax."""
utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py"
code = utils_file.read_text()
ast.parse(code)
@@ -129,18 +140,24 @@ class TestAddonMetadata:
code = server_file.read_text()
assert "class FreecadMCPPlugin" in code
def test_headless_server_imports_plugin(self):
"""headless_server.py should import FreecadMCPPlugin."""
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
code = headless_file.read_text()
def test_blocking_bridge_imports_plugin(self):
"""blocking_bridge.py should import FreecadMCPPlugin."""
blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
code = blocking_file.read_text()
assert "FreecadMCPPlugin" in code
def test_headless_server_has_run_forever(self):
"""headless_server.py should call run_forever for blocking execution."""
headless_file = ADDON_DIR / "freecad_mcp_bridge" / "headless_server.py"
code = headless_file.read_text()
def test_blocking_bridge_has_run_forever(self):
"""blocking_bridge.py should call run_forever for blocking execution."""
blocking_file = ADDON_DIR / "freecad_mcp_bridge" / "blocking_bridge.py"
code = blocking_file.read_text()
assert "run_forever" in code
def test_bridge_utils_has_get_running_plugin(self):
"""bridge_utils.py should have get_running_plugin function."""
utils_file = ADDON_DIR / "freecad_mcp_bridge" / "bridge_utils.py"
code = utils_file.read_text()
assert "def get_running_plugin" in code
def test_icon_is_valid_svg(self):
"""The icon should be a valid SVG file."""
icon_file = ADDON_DIR / "FreecadRobustMCP.svg"
+7
View File
@@ -1,11 +1,15 @@
"""Tests for the main server module."""
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from freecad_mcp.config import FreecadMode
# Default argv for main() tests to avoid argparse errors
DEFAULT_ARGV: list[str] = ["freecad-mcp"]
class TestGetInstanceId:
"""Tests for get_instance_id function."""
@@ -232,6 +236,7 @@ class TestMain:
mock_config.transport = TransportType.STDIO
with (
patch.object(sys, "argv", DEFAULT_ARGV),
patch.object(server_module, "get_config", return_value=mock_config),
patch.object(server_module.mcp, "run") as mock_run,
patch("builtins.print") as mock_print,
@@ -257,6 +262,7 @@ class TestMain:
mock_config.http_port = 8080
with (
patch.object(sys, "argv", DEFAULT_ARGV),
patch.object(server_module, "get_config", return_value=mock_config),
patch.object(server_module.mcp, "run") as mock_run,
patch("builtins.print"),
@@ -280,6 +286,7 @@ class TestMain:
mock_config.transport = TransportType.STDIO
with (
patch.object(sys, "argv", DEFAULT_ARGV),
patch.object(server_module, "get_config", return_value=mock_config),
patch.object(server_module.mcp, "run") as mock_run,
patch("builtins.print"),
+6 -1
View File
@@ -240,7 +240,12 @@ class TestExecutionTools:
assert "hostname" in result
assert "os_name" in result
assert "python_version" in result
assert "in_docker" 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