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:
co-authored by
Claude Opus 4.5
parent
bcc3048876
commit
8c338f6da7
@@ -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"]
|
||||
Executable
+290
@@ -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 "========================================"
|
||||
Executable
+292
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user