diff --git a/README.md b/README.md index e6c0035c70..fdbdc39d46 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![Auto Claude Kanban Board](.github/assets/Auto-Claude-Kanban.png) -[![Version](https://img.shields.io/badge/version-2.7.1-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1) +[![Version](https://img.shields.io/badge/version-2.7.2--beta.10-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10) [![License](https://img.shields.io/badge/license-AGPL--3.0-green?style=flat-square)](./agpl-3.0.txt) [![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/KCXaPBr4Dj) @@ -18,17 +18,17 @@ ### Stable Release -[![Stable](https://img.shields.io/badge/stable-2.7.1-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1) +[![Stable](https://img.shields.io/badge/stable-2.7.1-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10) | Platform | Download | |----------|----------| -| **Windows** | [Auto-Claude-2.7.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-win32-x64.exe) | -| **macOS (Apple Silicon)** | [Auto-Claude-2.7.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-arm64.dmg) | -| **macOS (Intel)** | [Auto-Claude-2.7.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-x64.dmg) | -| **Linux** | [Auto-Claude-2.7.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-x86_64.AppImage) | -| **Linux (Debian)** | [Auto-Claude-2.7.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-amd64.deb) | +| **Windows** | [Auto-Claude-2.7.2-beta.10-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-win32-x64.exe) | +| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg) | +| **macOS (Intel)** | [Auto-Claude-2.7.2-beta.10-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-x64.dmg) | +| **Linux** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage) | +| **Linux (Debian)** | [Auto-Claude-2.7.2-beta.10-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-amd64.deb) | ### Beta Release diff --git a/apps/backend/services/dependency_installer.py b/apps/backend/services/dependency_installer.py new file mode 100644 index 0000000000..62462b3f8e --- /dev/null +++ b/apps/backend/services/dependency_installer.py @@ -0,0 +1,872 @@ +#!/usr/bin/env python3 +""" +Dependency Installer Module +=========================== + +Installs requirements.txt to both bundled and venv Python interpreters. +Ensures dependencies are synchronized across all Python environments used by Auto-Claude. + +The dependency installer is used by: +- Startup sequence: To ensure both interpreters have required packages +- Manual recovery: To fix dependency mismatches between interpreters +- CI/CD: To verify dependency synchronization + +Usage: + # Verify dependencies are synchronized (no installation) + python services/dependency_installer.py --verify-only + + # Install to both interpreters + python services/dependency_installer.py --install-all + + # Install to specific interpreter + python services/dependency_installer.py --install-bundled + python services/dependency_installer.py --install-venv + + # Validate from code + from dependency_installer import DependencyInstaller + + installer = DependencyInstaller() + result = installer.install_to_both() + if not result.success: + print(result.errors) +""" + +from __future__ import annotations + +import json +import platform +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +# Default timeout for pip operations (5 minutes) +DEFAULT_PIP_TIMEOUT = 300 + +# Packages to verify after installation +VERIFICATION_PACKAGES = [ + "claude_agent_sdk", + "dotenv", + "pydantic", +] + + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + + +@dataclass +class PackageInfo: + """ + Information about an installed package. + + Attributes: + name: Package name + version: Installed version + """ + + name: str + version: str + + +@dataclass +class InstallationResult: + """ + Result of a single interpreter installation. + + Attributes: + success: Whether installation succeeded + interpreter_path: Path to the Python interpreter + interpreter_type: Type of interpreter (bundled, venv) + packages_installed: List of packages that were installed + packages_failed: List of packages that failed to install + stdout: Standard output from pip + stderr: Standard error from pip + errors: List of error messages + """ + + success: bool = False + interpreter_path: str = "" + interpreter_type: str = "" + packages_installed: list[str] = field(default_factory=list) + packages_failed: list[str] = field(default_factory=list) + stdout: str = "" + stderr: str = "" + errors: list[str] = field(default_factory=list) + + +@dataclass +class SyncResult: + """ + Result of dependency synchronization across interpreters. + + Attributes: + success: Whether all interpreters are synchronized + bundled: Installation result for bundled Python + venv: Installation result for venv Python + synchronized: Whether both interpreters have same packages + differences: List of package differences between interpreters + summary: Human-readable summary + """ + + success: bool = False + bundled: InstallationResult | None = None + venv: InstallationResult | None = None + synchronized: bool = False + differences: list[str] = field(default_factory=list) + summary: str = "" + + +@dataclass +class VerificationResult: + """ + Result of dependency verification (no installation). + + Attributes: + success: Whether verification passed + bundled_packages: Packages installed in bundled Python + venv_packages: Packages installed in venv Python + synchronized: Whether both have same packages + differences: List of differences found + missing_in_bundled: Packages in venv but not bundled + missing_in_venv: Packages in bundled but not venv + summary: Human-readable summary + """ + + success: bool = False + bundled_packages: dict[str, str] = field(default_factory=dict) + venv_packages: dict[str, str] = field(default_factory=dict) + synchronized: bool = False + differences: list[str] = field(default_factory=list) + missing_in_bundled: list[str] = field(default_factory=list) + missing_in_venv: list[str] = field(default_factory=list) + summary: str = "" + + +# ============================================================================= +# PATH DETECTION +# ============================================================================= + + +def get_bundled_python_path() -> Path | None: + """ + Get the path to the bundled Python interpreter. + + Returns: + Path to bundled Python, or None if not found. + """ + system = platform.system() + + if system == "Darwin": # macOS + candidates = [ + Path("/Applications/Auto-Claude.app/Contents/Resources/python/bin/python3"), + Path.home() + / "Applications/Auto-Claude.app/Contents/Resources/python/bin/python3", + ] + elif system == "Windows": + appdata_local = Path.home() / "AppData/Local" + candidates = [ + appdata_local / "Programs/auto-claude-ui/resources/python/python.exe", + Path.home() + / "AppData/Roaming/../Local/Programs/auto-claude-ui/resources/python/python.exe", + ] + else: # Linux + candidates = [ + Path("/opt/auto-claude/python/bin/python3"), + Path.home() / ".local/share/auto-claude/python/bin/python3", + ] + + for candidate in candidates: + if candidate.exists(): + return candidate + + return None + + +def get_venv_python_path() -> Path | None: + """ + Get the path to the venv Python interpreter. + + Returns: + Path to venv Python, or None if not found. + """ + system = platform.system() + + if system == "Darwin": # macOS + venv_path = ( + Path.home() + / "Library/Application Support/auto-claude-ui/python-venv/bin/python" + ) + elif system == "Windows": + venv_path = ( + Path.home() + / "AppData/Roaming/auto-claude-ui/python-venv/Scripts/python.exe" + ) + else: # Linux + venv_path = Path.home() / ".config/auto-claude-ui/python-venv/bin/python" + + if venv_path.exists(): + return venv_path + + return None + + +def get_requirements_path() -> Path | None: + """ + Get the path to requirements.txt. + + Searches for requirements.txt in common locations. + + Returns: + Path to requirements.txt, or None if not found. + """ + # Try relative to this script + script_dir = Path(__file__).parent + candidates = [ + script_dir.parent / "requirements.txt", # apps/backend/requirements.txt + script_dir / "requirements.txt", + Path.cwd() / "requirements.txt", + ] + + # Platform-specific bundled locations + system = platform.system() + if system == "Darwin": + candidates.extend( + [ + Path( + "/Applications/Auto-Claude.app/Contents/Resources/auto-claude/requirements.txt" + ), + Path( + "/Applications/Auto-Claude.app/Contents/Resources/backend/requirements.txt" + ), + ] + ) + elif system == "Windows": + appdata_local = Path.home() / "AppData/Local" + candidates.extend( + [ + appdata_local + / "Programs/auto-claude-ui/resources/auto-claude/requirements.txt", + appdata_local + / "Programs/auto-claude-ui/resources/backend/requirements.txt", + ] + ) + + for candidate in candidates: + if candidate.exists(): + return candidate + + return None + + +# ============================================================================= +# DEPENDENCY INSTALLER +# ============================================================================= + + +class DependencyInstaller: + """ + Installs and synchronizes dependencies across Python interpreters. + + Handles: + - Installing requirements.txt to bundled Python + - Installing requirements.txt to venv Python + - Verifying both interpreters have synchronized packages + - Detailed error reporting for installation failures + """ + + def __init__( + self, + requirements_path: str | Path | None = None, + timeout: int = DEFAULT_PIP_TIMEOUT, + ) -> None: + """ + Initialize the dependency installer. + + Args: + requirements_path: Path to requirements.txt (auto-detected if None) + timeout: Timeout for pip operations in seconds + """ + self.requirements_path = ( + Path(requirements_path) if requirements_path else get_requirements_path() + ) + self.timeout = timeout + + def install_to_interpreter( + self, + python_path: str | Path, + interpreter_type: str = "unknown", + ) -> InstallationResult: + """ + Install requirements to a specific Python interpreter. + + Args: + python_path: Path to the Python interpreter + interpreter_type: Label for the interpreter (bundled, venv) + + Returns: + InstallationResult with detailed status + """ + result = InstallationResult( + interpreter_path=str(python_path), + interpreter_type=interpreter_type, + ) + + if not self.requirements_path: + result.errors.append("requirements.txt not found") + return result + + if not self.requirements_path.exists(): + result.errors.append( + f"requirements.txt not found at: {self.requirements_path}" + ) + return result + + python_path = Path(python_path) + if not python_path.exists(): + result.errors.append(f"Python interpreter not found: {python_path}") + return result + + # Build pip install command + cmd = [ + str(python_path), + "-m", + "pip", + "install", + "-r", + str(self.requirements_path), + "--quiet", # Reduce output noise + ] + + try: + proc_result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.timeout, + ) + + result.stdout = proc_result.stdout + result.stderr = proc_result.stderr + + if proc_result.returncode == 0: + result.success = True + # Get list of installed packages + result.packages_installed = self._get_installed_packages(python_path) + else: + result.success = False + result.errors.append( + f"pip install failed with exit code {proc_result.returncode}" + ) + if proc_result.stderr: + result.errors.append(f"STDERR: {proc_result.stderr.strip()}") + if proc_result.stdout: + result.errors.append(f"STDOUT: {proc_result.stdout.strip()}") + + except subprocess.TimeoutExpired: + result.errors.append(f"pip install timed out after {self.timeout} seconds") + except FileNotFoundError: + result.errors.append(f"Python interpreter not found: {python_path}") + except Exception as e: + result.errors.append(f"Installation error: {e}") + + return result + + def install_to_bundled(self) -> InstallationResult: + """ + Install requirements to the bundled Python interpreter. + + Returns: + InstallationResult with installation status + """ + bundled_path = get_bundled_python_path() + + if not bundled_path: + result = InstallationResult(interpreter_type="bundled") + result.errors.append( + "Bundled Python interpreter not found. " + "This is expected in development environments." + ) + return result + + return self.install_to_interpreter(bundled_path, "bundled") + + def install_to_venv(self) -> InstallationResult: + """ + Install requirements to the venv Python interpreter. + + Returns: + InstallationResult with installation status + """ + venv_path = get_venv_python_path() + + if not venv_path: + result = InstallationResult(interpreter_type="venv") + result.errors.append( + "Venv Python interpreter not found. " + "Please create a venv first using the app's Python environment manager." + ) + return result + + return self.install_to_interpreter(venv_path, "venv") + + def install_to_both(self) -> SyncResult: + """ + Install requirements to both bundled and venv interpreters. + + Returns: + SyncResult with status for both interpreters + """ + result = SyncResult() + summaries = [] + + # Install to bundled Python + bundled_path = get_bundled_python_path() + if bundled_path: + result.bundled = self.install_to_interpreter(bundled_path, "bundled") + if result.bundled.success: + summaries.append(f"Bundled Python ({bundled_path}): Installation OK") + else: + summaries.append( + f"Bundled Python ({bundled_path}): FAILED - {', '.join(result.bundled.errors)}" + ) + else: + summaries.append( + "Bundled Python: Not found (expected for development environment)" + ) + + # Install to venv Python + venv_path = get_venv_python_path() + if venv_path: + result.venv = self.install_to_interpreter(venv_path, "venv") + if result.venv.success: + summaries.append(f"Venv Python ({venv_path}): Installation OK") + else: + summaries.append( + f"Venv Python ({venv_path}): FAILED - {', '.join(result.venv.errors)}" + ) + else: + summaries.append("Venv Python: Not found (create venv first)") + + # Check synchronization after installation + verification = self.verify_synchronization() + result.synchronized = verification.synchronized + result.differences = verification.differences + + # Overall success requires at least one interpreter installed + bundled_ok = result.bundled is not None and result.bundled.success + venv_ok = result.venv is not None and result.venv.success + + result.success = bundled_ok or venv_ok + result.summary = "\n".join(summaries) + + if result.synchronized: + result.summary += "\n\nDependencies synchronized across both interpreters" + elif result.differences: + result.summary += "\n\nSynchronization differences:\n " + "\n ".join( + result.differences + ) + + return result + + def verify_synchronization( + self, + bundled_path: str | Path | None = None, + venv_path: str | Path | None = None, + ) -> VerificationResult: + """ + Verify that dependencies are synchronized across interpreters. + + Does not install anything, only checks current state. + + Args: + bundled_path: Path to bundled Python (auto-detected if None) + venv_path: Path to venv Python (auto-detected if None) + + Returns: + VerificationResult with synchronization status + """ + result = VerificationResult() + summaries = [] + + # Get bundled packages + if bundled_path is None: + bundled_path = get_bundled_python_path() + + if bundled_path and Path(bundled_path).exists(): + result.bundled_packages = self._get_packages_dict(bundled_path) + summaries.append(f"Bundled Python: {len(result.bundled_packages)} packages") + else: + summaries.append("Bundled Python: Not available") + + # Get venv packages + if venv_path is None: + venv_path = get_venv_python_path() + + if venv_path and Path(venv_path).exists(): + result.venv_packages = self._get_packages_dict(venv_path) + summaries.append(f"Venv Python: {len(result.venv_packages)} packages") + else: + summaries.append("Venv Python: Not available") + + # Compare packages (only if both are available) + if result.bundled_packages and result.venv_packages: + # Check for critical packages in both + for pkg in VERIFICATION_PACKAGES: + pkg_lower = pkg.lower().replace("_", "-") + in_bundled = any( + p.lower().replace("_", "-") == pkg_lower + for p in result.bundled_packages + ) + in_venv = any( + p.lower().replace("_", "-") == pkg_lower + for p in result.venv_packages + ) + + if not in_bundled and in_venv: + result.missing_in_bundled.append(pkg) + result.differences.append(f"{pkg}: missing in bundled") + elif in_bundled and not in_venv: + result.missing_in_venv.append(pkg) + result.differences.append(f"{pkg}: missing in venv") + + # Check version differences for common packages + bundled_lower = { + k.lower().replace("_", "-"): v + for k, v in result.bundled_packages.items() + } + venv_lower = { + k.lower().replace("_", "-"): v for k, v in result.venv_packages.items() + } + + for pkg in set(bundled_lower.keys()) & set(venv_lower.keys()): + if bundled_lower[pkg] != venv_lower[pkg]: + result.differences.append( + f"{pkg}: bundled={bundled_lower[pkg]}, venv={venv_lower[pkg]}" + ) + + result.synchronized = ( + len(result.missing_in_bundled) == 0 and len(result.missing_in_venv) == 0 + ) + elif result.bundled_packages or result.venv_packages: + # Only one interpreter available + result.synchronized = True # Can't compare, assume OK + summaries.append("Note: Only one interpreter available for comparison") + else: + # Neither interpreter available + result.synchronized = False + summaries.append("Warning: No interpreters found to verify") + + result.success = result.synchronized + result.summary = "\n".join(summaries) + + if result.synchronized: + result.summary += "\n\nDependencies synchronized across both interpreters" + + return result + + def _get_installed_packages(self, python_path: str | Path) -> list[str]: + """ + Get list of installed package names. + + Args: + python_path: Path to Python interpreter + + Returns: + List of package names + """ + packages_dict = self._get_packages_dict(python_path) + return list(packages_dict.keys()) + + def _get_packages_dict(self, python_path: str | Path) -> dict[str, str]: + """ + Get dictionary of installed packages and versions. + + Args: + python_path: Path to Python interpreter + + Returns: + Dictionary mapping package names to versions + """ + packages: dict[str, str] = {} + + try: + cmd = [ + str(python_path), + "-m", + "pip", + "list", + "--format=json", + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode == 0: + try: + package_list = json.loads(result.stdout) + for pkg in package_list: + packages[pkg.get("name", "")] = pkg.get("version", "") + except json.JSONDecodeError: + pass + + except Exception: + pass + + return packages + + +# ============================================================================= +# CLI +# ============================================================================= + + +def main() -> int: + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Install and synchronize dependencies across Python interpreters", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Verify dependencies are synchronized (no installation) + python services/dependency_installer.py --verify-only + + # Install to both interpreters + python services/dependency_installer.py --install-all + + # Install to specific interpreter + python services/dependency_installer.py --install-bundled + python services/dependency_installer.py --install-venv + + # Specify custom requirements file + python services/dependency_installer.py --install-all --requirements /path/to/requirements.txt + """, + ) + parser.add_argument( + "--verify-only", + action="store_true", + help="Only verify synchronization, don't install anything", + ) + parser.add_argument( + "--install-all", + action="store_true", + help="Install to both bundled and venv interpreters", + ) + parser.add_argument( + "--install-bundled", + action="store_true", + help="Install to bundled Python interpreter", + ) + parser.add_argument( + "--install-venv", + action="store_true", + help="Install to venv Python interpreter", + ) + parser.add_argument( + "--requirements", + type=Path, + default=None, + help="Path to requirements.txt file", + ) + parser.add_argument( + "--timeout", + type=int, + default=DEFAULT_PIP_TIMEOUT, + help=f"Timeout for pip operations in seconds (default: {DEFAULT_PIP_TIMEOUT})", + ) + parser.add_argument( + "--json", + action="store_true", + help="Output results as JSON", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show detailed package information", + ) + + args = parser.parse_args() + + installer = DependencyInstaller( + requirements_path=args.requirements, + timeout=args.timeout, + ) + + # Default to verify-only if no action specified + if not any( + [args.verify_only, args.install_all, args.install_bundled, args.install_venv] + ): + args.verify_only = True + + # Handle verify-only mode + if args.verify_only: + result = installer.verify_synchronization() + return _output_verification_result(result, args.json, args.verbose) + + # Handle installation modes + if args.install_all: + result = installer.install_to_both() + return _output_sync_result(result, args.json, args.verbose) + + if args.install_bundled: + result = installer.install_to_bundled() + return _output_installation_result(result, args.json, args.verbose) + + if args.install_venv: + result = installer.install_to_venv() + return _output_installation_result(result, args.json, args.verbose) + + return 0 + + +def _output_verification_result( + result: VerificationResult, as_json: bool, verbose: bool +) -> int: + """Output verification result.""" + if as_json: + output: dict[str, Any] = { + "success": result.success, + "synchronized": result.synchronized, + "differences": result.differences, + "missing_in_bundled": result.missing_in_bundled, + "missing_in_venv": result.missing_in_venv, + } + if verbose: + output["bundled_packages"] = result.bundled_packages + output["venv_packages"] = result.venv_packages + print(json.dumps(output, indent=2)) + else: + status = "OK" if result.success else "NEEDS SYNC" + print(f"Dependency Verification: {status}") + print() + print(result.summary) + + if result.differences: + print("\nDifferences found:") + for diff in result.differences: + print(f" - {diff}") + + if verbose: + if result.bundled_packages: + print(f"\nBundled packages ({len(result.bundled_packages)}):") + for pkg, version in sorted(result.bundled_packages.items())[:10]: + print(f" {pkg}=={version}") + if len(result.bundled_packages) > 10: + print(f" ... and {len(result.bundled_packages) - 10} more") + + if result.venv_packages: + print(f"\nVenv packages ({len(result.venv_packages)}):") + for pkg, version in sorted(result.venv_packages.items())[:10]: + print(f" {pkg}=={version}") + if len(result.venv_packages) > 10: + print(f" ... and {len(result.venv_packages) - 10} more") + + if result.success: + print("\nDependencies synchronized across both interpreters") + + return 0 if result.success else 1 + + +def _output_installation_result( + result: InstallationResult, as_json: bool, verbose: bool +) -> int: + """Output single installation result.""" + if as_json: + output: dict[str, Any] = { + "success": result.success, + "interpreter_path": result.interpreter_path, + "interpreter_type": result.interpreter_type, + "errors": result.errors, + } + if verbose: + output["packages_installed"] = result.packages_installed + output["stdout"] = result.stdout + output["stderr"] = result.stderr + print(json.dumps(output, indent=2)) + else: + status = "OK" if result.success else "FAILED" + print(f"Dependency Installation ({result.interpreter_type}): {status}") + print(f" Interpreter: {result.interpreter_path}") + + if result.errors: + print("\nErrors:") + for error in result.errors: + print(f" - {error}") + + if verbose and result.packages_installed: + print(f"\nInstalled packages ({len(result.packages_installed)}):") + for pkg in result.packages_installed[:10]: + print(f" {pkg}") + if len(result.packages_installed) > 10: + print(f" ... and {len(result.packages_installed) - 10} more") + + return 0 if result.success else 1 + + +def _output_sync_result(result: SyncResult, as_json: bool, verbose: bool) -> int: + """Output sync result for both interpreters.""" + if as_json: + output: dict[str, Any] = { + "success": result.success, + "synchronized": result.synchronized, + "differences": result.differences, + "bundled": None, + "venv": None, + } + + if result.bundled: + output["bundled"] = { + "success": result.bundled.success, + "interpreter_path": result.bundled.interpreter_path, + "errors": result.bundled.errors, + } + if verbose: + output["bundled"]["packages_installed"] = ( + result.bundled.packages_installed + ) + + if result.venv: + output["venv"] = { + "success": result.venv.success, + "interpreter_path": result.venv.interpreter_path, + "errors": result.venv.errors, + } + if verbose: + output["venv"]["packages_installed"] = result.venv.packages_installed + + print(json.dumps(output, indent=2)) + else: + status = "OK" if result.success else "FAILED" + print(f"Dependency Installation (both): {status}") + print() + print(result.summary) + + if verbose: + if result.bundled and result.bundled.packages_installed: + print(f"\nBundled packages ({len(result.bundled.packages_installed)}):") + for pkg in result.bundled.packages_installed[:5]: + print(f" {pkg}") + + if result.venv and result.venv.packages_installed: + print(f"\nVenv packages ({len(result.venv.packages_installed)}):") + for pkg in result.venv.packages_installed[:5]: + print(f" {pkg}") + + return 0 if result.success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/backend/services/environment_validator.py b/apps/backend/services/environment_validator.py new file mode 100644 index 0000000000..b3f7f54ff8 --- /dev/null +++ b/apps/backend/services/environment_validator.py @@ -0,0 +1,660 @@ +#!/usr/bin/env python3 +""" +Environment Validator Module +============================ + +Validates Python version and dependency imports in both bundled and venv interpreters. +Provides detailed diagnostics when validation fails. + +The environment validator is used by: +- Startup sequence: To verify dependencies before running features +- Installation flow: To confirm successful dependency installation +- Troubleshooting: To diagnose "Process exited with code 1" errors + +Usage: + # Check both interpreters + python services/environment_validator.py --check-bundled --check-venv + + # Check specific interpreter + python services/environment_validator.py --check-bundled + + # Use custom Python path + python services/environment_validator.py --python /path/to/python + + # Validate from code + from environment_validator import EnvironmentValidator + + validator = EnvironmentValidator() + result = validator.validate_environment("/path/to/python") + if not result.success: + print(result.errors) +""" + +from __future__ import annotations + +import json +import platform +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +# Minimum Python version required +MIN_PYTHON_VERSION = (3, 12) + +# Core dependencies that must be importable +CORE_DEPENDENCIES = [ + "claude_agent_sdk", + "dotenv", + "pydantic", +] + +# Optional dependencies (Python 3.12+ only) +OPTIONAL_DEPENDENCIES = [ + "real_ladybug", + "graphiti_core", + "google.generativeai", +] + + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + + +@dataclass +class DependencyStatus: + """ + Status of a single dependency. + + Attributes: + name: Name of the dependency/module + installed: Whether the dependency is importable + version: Version string if available + error: Error message if import failed + """ + + name: str + installed: bool = False + version: str | None = None + error: str | None = None + + +@dataclass +class ValidationResult: + """ + Result of environment validation. + + Attributes: + success: Whether all required validations passed + python_path: Path to the Python interpreter validated + python_version: Python version string (e.g., "3.12.0") + python_version_valid: Whether Python version meets minimum requirement + dependencies: List of dependency statuses + missing_required: List of missing required dependencies + missing_optional: List of missing optional dependencies + errors: List of error messages + warnings: List of warning messages + """ + + success: bool = False + python_path: str = "" + python_version: str = "" + python_version_valid: bool = False + dependencies: list[DependencyStatus] = field(default_factory=list) + missing_required: list[str] = field(default_factory=list) + missing_optional: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +@dataclass +class DualValidationResult: + """ + Result of validating both bundled and venv interpreters. + + Attributes: + success: Whether both interpreters are valid + bundled: Validation result for bundled Python + venv: Validation result for venv Python + summary: Human-readable summary of validation + """ + + success: bool = False + bundled: ValidationResult | None = None + venv: ValidationResult | None = None + summary: str = "" + + +# ============================================================================= +# PATH DETECTION +# ============================================================================= + + +def get_bundled_python_path() -> Path | None: + """ + Get the path to the bundled Python interpreter. + + Returns: + Path to bundled Python, or None if not found. + """ + system = platform.system() + + if system == "Darwin": # macOS + # Try multiple possible locations + candidates = [ + Path("/Applications/Auto-Claude.app/Contents/Resources/python/bin/python3"), + Path.home() + / "Applications/Auto-Claude.app/Contents/Resources/python/bin/python3", + ] + elif system == "Windows": + appdata_local = Path.home() / "AppData/Local" + candidates = [ + appdata_local / "Programs/auto-claude-ui/resources/python/python.exe", + Path.home() + / "AppData/Roaming/../Local/Programs/auto-claude-ui/resources/python/python.exe", + ] + else: # Linux + candidates = [ + Path("/opt/auto-claude/python/bin/python3"), + Path.home() / ".local/share/auto-claude/python/bin/python3", + ] + + for candidate in candidates: + if candidate.exists(): + return candidate + + return None + + +def get_venv_python_path() -> Path | None: + """ + Get the path to the venv Python interpreter. + + Returns: + Path to venv Python, or None if not found. + """ + system = platform.system() + + if system == "Darwin": # macOS + venv_path = ( + Path.home() + / "Library/Application Support/auto-claude-ui/python-venv/bin/python" + ) + elif system == "Windows": + venv_path = ( + Path.home() + / "AppData/Roaming/auto-claude-ui/python-venv/Scripts/python.exe" + ) + else: # Linux + venv_path = Path.home() / ".config/auto-claude-ui/python-venv/bin/python" + + if venv_path.exists(): + return venv_path + + return None + + +# ============================================================================= +# ENVIRONMENT VALIDATOR +# ============================================================================= + + +class EnvironmentValidator: + """ + Validates Python environments for Auto-Claude. + + Checks: + - Python version >= 3.12 + - Core dependencies are importable + - Optional dependencies (reports warnings if missing) + """ + + def __init__( + self, + core_dependencies: list[str] | None = None, + optional_dependencies: list[str] | None = None, + ) -> None: + """ + Initialize the environment validator. + + Args: + core_dependencies: List of required module names to check + optional_dependencies: List of optional module names to check + """ + self.core_dependencies = core_dependencies or CORE_DEPENDENCIES + self.optional_dependencies = optional_dependencies or OPTIONAL_DEPENDENCIES + + def validate_environment(self, python_path: str | Path) -> ValidationResult: + """ + Validate a Python environment. + + Args: + python_path: Path to the Python interpreter to validate + + Returns: + ValidationResult with detailed status + """ + result = ValidationResult(python_path=str(python_path)) + + # Check Python version + version_result = self._check_python_version(python_path) + if not version_result["success"]: + result.errors.append( + version_result.get("error", "Failed to get Python version") + ) + return result + + result.python_version = version_result["version"] + result.python_version_valid = version_result["valid"] + + if not result.python_version_valid: + result.errors.append( + f"Python version {result.python_version} is below minimum required " + f"{MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}" + ) + return result + + # Check core dependencies + for dep in self.core_dependencies: + status = self._check_dependency(python_path, dep) + result.dependencies.append(status) + if not status.installed: + result.missing_required.append(dep) + + # Check optional dependencies (only for Python 3.12+) + for dep in self.optional_dependencies: + status = self._check_dependency(python_path, dep) + result.dependencies.append(status) + if not status.installed: + result.missing_optional.append(dep) + result.warnings.append( + f"Optional dependency '{dep}' not installed (some features may be unavailable)" + ) + + # Set overall success + result.success = ( + len(result.missing_required) == 0 and result.python_version_valid + ) + + if result.missing_required: + result.errors.append( + f"Missing required dependencies: {', '.join(result.missing_required)}" + ) + + return result + + def _check_python_version(self, python_path: str | Path) -> dict[str, Any]: + """ + Check the Python version of an interpreter. + + Args: + python_path: Path to Python interpreter + + Returns: + Dict with version info and validation result + """ + try: + cmd = [ + str(python_path), + "-c", + "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')", + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0: + return { + "success": False, + "error": f"Failed to get Python version: {result.stderr.strip()}", + } + + version_str = result.stdout.strip() + parts = version_str.split(".") + major = int(parts[0]) + minor = int(parts[1]) if len(parts) > 1 else 0 + + is_valid = (major, minor) >= MIN_PYTHON_VERSION + + return { + "success": True, + "version": version_str, + "valid": is_valid, + } + + except subprocess.TimeoutExpired: + return { + "success": False, + "error": "Timeout while checking Python version", + } + except FileNotFoundError: + return { + "success": False, + "error": f"Python interpreter not found: {python_path}", + } + except Exception as e: + return { + "success": False, + "error": f"Error checking Python version: {e}", + } + + def _check_dependency( + self, python_path: str | Path, module_name: str + ) -> DependencyStatus: + """ + Check if a dependency is importable. + + Args: + python_path: Path to Python interpreter + module_name: Name of the module to import + + Returns: + DependencyStatus with import result + """ + status = DependencyStatus(name=module_name) + + try: + # Build import check script + # Handle dotted module names (e.g., "google.generativeai") + import_name = module_name.split(".")[0] + # Use string concatenation to avoid f-string escaping issues + version_code = ( + """ +import sys +import json +try: + import """ + + import_name + + """ + version = getattr(""" + + import_name + + """, '__version__', 'unknown') + print(json.dumps({"installed": True, "version": version})) +except ImportError as e: + print(json.dumps({"installed": False, "error": str(e)})) +""" + ) + + cmd = [str(python_path), "-c", version_code] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode == 0: + try: + output = json.loads(result.stdout.strip()) + status.installed = output.get("installed", False) + status.version = output.get("version") + status.error = output.get("error") + except json.JSONDecodeError: + status.installed = False + status.error = f"Failed to parse output: {result.stdout.strip()}" + else: + status.installed = False + status.error = result.stderr.strip() or "Unknown error" + + except subprocess.TimeoutExpired: + status.error = "Timeout while checking dependency" + except Exception as e: + status.error = str(e) + + return status + + def validate_dual_environment( + self, + bundled_path: str | Path | None = None, + venv_path: str | Path | None = None, + ) -> DualValidationResult: + """ + Validate both bundled and venv Python environments. + + Args: + bundled_path: Path to bundled Python (auto-detected if None) + venv_path: Path to venv Python (auto-detected if None) + + Returns: + DualValidationResult with status for both environments + """ + result = DualValidationResult() + summaries = [] + + # Validate bundled Python + if bundled_path is None: + bundled_path = get_bundled_python_path() + + if bundled_path: + result.bundled = self.validate_environment(bundled_path) + if result.bundled.success: + summaries.append(f"Bundled Python ({bundled_path}): OK") + else: + summaries.append( + f"Bundled Python ({bundled_path}): FAILED - {', '.join(result.bundled.errors)}" + ) + else: + summaries.append( + "Bundled Python: Not found (expected for development environment)" + ) + + # Validate venv Python + if venv_path is None: + venv_path = get_venv_python_path() + + if venv_path: + result.venv = self.validate_environment(venv_path) + if result.venv.success: + summaries.append(f"Venv Python ({venv_path}): OK") + else: + summaries.append( + f"Venv Python ({venv_path}): FAILED - {', '.join(result.venv.errors)}" + ) + else: + summaries.append( + "Venv Python: Not found (run 'pip install -r requirements.txt' in venv)" + ) + + # Overall success requires at least one valid environment + # In production, both should be valid, but in dev mode venv is sufficient + bundled_ok = result.bundled is not None and result.bundled.success + venv_ok = result.venv is not None and result.venv.success + + result.success = bundled_ok or venv_ok + result.summary = "\n".join(summaries) + + return result + + +# ============================================================================= +# CLI +# ============================================================================= + + +def main() -> int: + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Validate Python environments for Auto-Claude", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Check both bundled and venv interpreters + python services/environment_validator.py --check-bundled --check-venv + + # Check only venv interpreter + python services/environment_validator.py --check-venv + + # Check a specific Python interpreter + python services/environment_validator.py --python /path/to/python + """, + ) + parser.add_argument( + "--check-bundled", + action="store_true", + help="Check bundled Python interpreter", + ) + parser.add_argument( + "--check-venv", + action="store_true", + help="Check venv Python interpreter", + ) + parser.add_argument( + "--python", + type=Path, + default=None, + help="Path to specific Python interpreter to check", + ) + parser.add_argument( + "--json", + action="store_true", + help="Output results as JSON", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show detailed dependency information", + ) + + args = parser.parse_args() + + validator = EnvironmentValidator() + + # If no options specified, check current Python + if not args.check_bundled and not args.check_venv and args.python is None: + args.python = Path(sys.executable) + + # Single interpreter check + if args.python: + result = validator.validate_environment(args.python) + return _output_single_result(result, args.json, args.verbose) + + # Dual interpreter check + result = validator.validate_dual_environment( + bundled_path=get_bundled_python_path() if args.check_bundled else None, + venv_path=get_venv_python_path() if args.check_venv else None, + ) + return _output_dual_result(result, args) + + +def _output_single_result( + result: ValidationResult, as_json: bool, verbose: bool +) -> int: + """Output single validation result.""" + if as_json: + output = { + "success": result.success, + "python_path": result.python_path, + "python_version": result.python_version, + "python_version_valid": result.python_version_valid, + "missing_required": result.missing_required, + "missing_optional": result.missing_optional, + "errors": result.errors, + "warnings": result.warnings, + } + if verbose: + output["dependencies"] = [ + { + "name": d.name, + "installed": d.installed, + "version": d.version, + "error": d.error, + } + for d in result.dependencies + ] + print(json.dumps(output, indent=2)) + else: + status = "OK" if result.success else "FAILED" + print(f"Python Environment Validation: {status}") + print(f" Path: {result.python_path}") + print( + f" Version: {result.python_version} (valid: {result.python_version_valid})" + ) + + if verbose: + print("\nDependencies:") + for dep in result.dependencies: + status_str = "OK" if dep.installed else "MISSING" + version_str = f" ({dep.version})" if dep.version else "" + error_str = f" - {dep.error}" if dep.error else "" + print(f" {dep.name}: {status_str}{version_str}{error_str}") + + if result.errors: + print("\nErrors:") + for error in result.errors: + print(f" - {error}") + + if result.warnings: + print("\nWarnings:") + for warning in result.warnings: + print(f" - {warning}") + + return 0 if result.success else 1 + + +def _output_dual_result(result: DualValidationResult, args: Any) -> int: + """Output dual validation result.""" + if args.json: + output = { + "success": result.success, + "summary": result.summary, + "bundled": None, + "venv": None, + } + + if result.bundled: + output["bundled"] = { + "success": result.bundled.success, + "python_path": result.bundled.python_path, + "python_version": result.bundled.python_version, + "missing_required": result.bundled.missing_required, + "errors": result.bundled.errors, + } + + if result.venv: + output["venv"] = { + "success": result.venv.success, + "python_path": result.venv.python_path, + "python_version": result.venv.python_version, + "missing_required": result.venv.missing_required, + "errors": result.venv.errors, + } + + print(json.dumps(output, indent=2)) + else: + status = "OK" if result.success else "FAILED" + print(f"Dual Environment Validation: {status}") + print() + print(result.summary) + + # Show detailed info if verbose + if args.verbose: + if result.bundled: + print("\n--- Bundled Python Details ---") + _output_single_result(result.bundled, False, True) + + if result.venv: + print("\n--- Venv Python Details ---") + _output_single_result(result.venv, False, True) + + if result.success: + print("\nBoth interpreters validated successfully") + + return 0 if result.success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/backend/services/system_check.py b/apps/backend/services/system_check.py new file mode 100644 index 0000000000..bb3db64f91 --- /dev/null +++ b/apps/backend/services/system_check.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +""" +System Check Module +=================== + +Validates system build tools (make, cmake) availability for package compilation. +Some Python packages (like real_ladybug) require native compilation tools. + +The system check is used by: +- Startup sequence: To verify build tools before pip installation +- Dependency installation: To pre-validate before attempting to compile packages +- Troubleshooting: To diagnose build failures with clear installation instructions + +Usage: + # Check tools for current platform (auto-detected) + python services/system_check.py + + # Check tools for specific platform + python services/system_check.py --platform macos + python services/system_check.py --platform windows + + # Validate from code + from system_check import SystemChecker + + checker = SystemChecker() + result = checker.validate_build_tools() + if not result.success: + print(result.installation_instructions) +""" + +from __future__ import annotations + +import json +import platform +import subprocess +import sys +from dataclasses import dataclass, field +from typing import Any + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +# Build tools required by platform +# macOS: cmake is primary requirement (make is usually pre-installed with Xcode CLT) +# Windows: Both make and cmake are typically needed +# Linux: cmake is primary requirement (make is usually available) +PLATFORM_REQUIREMENTS = { + "Darwin": ["cmake"], # macOS - make comes with Xcode Command Line Tools + "Windows": ["make", "cmake"], # Windows needs both + "Linux": ["cmake"], # Linux - make usually pre-installed +} + +# Installation instructions by platform and tool +INSTALLATION_INSTRUCTIONS = { + "Darwin": { + "cmake": "Install cmake using Homebrew: brew install cmake", + "make": "Install Xcode Command Line Tools: xcode-select --install", + }, + "Windows": { + "cmake": "Install cmake using Chocolatey: choco install cmake -y\n" + "Or download from: https://cmake.org/download/", + "make": "Install make using Chocolatey: choco install make -y\n" + "Or install MinGW/MSYS2 which includes make", + }, + "Linux": { + "cmake": "Install cmake using package manager:\n" + " Ubuntu/Debian: sudo apt-get install cmake\n" + " Fedora: sudo dnf install cmake\n" + " Arch: sudo pacman -S cmake", + "make": "Install build-essential:\n" + " Ubuntu/Debian: sudo apt-get install build-essential\n" + " Fedora: sudo dnf groupinstall 'Development Tools'\n" + " Arch: sudo pacman -S base-devel", + }, +} + + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + + +@dataclass +class ToolStatus: + """ + Status of a single build tool. + + Attributes: + name: Name of the tool (e.g., "cmake", "make") + installed: Whether the tool is available on PATH + version: Version string if available + path: Path to the tool executable + error: Error message if check failed + """ + + name: str + installed: bool = False + version: str | None = None + path: str | None = None + error: str | None = None + + +@dataclass +class SystemCheckResult: + """ + Result of system build tools validation. + + Attributes: + success: Whether all required tools are available + platform: Platform name (Darwin, Windows, Linux) + tools: List of tool statuses + missing_tools: List of missing tool names + errors: List of error messages + installation_instructions: Instructions to install missing tools + """ + + success: bool = False + platform: str = "" + tools: list[ToolStatus] = field(default_factory=list) + missing_tools: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + installation_instructions: str = "" + + +# ============================================================================= +# SYSTEM CHECKER +# ============================================================================= + + +class SystemChecker: + """ + Validates system build tools for Auto-Claude. + + Checks: + - cmake availability (required for compiling native packages) + - make availability (required on Windows, usually pre-installed on Unix) + - Version information when available + """ + + def __init__( + self, + platform_override: str | None = None, + additional_tools: list[str] | None = None, + ) -> None: + """ + Initialize the system checker. + + Args: + platform_override: Override platform detection (Darwin, Windows, Linux) + additional_tools: Additional tools to check beyond platform defaults + """ + self.platform_name = platform_override or platform.system() + self.additional_tools = additional_tools or [] + + def get_required_tools(self) -> list[str]: + """ + Get list of required tools for the current platform. + + Returns: + List of tool names required for this platform + """ + tools = PLATFORM_REQUIREMENTS.get(self.platform_name, ["cmake"]) + return list(tools) + self.additional_tools + + def validate_build_tools(self) -> SystemCheckResult: + """ + Validate all required build tools are available. + + Returns: + SystemCheckResult with detailed status + """ + result = SystemCheckResult(platform=self.platform_name) + required_tools = self.get_required_tools() + + for tool in required_tools: + status = self._check_tool(tool) + result.tools.append(status) + + if not status.installed: + result.missing_tools.append(tool) + result.errors.append(f"Build tool '{tool}' not found") + + # Build installation instructions for missing tools + if result.missing_tools: + instructions = self._get_installation_instructions(result.missing_tools) + result.installation_instructions = instructions + + result.success = len(result.missing_tools) == 0 + + return result + + def _check_tool(self, tool_name: str) -> ToolStatus: + """ + Check if a build tool is installed. + + Args: + tool_name: Name of the tool to check + + Returns: + ToolStatus with installation result + """ + status = ToolStatus(name=tool_name) + + try: + # Try to get version using --version flag + result = subprocess.run( + [tool_name, "--version"], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + status.installed = True + # Parse version from first line of output + output = result.stdout.strip() or result.stderr.strip() + if output: + # Take first line and extract version info + first_line = output.split("\n")[0] + status.version = self._extract_version(first_line, tool_name) + + # Try to get full path using 'which' or 'where' + status.path = self._get_tool_path(tool_name) + else: + status.installed = False + status.error = f"Tool returned non-zero exit code: {result.returncode}" + + except FileNotFoundError: + status.installed = False + status.error = f"'{tool_name}' not found in PATH" + except subprocess.TimeoutExpired: + status.installed = False + status.error = f"Timeout while checking '{tool_name}'" + except Exception as e: + status.installed = False + status.error = str(e) + + return status + + def _extract_version(self, output: str, tool_name: str) -> str: + """ + Extract version string from tool output. + + Args: + output: Raw output from --version command + tool_name: Name of the tool for context + + Returns: + Extracted or cleaned version string + """ + # Common patterns for version extraction + import re + + # Try to find version pattern like "X.Y.Z" or "X.Y" + version_pattern = re.search(r"(\d+\.\d+(?:\.\d+)?)", output) + if version_pattern: + return version_pattern.group(1) + + # If no version found, return first 50 chars of output + return output[:50] if len(output) > 50 else output + + def _get_tool_path(self, tool_name: str) -> str | None: + """ + Get the full path to a tool executable. + + Args: + tool_name: Name of the tool + + Returns: + Full path or None if not found + """ + try: + # Use 'where' on Windows, 'which' on Unix + cmd = "where" if self.platform_name == "Windows" else "which" + + result = subprocess.run( + [cmd, tool_name], + capture_output=True, + text=True, + timeout=5, + ) + + if result.returncode == 0: + # Return first path if multiple found + return result.stdout.strip().split("\n")[0] + + except Exception: + pass + + return None + + def _get_installation_instructions(self, missing_tools: list[str]) -> str: + """ + Get installation instructions for missing tools. + + Args: + missing_tools: List of tool names that are missing + + Returns: + Formatted installation instructions + """ + instructions = [] + platform_instructions = INSTALLATION_INSTRUCTIONS.get(self.platform_name, {}) + + for tool in missing_tools: + if tool in platform_instructions: + instructions.append(f" {tool}: {platform_instructions[tool]}") + else: + instructions.append( + f" {tool}: Install using your system package manager" + ) + + if instructions: + header = f"Missing build tools on {self.platform_name}:\n" + return header + "\n".join(instructions) + + return "" + + +# ============================================================================= +# CLI +# ============================================================================= + + +def main() -> int: + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Validate system build tools for Auto-Claude", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Check tools for current platform + python services/system_check.py + + # Check tools for specific platform + python services/system_check.py --platform macos + python services/system_check.py --platform windows + + # Check additional custom tools + python services/system_check.py --tools gcc g++ + """, + ) + parser.add_argument( + "--platform", + type=str, + choices=["macos", "windows", "linux"], + default=None, + help="Platform to check (defaults to auto-detect)", + ) + parser.add_argument( + "--tools", + nargs="*", + default=[], + help="Additional tools to check", + ) + parser.add_argument( + "--json", + action="store_true", + help="Output results as JSON", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show detailed tool information", + ) + + args = parser.parse_args() + + # Map CLI platform names to platform.system() values + platform_map = { + "macos": "Darwin", + "windows": "Windows", + "linux": "Linux", + } + + platform_override = None + if args.platform: + platform_override = platform_map.get(args.platform.lower()) + + checker = SystemChecker( + platform_override=platform_override, + additional_tools=args.tools, + ) + + result = checker.validate_build_tools() + return _output_result(result, args.json, args.verbose) + + +def _output_result(result: SystemCheckResult, as_json: bool, verbose: bool) -> int: + """Output validation result.""" + if as_json: + output: dict[str, Any] = { + "success": result.success, + "platform": result.platform, + "missing_tools": result.missing_tools, + "errors": result.errors, + } + if verbose: + output["tools"] = [ + { + "name": t.name, + "installed": t.installed, + "version": t.version, + "path": t.path, + "error": t.error, + } + for t in result.tools + ] + if result.installation_instructions: + output["installation_instructions"] = result.installation_instructions + + print(json.dumps(output, indent=2)) + else: + status = "OK" if result.success else "FAILED" + print(f"Build Tools Validation: {status}") + print(f" Platform: {result.platform}") + + if verbose or result.missing_tools: + print("\nTools:") + for tool in result.tools: + status_str = "OK" if tool.installed else "MISSING" + version_str = f" (v{tool.version})" if tool.version else "" + path_str = f" at {tool.path}" if tool.path and verbose else "" + error_str = f" - {tool.error}" if tool.error else "" + print(f" {tool.name}: {status_str}{version_str}{path_str}{error_str}") + + if result.errors and not verbose: + print("\nErrors:") + for error in result.errors: + print(f" - {error}") + + if result.installation_instructions: + print(f"\n{result.installation_instructions}") + + if result.success: + print("\nBuild tools validation completed") + + return 0 if result.success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/backend/spec/test_environment_validator.py b/apps/backend/spec/test_environment_validator.py new file mode 100644 index 0000000000..e05766ea68 --- /dev/null +++ b/apps/backend/spec/test_environment_validator.py @@ -0,0 +1,682 @@ +#!/usr/bin/env python3 +""" +Tests for Environment Validator +=============================== + +Tests the environment_validator.py module functionality including: +- Python version detection +- Dependency checks +- Data class behavior +- Path detection functions +- Dual environment validation +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Add the services directory to the path +sys.path.insert(0, str(Path(__file__).parent.parent / "services")) + +from environment_validator import ( + CORE_DEPENDENCIES, + MIN_PYTHON_VERSION, + OPTIONAL_DEPENDENCIES, + DependencyStatus, + DualValidationResult, + EnvironmentValidator, + ValidationResult, + get_bundled_python_path, + get_venv_python_path, +) + +# ============================================================================= +# DATA CLASS TESTS +# ============================================================================= + + +class TestDependencyStatus: + """Tests for DependencyStatus dataclass.""" + + def test_default_values(self): + """Creates with correct default values.""" + status = DependencyStatus(name="test_package") + assert status.name == "test_package" + assert status.installed is False + assert status.version is None + assert status.error is None + + def test_installed_dependency(self): + """Records installed dependency with version.""" + status = DependencyStatus( + name="pydantic", + installed=True, + version="2.0.0", + ) + assert status.installed is True + assert status.version == "2.0.0" + + def test_missing_dependency_with_error(self): + """Records missing dependency with error message.""" + status = DependencyStatus( + name="missing_package", + installed=False, + error="No module named 'missing_package'", + ) + assert status.installed is False + assert "No module named" in status.error + + +class TestValidationResult: + """Tests for ValidationResult dataclass.""" + + def test_default_values(self): + """Creates with correct default values.""" + result = ValidationResult() + assert result.success is False + assert result.python_path == "" + assert result.python_version == "" + assert result.python_version_valid is False + assert result.dependencies == [] + assert result.missing_required == [] + assert result.missing_optional == [] + assert result.errors == [] + assert result.warnings == [] + + def test_successful_validation(self): + """Records successful validation.""" + result = ValidationResult( + success=True, + python_path="/usr/bin/python3", + python_version="3.12.0", + python_version_valid=True, + ) + assert result.success is True + assert result.python_version == "3.12.0" + assert result.python_version_valid is True + + def test_failed_validation_with_errors(self): + """Records failed validation with errors.""" + result = ValidationResult( + success=False, + python_path="/usr/bin/python3", + python_version="3.10.0", + python_version_valid=False, + errors=["Python version 3.10.0 is below minimum required 3.12"], + ) + assert result.success is False + assert len(result.errors) == 1 + assert "below minimum" in result.errors[0] + + def test_missing_dependencies(self): + """Tracks missing required and optional dependencies.""" + result = ValidationResult( + missing_required=["claude_agent_sdk"], + missing_optional=["real_ladybug"], + ) + assert "claude_agent_sdk" in result.missing_required + assert "real_ladybug" in result.missing_optional + + +class TestDualValidationResult: + """Tests for DualValidationResult dataclass.""" + + def test_default_values(self): + """Creates with correct default values.""" + result = DualValidationResult() + assert result.success is False + assert result.bundled is None + assert result.venv is None + assert result.summary == "" + + def test_dual_success(self): + """Records dual validation success.""" + bundled = ValidationResult(success=True, python_path="/bundled/python") + venv = ValidationResult(success=True, python_path="/venv/python") + + result = DualValidationResult( + success=True, + bundled=bundled, + venv=venv, + summary="Both environments valid", + ) + assert result.success is True + assert result.bundled.success is True + assert result.venv.success is True + + +# ============================================================================= +# PATH DETECTION TESTS +# ============================================================================= + + +class TestGetBundledPythonPath: + """Tests for bundled Python path detection.""" + + @patch("environment_validator.platform.system") + def test_macos_path_detection(self, mock_system): + """Returns macOS bundled Python path when exists.""" + mock_system.return_value = "Darwin" + + with patch.object(Path, "exists", return_value=False): + result = get_bundled_python_path() + # Should return None when path doesn't exist + assert result is None + + @patch("environment_validator.platform.system") + def test_windows_path_detection(self, mock_system): + """Returns Windows bundled Python path when exists.""" + mock_system.return_value = "Windows" + + with patch.object(Path, "exists", return_value=False): + result = get_bundled_python_path() + assert result is None + + @patch("environment_validator.platform.system") + def test_linux_path_detection(self, mock_system): + """Returns Linux bundled Python path when exists.""" + mock_system.return_value = "Linux" + + with patch.object(Path, "exists", return_value=False): + result = get_bundled_python_path() + assert result is None + + @patch("environment_validator.platform.system") + def test_returns_none_when_not_found(self, mock_system): + """Returns None when bundled Python not found.""" + mock_system.return_value = "Darwin" + + with patch.object(Path, "exists", return_value=False): + result = get_bundled_python_path() + assert result is None + + +class TestGetVenvPythonPath: + """Tests for venv Python path detection.""" + + @patch("environment_validator.platform.system") + def test_macos_venv_path(self, mock_system): + """Returns macOS venv Python path when exists.""" + mock_system.return_value = "Darwin" + + with patch.object(Path, "exists", return_value=False): + result = get_venv_python_path() + assert result is None + + @patch("environment_validator.platform.system") + def test_windows_venv_path(self, mock_system): + """Returns Windows venv Python path when exists.""" + mock_system.return_value = "Windows" + + with patch.object(Path, "exists", return_value=False): + result = get_venv_python_path() + assert result is None + + @patch("environment_validator.platform.system") + def test_linux_venv_path(self, mock_system): + """Returns Linux venv Python path when exists.""" + mock_system.return_value = "Linux" + + with patch.object(Path, "exists", return_value=False): + result = get_venv_python_path() + assert result is None + + +# ============================================================================= +# ENVIRONMENT VALIDATOR TESTS +# ============================================================================= + + +class TestEnvironmentValidatorInit: + """Tests for EnvironmentValidator initialization.""" + + def test_default_dependencies(self): + """Uses default dependencies when not specified.""" + validator = EnvironmentValidator() + assert validator.core_dependencies == CORE_DEPENDENCIES + assert validator.optional_dependencies == OPTIONAL_DEPENDENCIES + + def test_custom_dependencies(self): + """Uses custom dependencies when specified.""" + validator = EnvironmentValidator( + core_dependencies=["custom_dep"], + optional_dependencies=["optional_custom"], + ) + assert validator.core_dependencies == ["custom_dep"] + assert validator.optional_dependencies == ["optional_custom"] + + +class TestPythonVersionDetection: + """Tests for Python version detection.""" + + def test_detects_valid_python_version(self): + """Detects valid Python 3.12+ version.""" + validator = EnvironmentValidator() + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "3.12.0\n" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + version_result = validator._check_python_version("/usr/bin/python3") + + assert version_result["success"] is True + assert version_result["version"] == "3.12.0" + assert version_result["valid"] is True + + def test_detects_old_python_version(self): + """Detects Python below 3.12 as invalid.""" + validator = EnvironmentValidator() + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "3.10.0\n" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + version_result = validator._check_python_version("/usr/bin/python3") + + assert version_result["success"] is True + assert version_result["version"] == "3.10.0" + assert version_result["valid"] is False + + def test_detects_python_311_as_invalid(self): + """Python 3.11 is below minimum 3.12.""" + validator = EnvironmentValidator() + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "3.11.5\n" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + version_result = validator._check_python_version("/usr/bin/python3") + + assert version_result["valid"] is False + + def test_detects_python_313_as_valid(self): + """Python 3.13 is valid (above 3.12).""" + validator = EnvironmentValidator() + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "3.13.0\n" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + version_result = validator._check_python_version("/usr/bin/python3") + + assert version_result["valid"] is True + + def test_handles_subprocess_failure(self): + """Handles subprocess failures gracefully.""" + validator = EnvironmentValidator() + + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = "python not found" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + version_result = validator._check_python_version("/invalid/python") + + assert version_result["success"] is False + assert "error" in version_result + + def test_handles_timeout(self): + """Handles subprocess timeout.""" + validator = EnvironmentValidator() + + import subprocess + + with patch( + "environment_validator.subprocess.run", + side_effect=subprocess.TimeoutExpired("cmd", 30), + ): + version_result = validator._check_python_version("/usr/bin/python3") + + assert version_result["success"] is False + assert "Timeout" in version_result["error"] + + def test_handles_file_not_found(self): + """Handles missing Python interpreter.""" + validator = EnvironmentValidator() + + with patch( + "environment_validator.subprocess.run", + side_effect=FileNotFoundError("No such file"), + ): + version_result = validator._check_python_version("/nonexistent/python") + + assert version_result["success"] is False + assert "not found" in version_result["error"] + + +class TestDependencyCheck: + """Tests for dependency checking.""" + + def test_detects_installed_dependency(self): + """Detects installed dependency with version.""" + validator = EnvironmentValidator() + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = json.dumps({"installed": True, "version": "2.0.0"}) + + with patch("environment_validator.subprocess.run", return_value=mock_result): + status = validator._check_dependency("/usr/bin/python3", "pydantic") + + assert status.name == "pydantic" + assert status.installed is True + assert status.version == "2.0.0" + + def test_detects_missing_dependency(self): + """Detects missing dependency with error.""" + validator = EnvironmentValidator() + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = json.dumps( + { + "installed": False, + "error": "No module named 'missing'", + } + ) + + with patch("environment_validator.subprocess.run", return_value=mock_result): + status = validator._check_dependency("/usr/bin/python3", "missing") + + assert status.name == "missing" + assert status.installed is False + assert "No module named" in status.error + + def test_handles_dotted_module_names(self): + """Handles dotted module names like google.generativeai.""" + validator = EnvironmentValidator() + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = json.dumps({"installed": True, "version": "1.0.0"}) + + with patch("environment_validator.subprocess.run", return_value=mock_result): + status = validator._check_dependency( + "/usr/bin/python3", + "google.generativeai", + ) + + assert status.name == "google.generativeai" + assert status.installed is True + + def test_handles_subprocess_timeout(self): + """Handles timeout during dependency check.""" + validator = EnvironmentValidator() + + import subprocess + + with patch( + "environment_validator.subprocess.run", + side_effect=subprocess.TimeoutExpired("cmd", 30), + ): + status = validator._check_dependency("/usr/bin/python3", "slow_module") + + assert status.installed is False + assert "Timeout" in status.error + + def test_handles_invalid_json_output(self): + """Handles invalid JSON output from dependency check.""" + validator = EnvironmentValidator() + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "not valid json" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + status = validator._check_dependency("/usr/bin/python3", "broken_module") + + assert status.installed is False + assert "Failed to parse output" in status.error + + +class TestValidateEnvironment: + """Tests for full environment validation.""" + + def test_validates_good_environment(self): + """Validates environment with valid Python and all deps.""" + validator = EnvironmentValidator( + core_dependencies=["dep1"], + optional_dependencies=["opt1"], + ) + + def mock_subprocess_run(cmd, **kwargs): + result = MagicMock() + result.returncode = 0 + + # Python version check + if "version_info" in cmd[2]: + result.stdout = "3.12.0" + # Dependency check + else: + result.stdout = json.dumps({"installed": True, "version": "1.0.0"}) + + return result + + with patch( + "environment_validator.subprocess.run", side_effect=mock_subprocess_run + ): + result = validator.validate_environment("/usr/bin/python3") + + assert result.success is True + assert result.python_version == "3.12.0" + assert result.python_version_valid is True + assert len(result.missing_required) == 0 + + def test_fails_with_old_python(self): + """Fails validation with old Python version.""" + validator = EnvironmentValidator(core_dependencies=[]) + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "3.10.0" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + result = validator.validate_environment("/usr/bin/python3") + + assert result.success is False + assert result.python_version_valid is False + assert any("below minimum" in e for e in result.errors) + + def test_fails_with_missing_core_dependency(self): + """Fails validation when core dependency is missing.""" + validator = EnvironmentValidator( + core_dependencies=["missing_core"], + optional_dependencies=[], + ) + + call_count = 0 + + def mock_subprocess_run(cmd, **kwargs): + nonlocal call_count + call_count += 1 + result = MagicMock() + result.returncode = 0 + + if call_count == 1: # Version check + result.stdout = "3.12.0" + else: # Dependency check + result.stdout = json.dumps( + { + "installed": False, + "error": "No module named 'missing_core'", + } + ) + + return result + + with patch( + "environment_validator.subprocess.run", side_effect=mock_subprocess_run + ): + result = validator.validate_environment("/usr/bin/python3") + + assert result.success is False + assert "missing_core" in result.missing_required + assert any("Missing required" in e for e in result.errors) + + def test_warns_on_missing_optional_dependency(self): + """Warns but succeeds when optional dependency is missing.""" + validator = EnvironmentValidator( + core_dependencies=[], + optional_dependencies=["optional_dep"], + ) + + call_count = 0 + + def mock_subprocess_run(cmd, **kwargs): + nonlocal call_count + call_count += 1 + result = MagicMock() + result.returncode = 0 + + if call_count == 1: # Version check + result.stdout = "3.12.0" + else: # Dependency check + result.stdout = json.dumps( + { + "installed": False, + "error": "No module named 'optional_dep'", + } + ) + + return result + + with patch( + "environment_validator.subprocess.run", side_effect=mock_subprocess_run + ): + result = validator.validate_environment("/usr/bin/python3") + + assert result.success is True # Still succeeds + assert "optional_dep" in result.missing_optional + assert len(result.warnings) > 0 + + +class TestDualEnvironmentValidation: + """Tests for dual environment validation.""" + + def test_succeeds_when_both_valid(self): + """Succeeds when both bundled and venv are valid.""" + validator = EnvironmentValidator( + core_dependencies=[], + optional_dependencies=[], + ) + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "3.12.0" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + result = validator.validate_dual_environment( + bundled_path="/bundled/python", + venv_path="/venv/python", + ) + + assert result.success is True + assert result.bundled is not None + assert result.venv is not None + + def test_succeeds_when_only_venv_valid(self): + """Succeeds when only venv is valid (dev mode).""" + validator = EnvironmentValidator( + core_dependencies=[], + optional_dependencies=[], + ) + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "3.12.0" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + result = validator.validate_dual_environment( + bundled_path=None, # No bundled Python + venv_path="/venv/python", + ) + + assert result.success is True + assert result.bundled is None + assert result.venv is not None + + def test_fails_when_neither_valid(self): + """Fails when neither environment is valid.""" + validator = EnvironmentValidator( + core_dependencies=[], + optional_dependencies=[], + ) + + result = validator.validate_dual_environment( + bundled_path=None, + venv_path=None, + ) + + assert result.success is False + assert result.bundled is None + assert result.venv is None + + def test_auto_detects_paths_when_not_specified(self): + """Uses auto-detection when paths not specified.""" + validator = EnvironmentValidator( + core_dependencies=[], + optional_dependencies=[], + ) + + with patch("environment_validator.get_bundled_python_path", return_value=None): + with patch("environment_validator.get_venv_python_path", return_value=None): + result = validator.validate_dual_environment() + + # Should fail since no paths found + assert result.success is False + + +# ============================================================================= +# CONSTANTS TESTS +# ============================================================================= + + +class TestConstants: + """Tests for module constants.""" + + def test_min_python_version(self): + """Minimum Python version is 3.12.""" + assert MIN_PYTHON_VERSION == (3, 12) + + def test_core_dependencies_defined(self): + """Core dependencies list is not empty.""" + assert len(CORE_DEPENDENCIES) > 0 + assert "claude_agent_sdk" in CORE_DEPENDENCIES + + def test_optional_dependencies_defined(self): + """Optional dependencies list is defined.""" + assert isinstance(OPTIONAL_DEPENDENCIES, list) + + +# ============================================================================= +# FIXTURES +# ============================================================================= + + +@pytest.fixture +def mock_valid_python(): + """Fixture that mocks a valid Python 3.12 environment.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "3.12.0" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + yield + + +@pytest.fixture +def mock_old_python(): + """Fixture that mocks an old Python 3.10 environment.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "3.10.0" + + with patch("environment_validator.subprocess.run", return_value=mock_result): + yield diff --git a/apps/backend/spec/test_system_check.py b/apps/backend/spec/test_system_check.py new file mode 100644 index 0000000000..557f088325 --- /dev/null +++ b/apps/backend/spec/test_system_check.py @@ -0,0 +1,713 @@ +#!/usr/bin/env python3 +""" +Tests for System Check Module +============================== + +Tests the system_check.py module functionality including: +- Platform-specific tool requirements (Windows vs macOS vs Linux) +- Tool detection (make, cmake) +- Version extraction from tool output +- Installation instructions by platform +- Error handling for missing tools and timeouts +""" + +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Add the services directory to the path +sys.path.insert(0, str(Path(__file__).parent.parent / "services")) + +from system_check import ( + INSTALLATION_INSTRUCTIONS, + PLATFORM_REQUIREMENTS, + SystemChecker, + SystemCheckResult, + ToolStatus, +) + +# ============================================================================= +# DATA CLASS TESTS +# ============================================================================= + + +class TestToolStatus: + """Tests for ToolStatus dataclass.""" + + def test_default_values(self): + """Creates with correct default values.""" + status = ToolStatus(name="cmake") + assert status.name == "cmake" + assert status.installed is False + assert status.version is None + assert status.path is None + assert status.error is None + + def test_installed_tool(self): + """Records installed tool with version and path.""" + status = ToolStatus( + name="cmake", + installed=True, + version="3.28.0", + path="/usr/local/bin/cmake", + ) + assert status.installed is True + assert status.version == "3.28.0" + assert status.path == "/usr/local/bin/cmake" + + def test_missing_tool_with_error(self): + """Records missing tool with error message.""" + status = ToolStatus( + name="make", + installed=False, + error="'make' not found in PATH", + ) + assert status.installed is False + assert "not found" in status.error + + +class TestSystemCheckResult: + """Tests for SystemCheckResult dataclass.""" + + def test_default_values(self): + """Creates with correct default values.""" + result = SystemCheckResult() + assert result.success is False + assert result.platform == "" + assert result.tools == [] + assert result.missing_tools == [] + assert result.errors == [] + assert result.installation_instructions == "" + + def test_successful_validation(self): + """Records successful validation.""" + result = SystemCheckResult( + success=True, + platform="Darwin", + tools=[ToolStatus(name="cmake", installed=True)], + ) + assert result.success is True + assert result.platform == "Darwin" + assert len(result.tools) == 1 + + def test_failed_validation(self): + """Records failed validation with missing tools.""" + result = SystemCheckResult( + success=False, + platform="Windows", + missing_tools=["cmake", "make"], + errors=["Build tool 'cmake' not found", "Build tool 'make' not found"], + installation_instructions="Install cmake using Chocolatey...", + ) + assert result.success is False + assert len(result.missing_tools) == 2 + assert len(result.errors) == 2 + + +# ============================================================================= +# PLATFORM REQUIREMENTS TESTS +# ============================================================================= + + +class TestPlatformRequirements: + """Tests for platform-specific tool requirements.""" + + def test_macos_requires_cmake_only(self): + """macOS (Darwin) requires only cmake.""" + requirements = PLATFORM_REQUIREMENTS.get("Darwin", []) + assert "cmake" in requirements + assert "make" not in requirements + assert len(requirements) == 1 + + def test_windows_requires_both_make_and_cmake(self): + """Windows requires both make and cmake.""" + requirements = PLATFORM_REQUIREMENTS.get("Windows", []) + assert "cmake" in requirements + assert "make" in requirements + assert len(requirements) == 2 + + def test_linux_requires_cmake_only(self): + """Linux requires only cmake.""" + requirements = PLATFORM_REQUIREMENTS.get("Linux", []) + assert "cmake" in requirements + assert "make" not in requirements + assert len(requirements) == 1 + + +class TestInstallationInstructions: + """Tests for platform-specific installation instructions.""" + + def test_macos_cmake_instructions_use_homebrew(self): + """macOS cmake instructions use Homebrew.""" + instructions = INSTALLATION_INSTRUCTIONS.get("Darwin", {}) + assert "cmake" in instructions + assert "brew install cmake" in instructions["cmake"] + + def test_macos_make_instructions_use_xcode(self): + """macOS make instructions use xcode-select.""" + instructions = INSTALLATION_INSTRUCTIONS.get("Darwin", {}) + assert "make" in instructions + assert "xcode-select --install" in instructions["make"] + + def test_windows_cmake_instructions_use_chocolatey(self): + """Windows cmake instructions use Chocolatey.""" + instructions = INSTALLATION_INSTRUCTIONS.get("Windows", {}) + assert "cmake" in instructions + assert "choco install cmake" in instructions["cmake"] + + def test_windows_make_instructions_use_chocolatey(self): + """Windows make instructions use Chocolatey.""" + instructions = INSTALLATION_INSTRUCTIONS.get("Windows", {}) + assert "make" in instructions + assert "choco install make" in instructions["make"] + + def test_linux_cmake_instructions_include_apt(self): + """Linux cmake instructions include apt-get.""" + instructions = INSTALLATION_INSTRUCTIONS.get("Linux", {}) + assert "cmake" in instructions + assert "apt-get install cmake" in instructions["cmake"] + + +# ============================================================================= +# SYSTEM CHECKER INITIALIZATION TESTS +# ============================================================================= + + +class TestSystemCheckerInit: + """Tests for SystemChecker initialization.""" + + def test_auto_detects_platform(self): + """Auto-detects platform when not overridden.""" + checker = SystemChecker() + # Should be one of the valid platforms + assert checker.platform_name in ["Darwin", "Windows", "Linux"] + + def test_platform_override(self): + """Uses platform override when specified.""" + checker = SystemChecker(platform_override="Windows") + assert checker.platform_name == "Windows" + + def test_additional_tools(self): + """Includes additional tools in requirements.""" + checker = SystemChecker(additional_tools=["gcc", "g++"]) + assert checker.additional_tools == ["gcc", "g++"] + + def test_default_additional_tools_empty(self): + """Additional tools default to empty list.""" + checker = SystemChecker() + assert checker.additional_tools == [] + + +# ============================================================================= +# GET REQUIRED TOOLS TESTS +# ============================================================================= + + +class TestGetRequiredTools: + """Tests for get_required_tools method.""" + + def test_macos_required_tools(self): + """Returns cmake for macOS.""" + checker = SystemChecker(platform_override="Darwin") + tools = checker.get_required_tools() + assert "cmake" in tools + assert "make" not in tools + + def test_windows_required_tools(self): + """Returns make and cmake for Windows.""" + checker = SystemChecker(platform_override="Windows") + tools = checker.get_required_tools() + assert "cmake" in tools + assert "make" in tools + + def test_linux_required_tools(self): + """Returns cmake for Linux.""" + checker = SystemChecker(platform_override="Linux") + tools = checker.get_required_tools() + assert "cmake" in tools + assert "make" not in tools + + def test_includes_additional_tools(self): + """Includes additional tools with platform defaults.""" + checker = SystemChecker( + platform_override="Darwin", + additional_tools=["ninja"], + ) + tools = checker.get_required_tools() + assert "cmake" in tools + assert "ninja" in tools + + def test_unknown_platform_defaults_to_cmake(self): + """Unknown platform defaults to cmake requirement.""" + checker = SystemChecker(platform_override="UnknownOS") + tools = checker.get_required_tools() + assert "cmake" in tools + + +# ============================================================================= +# TOOL CHECK TESTS +# ============================================================================= + + +class TestToolCheck: + """Tests for _check_tool method.""" + + def test_detects_installed_tool(self): + """Detects installed tool with version.""" + checker = SystemChecker(platform_override="Darwin") + + mock_version_result = MagicMock() + mock_version_result.returncode = 0 + mock_version_result.stdout = "cmake version 3.28.0\n" + mock_version_result.stderr = "" + + mock_path_result = MagicMock() + mock_path_result.returncode = 0 + mock_path_result.stdout = "/usr/local/bin/cmake\n" + + with patch( + "subprocess.run", side_effect=[mock_version_result, mock_path_result] + ): + status = checker._check_tool("cmake") + + assert status.installed is True + assert status.version == "3.28.0" + assert status.path == "/usr/local/bin/cmake" + + def test_detects_missing_tool(self): + """Detects missing tool.""" + checker = SystemChecker(platform_override="Darwin") + + with patch("subprocess.run", side_effect=FileNotFoundError("cmake not found")): + status = checker._check_tool("cmake") + + assert status.installed is False + assert "not found" in status.error + + def test_handles_non_zero_exit_code(self): + """Handles tool returning non-zero exit code.""" + checker = SystemChecker(platform_override="Darwin") + + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "cmake: command not recognized" + + with patch("subprocess.run", return_value=mock_result): + status = checker._check_tool("cmake") + + assert status.installed is False + assert "non-zero exit code" in status.error + + def test_handles_timeout(self): + """Handles subprocess timeout.""" + checker = SystemChecker(platform_override="Darwin") + + with patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired("cmake", 10), + ): + status = checker._check_tool("cmake") + + assert status.installed is False + assert "Timeout" in status.error + + def test_windows_uses_where_for_path(self): + """Windows uses 'where' command for path detection.""" + checker = SystemChecker(platform_override="Windows") + + mock_version_result = MagicMock() + mock_version_result.returncode = 0 + mock_version_result.stdout = "cmake version 3.28.0\n" + mock_version_result.stderr = "" + + mock_path_result = MagicMock() + mock_path_result.returncode = 0 + mock_path_result.stdout = "C:\\Program Files\\CMake\\bin\\cmake.exe\n" + + with patch( + "subprocess.run", side_effect=[mock_version_result, mock_path_result] + ) as mock_run: + checker._check_tool("cmake") + + # Second call should use 'where' on Windows + calls = mock_run.call_args_list + assert len(calls) == 2 + assert calls[1][0][0] == ["where", "cmake"] + + def test_macos_uses_which_for_path(self): + """macOS uses 'which' command for path detection.""" + checker = SystemChecker(platform_override="Darwin") + + mock_version_result = MagicMock() + mock_version_result.returncode = 0 + mock_version_result.stdout = "cmake version 3.28.0\n" + mock_version_result.stderr = "" + + mock_path_result = MagicMock() + mock_path_result.returncode = 0 + mock_path_result.stdout = "/usr/local/bin/cmake\n" + + with patch( + "subprocess.run", side_effect=[mock_version_result, mock_path_result] + ) as mock_run: + checker._check_tool("cmake") + + # Second call should use 'which' on macOS + calls = mock_run.call_args_list + assert len(calls) == 2 + assert calls[1][0][0] == ["which", "cmake"] + + +# ============================================================================= +# VERSION EXTRACTION TESTS +# ============================================================================= + + +class TestVersionExtraction: + """Tests for _extract_version method.""" + + def test_extracts_cmake_version(self): + """Extracts version from cmake output.""" + checker = SystemChecker() + version = checker._extract_version("cmake version 3.28.0", "cmake") + assert version == "3.28.0" + + def test_extracts_make_version(self): + """Extracts version from make output.""" + checker = SystemChecker() + version = checker._extract_version("GNU Make 4.3", "make") + assert version == "4.3" + + def test_extracts_semantic_version(self): + """Extracts semantic version (X.Y.Z) pattern.""" + checker = SystemChecker() + version = checker._extract_version("Tool version: v1.2.3-beta", "tool") + assert version == "1.2.3" + + def test_extracts_major_minor_version(self): + """Extracts major.minor version (X.Y) pattern.""" + checker = SystemChecker() + version = checker._extract_version("Tool 4.2 (latest)", "tool") + assert version == "4.2" + + def test_truncates_long_output(self): + """Truncates long output when no version found.""" + checker = SystemChecker() + long_output = "x" * 100 + version = checker._extract_version(long_output, "tool") + assert len(version) == 50 + + def test_handles_empty_output(self): + """Handles empty output gracefully.""" + checker = SystemChecker() + version = checker._extract_version("", "tool") + assert version == "" + + +# ============================================================================= +# VALIDATE BUILD TOOLS TESTS +# ============================================================================= + + +class TestValidateBuildTools: + """Tests for validate_build_tools method.""" + + def test_succeeds_when_all_tools_present_macos(self): + """Succeeds on macOS when cmake is present.""" + checker = SystemChecker(platform_override="Darwin") + + mock_version_result = MagicMock() + mock_version_result.returncode = 0 + mock_version_result.stdout = "cmake version 3.28.0\n" + mock_version_result.stderr = "" + + mock_path_result = MagicMock() + mock_path_result.returncode = 0 + mock_path_result.stdout = "/usr/local/bin/cmake\n" + + with patch( + "subprocess.run", side_effect=[mock_version_result, mock_path_result] + ): + result = checker.validate_build_tools() + + assert result.success is True + assert result.platform == "Darwin" + assert len(result.missing_tools) == 0 + assert len(result.tools) == 1 + + def test_succeeds_when_all_tools_present_windows(self): + """Succeeds on Windows when both make and cmake are present.""" + checker = SystemChecker(platform_override="Windows") + + def mock_subprocess_run(cmd, **kwargs): + result = MagicMock() + result.returncode = 0 + result.stderr = "" + + if cmd[0] == "make": + result.stdout = "GNU Make 4.3\n" + elif cmd[0] == "cmake": + result.stdout = "cmake version 3.28.0\n" + elif cmd[0] == "where": + result.stdout = "C:\\Tools\\" + cmd[1] + ".exe\n" + + return result + + with patch("subprocess.run", side_effect=mock_subprocess_run): + result = checker.validate_build_tools() + + assert result.success is True + assert result.platform == "Windows" + assert len(result.missing_tools) == 0 + assert len(result.tools) == 2 + + def test_fails_when_cmake_missing_macos(self): + """Fails on macOS when cmake is missing.""" + checker = SystemChecker(platform_override="Darwin") + + with patch("subprocess.run", side_effect=FileNotFoundError("cmake not found")): + result = checker.validate_build_tools() + + assert result.success is False + assert "cmake" in result.missing_tools + assert len(result.errors) == 1 + assert "cmake" in result.errors[0] + + def test_fails_when_make_missing_windows(self): + """Fails on Windows when make is missing.""" + checker = SystemChecker(platform_override="Windows") + + def mock_subprocess_run(cmd, **kwargs): + if cmd[0] == "make": + raise FileNotFoundError("make not found") + + result = MagicMock() + result.returncode = 0 + result.stdout = "cmake version 3.28.0\n" + result.stderr = "" + return result + + with patch("subprocess.run", side_effect=mock_subprocess_run): + result = checker.validate_build_tools() + + assert result.success is False + assert "make" in result.missing_tools + + def test_fails_when_both_missing_windows(self): + """Fails on Windows when both make and cmake are missing.""" + checker = SystemChecker(platform_override="Windows") + + with patch("subprocess.run", side_effect=FileNotFoundError("tool not found")): + result = checker.validate_build_tools() + + assert result.success is False + assert "make" in result.missing_tools + assert "cmake" in result.missing_tools + assert len(result.missing_tools) == 2 + + def test_provides_installation_instructions_macos(self): + """Provides Homebrew instructions on macOS.""" + checker = SystemChecker(platform_override="Darwin") + + with patch("subprocess.run", side_effect=FileNotFoundError("cmake not found")): + result = checker.validate_build_tools() + + assert "brew install cmake" in result.installation_instructions + assert "Darwin" in result.installation_instructions + + def test_provides_installation_instructions_windows(self): + """Provides Chocolatey instructions on Windows.""" + checker = SystemChecker(platform_override="Windows") + + with patch("subprocess.run", side_effect=FileNotFoundError("tool not found")): + result = checker.validate_build_tools() + + assert "choco install" in result.installation_instructions + assert "Windows" in result.installation_instructions + + +# ============================================================================= +# INSTALLATION INSTRUCTIONS TESTS +# ============================================================================= + + +class TestGetInstallationInstructions: + """Tests for _get_installation_instructions method.""" + + def test_generates_macos_instructions(self): + """Generates Homebrew-based instructions for macOS.""" + checker = SystemChecker(platform_override="Darwin") + instructions = checker._get_installation_instructions(["cmake"]) + + assert "Darwin" in instructions + assert "cmake" in instructions + assert "brew" in instructions + + def test_generates_windows_instructions(self): + """Generates Chocolatey-based instructions for Windows.""" + checker = SystemChecker(platform_override="Windows") + instructions = checker._get_installation_instructions(["cmake", "make"]) + + assert "Windows" in instructions + assert "cmake" in instructions + assert "make" in instructions + assert "choco" in instructions + + def test_generates_linux_instructions(self): + """Generates apt-based instructions for Linux.""" + checker = SystemChecker(platform_override="Linux") + instructions = checker._get_installation_instructions(["cmake"]) + + assert "Linux" in instructions + assert "cmake" in instructions + assert "apt-get" in instructions + + def test_handles_unknown_tool(self): + """Provides generic message for unknown tools.""" + checker = SystemChecker(platform_override="Darwin") + instructions = checker._get_installation_instructions(["unknown_tool"]) + + assert "unknown_tool" in instructions + assert "package manager" in instructions + + def test_returns_empty_for_no_missing_tools(self): + """Returns empty string when no tools missing.""" + checker = SystemChecker(platform_override="Darwin") + instructions = checker._get_installation_instructions([]) + + assert instructions == "" + + +# ============================================================================= +# EDGE CASE TESTS +# ============================================================================= + + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + def test_handles_generic_exception(self): + """Handles generic exceptions during tool check.""" + checker = SystemChecker(platform_override="Darwin") + + with patch("subprocess.run", side_effect=Exception("Unknown error")): + status = checker._check_tool("cmake") + + assert status.installed is False + assert status.error == "Unknown error" + + def test_handles_path_lookup_failure(self): + """Handles failure in path lookup after successful version check.""" + checker = SystemChecker(platform_override="Darwin") + + mock_version_result = MagicMock() + mock_version_result.returncode = 0 + mock_version_result.stdout = "cmake version 3.28.0\n" + mock_version_result.stderr = "" + + def mock_subprocess(cmd, **kwargs): + if cmd[0] == "cmake": + return mock_version_result + # Path lookup fails + raise Exception("which not found") + + with patch("subprocess.run", side_effect=mock_subprocess): + status = checker._check_tool("cmake") + + assert status.installed is True + assert status.version == "3.28.0" + assert status.path is None # Path lookup failed but tool is installed + + def test_handles_stderr_version_output(self): + """Handles version info in stderr (some tools output there).""" + checker = SystemChecker(platform_override="Darwin") + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_result.stderr = "cmake version 3.28.0\n" + + mock_path_result = MagicMock() + mock_path_result.returncode = 0 + mock_path_result.stdout = "/usr/local/bin/cmake\n" + + with patch("subprocess.run", side_effect=[mock_result, mock_path_result]): + status = checker._check_tool("cmake") + + assert status.installed is True + assert status.version == "3.28.0" + + def test_handles_multiline_path_output(self): + """Handles multiple paths in 'where' output (Windows).""" + checker = SystemChecker(platform_override="Windows") + + mock_version_result = MagicMock() + mock_version_result.returncode = 0 + mock_version_result.stdout = "cmake version 3.28.0\n" + mock_version_result.stderr = "" + + mock_path_result = MagicMock() + mock_path_result.returncode = 0 + mock_path_result.stdout = "C:\\CMake\\bin\\cmake.exe\nC:\\Tools\\cmake.exe\n" + + with patch( + "subprocess.run", side_effect=[mock_version_result, mock_path_result] + ): + status = checker._check_tool("cmake") + + # Should return first path + assert status.path == "C:\\CMake\\bin\\cmake.exe" + + +# ============================================================================= +# FIXTURES +# ============================================================================= + + +@pytest.fixture +def mock_cmake_installed(): + """Fixture that mocks cmake as installed.""" + mock_version_result = MagicMock() + mock_version_result.returncode = 0 + mock_version_result.stdout = "cmake version 3.28.0\n" + mock_version_result.stderr = "" + + mock_path_result = MagicMock() + mock_path_result.returncode = 0 + mock_path_result.stdout = "/usr/local/bin/cmake\n" + + with patch("subprocess.run", side_effect=[mock_version_result, mock_path_result]): + yield + + +@pytest.fixture +def mock_cmake_missing(): + """Fixture that mocks cmake as missing.""" + with patch("subprocess.run", side_effect=FileNotFoundError("cmake not found")): + yield + + +@pytest.fixture +def mock_all_tools_installed(): + """Fixture that mocks all tools (make, cmake) as installed.""" + + def mock_subprocess(cmd, **kwargs): + result = MagicMock() + result.returncode = 0 + result.stderr = "" + + if cmd[0] == "make": + result.stdout = "GNU Make 4.3\n" + elif cmd[0] == "cmake": + result.stdout = "cmake version 3.28.0\n" + elif cmd[0] in ["which", "where"]: + result.stdout = f"/usr/local/bin/{cmd[1]}\n" + + return result + + with patch("subprocess.run", side_effect=mock_subprocess): + yield diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 6b22c98327..586608d869 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -1066,45 +1066,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -6279,15 +6240,6 @@ "buffer": "^5.1.0" } }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -12626,36 +12578,6 @@ "dev": true, "license": "MIT" }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/apps/frontend/src/main/__tests__/ipc-handlers.test.ts b/apps/frontend/src/main/__tests__/ipc-handlers.test.ts index 86699e5c7c..919c9cb565 100644 --- a/apps/frontend/src/main/__tests__/ipc-handlers.test.ts +++ b/apps/frontend/src/main/__tests__/ipc-handlers.test.ts @@ -226,7 +226,7 @@ describe('IPC Handlers', () => { success: false, error: 'Directory does not exist' }); - }); + }, 15000); it('should successfully add an existing project', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); @@ -239,7 +239,7 @@ describe('IPC Handlers', () => { const data = (result as { data: { path: string; name: string } }).data; expect(data.path).toBe(TEST_PROJECT_PATH); expect(data.name).toBe('test-project'); - }); + }, 15000); it('should return existing project if already added', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); diff --git a/apps/frontend/src/main/insights/insights-executor.ts b/apps/frontend/src/main/insights/insights-executor.ts index d5565620fe..471be6b941 100644 --- a/apps/frontend/src/main/insights/insights-executor.ts +++ b/apps/frontend/src/main/insights/insights-executor.ts @@ -130,6 +130,7 @@ export class InsightsExecutor extends EventEmitter { let suggestedTask: InsightsChatMessage['suggestedTask'] | undefined; const toolsUsed: InsightsToolUsage[] = []; let allInsightsOutput = ''; + let stderrOutput = ''; proc.stdout?.on('data', (data: Buffer) => { const text = data.toString(); @@ -161,6 +162,8 @@ export class InsightsExecutor extends EventEmitter { const text = data.toString(); // Collect stderr for rate limit detection too allInsightsOutput = (allInsightsOutput + text).slice(-10000); + // Collect stderr separately for error messages (keep last 5KB) + stderrOutput = (stderrOutput + text).slice(-5000); console.error('[Insights]', text); }); @@ -196,7 +199,11 @@ export class InsightsExecutor extends EventEmitter { toolsUsed }); } else { - const error = `Process exited with code ${code}`; + // Build error message with stderr details if available + const stderrTrimmed = stderrOutput.trim(); + const error = stderrTrimmed + ? `Process exited with code ${code}: ${stderrTrimmed}` + : `Process exited with code ${code}`; this.emit('stream-chunk', projectId, { type: 'error', error diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index 59235734d3..fbaf61f172 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -95,14 +95,14 @@ export function registerAgenteventsHandlers( if (code === 0) { notificationService.notifyReviewNeeded(taskTitle, project.id, taskId); - + // Fallback: Ensure status is updated even if COMPLETE phase event was missed // This prevents tasks from getting stuck in ai_review status // Uses inverted logic to also handle tasks with no subtasks (treats them as complete) const isActiveStatus = task.status === 'in_progress' || task.status === 'ai_review'; - const hasIncompleteSubtasks = task.subtasks && task.subtasks.length > 0 && + const hasIncompleteSubtasks = task.subtasks && task.subtasks.length > 0 && task.subtasks.some((s) => s.status !== 'completed'); - + if (isActiveStatus && !hasIncompleteSubtasks) { console.log(`[Task ${taskId}] Fallback: Moving to human_review (process exited successfully)`); mainWindow.webContents.send( diff --git a/apps/frontend/src/main/ipc-handlers/context/utils.ts b/apps/frontend/src/main/ipc-handlers/context/utils.ts index c815751778..6611e99740 100644 --- a/apps/frontend/src/main/ipc-handlers/context/utils.ts +++ b/apps/frontend/src/main/ipc-handlers/context/utils.ts @@ -131,7 +131,7 @@ export interface EmbeddingValidationResult { /** * Validate embedding configuration based on the configured provider * Supports: openai, ollama, google, voyage, azure_openai - * + * * @returns validation result with provider info and reason if invalid */ export function validateEmbeddingConfiguration( diff --git a/apps/frontend/src/main/ipc-handlers/environment-handlers.ts b/apps/frontend/src/main/ipc-handlers/environment-handlers.ts new file mode 100644 index 0000000000..9b3314f650 --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/environment-handlers.ts @@ -0,0 +1,162 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { IPCResult } from '../../shared/types'; +import { PythonEnvManager } from '../python-env-manager'; + +/** + * Environment validation status types + */ +export interface EnvironmentValidationStatus { + isValidating: boolean; + isComplete: boolean; + buildToolsResult: { + success: boolean; + platform: string; + missingTools: string[]; + errors: string[]; + installationInstructions: string; + } | null; + environmentResult: { + success: boolean; + bundled: { success: boolean; errors: string[] } | null; + venv: { success: boolean; errors: string[] } | null; + summary: string; + } | null; + overallSuccess: boolean; + lastValidatedAt: string | null; +} + +// Module-level state to track validation +let validationState: EnvironmentValidationStatus = { + isValidating: false, + isComplete: false, + buildToolsResult: null, + environmentResult: null, + overallSuccess: false, + lastValidatedAt: null +}; + +/** + * Register all environment validation IPC handlers + */ +export function registerEnvironmentHandlers( + pythonEnvManager: PythonEnvManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Environment Validation Operations + // ============================================ + + /** + * Get current environment validation status + * Returns the current state of validation including results if complete + */ + ipcMain.handle( + IPC_CHANNELS.ENV_VALIDATE_STATUS, + async (): Promise> => { + return { success: true, data: validationState }; + } + ); + + /** + * Start environment validation process + * Validates build tools and Python environments + * Sends progress events to renderer during validation + */ + ipcMain.handle( + IPC_CHANNELS.ENV_VALIDATE_START, + async (): Promise> => { + // Don't start if already validating + if (validationState.isValidating) { + return { + success: false, + error: 'Validation already in progress', + data: validationState + }; + } + + const mainWindow = getMainWindow(); + + // Reset state and start validation + validationState = { + isValidating: true, + isComplete: false, + buildToolsResult: null, + environmentResult: null, + overallSuccess: false, + lastValidatedAt: null + }; + + try { + // Step 1: Validate build tools + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.ENV_VALIDATE_PROGRESS, + 'Checking system build tools (make, cmake)...' + ); + } + + const buildToolsResult = await pythonEnvManager.validateBuildTools(); + validationState.buildToolsResult = buildToolsResult; + + if (!buildToolsResult.success) { + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.ENV_VALIDATE_PROGRESS, + `Missing build tools: ${buildToolsResult.missingTools.join(', ')}` + ); + } + } + + // Step 2: Validate Python environments + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.ENV_VALIDATE_PROGRESS, + 'Validating Python environments...' + ); + } + + const environmentResult = await pythonEnvManager.validateBothEnvironments(); + validationState.environmentResult = environmentResult; + + // Determine overall success + validationState.overallSuccess = buildToolsResult.success && environmentResult.success; + validationState.isValidating = false; + validationState.isComplete = true; + validationState.lastValidatedAt = new Date().toISOString(); + + // Send completion event + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.ENV_VALIDATE_COMPLETE, + validationState + ); + } + + return { success: true, data: validationState }; + } catch (error) { + validationState.isValidating = false; + validationState.isComplete = true; + validationState.overallSuccess = false; + validationState.lastValidatedAt = new Date().toISOString(); + + const errorMessage = error instanceof Error ? error.message : 'Unknown validation error'; + + // Send error event + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.ENV_VALIDATE_ERROR, + errorMessage + ); + } + + return { + success: false, + error: errorMessage, + data: validationState + }; + } + } + ); +} diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.test.ts b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.test.ts index 8fe079820b..1076f234a5 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.test.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.test.ts @@ -45,12 +45,12 @@ describe('runPythonSubprocess', () => { // Arrange const pythonPath = '/path/with spaces/python'; const mockArgs = ['-c', 'print("hello")']; - - // Mock parsePythonCommand to return the path split logic if needed, - // or just rely on the mock above. + + // Mock parsePythonCommand to return the path split logic if needed, + // or just rely on the mock above. // Let's make sure our mock enables the scenario we want. vi.mocked(parsePythonCommand).mockReturnValue(['/path/with spaces/python', []]); - + // Act runPythonSubprocess({ pythonPath, @@ -72,7 +72,7 @@ describe('runPythonSubprocess', () => { const pythonPath = 'python'; const pythonBaseArgs = ['-u', '-X', 'utf8']; const userArgs = ['script.py', '--verbose']; - + // Setup mock to simulate what parsePythonCommand would return for a standard python path vi.mocked(parsePythonCommand).mockReturnValue(['python', pythonBaseArgs]); @@ -87,7 +87,7 @@ describe('runPythonSubprocess', () => { // The critical check: verify the ORDER of arguments in the second parameter of spawn // expect call to be: spawn('python', ['-u', '-X', 'utf8', 'script.py', '--verbose'], ...) const expectedArgs = [...pythonBaseArgs, ...userArgs]; - + expect(mockSpawn).toHaveBeenCalledWith( expect.any(String), expectedArgs, // Exact array match verifies order diff --git a/apps/frontend/src/main/ipc-handlers/index.ts b/apps/frontend/src/main/ipc-handlers/index.ts index 3501abd8bc..f85a955744 100644 --- a/apps/frontend/src/main/ipc-handlers/index.ts +++ b/apps/frontend/src/main/ipc-handlers/index.ts @@ -20,6 +20,7 @@ import { registerFileHandlers } from './file-handlers'; import { registerRoadmapHandlers } from './roadmap-handlers'; import { registerContextHandlers } from './context-handlers'; import { registerEnvHandlers } from './env-handlers'; +import { registerEnvironmentHandlers } from './environment-handlers'; import { registerLinearHandlers } from './linear-handlers'; import { registerGithubHandlers } from './github-handlers'; import { registerGitlabHandlers } from './gitlab-handlers'; @@ -78,6 +79,9 @@ export function setupIpcHandlers( // Environment configuration handlers registerEnvHandlers(getMainWindow); + // Environment validation handlers (startup health check) + registerEnvironmentHandlers(pythonEnvManager, getMainWindow); + // Linear integration handlers registerLinearHandlers(agentManager, getMainWindow); @@ -128,6 +132,7 @@ export { registerRoadmapHandlers, registerContextHandlers, registerEnvHandlers, + registerEnvironmentHandlers, registerLinearHandlers, registerGithubHandlers, registerGitlabHandlers, diff --git a/apps/frontend/src/main/python-env-manager.ts b/apps/frontend/src/main/python-env-manager.ts index 608ba5fda5..6c81d3eadc 100644 --- a/apps/frontend/src/main/python-env-manager.ts +++ b/apps/frontend/src/main/python-env-manager.ts @@ -1,10 +1,132 @@ import { spawn, execSync, ChildProcess } from 'child_process'; -import { existsSync, readdirSync } from 'fs'; +import { existsSync, readdirSync, mkdirSync, appendFileSync } from 'fs'; import path from 'path'; +import os from 'os'; import { EventEmitter } from 'events'; import { app } from 'electron'; import { findPythonCommand, getBundledPythonPath } from './python-detector'; +/** + * Logger for Python environment validation. + * Writes startup validation logs to ~/Library/Logs/auto-claude/env-validation.log + * + * Following patterns from log-service.ts for consistent logging across the application. + */ +class EnvValidationLogger { + private logDir: string; + private logFile: string; + private initialized = false; + + constructor() { + // ~/Library/Logs/auto-claude/ on macOS + // For cross-platform support, fall back to userData/logs on other platforms + if (process.platform === 'darwin') { + this.logDir = path.join(os.homedir(), 'Library', 'Logs', 'auto-claude'); + } else if (process.platform === 'win32') { + this.logDir = path.join(os.homedir(), 'AppData', 'Local', 'auto-claude', 'logs'); + } else { + this.logDir = path.join(os.homedir(), '.local', 'share', 'auto-claude', 'logs'); + } + this.logFile = path.join(this.logDir, 'env-validation.log'); + } + + /** + * Ensure log directory exists and initialize the log file + */ + private ensureInitialized(): void { + if (this.initialized) return; + + try { + if (!existsSync(this.logDir)) { + mkdirSync(this.logDir, { recursive: true }); + } + this.initialized = true; + } catch (error) { + console.error('[EnvValidationLogger] Failed to create log directory:', error); + } + } + + /** + * Write a log entry with timestamp + */ + log(level: 'INFO' | 'WARN' | 'ERROR', message: string, details?: Record): void { + this.ensureInitialized(); + if (!this.initialized) return; + + try { + const timestamp = new Date().toISOString(); + let logLine = `[${timestamp}] [${level}] ${message}`; + + if (details) { + logLine += ` | ${JSON.stringify(details)}`; + } + + logLine += '\n'; + appendFileSync(this.logFile, logLine); + } catch (error) { + console.error('[EnvValidationLogger] Failed to write log:', error); + } + } + + /** + * Log the start of a validation session with header + */ + logSessionStart(): void { + this.ensureInitialized(); + if (!this.initialized) return; + + try { + const timestamp = new Date().toISOString(); + const header = [ + '', + '='.repeat(80), + `PYTHON ENV VALIDATION SESSION: ${timestamp}`, + `Platform: ${process.platform}`, + `Arch: ${process.arch}`, + `Node: ${process.version}`, + `App Packaged: ${app?.isPackaged ?? 'unknown'}`, + '='.repeat(80), + '' + ].join('\n'); + + appendFileSync(this.logFile, header); + } catch (error) { + console.error('[EnvValidationLogger] Failed to write session header:', error); + } + } + + /** + * Log validation step with result + */ + logValidationStep(step: string, success: boolean, details?: Record): void { + const level = success ? 'INFO' : 'WARN'; + const status = success ? 'PASS' : 'FAIL'; + this.log(level, `[${status}] ${step}`, details); + } + + /** + * Log Python path discovery + */ + logPythonPath(type: string, pythonPath: string | null): void { + if (pythonPath) { + this.log('INFO', `Python path (${type}): ${pythonPath}`); + } else { + this.log('WARN', `Python path (${type}): NOT FOUND`); + } + } + + /** + * Log validation summary + */ + logSummary(success: boolean, summary: string): void { + const level = success ? 'INFO' : 'ERROR'; + this.log(level, `VALIDATION SUMMARY: ${summary}`); + } +} + +// Singleton logger instance +const envValidationLogger = new EnvValidationLogger(); + export interface PythonEnvStatus { ready: boolean; pythonPath: string | null; @@ -180,6 +302,341 @@ if sys.version_info >= (3, 12): } } + /** + * Validate both bundled and venv Python environments. + * Calls the backend environment_validator.py to check that both interpreters + * have all required dependencies installed. This ensures features like + * Insights, Roadmap, and Ideation don't fail with "Process exited with code 1". + * + * @returns Object with validation results for both environments + */ + async validateBothEnvironments(): Promise<{ + success: boolean; + bundled: { success: boolean; errors: string[] } | null; + venv: { success: boolean; errors: string[] } | null; + summary: string; + }> { + console.log('[PythonEnvManager] Validating both Python environments'); + + // Start a new validation session in the log file + envValidationLogger.logSessionStart(); + envValidationLogger.log('INFO', 'Starting Python environment validation'); + + // Find a Python interpreter to run the validator + const python = this.pythonPath || this.findSystemPython(); + envValidationLogger.logPythonPath('validator', python); + + if (!python) { + const summary = 'No Python interpreter available to run validation'; + envValidationLogger.logSummary(false, summary); + return { + success: false, + bundled: null, + venv: null, + summary + }; + } + + // Locate the environment_validator.py script + const validatorPath = this.autoBuildSourcePath + ? path.join(this.autoBuildSourcePath, 'services', 'environment_validator.py') + : null; + + envValidationLogger.log('INFO', 'Looking for validator script', { + autoBuildSourcePath: this.autoBuildSourcePath, + validatorPath, + exists: validatorPath ? existsSync(validatorPath) : false + }); + + if (!validatorPath || !existsSync(validatorPath)) { + // Fallback: try to find it in resources for packaged apps + const resourcesPath = app.isPackaged + ? path.join(process.resourcesPath, 'auto-claude', 'services', 'environment_validator.py') + : null; + + envValidationLogger.log('INFO', 'Trying resources fallback', { + resourcesPath, + exists: resourcesPath ? existsSync(resourcesPath) : false + }); + + if (!resourcesPath || !existsSync(resourcesPath)) { + console.log('[PythonEnvManager] environment_validator.py not found, skipping dual validation'); + envValidationLogger.log('WARN', 'Validator script not found in any location'); + return { + success: true, // Don't block if validator not found + bundled: null, + venv: null, + summary: 'Validator script not found (skipping dual environment check)' + }; + } + + // Use the resources path + envValidationLogger.log('INFO', 'Using resources validator path', { path: resourcesPath }); + return this._runEnvironmentValidator(python, resourcesPath); + } + + envValidationLogger.log('INFO', 'Using source validator path', { path: validatorPath }); + return this._runEnvironmentValidator(python, validatorPath); + } + + /** + * Validate system build tools (make, cmake) are available. + * Calls the backend system_check.py to verify build tools required for + * compiling native packages like real_ladybug are installed. + * This should be called BEFORE pip install to provide clear error messages + * instead of cryptic build failures. + * + * @returns Object with validation results including missing tools and install instructions + */ + async validateBuildTools(): Promise<{ + success: boolean; + platform: string; + missingTools: string[]; + errors: string[]; + installationInstructions: string; + }> { + console.log('[PythonEnvManager] Validating system build tools'); + + // Find a Python interpreter to run the system check + const python = this.pythonPath || this.findSystemPython(); + if (!python) { + return { + success: false, + platform: process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'Darwin' : 'Linux', + missingTools: [], + errors: ['No Python interpreter available to run system check'], + installationInstructions: '' + }; + } + + // Locate the system_check.py script + const systemCheckPath = this.autoBuildSourcePath + ? path.join(this.autoBuildSourcePath, 'services', 'system_check.py') + : null; + + if (!systemCheckPath || !existsSync(systemCheckPath)) { + // Fallback: try to find it in resources for packaged apps + const resourcesPath = app.isPackaged + ? path.join(process.resourcesPath, 'auto-claude', 'services', 'system_check.py') + : null; + + if (!resourcesPath || !existsSync(resourcesPath)) { + console.log('[PythonEnvManager] system_check.py not found, skipping build tools validation'); + return { + success: true, // Don't block if validator not found + platform: process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'Darwin' : 'Linux', + missingTools: [], + errors: [], + installationInstructions: '' + }; + } + + // Use the resources path + return this._runSystemCheck(python, resourcesPath); + } + + return this._runSystemCheck(python, systemCheckPath); + } + + /** + * Internal method to run the system check script. + * @param pythonPath Path to Python interpreter + * @param systemCheckPath Path to system_check.py + */ + private async _runSystemCheck( + pythonPath: string, + systemCheckPath: string + ): Promise<{ + success: boolean; + platform: string; + missingTools: string[]; + errors: string[]; + installationInstructions: string; + }> { + const defaultPlatform = process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'Darwin' : 'Linux'; + + return new Promise((resolve) => { + try { + // Run with --json for structured output + const result = execSync( + `"${pythonPath}" "${systemCheckPath}" --json`, + { + stdio: 'pipe', + timeout: 30000, // 30 second timeout for system check + encoding: 'utf-8' + } + ); + + // Parse JSON output + const output = JSON.parse(result.trim()); + + console.log('[PythonEnvManager] Build tools validation result:', output.success ? 'OK' : 'FAILED'); + + if (output.missing_tools && output.missing_tools.length > 0) { + console.log('[PythonEnvManager] Missing build tools:', output.missing_tools.join(', ')); + } + + resolve({ + success: output.success, + platform: output.platform || defaultPlatform, + missingTools: output.missing_tools || [], + errors: output.errors || [], + installationInstructions: output.installation_instructions || '' + }); + } catch (error) { + // Handle exec errors + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[PythonEnvManager] Failed to run system check:', errorMessage); + + // If the command failed but returned JSON (exit code 1 with valid output) + if (error && typeof error === 'object' && 'stdout' in error) { + const stdout = (error as { stdout: string }).stdout; + if (stdout) { + try { + const output = JSON.parse(stdout.trim()); + console.log('[PythonEnvManager] Build tools validation result:', output.success ? 'OK' : 'FAILED'); + + if (output.missing_tools && output.missing_tools.length > 0) { + console.log('[PythonEnvManager] Missing build tools:', output.missing_tools.join(', ')); + } + + resolve({ + success: output.success, + platform: output.platform || defaultPlatform, + missingTools: output.missing_tools || [], + errors: output.errors || [], + installationInstructions: output.installation_instructions || '' + }); + return; + } catch { + // JSON parse failed, continue to default error handling + } + } + } + + resolve({ + success: false, + platform: defaultPlatform, + missingTools: [], + errors: [`System check failed: ${errorMessage}`], + installationInstructions: '' + }); + } + }); + } + + /** + * Internal method to run the environment validator script. + * @param pythonPath Path to Python interpreter + * @param validatorPath Path to environment_validator.py + */ + private async _runEnvironmentValidator( + pythonPath: string, + validatorPath: string + ): Promise<{ + success: boolean; + bundled: { success: boolean; errors: string[] } | null; + venv: { success: boolean; errors: string[] } | null; + summary: string; + }> { + envValidationLogger.log('INFO', 'Running environment validator', { + pythonPath, + validatorPath + }); + + return new Promise((resolve) => { + try { + // Run with --check-bundled --check-venv --json for structured output + const result = execSync( + `"${pythonPath}" "${validatorPath}" --check-bundled --check-venv --json`, + { + stdio: 'pipe', + timeout: 60000, // 60 second timeout for validation + encoding: 'utf-8' + } + ); + + // Parse JSON output + const output = JSON.parse(result.trim()); + + console.log('[PythonEnvManager] Dual environment validation result:', output.success ? 'OK' : 'FAILED'); + + // Log validation results + envValidationLogger.logValidationStep('Dual environment validation', output.success); + + if (output.bundled) { + console.log('[PythonEnvManager] Bundled Python:', output.bundled.success ? 'OK' : 'FAILED'); + envValidationLogger.logValidationStep('Bundled Python', output.bundled.success, { + errors: output.bundled.errors || [] + }); + } + if (output.venv) { + console.log('[PythonEnvManager] Venv Python:', output.venv.success ? 'OK' : 'FAILED'); + envValidationLogger.logValidationStep('Venv Python', output.venv.success, { + errors: output.venv.errors || [] + }); + } + + envValidationLogger.logSummary(output.success, output.summary || 'Validation completed'); + + resolve({ + success: output.success, + bundled: output.bundled + ? { success: output.bundled.success, errors: output.bundled.errors || [] } + : null, + venv: output.venv + ? { success: output.venv.success, errors: output.venv.errors || [] } + : null, + summary: output.summary || '' + }); + } catch (error) { + // Handle exec errors + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[PythonEnvManager] Failed to run environment validator:', errorMessage); + envValidationLogger.log('ERROR', 'Validator execution failed', { error: errorMessage }); + + // If the command failed but returned JSON (exit code 1 with valid output) + if (error && typeof error === 'object' && 'stdout' in error) { + const stdout = (error as { stdout: string }).stdout; + if (stdout) { + try { + const output = JSON.parse(stdout.trim()); + envValidationLogger.logValidationStep('Validator (with errors)', output.success, { + bundled: output.bundled, + venv: output.venv + }); + envValidationLogger.logSummary(output.success, output.summary || 'Completed with errors'); + resolve({ + success: output.success, + bundled: output.bundled + ? { success: output.bundled.success, errors: output.bundled.errors || [] } + : null, + venv: output.venv + ? { success: output.venv.success, errors: output.venv.errors || [] } + : null, + summary: output.summary || '' + }); + return; + } catch { + // JSON parse failed, continue to default error handling + envValidationLogger.log('ERROR', 'Failed to parse validator output'); + } + } + } + + const summary = `Validation failed: ${errorMessage}`; + envValidationLogger.logSummary(false, summary); + + resolve({ + success: false, + bundled: null, + venv: null, + summary + }); + } + }); + } + /** * Find Python 3.10+ (bundled or system). * Uses the shared python-detector logic which validates version requirements. @@ -393,8 +850,21 @@ if sys.version_info >= (3, 12): this.emit('status', 'Dependencies installed successfully'); resolve(true); } else { - console.error('[PythonEnvManager] Failed to install deps:', stderr || stdout); - this.emit('error', `Failed to install dependencies: ${stderr || stdout}`); + // Combine stderr and stdout for comprehensive error output + const combinedOutput = (stderr + '\n' + stdout).trim(); + // Keep last 2000 chars to avoid overly long error messages but capture the actual error + const truncatedOutput = combinedOutput.length > 2000 + ? '...' + combinedOutput.slice(-2000) + : combinedOutput; + + console.error('[PythonEnvManager] Failed to install deps (exit code', code + '):', truncatedOutput); + + // Provide detailed error message with exit code and pip output + const errorMessage = truncatedOutput + ? `pip install failed (exit code ${code}):\n${truncatedOutput}` + : `pip install failed with exit code ${code}`; + + this.emit('error', errorMessage); resolve(false); } }); @@ -455,14 +925,26 @@ if sys.version_info >= (3, 12): console.warn('[PythonEnvManager] Initializing with path:', autoBuildSourcePath); + // Log initialization start + envValidationLogger.logSessionStart(); + envValidationLogger.log('INFO', 'Python environment initialization started', { + autoBuildSourcePath, + isPackaged: app.isPackaged, + platform: process.platform + }); + try { // For packaged apps, try to use bundled packages first (no pip install needed!) if (app.isPackaged && this.hasBundledPackages()) { console.warn('[PythonEnvManager] Using bundled Python packages (no pip install needed)'); + envValidationLogger.logValidationStep('Bundled packages check', true); const bundledPython = getBundledPythonPath(); const bundledSitePackages = this.getBundledSitePackagesPath(); + envValidationLogger.logPythonPath('bundled', bundledPython); + envValidationLogger.log('INFO', 'Bundled site-packages path', { path: bundledSitePackages }); + if (bundledPython && bundledSitePackages) { this.pythonPath = bundledPython; this.sitePackagesPath = bundledSitePackages; @@ -474,6 +956,8 @@ if sys.version_info >= (3, 12): console.warn('[PythonEnvManager] Ready with bundled Python:', this.pythonPath); console.warn('[PythonEnvManager] Using bundled site-packages:', this.sitePackagesPath); + envValidationLogger.logSummary(true, 'Initialized with bundled Python'); + return { ready: true, pythonPath: this.pythonPath, @@ -487,14 +971,22 @@ if sys.version_info >= (3, 12): // Fallback to venv-based setup (for development or if bundled packages missing) console.warn('[PythonEnvManager] Using venv-based setup (development mode or bundled packages missing)'); + envValidationLogger.log('INFO', 'Using venv-based setup (development mode or bundled packages missing)'); this.usingBundledPackages = false; // Check if venv exists + const venvPath = this.getVenvBasePath(); + const venvPythonPath = this.getVenvPythonPath(); + envValidationLogger.log('INFO', 'Venv paths', { venvPath, venvPythonPath }); + if (!this.venvExists()) { console.warn('[PythonEnvManager] Venv not found, creating...'); + envValidationLogger.log('INFO', 'Venv not found, creating...'); const created = await this.createVenv(); + envValidationLogger.logValidationStep('Venv creation', created); if (!created) { this.isInitializing = false; + envValidationLogger.logSummary(false, 'Failed to create virtual environment'); return { ready: false, pythonPath: null, @@ -507,15 +999,21 @@ if sys.version_info >= (3, 12): } } else { console.warn('[PythonEnvManager] Venv already exists'); + envValidationLogger.logValidationStep('Venv exists', true); } // Check if deps are installed const depsInstalled = await this.checkDepsInstalled(); + envValidationLogger.logValidationStep('Dependencies check', depsInstalled); + if (!depsInstalled) { console.warn('[PythonEnvManager] Dependencies not installed, installing...'); + envValidationLogger.log('INFO', 'Dependencies not installed, installing...'); const installed = await this.installDeps(); + envValidationLogger.logValidationStep('Dependencies installation', installed); if (!installed) { this.isInitializing = false; + envValidationLogger.logSummary(false, 'Failed to install dependencies'); return { ready: false, pythonPath: this.getVenvPythonPath(), @@ -565,6 +1063,10 @@ if sys.version_info >= (3, 12): this.emit('ready', this.pythonPath); console.warn('[PythonEnvManager] Ready with Python path:', this.pythonPath); + envValidationLogger.logPythonPath('venv', this.pythonPath); + envValidationLogger.log('INFO', 'Site-packages path', { path: this.sitePackagesPath }); + envValidationLogger.logSummary(true, 'Initialized with venv Python'); + return { ready: true, pythonPath: this.pythonPath, @@ -576,6 +1078,8 @@ if sys.version_info >= (3, 12): } catch (error) { this.isInitializing = false; const message = error instanceof Error ? error.message : String(error); + envValidationLogger.log('ERROR', 'Initialization failed with exception', { error: message }); + envValidationLogger.logSummary(false, `Initialization failed: ${message}`); return { ready: false, pythonPath: null, diff --git a/apps/frontend/src/preload/api/index.ts b/apps/frontend/src/preload/api/index.ts index 51e28c76ae..db9ccd6b7f 100644 --- a/apps/frontend/src/preload/api/index.ts +++ b/apps/frontend/src/preload/api/index.ts @@ -10,6 +10,7 @@ import { AppUpdateAPI, createAppUpdateAPI } from './app-update-api'; import { GitHubAPI, createGitHubAPI } from './modules/github-api'; import { GitLabAPI, createGitLabAPI } from './modules/gitlab-api'; import { DebugAPI, createDebugAPI } from './modules/debug-api'; +import { EnvironmentAPI, createEnvironmentAPI } from './modules/environment-api'; import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api'; import { McpAPI, createMcpAPI } from './modules/mcp-api'; @@ -25,6 +26,7 @@ export interface ElectronAPI extends AppUpdateAPI, GitLabAPI, DebugAPI, + EnvironmentAPI, ClaudeCodeAPI, McpAPI { github: GitHubAPI; @@ -42,6 +44,7 @@ export const createElectronAPI = (): ElectronAPI => ({ ...createAppUpdateAPI(), ...createGitLabAPI(), ...createDebugAPI(), + ...createEnvironmentAPI(), ...createClaudeCodeAPI(), ...createMcpAPI(), github: createGitHubAPI() @@ -61,6 +64,7 @@ export { createGitHubAPI, createGitLabAPI, createDebugAPI, + createEnvironmentAPI, createClaudeCodeAPI, createMcpAPI }; @@ -78,6 +82,7 @@ export type { GitHubAPI, GitLabAPI, DebugAPI, + EnvironmentAPI, ClaudeCodeAPI, McpAPI }; diff --git a/apps/frontend/src/preload/api/modules/environment-api.ts b/apps/frontend/src/preload/api/modules/environment-api.ts new file mode 100644 index 0000000000..c148c284a9 --- /dev/null +++ b/apps/frontend/src/preload/api/modules/environment-api.ts @@ -0,0 +1,62 @@ +import { IPC_CHANNELS } from '../../../shared/constants'; +import type { IPCResult } from '../../../shared/types'; +import { createIpcListener, invokeIpc, IpcListenerCleanup } from './ipc-utils'; + +/** + * Environment validation status types (matches environment-handlers.ts) + */ +export interface EnvironmentValidationStatus { + isValidating: boolean; + isComplete: boolean; + buildToolsResult: { + success: boolean; + platform: string; + missingTools: string[]; + errors: string[]; + installationInstructions: string; + } | null; + environmentResult: { + success: boolean; + bundled: { success: boolean; errors: string[] } | null; + venv: { success: boolean; errors: string[] } | null; + summary: string; + } | null; + overallSuccess: boolean; + lastValidatedAt: string | null; +} + +/** + * Environment Validation API operations + */ +export interface EnvironmentAPI { + // Operations + getValidationStatus: () => Promise>; + startValidation: () => Promise>; + + // Event Listeners + onValidationProgress: (callback: (message: string) => void) => IpcListenerCleanup; + onValidationComplete: (callback: (status: EnvironmentValidationStatus) => void) => IpcListenerCleanup; + onValidationError: (callback: (error: string) => void) => IpcListenerCleanup; +} + +/** + * Creates the Environment Validation API implementation + */ +export const createEnvironmentAPI = (): EnvironmentAPI => ({ + // Operations + getValidationStatus: (): Promise> => + invokeIpc(IPC_CHANNELS.ENV_VALIDATE_STATUS), + + startValidation: (): Promise> => + invokeIpc(IPC_CHANNELS.ENV_VALIDATE_START), + + // Event Listeners + onValidationProgress: (callback: (message: string) => void): IpcListenerCleanup => + createIpcListener(IPC_CHANNELS.ENV_VALIDATE_PROGRESS, callback), + + onValidationComplete: (callback: (status: EnvironmentValidationStatus) => void): IpcListenerCleanup => + createIpcListener(IPC_CHANNELS.ENV_VALIDATE_COMPLETE, callback), + + onValidationError: (callback: (error: string) => void): IpcListenerCleanup => + createIpcListener(IPC_CHANNELS.ENV_VALIDATE_ERROR, callback) +}); diff --git a/apps/frontend/src/preload/api/modules/index.ts b/apps/frontend/src/preload/api/modules/index.ts index 48b4f8b2cf..8f119cf5ee 100644 --- a/apps/frontend/src/preload/api/modules/index.ts +++ b/apps/frontend/src/preload/api/modules/index.ts @@ -14,3 +14,4 @@ export * from './github-api'; export * from './autobuild-api'; export * from './shell-api'; export * from './debug-api'; +export * from './environment-api'; diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index 1c18645503..cbc5b61099 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -49,6 +49,7 @@ import { OnboardingWizard } from './components/onboarding'; import { AppUpdateNotification } from './components/AppUpdateNotification'; import { ProactiveSwapListener } from './components/ProactiveSwapListener'; import { GitHubSetupModal } from './components/GitHubSetupModal'; +import { StartupValidator } from './components/StartupValidator'; import { useProjectStore, loadProjects, addProject, initializeProject } from './stores/project-store'; import { useTaskStore, loadTasks } from './stores/task-store'; import { useSettingsStore, loadSettings } from './stores/settings-store'; @@ -142,6 +143,14 @@ export function App() { const [showGitHubSetup, setShowGitHubSetup] = useState(false); const [gitHubSetupProject, setGitHubSetupProject] = useState(null); + // Environment validation state (startup check for build tools and Python) + const [isEnvValidated, setIsEnvValidated] = useState(() => { + // Check if validation was already completed in this session + const validated = sessionStorage.getItem('env-validated'); + return validated === 'true'; + }); + const [showStartupValidator, setShowStartupValidator] = useState(false); + // Setup drag sensors const sensors = useSensors( useSensor(PointerSensor, { @@ -172,6 +181,24 @@ export function App() { }; }, []); + // Trigger environment validation on first project load (if not already validated) + useEffect(() => { + // Only trigger validation when: + // 1. Projects are loaded + // 2. There's a selected project (first project load) + // 3. Environment hasn't been validated yet + // 4. Validator isn't already showing + const currentProjectId = activeProjectId || selectedProjectId; + if ( + projects.length > 0 && + currentProjectId && + !isEnvValidated && + !showStartupValidator + ) { + setShowStartupValidator(true); + } + }, [projects, activeProjectId, selectedProjectId, isEnvValidated, showStartupValidator]); + // Restore tab state and open tabs for loaded projects useEffect(() => { console.log('[App] Tab restore useEffect triggered:', { @@ -621,6 +648,21 @@ export function App() { } }; + // Handle environment validation completion + const handleEnvValidationComplete = () => { + // Mark as validated in session storage so it persists until app restart + sessionStorage.setItem('env-validated', 'true'); + setIsEnvValidated(true); + setShowStartupValidator(false); + }; + + // Handle environment validation skip + const handleEnvValidationSkip = () => { + // Allow skipping but don't persist - will show again on next app launch + setIsEnvValidated(true); + setShowStartupValidator(false); + }; + return ( @@ -919,6 +961,18 @@ export function App() { {/* Global Download Indicator - shows Ollama model download progress */} + + {/* Environment Validation Overlay - shows on first project load */} + {showStartupValidator && ( +
+ +
+ )}
diff --git a/apps/frontend/src/renderer/components/StartupValidator.tsx b/apps/frontend/src/renderer/components/StartupValidator.tsx new file mode 100644 index 0000000000..b272b5da0a --- /dev/null +++ b/apps/frontend/src/renderer/components/StartupValidator.tsx @@ -0,0 +1,475 @@ +import { useState, useEffect, useCallback } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { + CheckCircle2, + AlertCircle, + Loader2, + Settings, + AlertTriangle, + RefreshCw, + ChevronDown, + ChevronUp, + Copy, + Check +} from 'lucide-react'; +import { Button } from './ui/button'; +import { cn } from '../lib/utils'; +import type { EnvironmentValidationStatus } from '../../shared/types/ipc'; + +/** + * Validation phase enum for UI display + */ +type ValidationPhase = 'idle' | 'checking-tools' | 'checking-python' | 'complete' | 'error'; + +/** + * Hook to detect user's reduced motion preference. + */ +function useReducedMotion(): boolean { + const [reducedMotion, setReducedMotion] = useState(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + }); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + + const handleChange = (event: MediaQueryListEvent) => { + setReducedMotion(event.matches); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, []); + + return reducedMotion; +} + +interface StartupValidatorProps { + /** Called when validation completes successfully */ + onComplete?: () => void; + /** Called when user chooses to skip validation */ + onSkip?: () => void; + /** Whether to auto-start validation on mount */ + autoStart?: boolean; + /** Optional CSS class */ + className?: string; +} + +/** + * StartupValidator Component + * + * Displays validation progress and errors during app startup. + * Checks system build tools (make, cmake) and Python environments. + */ +export function StartupValidator({ + onComplete, + onSkip, + autoStart = true, + className +}: StartupValidatorProps) { + const reducedMotion = useReducedMotion(); + + // Validation state + const [status, setStatus] = useState(null); + const [phase, setPhase] = useState('idle'); + const [progressMessage, setProgressMessage] = useState(''); + const [isExpanded, setIsExpanded] = useState(true); + const [copiedCommand, setCopiedCommand] = useState(false); + + /** + * Start the validation process + */ + const startValidation = useCallback(async () => { + setPhase('checking-tools'); + setProgressMessage('Checking system build tools (make, cmake)...'); + + try { + const result = await window.electronAPI?.startValidation?.(); + + if (result?.success && result.data) { + setStatus(result.data); + if (result.data.overallSuccess) { + setPhase('complete'); + onComplete?.(); + } else { + setPhase('error'); + } + } else { + setPhase('error'); + setProgressMessage(result?.error || 'Validation failed'); + } + } catch (error) { + setPhase('error'); + setProgressMessage(error instanceof Error ? error.message : 'Unexpected error during validation'); + } + }, [onComplete]); + + /** + * Retry validation + */ + const handleRetry = useCallback(() => { + setStatus(null); + setPhase('idle'); + setProgressMessage(''); + startValidation(); + }, [startValidation]); + + /** + * Copy installation command to clipboard + */ + const handleCopyCommand = useCallback((command: string) => { + navigator.clipboard.writeText(command); + setCopiedCommand(true); + setTimeout(() => setCopiedCommand(false), 2000); + }, []); + + // Set up IPC listeners for progress events + useEffect(() => { + // Skip if not in Electron context + if (!window.electronAPI) { + return; + } + + // Listen for progress updates + const cleanupProgress = window.electronAPI.onValidationProgress?.((message: string) => { + setProgressMessage(message); + if (message.toLowerCase().includes('python')) { + setPhase('checking-python'); + } + }); + + // Listen for completion + const cleanupComplete = window.electronAPI.onValidationComplete?.((validationStatus: EnvironmentValidationStatus) => { + setStatus(validationStatus); + if (validationStatus.overallSuccess) { + setPhase('complete'); + onComplete?.(); + } else { + setPhase('error'); + } + }); + + // Listen for errors + const cleanupError = window.electronAPI.onValidationError?.((errorMessage: string) => { + setPhase('error'); + setProgressMessage(errorMessage); + }); + + return () => { + cleanupProgress?.(); + cleanupComplete?.(); + cleanupError?.(); + }; + }, [onComplete]); + + // Auto-start validation if enabled + useEffect(() => { + if (autoStart && phase === 'idle') { + startValidation(); + } + }, [autoStart, phase, startValidation]); + + // Don't render if complete and successful (validation passed, app can proceed) + if (phase === 'complete' && status?.overallSuccess) { + return null; + } + + // Animation values respecting reduced motion + const spinAnimation = reducedMotion ? {} : { rotate: 360 }; + const spinTransition = reducedMotion + ? { duration: 0 } + : { duration: 1, repeat: Infinity, ease: 'linear' as const }; + + const pulseAnimation = reducedMotion ? {} : { scale: [1, 1.05, 1] }; + const pulseTransition = reducedMotion + ? { duration: 0 } + : { duration: 1.5, repeat: Infinity, ease: 'easeInOut' as const }; + + // Get platform-specific installation instructions + const getInstallInstructions = () => { + if (!status?.buildToolsResult) return null; + + const { platform, missingTools, installationInstructions } = status.buildToolsResult; + + if (missingTools.length === 0) return null; + + // Default instructions based on platform + const defaultInstructions: Record = { + Darwin: `brew install ${missingTools.join(' ')}`, + Windows: `choco install ${missingTools.join(' ')} -y`, + Linux: `sudo apt-get install ${missingTools.join(' ')}` + }; + + return installationInstructions || defaultInstructions[platform] || `Install: ${missingTools.join(', ')}`; + }; + + return ( +
+ {/* Header */} +
+
+
+ {phase === 'checking-tools' || phase === 'checking-python' ? ( + + + + ) : phase === 'complete' ? ( + + ) : phase === 'error' ? ( + + ) : ( + + )} +
+
+

Environment Validation

+

+ {phase === 'idle' && 'Ready to validate environment'} + {phase === 'checking-tools' && 'Checking build tools...'} + {phase === 'checking-python' && 'Validating Python environments...'} + {phase === 'complete' && 'Validation complete'} + {phase === 'error' && 'Validation issues found'} +

+
+
+ + {/* Expand/collapse toggle */} + {phase === 'error' && ( + + )} +
+ + {/* Progress indicator */} + {(phase === 'checking-tools' || phase === 'checking-python') && ( + +
+ +
+

+ {progressMessage || 'Validating environment...'} +

+
+ )} + + {/* Error details */} + + {phase === 'error' && isExpanded && ( + + {/* Build tools errors */} + {status?.buildToolsResult && !status.buildToolsResult.success && ( +
+
+ +
+

Missing Build Tools

+

+ The following tools are required but not installed: + + {status.buildToolsResult.missingTools.join(', ')} + +

+ + {/* Installation command */} + {getInstallInstructions() && ( +
+

+ Install with ({status.buildToolsResult.platform}): +

+
+ + {getInstallInstructions()} + + +
+
+ )} +
+
+
+ )} + + {/* Python environment errors */} + {status?.environmentResult && !status.environmentResult.success && ( +
+
+ +
+

Python Environment Issues

+

+ {status.environmentResult.summary} +

+ + {/* Bundled Python errors */} + {status.environmentResult.bundled && !status.environmentResult.bundled.success && ( +
+

Bundled Python:

+
    + {status.environmentResult.bundled.errors.map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )} + + {/* Venv Python errors */} + {status.environmentResult.venv && !status.environmentResult.venv.success && ( +
+

Virtual Environment:

+
    + {status.environmentResult.venv.errors.map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )} +
+
+
+ )} + + {/* Generic error message */} + {progressMessage && phase === 'error' && !status && ( +
+
+ +
+

Validation Error

+

{progressMessage}

+
+
+
+ )} +
+ )} +
+ + {/* Action buttons */} +
+ {phase === 'error' && ( + <> + + + + )} + {phase === 'idle' && !autoStart && ( + + )} +
+ + {/* Step indicators */} +
+ +
+ +
+
+ ); +} + +/** + * Step indicator component + */ +function StepIndicator({ + label, + status +}: { + label: string; + status: 'pending' | 'active' | 'complete' | 'error'; +}) { + return ( +
+ {status === 'complete' && } + {status === 'active' && } + {status === 'error' && } + {label} +
+ ); +} + +export default StartupValidator; diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index 917a84b25d..9431cb4a47 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -228,7 +228,34 @@ const browserMockAPI: ElectronAPI = { openLogsFolder: async () => ({ success: false, error: 'Not available in browser mode' }), copyDebugInfo: async () => ({ success: false, error: 'Not available in browser mode' }), getRecentErrors: async () => [], - listLogFiles: async () => [] + listLogFiles: async () => [], + + // Environment Validation Operations + getValidationStatus: async () => ({ + success: true, + data: { + isValidating: false, + isComplete: true, + buildToolsResult: null, + environmentResult: null, + overallSuccess: true, + lastValidatedAt: null + } + }), + startValidation: async () => ({ + success: true, + data: { + isValidating: false, + isComplete: true, + buildToolsResult: null, + environmentResult: null, + overallSuccess: true, + lastValidatedAt: new Date().toISOString() + } + }), + onValidationProgress: () => () => {}, + onValidationComplete: () => () => {}, + onValidationError: () => () => {} }; /** diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index b5f22d7ac4..9633878c3a 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -153,6 +153,13 @@ export const IPC_CHANNELS = { ENV_CHECK_CLAUDE_AUTH: 'env:checkClaudeAuth', ENV_INVOKE_CLAUDE_SETUP: 'env:invokeClaudeSetup', + // Environment validation (startup health check) + ENV_VALIDATE_STATUS: 'env:validateStatus', // Get current validation status + ENV_VALIDATE_START: 'env:validateStart', // Start validation process + ENV_VALIDATE_PROGRESS: 'env:validateProgress', // Event: validation progress (main -> renderer) + ENV_VALIDATE_COMPLETE: 'env:validateComplete', // Event: validation complete (main -> renderer) + ENV_VALIDATE_ERROR: 'env:validateError', // Event: validation error (main -> renderer) + // Ideation operations IDEATION_GET: 'ideation:get', IDEATION_GENERATE: 'ideation:generate', diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 0e6925d4db..a100a4bd0b 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -124,6 +124,27 @@ import type { GitLabNewCommitsCheck } from './integrations'; +// Environment validation status types +export interface EnvironmentValidationStatus { + isValidating: boolean; + isComplete: boolean; + buildToolsResult: { + success: boolean; + platform: string; + missingTools: string[]; + errors: string[]; + installationInstructions: string; + } | null; + environmentResult: { + success: boolean; + bundled: { success: boolean; errors: string[] } | null; + venv: { success: boolean; errors: string[] } | null; + summary: string; + } | null; + overallSuccess: boolean; + lastValidatedAt: string | null; +} + // Electron API exposed via contextBridge // Tab state interface (persisted in main process) export interface TabState { @@ -746,6 +767,15 @@ export interface ElectronAPI { // MCP Server health check operations checkMcpHealth: (server: CustomMcpServer) => Promise>; testMcpConnection: (server: CustomMcpServer) => Promise>; + + // Environment validation operations + getValidationStatus: () => Promise>; + startValidation: () => Promise>; + + // Environment validation event listeners + onValidationProgress: (callback: (message: string) => void) => () => void; + onValidationComplete: (callback: (status: EnvironmentValidationStatus) => void) => () => void; + onValidationError: (callback: (error: string) => void) => () => void; } declare global { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 9b60ae1782..0000000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,9 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: {} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000000..c6be840ac3 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +""" +Integration Tests Package +========================= + +Contains integration tests that verify cross-module functionality +and end-to-end validation flows. +""" diff --git a/tests/integration/test_startup_validation.py b/tests/integration/test_startup_validation.py new file mode 100644 index 0000000000..2cb18e62ed --- /dev/null +++ b/tests/integration/test_startup_validation.py @@ -0,0 +1,879 @@ +#!/usr/bin/env python3 +""" +Integration Tests for Startup Validation +========================================= + +Tests the E2E validation flow from startup to feature usage, including: +- Environment validation (both bundled and venv interpreters) +- System build tools check (make, cmake) +- Dependency synchronization verification +- Feature operation without 'Process exited with code 1' errors + +These tests verify that the startup validation system correctly: +1. Validates Python environments +2. Checks for required build tools +3. Ensures dependencies are synchronized +4. Prevents cryptic 'code 1' errors for Insights/Roadmap/Ideation features + +Note: These tests require Python 3.10+ due to the codebase using modern type +annotations. The tests will be skipped on older Python versions. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Generator +from unittest.mock import MagicMock, patch + +import pytest + +# Check Python version - codebase requires 3.10+ for type syntax +PYTHON_VERSION_OK = sys.version_info >= (3, 10) +pytestmark = pytest.mark.skipif( + not PYTHON_VERSION_OK, + reason="Tests require Python 3.10+ due to modern type annotations in codebase" +) + +# Backend services path +_backend_path = Path(__file__).parent.parent.parent / "apps" / "backend" + + +def _load_module_directly(name: str, path: str): + """Load a module directly bypassing __init__.py for Py3.9 compatibility.""" + spec = importlib.util.spec_from_file_location(name, path) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + return None + + +# Try standard import path first; fall back to direct load for Py3.9 +try: + if str(_backend_path) not in sys.path: + sys.path.insert(0, str(_backend_path)) + # Test if imports work (will fail on Py3.9 due to type syntax in __init__.py) + from services import environment_validator as _test_import + del _test_import + _USE_DIRECT_IMPORTS = False +except (TypeError, SyntaxError): + # Python < 3.10, use direct module loading + _USE_DIRECT_IMPORTS = True + + +def _get_environment_validator_module(): + """Get environment_validator module with compatibility handling.""" + if _USE_DIRECT_IMPORTS: + return _load_module_directly( + 'environment_validator', + str(_backend_path / 'services' / 'environment_validator.py') + ) + from services import environment_validator + return environment_validator + + +def _get_system_check_module(): + """Get system_check module with compatibility handling.""" + if _USE_DIRECT_IMPORTS: + return _load_module_directly( + 'system_check', + str(_backend_path / 'services' / 'system_check.py') + ) + from services import system_check + return system_check + + +def _get_dependency_installer_module(): + """Get dependency_installer module with compatibility handling.""" + if _USE_DIRECT_IMPORTS: + return _load_module_directly( + 'dependency_installer', + str(_backend_path / 'services' / 'dependency_installer.py') + ) + from services import dependency_installer + return dependency_installer + + +# ============================================================================= +# FIXTURES +# ============================================================================= + + +@pytest.fixture +def temp_python_script() -> Generator[Path, None, None]: + """Create a temporary Python script for validation testing.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False + ) as f: + f.write( + """#!/usr/bin/env python3 +import sys +print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}") +sys.exit(0) +""" + ) + script_path = Path(f.name) + + yield script_path + + # Cleanup + if script_path.exists(): + script_path.unlink() + + +@pytest.fixture +def mock_python_paths(temp_dir: Path) -> dict[str, Path]: + """Create mock Python interpreter paths for testing.""" + # Create mock bundled Python + bundled_dir = temp_dir / "bundled" / "bin" + bundled_dir.mkdir(parents=True) + bundled_python = bundled_dir / "python3" + + # Create mock venv Python + venv_dir = temp_dir / "venv" / "bin" + venv_dir.mkdir(parents=True) + venv_python = venv_dir / "python" + + # Create executable scripts that mimic Python + python_script = f"""#!/bin/bash +echo "3.12.0" +exit 0 +""" + bundled_python.write_text(python_script) + bundled_python.chmod(0o755) + venv_python.write_text(python_script) + venv_python.chmod(0o755) + + return { + "bundled": bundled_python, + "venv": venv_python, + } + + +@pytest.fixture +def mock_requirements_file(temp_dir: Path) -> Path: + """Create a mock requirements.txt file.""" + requirements = temp_dir / "requirements.txt" + requirements.write_text( + """# Core dependencies +pydantic>=2.0 +python-dotenv>=1.0 + +# Optional dependencies +# real_ladybug>=0.1.0 +# graphiti-core>=0.1.0 +""" + ) + return requirements + + +# ============================================================================= +# ENVIRONMENT VALIDATOR INTEGRATION TESTS +# ============================================================================= + + +class TestEnvironmentValidatorIntegration: + """Integration tests for environment_validator.py.""" + + def test_validator_module_imports(self): + """Environment validator module imports successfully.""" + mod = _get_environment_validator_module() + + assert mod.EnvironmentValidator is not None + assert mod.ValidationResult is not None + assert mod.DualValidationResult is not None + assert mod.DependencyStatus is not None + + def test_validator_dataclasses_initialization(self): + """ValidationResult and DependencyStatus initialize correctly.""" + mod = _get_environment_validator_module() + ValidationResult = mod.ValidationResult + DependencyStatus = mod.DependencyStatus + + # Test DependencyStatus + dep_status = DependencyStatus(name="test-pkg") + assert dep_status.name == "test-pkg" + assert dep_status.installed is False + assert dep_status.version is None + assert dep_status.error is None + + # Test ValidationResult + result = ValidationResult() + assert result.success is False + assert result.python_path == "" + assert result.python_version == "" + assert result.dependencies == [] + assert result.errors == [] + + def test_validator_with_current_python(self): + """Environment validator works with current Python interpreter.""" + mod = _get_environment_validator_module() + EnvironmentValidator = mod.EnvironmentValidator + + validator = EnvironmentValidator( + core_dependencies=[], # Empty to avoid import failures + optional_dependencies=[], + ) + + result = validator.validate_environment(sys.executable) + + # Should succeed with current Python (>= 3.12) + assert result.python_path == sys.executable + assert result.python_version != "" + # Only check version validity if we're on 3.12+ + if sys.version_info >= (3, 12): + assert result.python_version_valid is True + + def test_validator_version_check(self): + """Validator correctly checks Python version.""" + mod = _get_environment_validator_module() + EnvironmentValidator = mod.EnvironmentValidator + MIN_PYTHON_VERSION = mod.MIN_PYTHON_VERSION + + # MIN_PYTHON_VERSION should be (3, 12) + assert MIN_PYTHON_VERSION == (3, 12) + + validator = EnvironmentValidator( + core_dependencies=[], + optional_dependencies=[], + ) + + result = validator._check_python_version(sys.executable) + assert result["success"] is True + assert "version" in result + assert "valid" in result + + def test_validator_handles_missing_interpreter(self): + """Validator handles non-existent Python path gracefully.""" + mod = _get_environment_validator_module() + EnvironmentValidator = mod.EnvironmentValidator + + validator = EnvironmentValidator( + core_dependencies=[], + optional_dependencies=[], + ) + + result = validator.validate_environment("/nonexistent/python") + + assert result.success is False + assert len(result.errors) > 0 + assert any("not found" in err.lower() for err in result.errors) + + +class TestDualEnvironmentValidation: + """Integration tests for dual-interpreter validation.""" + + def test_dual_validation_result_structure(self): + """DualValidationResult has correct structure.""" + mod = _get_environment_validator_module() + DualValidationResult = mod.DualValidationResult + + result = DualValidationResult() + assert result.success is False + assert result.bundled is None + assert result.venv is None + assert result.summary == "" + + def test_validate_dual_environment_with_mocked_paths(self): + """Dual validation works when paths are provided.""" + mod = _get_environment_validator_module() + EnvironmentValidator = mod.EnvironmentValidator + + validator = EnvironmentValidator( + core_dependencies=[], + optional_dependencies=[], + ) + + # Validate with current Python as both bundled and venv + # (simulates development environment) + result = validator.validate_dual_environment( + bundled_path=sys.executable, + venv_path=sys.executable, + ) + + # At least one should be valid in dev environment + assert result.bundled is not None + assert result.venv is not None + assert result.summary != "" + + def test_validate_dual_environment_missing_bundled(self): + """Dual validation handles missing bundled Python.""" + mod = _get_environment_validator_module() + EnvironmentValidator = mod.EnvironmentValidator + + validator = EnvironmentValidator( + core_dependencies=[], + optional_dependencies=[], + ) + + # Only provide venv path + result = validator.validate_dual_environment( + bundled_path="/nonexistent/python", + venv_path=sys.executable, + ) + + # Should still succeed with venv + assert result.venv is not None + # Bundled should have errors + assert result.bundled is not None + assert result.bundled.success is False + + +# ============================================================================= +# SYSTEM CHECK INTEGRATION TESTS +# ============================================================================= + + +class TestSystemCheckIntegration: + """Integration tests for system_check.py.""" + + def test_system_check_module_imports(self): + """System check module imports successfully.""" + mod = _get_system_check_module() + + assert mod.SystemChecker is not None + assert mod.SystemCheckResult is not None + assert mod.ToolStatus is not None + assert mod.PLATFORM_REQUIREMENTS is not None + assert mod.INSTALLATION_INSTRUCTIONS is not None + + def test_tool_status_dataclass(self): + """ToolStatus dataclass initializes correctly.""" + mod = _get_system_check_module() + ToolStatus = mod.ToolStatus + + status = ToolStatus(name="cmake") + assert status.name == "cmake" + assert status.installed is False + assert status.version is None + assert status.path is None + assert status.error is None + + def test_system_check_result_dataclass(self): + """SystemCheckResult dataclass initializes correctly.""" + mod = _get_system_check_module() + SystemCheckResult = mod.SystemCheckResult + + result = SystemCheckResult() + assert result.success is False + assert result.platform == "" + assert result.tools == [] + assert result.missing_tools == [] + assert result.errors == [] + assert result.installation_instructions == "" + + def test_get_required_tools_darwin(self): + """Get required tools for macOS.""" + mod = _get_system_check_module() + SystemChecker = mod.SystemChecker + + checker = SystemChecker(platform_override="Darwin") + tools = checker.get_required_tools() + + assert "cmake" in tools + # macOS doesn't require make by default (comes with Xcode CLT) + + def test_get_required_tools_windows(self): + """Get required tools for Windows.""" + mod = _get_system_check_module() + SystemChecker = mod.SystemChecker + + checker = SystemChecker(platform_override="Windows") + tools = checker.get_required_tools() + + assert "cmake" in tools + assert "make" in tools + + def test_get_required_tools_linux(self): + """Get required tools for Linux.""" + mod = _get_system_check_module() + SystemChecker = mod.SystemChecker + + checker = SystemChecker(platform_override="Linux") + tools = checker.get_required_tools() + + assert "cmake" in tools + + def test_validate_build_tools_returns_result(self): + """validate_build_tools returns SystemCheckResult.""" + mod = _get_system_check_module() + SystemChecker = mod.SystemChecker + SystemCheckResult = mod.SystemCheckResult + + checker = SystemChecker() + result = checker.validate_build_tools() + + assert isinstance(result, SystemCheckResult) + assert result.platform != "" + + def test_installation_instructions_format(self): + """Installation instructions are properly formatted.""" + mod = _get_system_check_module() + SystemChecker = mod.SystemChecker + + checker = SystemChecker(platform_override="Darwin") + instructions = checker._get_installation_instructions(["cmake"]) + + assert "cmake" in instructions.lower() + assert "brew" in instructions.lower() or "install" in instructions.lower() + + +# ============================================================================= +# DEPENDENCY INSTALLER INTEGRATION TESTS +# ============================================================================= + + +class TestDependencyInstallerIntegration: + """Integration tests for dependency_installer.py.""" + + def test_installer_module_imports(self): + """Dependency installer module imports successfully.""" + mod = _get_dependency_installer_module() + + assert mod.DependencyInstaller is not None + assert mod.InstallationResult is not None + assert mod.SyncResult is not None + assert mod.VerificationResult is not None + + def test_installation_result_dataclass(self): + """InstallationResult dataclass initializes correctly.""" + mod = _get_dependency_installer_module() + InstallationResult = mod.InstallationResult + + result = InstallationResult() + assert result.success is False + assert result.interpreter_path == "" + assert result.interpreter_type == "" + assert result.packages_installed == [] + assert result.packages_failed == [] + assert result.errors == [] + + def test_verification_result_dataclass(self): + """VerificationResult dataclass initializes correctly.""" + mod = _get_dependency_installer_module() + VerificationResult = mod.VerificationResult + + result = VerificationResult() + assert result.success is False + assert result.bundled_packages == {} + assert result.venv_packages == {} + assert result.synchronized is False + assert result.differences == [] + + def test_sync_result_dataclass(self): + """SyncResult dataclass initializes correctly.""" + mod = _get_dependency_installer_module() + SyncResult = mod.SyncResult + + result = SyncResult() + assert result.success is False + assert result.bundled is None + assert result.venv is None + assert result.synchronized is False + + def test_get_packages_dict_with_current_python(self): + """Can retrieve package list from current Python.""" + mod = _get_dependency_installer_module() + DependencyInstaller = mod.DependencyInstaller + + installer = DependencyInstaller() + packages = installer._get_packages_dict(sys.executable) + + # Should be a dict (may be empty in some test environments) + assert isinstance(packages, dict) + + # pip should be installed + # Check for any variant of pip in package names + pip_found = any("pip" in pkg.lower() for pkg in packages.keys()) + if packages: # Only check if we got any packages + assert pip_found, f"pip not found in packages: {list(packages.keys())[:10]}" + + def test_verify_synchronization_with_single_interpreter(self): + """Verification works with only one interpreter available.""" + mod = _get_dependency_installer_module() + DependencyInstaller = mod.DependencyInstaller + + installer = DependencyInstaller() + + # Only provide one path (current Python as venv) + result = installer.verify_synchronization( + bundled_path="/nonexistent/python", + venv_path=sys.executable, + ) + + # Should succeed with single interpreter + assert result.venv_packages is not None + # Can't compare, so assumes synchronized + if not result.bundled_packages: + assert result.synchronized is True + + +# ============================================================================= +# E2E STARTUP VALIDATION FLOW TESTS +# ============================================================================= + + +class TestStartupValidationFlow: + """End-to-end tests for the complete startup validation flow.""" + + def test_full_validation_flow_simulation(self): + """Simulate full startup validation flow.""" + env_mod = _get_environment_validator_module() + sys_mod = _get_system_check_module() + dep_mod = _get_dependency_installer_module() + + EnvironmentValidator = env_mod.EnvironmentValidator + SystemChecker = sys_mod.SystemChecker + DependencyInstaller = dep_mod.DependencyInstaller + + # Step 1: Check system build tools + system_checker = SystemChecker() + build_tools_result = system_checker.validate_build_tools() + + # Should return a result (may pass or fail depending on system) + assert build_tools_result.platform != "" + + # Step 2: Validate Python environment + env_validator = EnvironmentValidator( + core_dependencies=[], # Skip actual imports for test + optional_dependencies=[], + ) + env_result = env_validator.validate_environment(sys.executable) + + assert env_result.python_path == sys.executable + assert env_result.python_version != "" + + # Step 3: Verify dependency synchronization + dep_installer = DependencyInstaller() + sync_result = dep_installer.verify_synchronization( + bundled_path=None, # Not available in dev + venv_path=sys.executable, + ) + + assert sync_result.summary != "" + + def test_validation_flow_returns_structured_results(self): + """All validators return properly structured results.""" + env_mod = _get_environment_validator_module() + sys_mod = _get_system_check_module() + dep_mod = _get_dependency_installer_module() + + EnvironmentValidator = env_mod.EnvironmentValidator + ValidationResult = env_mod.ValidationResult + SystemChecker = sys_mod.SystemChecker + SystemCheckResult = sys_mod.SystemCheckResult + DependencyInstaller = dep_mod.DependencyInstaller + VerificationResult = dep_mod.VerificationResult + + # All results should be structured dataclasses + env_result = EnvironmentValidator([], []).validate_environment( + sys.executable + ) + assert isinstance(env_result, ValidationResult) + + system_result = SystemChecker().validate_build_tools() + assert isinstance(system_result, SystemCheckResult) + + dep_result = DependencyInstaller().verify_synchronization( + venv_path=sys.executable + ) + assert isinstance(dep_result, VerificationResult) + + def test_validation_flow_no_code_1_errors(self): + """Validation flow provides detailed errors, not generic 'code 1'.""" + mod = _get_environment_validator_module() + EnvironmentValidator = mod.EnvironmentValidator + + # Test with invalid path + validator = EnvironmentValidator( + core_dependencies=["nonexistent_module_xyz123"], + optional_dependencies=[], + ) + + result = validator.validate_environment(sys.executable) + + # Should have detailed errors, not just "code 1" + if not result.success and result.errors: + for error in result.errors: + # Error should be descriptive + assert "code 1" not in error.lower() or len(error) > 30 + + +# ============================================================================= +# FEATURE VALIDATION TESTS (Insights, Roadmap, Ideation) +# ============================================================================= + + +class TestFeatureValidation: + """Tests to verify features can run without 'code 1' errors.""" + + def test_insights_module_would_not_code_1(self): + """Insights feature has proper error handling.""" + # The fix ensures that when Python subprocess fails, + # we get actual error messages, not just "code 1" + + # Simulate a subprocess error + cmd = [sys.executable, "-c", "import nonexistent_module_xyz"] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + ) + + # Should capture actual error message + assert result.returncode != 0 + assert "ModuleNotFoundError" in result.stderr or "No module named" in result.stderr + + def test_roadmap_module_would_not_code_1(self): + """Roadmap feature has proper error handling.""" + # Same pattern - verify subprocess captures real errors + cmd = [sys.executable, "-c", "raise ValueError('Roadmap test error')"] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "ValueError" in result.stderr + assert "Roadmap test error" in result.stderr + + def test_ideation_module_would_not_code_1(self): + """Ideation feature has proper error handling.""" + # Verify subprocess captures real errors + cmd = [ + sys.executable, + "-c", + "import sys; sys.stderr.write('Ideation error details'); sys.exit(1)", + ] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "Ideation error details" in result.stderr + + def test_subprocess_error_capture_pattern(self): + """Verify the error capture pattern used by all features.""" + # This is the pattern all features should use + cmd = [sys.executable, "-c", "print('stdout'); raise Exception('test error')"] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + # Should capture both stdout and stderr + assert "stdout" in result.stdout + assert "Exception" in result.stderr + assert "test error" in result.stderr + + # The key fix: we now have actual error info, not just exit code + error_message = f"Process failed with code {result.returncode}: {result.stderr}" + assert "test error" in error_message + + +# ============================================================================= +# CLI VALIDATION TESTS +# ============================================================================= + + +class TestCLIValidation: + """Tests for CLI interfaces of validation modules.""" + + def test_environment_validator_cli_help(self): + """Environment validator CLI shows help.""" + mod = _get_environment_validator_module() + main = mod.main + import io + from contextlib import redirect_stdout + + with patch("sys.argv", ["environment_validator.py", "--help"]): + with pytest.raises(SystemExit) as exc_info: + main() + # --help exits with 0 + assert exc_info.value.code == 0 + + def test_system_check_cli_help(self): + """System check CLI shows help.""" + mod = _get_system_check_module() + main = mod.main + + with patch("sys.argv", ["system_check.py", "--help"]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + def test_dependency_installer_cli_help(self): + """Dependency installer CLI shows help.""" + mod = _get_dependency_installer_module() + main = mod.main + + with patch("sys.argv", ["dependency_installer.py", "--help"]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + def test_environment_validator_cli_json_output(self): + """Environment validator CLI supports JSON output.""" + mod = _get_environment_validator_module() + main = mod.main + import io + from contextlib import redirect_stdout + + with patch( + "sys.argv", + ["environment_validator.py", "--python", sys.executable, "--json"], + ): + output = io.StringIO() + with redirect_stdout(output): + exit_code = main() + + output_str = output.getvalue() + # Should be valid JSON + try: + data = json.loads(output_str) + assert "success" in data + assert "python_path" in data + except json.JSONDecodeError: + pytest.fail(f"Invalid JSON output: {output_str}") + + def test_system_check_cli_json_output(self): + """System check CLI supports JSON output.""" + mod = _get_system_check_module() + main = mod.main + import io + from contextlib import redirect_stdout + + with patch("sys.argv", ["system_check.py", "--json"]): + output = io.StringIO() + with redirect_stdout(output): + exit_code = main() + + output_str = output.getvalue() + # Should be valid JSON + try: + data = json.loads(output_str) + assert "success" in data + assert "platform" in data + except json.JSONDecodeError: + pytest.fail(f"Invalid JSON output: {output_str}") + + +# ============================================================================= +# ERROR MESSAGE QUALITY TESTS +# ============================================================================= + + +class TestErrorMessageQuality: + """Tests to ensure error messages are descriptive, not cryptic.""" + + def test_missing_dependency_error_is_descriptive(self): + """Missing dependency errors include module name.""" + mod = _get_environment_validator_module() + EnvironmentValidator = mod.EnvironmentValidator + + validator = EnvironmentValidator( + core_dependencies=["definitely_not_a_real_module_xyz"], + optional_dependencies=[], + ) + + result = validator.validate_environment(sys.executable) + + assert result.success is False + # Error should mention the missing module + error_text = " ".join(result.errors) + assert "definitely_not_a_real_module_xyz" in error_text.lower() or "missing" in error_text.lower() + + def test_missing_build_tool_error_includes_install_instructions(self): + """Missing build tool errors include installation instructions.""" + mod = _get_system_check_module() + SystemChecker = mod.SystemChecker + + checker = SystemChecker( + platform_override="Darwin", + additional_tools=["nonexistent_tool_xyz"], + ) + + result = checker.validate_build_tools() + + # If nonexistent tool is missing (it will be), should have instructions + if "nonexistent_tool_xyz" in result.missing_tools: + assert result.installation_instructions != "" + + def test_python_version_error_is_clear(self): + """Python version error clearly states the requirement.""" + mod = _get_environment_validator_module() + MIN_PYTHON_VERSION = mod.MIN_PYTHON_VERSION + + # The minimum version should be clearly defined + assert MIN_PYTHON_VERSION == (3, 12) + + +# ============================================================================= +# INTEGRATION WITH FRESH INSTALL SIMULATION +# ============================================================================= + + +class TestFreshInstallSimulation: + """Tests that simulate a fresh install scenario.""" + + def test_fresh_install_validation_sequence(self): + """Simulate the validation sequence on fresh install.""" + env_mod = _get_environment_validator_module() + sys_mod = _get_system_check_module() + dep_mod = _get_dependency_installer_module() + + EnvironmentValidator = env_mod.EnvironmentValidator + SystemChecker = sys_mod.SystemChecker + DependencyInstaller = dep_mod.DependencyInstaller + + # 1. First, check build tools (before pip install attempts) + system_checker = SystemChecker() + build_result = system_checker.validate_build_tools() + + # Should always return a result + assert build_result.platform in ["Darwin", "Windows", "Linux"] + + # 2. Then validate Python environments + validator = EnvironmentValidator( + core_dependencies=[], + optional_dependencies=[], + ) + + # In fresh install, venv might exist from setup + venv_result = validator.validate_environment(sys.executable) + assert venv_result.python_version != "" + + # 3. Finally, check dependency sync status + installer = DependencyInstaller() + sync_result = installer.verify_synchronization(venv_path=sys.executable) + + # Should have a summary + assert sync_result.summary != "" + + def test_validation_provides_actionable_output(self): + """Validation output provides actionable information.""" + mod = _get_system_check_module() + SystemChecker = mod.SystemChecker + + checker = SystemChecker(platform_override="Windows") + result = checker.validate_build_tools() + + if not result.success: + # Should have installation instructions + assert result.installation_instructions != "" + # Instructions should mention package manager + instructions_lower = result.installation_instructions.lower() + assert "choco" in instructions_lower or "install" in instructions_lower diff --git a/tests/test_security_cache.py b/tests/test_security_cache.py index f755d1d5b7..1ec92ab7d4 100644 --- a/tests/test_security_cache.py +++ b/tests/test_security_cache.py @@ -99,18 +99,18 @@ def test_cache_invalidation_on_file_modification(mock_project_dir, mock_profile_ def test_cache_invalidation_on_file_deletion(mock_project_dir, mock_profile_path): reset_profile_cache() - + # 1. Create file current_hash = get_dir_hash(mock_project_dir) mock_profile_path.write_text(create_valid_profile_json(["unique_cmd_A"], current_hash)) - + # 2. Load profile profile1 = get_security_profile(mock_project_dir) assert "unique_cmd_A" in profile1.get_all_allowed_commands() - + # 3. Delete file mock_profile_path.unlink() - + # 4. Call again - should handle deletion gracefully and fallback to fresh analysis profile2 = get_security_profile(mock_project_dir) - assert "unique_cmd_A" not in profile2.get_all_allowed_commands() + assert "unique_cmd_A" not in profile2.get_all_allowed_commands()