diff --git a/apps/backend/agents/test_refactoring.py b/apps/backend/agents/test_refactoring.py deleted file mode 100644 index 965dbc2d..00000000 --- a/apps/backend/agents/test_refactoring.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -""" -Verification script for agent module refactoring. - -This script verifies that: -1. All modules can be imported -2. All public API functions are accessible -3. Backwards compatibility is maintained -""" - -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - - -def test_imports(): - """Test that all modules can be imported.""" - print("Testing module imports...") - - # Test base module - from agents import base - - assert hasattr(base, "AUTO_CONTINUE_DELAY_SECONDS") - assert hasattr(base, "HUMAN_INTERVENTION_FILE") - print(" ✓ agents.base") - - # Test utils module - from agents import utils - - assert hasattr(utils, "get_latest_commit") - assert hasattr(utils, "load_implementation_plan") - print(" ✓ agents.utils") - - # Test memory module - from agents import memory - - assert hasattr(memory, "save_session_memory") - assert hasattr(memory, "get_graphiti_context") - print(" ✓ agents.memory") - - # Test session module - from agents import session - - assert hasattr(session, "run_agent_session") - assert hasattr(session, "post_session_processing") - print(" ✓ agents.session") - - # Test planner module - from agents import planner - - assert hasattr(planner, "run_followup_planner") - print(" ✓ agents.planner") - - # Test coder module - from agents import coder - - assert hasattr(coder, "run_autonomous_agent") - print(" ✓ agents.coder") - - print("\n✓ All module imports successful!\n") - - -def test_public_api(): - """Test that the public API is accessible.""" - print("Testing public API...") - - # Test main agent module exports - import agents - - required_functions = [ - "run_autonomous_agent", - "run_followup_planner", - "save_session_memory", - "get_graphiti_context", - "run_agent_session", - "post_session_processing", - "get_latest_commit", - "load_implementation_plan", - ] - - for func_name in required_functions: - assert hasattr(agents, func_name), f"Missing function: {func_name}" - print(f" ✓ agents.{func_name}") - - print("\n✓ All public API functions accessible!\n") - - -def test_backwards_compatibility(): - """Test that the old agent.py facade maintains backwards compatibility.""" - print("Testing backwards compatibility...") - - # Test that agent.py can be imported - import agent - - required_functions = [ - "run_autonomous_agent", - "run_followup_planner", - "save_session_memory", - "save_session_to_graphiti", - "run_agent_session", - "post_session_processing", - ] - - for func_name in required_functions: - assert hasattr(agent, func_name), ( - f"Missing function in agent module: {func_name}" - ) - print(f" ✓ agent.{func_name}") - - print("\n✓ Backwards compatibility maintained!\n") - - -def test_module_structure(): - """Test that the module structure is correct.""" - print("Testing module structure...") - - from pathlib import Path - - agents_dir = Path(__file__).parent - - required_files = [ - "__init__.py", - "base.py", - "utils.py", - "memory.py", - "session.py", - "planner.py", - "coder.py", - ] - - for filename in required_files: - filepath = agents_dir / filename - assert filepath.exists(), f"Missing file: {filename}" - print(f" ✓ agents/{filename}") - - print("\n✓ Module structure correct!\n") - - -if __name__ == "__main__": - try: - test_module_structure() - test_imports() - test_public_api() - test_backwards_compatibility() - - print("=" * 60) - print("✓ ALL TESTS PASSED - Refactoring verified!") - print("=" * 60) - - except AssertionError as e: - print(f"\n✗ TEST FAILED: {e}") - sys.exit(1) - except ImportError as e: - print(f"\n✗ IMPORT ERROR: {e}") - print("Note: Some imports may fail due to missing dependencies.") - print("This is expected in test environments.") - sys.exit(0) # Don't fail on import errors (expected in test env) diff --git a/apps/backend/analysis/__init__.py b/apps/backend/analysis/__init__.py index 49d59ee5..5cc83c1f 100644 --- a/apps/backend/analysis/__init__.py +++ b/apps/backend/analysis/__init__.py @@ -23,7 +23,8 @@ from .ci_discovery import CIDiscovery from .project_analyzer import ProjectAnalyzer from .risk_classifier import RiskClassifier from .security_scanner import SecurityScanner -from .test_discovery import TestDiscovery + +# TestDiscovery was removed - tests are now co-located in their respective modules # insight_extractor is a module with functions, not a class, so don't import it here # Import it directly when needed: from analysis import insight_extractor @@ -37,5 +38,5 @@ __all__ = [ "RiskClassifier", "SecurityScanner", "CIDiscovery", - "TestDiscovery", + # "TestDiscovery", # Removed - tests now co-located in their modules ] diff --git a/apps/backend/analysis/test_discovery.py b/apps/backend/analysis/test_discovery.py deleted file mode 100644 index 0ebafa55..00000000 --- a/apps/backend/analysis/test_discovery.py +++ /dev/null @@ -1,690 +0,0 @@ -#!/usr/bin/env python3 -""" -Test Discovery Module -===================== - -Detects test frameworks, test commands, and test directories in a project. -This module analyzes project configuration files to discover how tests -should be run. - -The test discovery results are used by: -- QA Agent: To determine what test commands to run -- Test Creator: To know what framework to use when creating tests -- Planner: To include correct test commands in verification strategy - -Usage: - from test_discovery import TestDiscovery - - discovery = TestDiscovery() - result = discovery.discover(project_dir) - - print(f"Test frameworks: {result['frameworks']}") - print(f"Test command: {result['test_command']}") -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -# ============================================================================= -# DATA CLASSES -# ============================================================================= - - -@dataclass -class TestFramework: - """ - Represents a detected test framework. - - Attributes: - name: Name of the framework (e.g., "pytest", "jest", "vitest") - type: Type of testing (unit, integration, e2e, all) - command: Command to run tests - config_file: Configuration file if found - version: Version if detected - coverage_command: Command for coverage if available - """ - - __test__ = False # Prevent pytest from collecting this as a test class - - name: str - type: str # unit, integration, e2e, all - command: str - config_file: str | None = None - version: str | None = None - coverage_command: str | None = None - - -@dataclass -class TestDiscoveryResult: - """ - Result of test framework discovery. - - Attributes: - frameworks: List of detected test frameworks - test_command: Primary test command to run - test_directories: Discovered test directories - package_manager: Detected package manager - has_tests: Whether any test files were found - coverage_command: Command for coverage if available - """ - - __test__ = False # Prevent pytest from collecting this as a test class - - frameworks: list[TestFramework] = field(default_factory=list) - test_command: str = "" - test_directories: list[str] = field(default_factory=list) - package_manager: str = "" - has_tests: bool = False - coverage_command: str | None = None - - -# ============================================================================= -# FRAMEWORK DETECTORS -# ============================================================================= - - -# Pattern-based framework detection -FRAMEWORK_PATTERNS = { - # JavaScript/TypeScript - "jest": { - "config_files": [ - "jest.config.js", - "jest.config.ts", - "jest.config.mjs", - "jest.config.cjs", - ], - "package_key": "jest", - "type": "unit", - "command": "npx jest", - "coverage_command": "npx jest --coverage", - }, - "vitest": { - "config_files": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"], - "package_key": "vitest", - "type": "unit", - "command": "npx vitest run", - "coverage_command": "npx vitest run --coverage", - }, - "mocha": { - "config_files": [ - ".mocharc.js", - ".mocharc.json", - ".mocharc.yaml", - ".mocharc.yml", - ], - "package_key": "mocha", - "type": "unit", - "command": "npx mocha", - "coverage_command": "npx nyc mocha", - }, - "playwright": { - "config_files": ["playwright.config.js", "playwright.config.ts"], - "package_key": "@playwright/test", - "type": "e2e", - "command": "npx playwright test", - "coverage_command": None, - }, - "cypress": { - "config_files": ["cypress.config.js", "cypress.config.ts", "cypress.json"], - "package_key": "cypress", - "type": "e2e", - "command": "npx cypress run", - "coverage_command": None, - }, - # Python - "pytest": { - "config_files": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"], - "pyproject_key": "pytest", - "requirements_key": "pytest", - "type": "all", - "command": "pytest", - "coverage_command": "pytest --cov", - }, - "unittest": { - "config_files": [], - "type": "unit", - "command": "python -m unittest discover", - "coverage_command": "coverage run -m unittest discover", - }, - # Rust - "cargo_test": { - "config_files": ["Cargo.toml"], - "type": "all", - "command": "cargo test", - "coverage_command": "cargo tarpaulin", - }, - # Go - "go_test": { - "config_files": ["go.mod"], - "type": "all", - "command": "go test ./...", - "coverage_command": "go test -cover ./...", - }, - # Ruby - "rspec": { - "config_files": [".rspec", "spec/spec_helper.rb"], - "gemfile_key": "rspec", - "type": "all", - "command": "bundle exec rspec", - "coverage_command": "bundle exec rspec --format documentation", - }, - "minitest": { - "config_files": [], - "gemfile_key": "minitest", - "type": "unit", - "command": "bundle exec rake test", - "coverage_command": None, - }, -} - - -# ============================================================================= -# TEST DISCOVERY -# ============================================================================= - - -class TestDiscovery: - """ - Discovers test frameworks and configurations in a project. - - Analyzes: - - Package files (package.json, pyproject.toml, Cargo.toml, etc.) - - Configuration files (jest.config.js, pytest.ini, etc.) - - Directory structure (tests/, spec/, __tests__/) - """ - - __test__ = False # Prevent pytest from collecting this as a test class - - def __init__(self) -> None: - """Initialize the test discovery.""" - self._cache: dict[str, TestDiscoveryResult] = {} - - def discover(self, project_dir: Path) -> TestDiscoveryResult: - """ - Discover test frameworks and configuration in the project. - - Args: - project_dir: Path to the project root - - Returns: - TestDiscoveryResult with detected frameworks and commands - """ - project_dir = Path(project_dir) - cache_key = str(project_dir.resolve()) - - if cache_key in self._cache: - return self._cache[cache_key] - - result = TestDiscoveryResult() - - # Detect package manager - result.package_manager = self._detect_package_manager(project_dir) - - # Discover frameworks based on project type - if (project_dir / "package.json").exists(): - self._discover_js_frameworks(project_dir, result) - - # Check for Python project indicators - python_indicators = [ - project_dir / "pyproject.toml", - project_dir / "requirements.txt", - project_dir / "setup.py", - project_dir / "pytest.ini", - project_dir / "conftest.py", - project_dir / "tests" / "conftest.py", - ] - if any(p.exists() for p in python_indicators): - self._discover_python_frameworks(project_dir, result) - - if (project_dir / "Cargo.toml").exists(): - self._discover_rust_frameworks(project_dir, result) - if (project_dir / "go.mod").exists(): - self._discover_go_frameworks(project_dir, result) - if (project_dir / "Gemfile").exists(): - self._discover_ruby_frameworks(project_dir, result) - - # Find test directories - result.test_directories = self._find_test_directories(project_dir) - - # Check if tests exist - result.has_tests = self._has_test_files(project_dir, result.test_directories) - - # Set primary test command - if result.frameworks: - result.test_command = result.frameworks[0].command - - # Set coverage command from first framework that has one - if not result.coverage_command: - for framework in result.frameworks: - if framework.coverage_command: - result.coverage_command = framework.coverage_command - break - - self._cache[cache_key] = result - return result - - def _detect_package_manager(self, project_dir: Path) -> str: - """Detect the package manager used by the project.""" - if (project_dir / "pnpm-lock.yaml").exists(): - return "pnpm" - if (project_dir / "yarn.lock").exists(): - return "yarn" - if (project_dir / "package-lock.json").exists(): - return "npm" - if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists(): - return "bun" - if (project_dir / "uv.lock").exists(): - return "uv" - if (project_dir / "poetry.lock").exists(): - return "poetry" - if (project_dir / "Pipfile.lock").exists(): - return "pipenv" - if (project_dir / "Cargo.lock").exists(): - return "cargo" - if (project_dir / "go.sum").exists(): - return "go" - if (project_dir / "Gemfile.lock").exists(): - return "bundler" - return "" - - def _discover_js_frameworks( - self, project_dir: Path, result: TestDiscoveryResult - ) -> None: - """Discover JavaScript/TypeScript test frameworks.""" - package_json = project_dir / "package.json" - if not package_json.exists(): - return - - try: - with open(package_json, encoding="utf-8") as f: - pkg = json.load(f) - except (OSError, json.JSONDecodeError, UnicodeDecodeError): - return - - deps = pkg.get("dependencies", {}) - dev_deps = pkg.get("devDependencies", {}) - all_deps = {**deps, **dev_deps} - scripts = pkg.get("scripts", {}) - - # Check for test frameworks in dependencies - for name, pattern in FRAMEWORK_PATTERNS.items(): - if "package_key" not in pattern: - continue - - if pattern["package_key"] in all_deps: - # Check for config file - config_file = None - for cf in pattern.get("config_files", []): - if (project_dir / cf).exists(): - config_file = cf - break - - # Get version - version = all_deps.get(pattern["package_key"], "") - if version.startswith("^") or version.startswith("~"): - version = version[1:] - - # Determine command - prefer npm scripts if available - command = pattern["command"] - if "test" in scripts and pattern["package_key"] in scripts.get( - "test", "" - ): - command = f"{result.package_manager or 'npm'} test" - - result.frameworks.append( - TestFramework( - name=name, - type=pattern["type"], - command=command, - config_file=config_file, - version=version, - coverage_command=pattern.get("coverage_command"), - ) - ) - - # Check npm scripts for test commands - if not result.frameworks and "test" in scripts: - test_script = scripts["test"] - if ( - test_script - and test_script != 'echo "Error: no test specified" && exit 1' - ): - # Try to infer framework from script - framework_name = "npm_test" - framework_type = "unit" - - if "jest" in test_script: - framework_name = "jest" - elif "vitest" in test_script: - framework_name = "vitest" - elif "mocha" in test_script: - framework_name = "mocha" - elif "playwright" in test_script: - framework_name = "playwright" - framework_type = "e2e" - elif "cypress" in test_script: - framework_name = "cypress" - framework_type = "e2e" - - result.frameworks.append( - TestFramework( - name=framework_name, - type=framework_type, - command=f"{result.package_manager or 'npm'} test", - config_file=None, - ) - ) - - def _discover_python_frameworks( - self, project_dir: Path, result: TestDiscoveryResult - ) -> None: - """Discover Python test frameworks.""" - # Check for pytest.ini first (explicit pytest config) - if (project_dir / "pytest.ini").exists(): - if not any(f.name == "pytest" for f in result.frameworks): - result.frameworks.append( - TestFramework( - name="pytest", - type="all", - command="pytest", - config_file="pytest.ini", - ) - ) - - # Check pyproject.toml - pyproject = project_dir / "pyproject.toml" - if pyproject.exists(): - content = pyproject.read_text(encoding="utf-8") - - # Check for pytest - if "pytest" in content: - if not any(f.name == "pytest" for f in result.frameworks): - config_file = ( - "pyproject.toml" if "[tool.pytest" in content else None - ) - result.frameworks.append( - TestFramework( - name="pytest", - type="all", - command="pytest", - config_file=config_file, - ) - ) - - # Check requirements.txt - requirements = project_dir / "requirements.txt" - if requirements.exists(): - content = requirements.read_text(encoding="utf-8").lower() - if "pytest" in content and not any( - f.name == "pytest" for f in result.frameworks - ): - result.frameworks.append( - TestFramework( - name="pytest", - type="all", - command="pytest", - config_file=None, - ) - ) - - # Check for conftest.py (pytest marker) - conftest_root = project_dir / "conftest.py" - conftest_tests = project_dir / "tests" / "conftest.py" - if conftest_root.exists() or conftest_tests.exists(): - if not any(f.name == "pytest" for f in result.frameworks): - result.frameworks.append( - TestFramework( - name="pytest", - type="all", - command="pytest", - config_file="conftest.py", - ) - ) - - # Fall back to unittest if test files exist but no framework detected - if not result.frameworks: - test_dirs = self._find_test_directories(project_dir) - if test_dirs: - result.frameworks.append( - TestFramework( - name="unittest", - type="unit", - command="python -m unittest discover", - config_file=None, - ) - ) - - def _discover_rust_frameworks( - self, project_dir: Path, result: TestDiscoveryResult - ) -> None: - """Discover Rust test frameworks.""" - cargo_toml = project_dir / "Cargo.toml" - if cargo_toml.exists(): - result.frameworks.append( - TestFramework( - name="cargo_test", - type="all", - command="cargo test", - config_file="Cargo.toml", - ) - ) - - def _discover_go_frameworks( - self, project_dir: Path, result: TestDiscoveryResult - ) -> None: - """Discover Go test frameworks.""" - go_mod = project_dir / "go.mod" - if go_mod.exists(): - result.frameworks.append( - TestFramework( - name="go_test", - type="all", - command="go test ./...", - config_file="go.mod", - ) - ) - - def _discover_ruby_frameworks( - self, project_dir: Path, result: TestDiscoveryResult - ) -> None: - """Discover Ruby test frameworks.""" - gemfile = project_dir / "Gemfile" - if not gemfile.exists(): - return - - content = gemfile.read_text(encoding="utf-8").lower() - - if "rspec" in content or (project_dir / ".rspec").exists(): - result.frameworks.append( - TestFramework( - name="rspec", - type="all", - command="bundle exec rspec", - config_file=".rspec" if (project_dir / ".rspec").exists() else None, - ) - ) - elif "minitest" in content: - result.frameworks.append( - TestFramework( - name="minitest", - type="unit", - command="bundle exec rake test", - config_file=None, - ) - ) - - def _find_test_directories(self, project_dir: Path) -> list[str]: - """Find test directories in the project.""" - test_dir_patterns = [ - "tests", - "test", - "spec", - "__tests__", - "specs", - "test_*", - ] - - found_dirs = [] - for pattern in test_dir_patterns: - if pattern.endswith("*"): - # Glob pattern - for d in project_dir.glob(pattern): - if d.is_dir(): - found_dirs.append(str(d.relative_to(project_dir))) - else: - # Exact name - test_dir = project_dir / pattern - if test_dir.is_dir(): - found_dirs.append(pattern) - - return found_dirs - - def _has_test_files(self, project_dir: Path, test_directories: list[str]) -> bool: - """Check if any test files exist.""" - test_file_patterns = [ - "**/test_*.py", - "**/*_test.py", - "**/*.test.js", - "**/*.test.ts", - "**/*.test.tsx", - "**/*.spec.js", - "**/*.spec.ts", - "**/*.spec.tsx", - "**/test_*.go", - "**/*_test.go", - "**/*_test.rs", - "**/spec/**/*_spec.rb", - ] - - # Check in test directories - for test_dir in test_directories: - test_path = project_dir / test_dir - if test_path.exists(): - for pattern in test_file_patterns: - if list(test_path.glob(pattern.replace("**/", ""))): - return True - - # Check project-wide - for pattern in test_file_patterns: - if list(project_dir.glob(pattern)): - return True - - return False - - def to_dict(self, result: TestDiscoveryResult) -> dict[str, Any]: - """Convert result to dictionary for JSON serialization.""" - return { - "frameworks": [ - { - "name": f.name, - "type": f.type, - "command": f.command, - "config_file": f.config_file, - "version": f.version, - "coverage_command": f.coverage_command, - } - for f in result.frameworks - ], - "test_command": result.test_command, - "test_directories": result.test_directories, - "package_manager": result.package_manager, - "has_tests": result.has_tests, - "coverage_command": result.coverage_command, - } - - def clear_cache(self) -> None: - """Clear the internal cache.""" - self._cache.clear() - - -# ============================================================================= -# CONVENIENCE FUNCTIONS -# ============================================================================= - - -def discover_tests(project_dir: Path) -> TestDiscoveryResult: - """ - Convenience function to discover tests in a project. - - Args: - project_dir: Path to project root - - Returns: - TestDiscoveryResult with detected frameworks - """ - discovery = TestDiscovery() - return discovery.discover(project_dir) - - -def get_test_command(project_dir: Path) -> str: - """ - Get the primary test command for a project. - - Args: - project_dir: Path to project root - - Returns: - Test command string, or empty string if not found - """ - discovery = TestDiscovery() - result = discovery.discover(project_dir) - return result.test_command - - -def get_test_frameworks(project_dir: Path) -> list[str]: - """ - Get list of test framework names in a project. - - Args: - project_dir: Path to project root - - Returns: - List of framework names - """ - discovery = TestDiscovery() - result = discovery.discover(project_dir) - return [f.name for f in result.frameworks] - - -# ============================================================================= -# CLI -# ============================================================================= - - -def main() -> None: - """CLI entry point for testing.""" - import argparse - - parser = argparse.ArgumentParser(description="Discover test frameworks") - parser.add_argument("project_dir", type=Path, help="Path to project root") - parser.add_argument("--json", action="store_true", help="Output as JSON") - - args = parser.parse_args() - - discovery = TestDiscovery() - result = discovery.discover(args.project_dir) - - if args.json: - print(json.dumps(discovery.to_dict(result), indent=2)) - else: - print(f"Package Manager: {result.package_manager or 'unknown'}") - print(f"Has Tests: {result.has_tests}") - print(f"Test Command: {result.test_command or 'none'}") - print(f"Test Directories: {', '.join(result.test_directories) or 'none'}") - print(f"Coverage Command: {result.coverage_command or 'none'}") - print(f"\nFrameworks ({len(result.frameworks)}):") - for f in result.frameworks: - print(f" - {f.name} ({f.type})") - print(f" Command: {f.command}") - if f.config_file: - print(f" Config: {f.config_file}") - if f.version: - print(f" Version: {f.version}") - - -if __name__ == "__main__": - main() diff --git a/apps/backend/core/workspace/__init__.py b/apps/backend/core/workspace/__init__.py index 5a185ba5..852fb45f 100644 --- a/apps/backend/core/workspace/__init__.py +++ b/apps/backend/core/workspace/__init__.py @@ -17,7 +17,6 @@ Public API exported from sub-modules. """ import importlib.util -import sys from pathlib import Path # Import merge functions from workspace.py (which coexists with this package) @@ -28,10 +27,17 @@ _workspace_module = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_workspace_module) merge_existing_build = _workspace_module.merge_existing_build _run_parallel_merges = _workspace_module._run_parallel_merges +_resolve_git_conflicts_with_ai = _workspace_module._resolve_git_conflicts_with_ai AI_MERGE_SYSTEM_PROMPT = _workspace_module.AI_MERGE_SYSTEM_PROMPT _build_merge_prompt = _workspace_module._build_merge_prompt _check_git_conflicts = _workspace_module._check_git_conflicts _rebase_spec_branch = _workspace_module._rebase_spec_branch +_create_merge_progress_callback = _workspace_module._create_merge_progress_callback +_infer_language_from_path = _workspace_module._infer_language_from_path +_strip_code_fences = _workspace_module._strip_code_fences +_try_simple_3way_merge = _workspace_module._try_simple_3way_merge +_attempt_ai_merge = _workspace_module._attempt_ai_merge +_merge_file_with_ai_async = _workspace_module._merge_file_with_ai_async # Models and Enums # Display Functions @@ -74,7 +80,9 @@ from .git_utils import ( # Export private names for backward compatibility _is_process_running, _validate_merged_syntax, + apply_path_mapping, create_conflict_file_with_git, + detect_file_renames, get_binary_file_content_from_ref, get_changed_files_from_branch, get_current_branch, @@ -91,6 +99,8 @@ from .models import ( MergeLockError, ParallelMergeResult, ParallelMergeTask, + SpecNumberLock, + SpecNumberLockError, WorkspaceChoice, WorkspaceMode, ) @@ -110,11 +120,9 @@ from .setup import ( __all__ = [ # Merge Operations (from workspace.py) "merge_existing_build", - "_run_parallel_merges", # Private but used internally - "AI_MERGE_SYSTEM_PROMPT", # System prompt for AI merge (ACS-194) - "_build_merge_prompt", # Internal prompt builder (ACS-194) - "_check_git_conflicts", # Internal git conflict detection (ACS-224) - "_rebase_spec_branch", # Internal rebase function (ACS-224) + # Note: Private functions (_run_parallel_merges, _resolve_git_conflicts_with_ai, etc.) + # are kept as module-level assignments for internal use but not exported in __all__ + # to maintain the underscore convention for private/internal APIs # Models "WorkspaceMode", "WorkspaceChoice", @@ -122,6 +130,8 @@ __all__ = [ "ParallelMergeResult", "MergeLock", "MergeLockError", + "SpecNumberLock", + "SpecNumberLockError", # Git Utils "has_uncommitted_changes", "get_current_branch", @@ -131,8 +141,11 @@ __all__ = [ "get_changed_files_from_branch", "is_process_running", "is_binary_file", + "is_lock_file", "validate_merged_syntax", "create_conflict_file_with_git", + "detect_file_renames", # File rename detection + "apply_path_mapping", # Path mapping for renamed files # Setup "choose_workspace", "copy_spec_to_worktree", diff --git a/apps/backend/core/workspace/tests/conftest.py b/apps/backend/core/workspace/tests/conftest.py new file mode 100644 index 00000000..7c80d19f --- /dev/null +++ b/apps/backend/core/workspace/tests/conftest.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +Pytest Configuration and Shared Fixtures for Workspace Tests +============================================================== + +Provides test fixtures for the workspace module tests. +""" + +import os +import shutil +import subprocess +import sys +import tempfile +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +# ============================================================================= +# MODULE MOCK CLEANUP - Prevents test isolation issues +# ============================================================================= + +# List of modules that might be mocked by test files +_POTENTIALLY_MOCKED_MODULES = [ + "claude_code_sdk", + "claude_code_sdk.types", + "claude_agent_sdk", + "claude_agent_sdk.types", +] + +# Store original module references at import time (BEFORE pre-mocking) +_original_module_state = {} +for _name in _POTENTIALLY_MOCKED_MODULES: + if _name in sys.modules: + _original_module_state[_name] = sys.modules[_name] + + +# ============================================================================= +# PRE-MOCK EXTERNAL SDK MODULES - Must happen BEFORE adding auto-claude to path +# ============================================================================= +# These SDK modules may not be installed, so we mock them before any imports +# that might trigger loading code that depends on them. + + +def _create_sdk_mock(): + """Create a comprehensive mock for SDK modules.""" + mock = MagicMock() + mock.ClaudeAgentOptions = MagicMock + mock.ClaudeSDKClient = MagicMock + mock.HookMatcher = MagicMock + return mock + + +# Pre-mock claude_agent_sdk if not installed +if "claude_agent_sdk" not in sys.modules: + sys.modules["claude_agent_sdk"] = _create_sdk_mock() + sys.modules["claude_agent_sdk.types"] = MagicMock() + +# Pre-mock claude_code_sdk if not installed +if "claude_code_sdk" not in sys.modules: + sys.modules["claude_code_sdk"] = _create_sdk_mock() + sys.modules["claude_code_sdk.types"] = MagicMock() + +# Add backend directory to path for imports +# When co-located at workspace/tests/, go up to backend directory +# workspace/tests -> workspace -> core -> backend (4 levels up) +_backend = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(_backend)) + +# Add repo root to sys.path for test_fixtures import fallback +_repo_root = _backend.parent.parent +sys.path.insert(0, str(_repo_root)) + + +def _cleanup_mocked_modules(): + """Remove any MagicMock modules from sys.modules.""" + for name in _POTENTIALLY_MOCKED_MODULES: + if name in sys.modules: + module = sys.modules[name] + if isinstance(module, MagicMock): + if name in _original_module_state: + sys.modules[name] = _original_module_state[name] + else: + del sys.modules[name] + + +def pytest_sessionstart(session): + """Clean up any mocked modules before the test session starts.""" + _cleanup_mocked_modules() + + +# ============================================================================= +# DIRECTORY FIXTURES +# ============================================================================= + + +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Create a temporary directory that's cleaned up after the test.""" + temp_path = Path(tempfile.mkdtemp()) + yield temp_path + shutil.rmtree(temp_path, ignore_errors=True) + + +@pytest.fixture +def temp_git_repo(temp_dir: Path) -> Generator[Path, None, None]: + """Create a temporary git repository with initial commit. + + IMPORTANT: This fixture properly isolates git operations by clearing + git environment variables that may be set by pre-commit hooks. Without + this isolation, git operations could affect the parent repository when + tests run inside a git worktree (e.g., during pre-commit validation). + + See: https://git-scm.com/docs/git#_environment_variables + """ + # Save original environment values to restore later + orig_env = {} + + # These git env vars may be set by pre-commit hooks and MUST be cleared + # to avoid git operations affecting the parent repository instead of + # our isolated test repo. This is critical when running inside worktrees. + git_vars_to_clear = [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + ] + + # Clear interfering git environment variables + for key in git_vars_to_clear: + orig_env[key] = os.environ.get(key) + if key in os.environ: + del os.environ[key] + + # Set GIT_CEILING_DIRECTORIES to prevent git from discovering parent .git + # directories. This is critical for test isolation when running inside + # another git repo (like during pre-commit hooks in worktrees). + orig_env["GIT_CEILING_DIRECTORIES"] = os.environ.get("GIT_CEILING_DIRECTORIES") + os.environ["GIT_CEILING_DIRECTORIES"] = str(temp_dir.parent) + + try: + # Initialize git repo + subprocess.run(["git", "init"], cwd=temp_dir, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=temp_dir, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], + cwd=temp_dir, + capture_output=True, + ) + + # Create initial commit + test_file = temp_dir / "README.md" + test_file.write_text("# Test Project\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_dir, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Initial commit"], cwd=temp_dir, capture_output=True + ) + + # Ensure branch is named 'main' (some git configs default to 'master') + subprocess.run( + ["git", "branch", "-M", "main"], cwd=temp_dir, capture_output=True + ) + + yield temp_dir + finally: + # Restore original environment variables + for key, value in orig_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +@pytest.fixture +def spec_dir(temp_dir: Path) -> Path: + """Create a spec directory inside temp_dir.""" + spec_path = temp_dir / "spec" + spec_path.mkdir(parents=True) + return spec_path + + +@pytest.fixture +def project_dir(temp_dir: Path) -> Path: + """Create a project directory inside temp_dir.""" + project_path = temp_dir / "project" + project_path.mkdir(parents=True) + return project_path + + +@pytest.fixture +def make_commit(temp_git_repo: Path): + """Fixture to make commits in the test git repo. + + Usage: + def test_something(make_commit): + make_commit("message", files={"file.txt": "content"}) + """ + + def _make_commit(message: str, files: dict[str, str] | None = None): + """Create a commit with the given message and files. + + Args: + message: Commit message + files: Optional dict of {filepath: content} to create before committing + """ + if files: + for file_path, content in files.items(): + full_path = temp_git_repo / file_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content, encoding="utf-8") + + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", message], + cwd=temp_git_repo, + capture_output=True, + ) + + return _make_commit + + +@pytest.fixture +def stage_files(temp_git_repo: Path): + """Fixture to stage files in the test git repo. + + Usage: + def test_something(stage_files): + stage_files({"file.txt": "content"}) + """ + + def _stage_files(files: dict[str, str]): + """Stage files for commit. + + Args: + files: Dict of {filepath: content} to create and stage + """ + for file_path, content in files.items(): + full_path = temp_git_repo / file_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content, encoding="utf-8") + + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + + return _stage_files diff --git a/apps/backend/core/workspace/tests/pytest.ini b/apps/backend/core/workspace/tests/pytest.ini new file mode 100644 index 00000000..351998b3 --- /dev/null +++ b/apps/backend/core/workspace/tests/pytest.ini @@ -0,0 +1,10 @@ +[pytest] +# Pytest configuration for workspace module tests + +# Async test mode +asyncio_mode = auto + +# Register custom markers +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests (deselect with '-m "not integration"') diff --git a/apps/backend/core/workspace/tests/test_display.py b/apps/backend/core/workspace/tests/test_display.py new file mode 100644 index 00000000..40e7c4a2 --- /dev/null +++ b/apps/backend/core/workspace/tests/test_display.py @@ -0,0 +1,856 @@ +#!/usr/bin/env python3 +""" +Tests for Workspace Display Functions +====================================== + +Tests the display.py module functionality including: +- Build summary display +- Changed files display +- Merge success printing +- Conflict info display +- Environment file operations +- Node modules symlink operations +""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +# Test constant - in the new per-spec architecture, each spec has its own worktree +# named after the spec itself. This constant is used for test assertions. +TEST_SPEC_NAME = "test-spec" + + +class TestShowBuildSummary: + """Tests for show_build_summary display function.""" + + def test_show_build_summary_no_changes(self, capsys): + """show_build_summary prints info message when no changes.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_build_summary + + mock_manager = MagicMock() + mock_manager.get_change_summary.return_value = { + "new_files": 0, + "modified_files": 0, + "deleted_files": 0, + } + mock_manager.get_changed_files.return_value = [] + + show_build_summary(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "No changes were made" in captured.out + + def test_show_build_summary_with_new_files(self, capsys): + """show_build_summary displays new files count correctly.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_build_summary + + mock_manager = MagicMock() + mock_manager.get_change_summary.return_value = { + "new_files": 3, + "modified_files": 0, + "deleted_files": 0, + } + mock_manager.get_changed_files.return_value = [ + ("A", "file1.py"), + ("A", "file2.py"), + ("A", "file3.py"), + ] + + show_build_summary(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "What was built" in captured.out + assert "+ 3 new files" in captured.out + + def test_show_build_summary_singular_new_file(self, capsys): + """show_build_summary uses singular form for one new file.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_build_summary + + mock_manager = MagicMock() + mock_manager.get_change_summary.return_value = { + "new_files": 1, + "modified_files": 0, + "deleted_files": 0, + } + mock_manager.get_changed_files.return_value = [("A", "file1.py")] + + show_build_summary(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "+ 1 new file" in captured.out + assert "files" not in captured.out.split("new file")[1].split("\n")[0] + + def test_show_build_summary_with_modified_files(self, capsys): + """show_build_summary displays modified files count correctly.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_build_summary + + mock_manager = MagicMock() + mock_manager.get_change_summary.return_value = { + "new_files": 0, + "modified_files": 2, + "deleted_files": 0, + } + mock_manager.get_changed_files.return_value = [ + ("M", "file1.py"), + ("M", "file2.py"), + ] + + show_build_summary(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "~ 2 modified files" in captured.out + + def test_show_build_summary_with_deleted_files(self, capsys): + """show_build_summary displays deleted files count correctly.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_build_summary + + mock_manager = MagicMock() + mock_manager.get_change_summary.return_value = { + "new_files": 0, + "modified_files": 0, + "deleted_files": 1, + } + mock_manager.get_changed_files.return_value = [("D", "old.py")] + + show_build_summary(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "- 1 deleted file" in captured.out + + def test_show_build_summary_mixed_changes(self, capsys): + """show_build_summary displays all change types together.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_build_summary + + mock_manager = MagicMock() + mock_manager.get_change_summary.return_value = { + "new_files": 2, + "modified_files": 3, + "deleted_files": 1, + } + mock_manager.get_changed_files.return_value = [ + ("A", "new1.py"), + ("A", "new2.py"), + ("M", "mod1.py"), + ("M", "mod2.py"), + ("M", "mod3.py"), + ("D", "old.py"), + ] + + show_build_summary(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "+ 2 new files" in captured.out + assert "~ 3 modified files" in captured.out + assert "- 1 deleted file" in captured.out + + +class TestShowChangedFiles: + """Tests for show_changed_files display function.""" + + def test_show_changed_files_empty_list(self, capsys): + """show_changed_files prints info message when no files changed.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_changed_files + + mock_manager = MagicMock() + mock_manager.get_changed_files.return_value = [] + + show_changed_files(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "No changes" in captured.out + + def test_show_changed_files_with_added_file(self, capsys): + """show_changed_files displays added file with + prefix.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_changed_files + + mock_manager = MagicMock() + mock_manager.get_changed_files.return_value = [("A", "new_file.py")] + + show_changed_files(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "Changed files" in captured.out + assert "+ new_file.py" in captured.out + + def test_show_changed_files_with_modified_file(self, capsys): + """show_changed_files displays modified file with ~ prefix.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_changed_files + + mock_manager = MagicMock() + mock_manager.get_changed_files.return_value = [("M", "changed.py")] + + show_changed_files(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "~ changed.py" in captured.out + + def test_show_changed_files_with_deleted_file(self, capsys): + """show_changed_files displays deleted file with - prefix.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_changed_files + + mock_manager = MagicMock() + mock_manager.get_changed_files.return_value = [("D", "removed.py")] + + show_changed_files(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "- removed.py" in captured.out + + def test_show_changed_files_with_unknown_status(self, capsys): + """show_changed_files displays unknown status code without decoration.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_changed_files + + mock_manager = MagicMock() + mock_manager.get_changed_files.return_value = [("R", "renamed.py")] + + show_changed_files(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "R renamed.py" in captured.out + + def test_show_changed_files_multiple_files(self, capsys): + """show_changed_files displays all changed files.""" + from unittest.mock import MagicMock + + from core.workspace.display import show_changed_files + + mock_manager = MagicMock() + mock_manager.get_changed_files.return_value = [ + ("A", "new.py"), + ("M", "modified.py"), + ("D", "deleted.py"), + ("R", "renamed.py"), + ] + + show_changed_files(mock_manager, "test-spec") + + captured = capsys.readouterr() + assert "+ new.py" in captured.out + assert "~ modified.py" in captured.out + assert "- deleted.py" in captured.out + assert "R renamed.py" in captured.out + + +class TestPrintMergeSuccess: + """Tests for print_merge_success display function.""" + + def test_print_merge_success_no_commit_basic(self, capsys): + """print_merge_success with no_commit=True shows basic message.""" + from core.workspace.display import print_merge_success + + print_merge_success(no_commit=True) + + captured = capsys.readouterr() + assert "CHANGES ADDED TO YOUR PROJECT" in captured.out + assert "working directory" in captured.out + assert "Review the changes" in captured.out + assert "commit when ready" in captured.out + + def test_print_merge_success_no_commit_with_lock_files(self, capsys): + """print_merge_success with lock_files_excluded shows lock file note.""" + from core.workspace.display import print_merge_success + + stats = {"lock_files_excluded": 2} + print_merge_success(no_commit=True, stats=stats) + + captured = capsys.readouterr() + assert "CHANGES ADDED TO YOUR PROJECT" in captured.out + assert "Lock files kept from main" in captured.out + assert "npm install" in captured.out + + def test_print_merge_success_no_commit_with_keep_worktree(self, capsys): + """print_merge_success with keep_worktree shows discard command.""" + from core.workspace.display import print_merge_success + + print_merge_success(no_commit=True, spec_name="spec-001", keep_worktree=True) + + captured = capsys.readouterr() + assert "CHANGES ADDED TO YOUR PROJECT" in captured.out + assert "Worktree kept for testing" in captured.out + assert "python auto-claude/run.py --spec spec-001 --discard" in captured.out + + def test_print_merge_success_no_commit_full_scenario(self, capsys): + """print_merge_success with all optional parameters.""" + from core.workspace.display import print_merge_success + + stats = {"lock_files_excluded": 1} + print_merge_success( + no_commit=True, + stats=stats, + spec_name="test-spec", + keep_worktree=True, + ) + + captured = capsys.readouterr() + assert "CHANGES ADDED TO YOUR PROJECT" in captured.out + assert "Lock files kept from main" in captured.out + assert "Worktree kept for testing" in captured.out + assert "--spec test-spec --discard" in captured.out + + def test_print_merge_success_with_commit_basic(self, capsys): + """print_merge_success with no_commit=False shows commit message.""" + from core.workspace.display import print_merge_success + + print_merge_success(no_commit=False) + + captured = capsys.readouterr() + assert "FEATURE ADDED TO YOUR PROJECT" in captured.out + assert "separate workspace has been cleaned up" in captured.out + + def test_print_merge_success_with_commit_and_stats(self, capsys): + """print_merge_success with stats shows file counts.""" + from core.workspace.display import print_merge_success + + stats = { + "files_added": 5, + "files_modified": 3, + "files_deleted": 1, + } + print_merge_success(no_commit=False, stats=stats) + + captured = capsys.readouterr() + assert "FEATURE ADDED TO YOUR PROJECT" in captured.out + assert "What changed" in captured.out + assert "+ 5 files added" in captured.out + assert "~ 3 files modified" in captured.out + assert "- 1 file deleted" in captured.out + + def test_print_merge_success_singular_file_counts(self, capsys): + """print_merge_success uses singular form for single file counts.""" + from core.workspace.display import print_merge_success + + stats = { + "files_added": 1, + "files_modified": 1, + "files_deleted": 1, + } + print_merge_success(no_commit=False, stats=stats) + + captured = capsys.readouterr() + assert "+ 1 file added" in captured.out + assert "~ 1 file modified" in captured.out + assert "- 1 file deleted" in captured.out + + def test_print_merge_success_with_keep_worktree(self, capsys): + """print_merge_success with keep_worktree shows discard command.""" + from core.workspace.display import print_merge_success + + print_merge_success(no_commit=False, keep_worktree=True, spec_name="my-spec") + + captured = capsys.readouterr() + assert "FEATURE ADDED TO YOUR PROJECT" in captured.out + assert "Worktree kept for testing" in captured.out + assert "--spec my-spec --discard" in captured.out + assert "separate workspace has been cleaned up" not in captured.out + + def test_print_merge_success_zero_file_counts_not_shown(self, capsys): + """print_merge_success doesn't show file types with zero count.""" + from core.workspace.display import print_merge_success + + stats = { + "files_added": 2, + "files_modified": 0, + "files_deleted": 0, + } + print_merge_success(no_commit=False, stats=stats) + + captured = capsys.readouterr() + assert "+ 2 files added" in captured.out + assert "files modified" not in captured.out + assert "files deleted" not in captured.out + + +class TestPrintConflictInfoExtended: + """Extended tests for print_conflict_info display function.""" + + def test_print_conflict_info_empty_conflicts(self, capsys): + """print_conflict_info returns early with empty conflicts list.""" + from core.workspace.display import print_conflict_info + + result = {"conflicts": []} + + print_conflict_info(result) + + captured = capsys.readouterr() + assert captured.out == "" + + def test_print_conflict_info_no_conflicts_key(self, capsys): + """print_conflict_info returns early when conflicts key missing.""" + from core.workspace.display import print_conflict_info + + result = {} + + print_conflict_info(result) + + captured = capsys.readouterr() + assert captured.out == "" + + def test_print_conflict_info_critical_severity(self, capsys): + """print_conflict_info shows critical severity icon.""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + { + "file": "critical.py", + "reason": "Breaking change", + "severity": "critical", + } + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "critical.py" in captured.out + assert "⛔" in captured.out + assert "Breaking change" in captured.out + + def test_print_conflict_info_high_severity(self, capsys): + """print_conflict_info shows high severity icon.""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + {"file": "high.py", "reason": "Major conflict", "severity": "high"} + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "high.py" in captured.out + assert "🔴" in captured.out + assert "Major conflict" in captured.out + + def test_print_conflict_info_medium_severity(self, capsys): + """print_conflict_info shows medium severity icon.""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + {"file": "medium.py", "reason": "Minor conflict", "severity": "medium"} + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "medium.py" in captured.out + assert "🟡" in captured.out + assert "Minor conflict" in captured.out + + def test_print_conflict_info_low_severity_no_icon(self, capsys): + """print_conflict_info shows no icon for low severity.""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + {"file": "low.py", "reason": "Trivial issue", "severity": "low"} + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "low.py" in captured.out + assert "Trivial issue" in captured.out + assert "⛔" not in captured.out + assert "🔴" not in captured.out + assert "🟡" not in captured.out + + def test_print_conflict_info_unknown_severity(self, capsys): + """print_conflict_info handles unknown severity gracefully.""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + {"file": "unknown.py", "reason": "Unknown", "severity": "unknown"} + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "unknown.py" in captured.out + assert "Unknown" in captured.out + + def test_print_conflict_info_missing_file_key(self, capsys): + """print_conflict_info handles missing file key.""" + from core.workspace.display import print_conflict_info + + result = {"conflicts": [{"reason": "No file specified", "severity": "high"}]} + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "unknown" in captured.out + assert "No file specified" in captured.out + + def test_print_conflict_info_missing_reason_key(self, capsys): + """print_conflict_info handles missing reason key.""" + from core.workspace.display import print_conflict_info + + result = {"conflicts": [{"file": "noreason.py", "severity": "medium"}]} + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "noreason.py" in captured.out + + def test_print_conflict_info_dict_no_reason(self, capsys): + """print_conflict_info with dict missing reason.""" + from core.workspace.display import print_conflict_info + + result = {"conflicts": [{"file": "test.py", "severity": "high"}]} + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "test.py" in captured.out + assert "🔴" in captured.out + + def test_print_conflict_info_multiple_conflicts(self, capsys): + """print_conflict_info handles multiple conflicts.""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + {"file": "critical.py", "reason": "Critical", "severity": "critical"}, + {"file": "high.py", "reason": "High", "severity": "high"}, + {"file": "medium.py", "reason": "Medium", "severity": "medium"}, + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "3 file" in captured.out + assert "⛔" in captured.out + assert "🔴" in captured.out + assert "🟡" in captured.out + + def test_print_conflict_info_shows_marker_conflict_message(self, capsys): + """print_conflict_info shows marker conflict message for string conflicts.""" + from core.workspace.display import print_conflict_info + + result = {"conflicts": ["conflict.py"]} + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "conflict markers" in captured.out + # Check that the conflict markers are mentioned in the message + + def test_print_conflict_info_shows_ai_conflict_message(self, capsys): + """print_conflict_info shows AI conflict message for dict conflicts.""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + { + "file": "ai-conflict.py", + "reason": "AI merge failed", + "severity": "high", + } + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "could not be auto-merged" in captured.out + + def test_print_conflict_info_shows_both_messages_mixed(self, capsys): + """print_conflict_info shows both messages for mixed conflicts.""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + "marker.py", + {"file": "ai.py", "reason": "AI failed", "severity": "high"}, + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "conflict markers" in captured.out + assert "could not be auto-merged" in captured.out + + def test_print_conflict_info_shows_git_commands(self, capsys): + """print_conflict_info shows git add and commit commands.""" + from core.workspace.display import print_conflict_info + + result = {"conflicts": ["file1.py", "file2.py"]} + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "git add" in captured.out + assert "git commit" in captured.out + + def test_print_conflict_info_quotes_special_paths(self, capsys): + """print_conflict_info properly quotes file paths with special characters.""" + from core.workspace.display import print_conflict_info + + result = {"conflicts": ["file with spaces.py", "file'with'quotes.py"]} + + print_conflict_info(result) + + captured = capsys.readouterr() + # shlex.quote should quote paths with spaces + assert "git add" in captured.out + assert "file with spaces.py" in captured.out + + def test_print_conflict_info_deduplicates_files(self, capsys): + """print_conflict_info deduplicates file paths in git command.""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + "file1.py", + {"file": "file1.py", "reason": "Also here", "severity": "medium"}, + "file2.py", + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + # Count occurrences of file1.py + count = captured.out.count("file1.py") + assert count == 3 # Display shows it twice (string + dict), once in git add + + def test_print_conflict_info_preserves_order(self, capsys): + """print_conflict_info preserves file order while deduplicating.""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + "first.py", + {"file": "second.py", "severity": "high"}, + "first.py", # Duplicate + {"file": "third.py", "severity": "medium"}, + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + # First occurrence should be preserved + lines = captured.out.split("\n") + first_idx = None + second_idx = None + for i, line in enumerate(lines): + if "first.py" in line: + if first_idx is None: + first_idx = i + if "second.py" in line: + if second_idx is None: + second_idx = i + assert first_idx is not None + assert second_idx is not None + + +class TestCopyEnvFilesToWorktree: + """Tests for copy_env_files_to_worktree function.""" + + def test_copies_all_env_files(self, temp_git_repo: Path): + """Copies all .env files when they exist in project dir.""" + from core.workspace.setup import copy_env_files_to_worktree + + # Create .env files in project + (temp_git_repo / ".env").write_text("FOO=bar", encoding="utf-8") + (temp_git_repo / ".env.local").write_text("LOCAL=1", encoding="utf-8") + (temp_git_repo / ".env.development").write_text("DEV=1", encoding="utf-8") + + # Create worktree directory + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Copy env files + copied = copy_env_files_to_worktree(temp_git_repo, worktree_path) + + # Check all files were copied + assert ".env" in copied + assert ".env.local" in copied + assert ".env.development" in copied + assert len(copied) == 3 + + # Verify files exist in worktree + assert (worktree_path / ".env").exists() + assert (worktree_path / ".env.local").exists() + assert (worktree_path / ".env.development").exists() + + def test_skips_nonexistent_env_files(self, temp_git_repo: Path): + """Only copies env files that exist.""" + from core.workspace.setup import copy_env_files_to_worktree + + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + copied = copy_env_files_to_worktree(temp_git_repo, worktree_path) + + assert len(copied) == 0 + + def test_does_not_overwrite_existing_env_files(self, temp_git_repo: Path): + """Does not overwrite .env files that already exist in worktree.""" + from core.workspace.setup import copy_env_files_to_worktree + + # Create .env in project + (temp_git_repo / ".env").write_text("PROJECT=1", encoding="utf-8") + + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Create existing .env in worktree with different content + (worktree_path / ".env").write_text("WORKTREE=1", encoding="utf-8") + + copied = copy_env_files_to_worktree(temp_git_repo, worktree_path) + + # .env should not be in copied list since it already existed + assert ".env" not in copied + + # Worktree .env should keep its original content + assert (worktree_path / ".env").read_text(encoding="utf-8") == "WORKTREE=1" + + +class TestSymlinkNodeModulesToWorktree: + """Tests for symlink_node_modules_to_worktree function.""" + + @pytest.mark.skipif(sys.platform != "linux", reason="Unix-specific test") + def test_symlinks_node_modules_on_unix(self, temp_git_repo: Path): + """Creates relative symlinks on Unix systems.""" + from core.workspace.setup import symlink_node_modules_to_worktree + + # Create node_modules in project + node_modules = temp_git_repo / "node_modules" + node_modules.mkdir() + (node_modules / "test.txt").write_text("test", encoding="utf-8") + + # Create apps/frontend/node_modules + frontend_node_modules = temp_git_repo / "apps" / "frontend" / "node_modules" + frontend_node_modules.mkdir(parents=True) + (frontend_node_modules / "test2.txt").write_text("test2", encoding="utf-8") + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + (worktree_path / "apps" / "frontend").mkdir(parents=True) + + # Create symlinks + symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path) + + assert len(symlinked) == 2 + assert "node_modules" in symlinked + assert "apps/frontend/node_modules" in symlinked + + # Verify symlinks exist and point to correct location + assert (worktree_path / "node_modules").is_symlink() + assert (worktree_path / "apps" / "frontend" / "node_modules").is_symlink() + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") + def test_creates_junctions_on_windows(self, temp_git_repo: Path, monkeypatch): + """Creates junctions on Windows systems.""" + from unittest.mock import patch + + from core.workspace.setup import symlink_node_modules_to_worktree + + # Create node_modules in project + node_modules = temp_git_repo / "node_modules" + node_modules.mkdir() + (node_modules / "test.txt").write_text("test", encoding="utf-8") + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Mock subprocess.run to simulate mklink /J success + def mock_subprocess_run(cmd, capture_output=False, text=False): + result = type("obj", (object,), {"returncode": 0, "stderr": ""})() + return result + + with patch("subprocess.run", side_effect=mock_subprocess_run): + with monkeypatch.context() as m: + m.setattr("sys.platform", "win32") + symlinked = symlink_node_modules_to_worktree( + temp_git_repo, worktree_path + ) + + assert "node_modules" in symlinked + + def test_skips_nonexistent_node_modules(self, temp_git_repo: Path): + """Skips node_modules that don't exist in project.""" + from core.workspace.setup import symlink_node_modules_to_worktree + + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path) + + assert len(symlinked) == 0 + + def test_skips_existing_symlinks(self, temp_git_repo: Path): + """Does not recreate symlinks that already exist.""" + from core.workspace.setup import symlink_node_modules_to_worktree + + # Create node_modules in project + node_modules = temp_git_repo / "node_modules" + node_modules.mkdir() + (node_modules / "test.txt").write_text("test", encoding="utf-8") + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Create existing symlink + if sys.platform != "win32": + os.symlink(temp_git_repo / "node_modules", worktree_path / "node_modules") + + symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path) + + # Should skip existing symlink + assert "node_modules" not in symlinked diff --git a/apps/backend/core/workspace/tests/test_finalization.py b/apps/backend/core/workspace/tests/test_finalization.py new file mode 100644 index 00000000..5e385f87 --- /dev/null +++ b/apps/backend/core/workspace/tests/test_finalization.py @@ -0,0 +1,805 @@ +#!/usr/bin/env python3 +""" +Tests for Workspace Selection and Management +============================================= + +Tests the workspace.py module functionality including: +- Workspace mode selection (isolated vs direct) +- Uncommitted changes detection +- Workspace setup +- Build finalization workflows +""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +# Add parent directory to path so we can import the workspace module +# When co-located at workspace/tests/, we need to add backend to path +# workspace/tests -> workspace -> core -> backend (4 levels up) +_backend = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(_backend)) + +from core.workspace import ( + WorkspaceChoice, + WorkspaceMode, + get_current_branch, + get_existing_build_worktree, + has_uncommitted_changes, + setup_workspace, +) +from worktree import WorktreeError, WorktreeManager + +# Test constant - in the new per-spec architecture, each spec has its own worktree +# named after the spec itself. This constant is used for test assertions. +TEST_SPEC_NAME = "test-spec" + +# ============================================================================= +# TESTS FOR finalization.py +# ============================================================================= + + +class TestFinalizeWorkspace: + """Tests for finalize_workspace function.""" + + def test_direct_mode_returns_merge(self, temp_git_repo: Path, monkeypatch, capsys): + """Direct mode returns MERGE choice and shows completion message.""" + from core.workspace.finalization import finalize_workspace + + # Mock the UI functions + def mock_box(content, width=60, style="heavy"): + return content + + monkeypatch.setattr("core.workspace.finalization.box", mock_box) + + result = finalize_workspace( + temp_git_repo, + "test-spec", + manager=None, + auto_continue=False, + ) + + assert result == WorkspaceChoice.MERGE + + captured = capsys.readouterr() + assert "BUILD COMPLETE" in captured.out + assert "directly to your project" in captured.out + + def test_auto_continue_mode_returns_later(self, temp_git_repo: Path): + """Auto-continue mode returns LATER choice.""" + from core.workspace.finalization import finalize_workspace + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree info + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + result = finalize_workspace( + temp_git_repo, + spec_name, + manager=manager, + auto_continue=True, + ) + + assert result == WorkspaceChoice.LATER + + def test_isolated_mode_shows_menu(self, temp_git_repo: Path, monkeypatch): + """Isolated mode shows menu with test/review/merge/later options.""" + from core.workspace.finalization import finalize_workspace + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + # Mock select_menu to return "test" + def mock_select_menu(title, options, allow_quit): + return "test" + + monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu) + + result = finalize_workspace( + temp_git_repo, + spec_name, + manager=manager, + auto_continue=False, + ) + + assert result == WorkspaceChoice.TEST + + +class TestHandleWorkspaceChoice: + """Tests for handle_workspace_choice function.""" + + def test_choice_test_shows_instructions( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """TEST choice shows testing instructions.""" + from core.workspace.finalization import handle_workspace_choice + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager) + + captured = capsys.readouterr() + assert "TEST YOUR FEATURE" in captured.out + assert str(worktree_path) in captured.out + + def test_choice_merge_calls_merge_worktree( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """MERGE choice calls manager.merge_worktree.""" + from core.workspace.finalization import handle_workspace_choice + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree and commit something + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + (worktree_path / "test.py").write_text("test", encoding="utf-8") + + # Initialize git in worktree and commit + subprocess.run(["git", "init"], cwd=worktree_path, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=worktree_path, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=worktree_path, + capture_output=True, + ) + subprocess.run(["git", "add", "."], cwd=worktree_path, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Test"], cwd=worktree_path, capture_output=True + ) + + handle_workspace_choice( + WorkspaceChoice.MERGE, temp_git_repo, spec_name, manager + ) + + captured = capsys.readouterr() + assert "Adding changes" in captured.out + + def test_choice_review_shows_changed_files( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """REVIEW choice shows changed files.""" + from core.workspace.finalization import handle_workspace_choice + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + # Mock show_changed_files + mock_shown = [] + + def mock_show_changed_files(manager, spec_name): + mock_shown.append(spec_name) + + monkeypatch.setattr( + "core.workspace.finalization.show_changed_files", mock_show_changed_files + ) + + handle_workspace_choice( + WorkspaceChoice.REVIEW, temp_git_repo, spec_name, manager + ) + + assert len(mock_shown) == 1 + assert mock_shown[0] == spec_name + + captured = capsys.readouterr() + assert "To see full details" in captured.out + + def test_choice_later_shows_deferred_message( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """LATER choice shows deferral message.""" + from core.workspace.finalization import handle_workspace_choice + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + handle_workspace_choice( + WorkspaceChoice.LATER, temp_git_repo, spec_name, manager + ) + + captured = capsys.readouterr() + assert "No problem!" in captured.out + assert "saved" in captured.out + + +class TestReviewExistingBuild: + """Tests for review_existing_build function.""" + + def test_no_existing_build_shows_warning(self, temp_git_repo: Path, capsys): + """Shows warning when no existing build found.""" + from core.workspace.finalization import review_existing_build + + result = review_existing_build(temp_git_repo, "nonexistent-spec") + + assert result is False + + captured = capsys.readouterr() + assert "No existing build found" in captured.out + + def test_shows_build_contents(self, temp_git_repo: Path, capsys): + """Shows build summary and changed files when build exists.""" + from core.workspace.finalization import review_existing_build + + spec_name = "test-spec" + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + result = review_existing_build(temp_git_repo, spec_name) + + assert result is True + + captured = capsys.readouterr() + assert "BUILD CONTENTS" in captured.out + + +class TestDiscardExistingBuild: + """Tests for discard_existing_build function.""" + + def test_no_existing_build_returns_false(self, temp_git_repo: Path, capsys): + """Returns False when no existing build found.""" + from core.workspace.finalization import discard_existing_build + + result = discard_existing_build(temp_git_repo, "nonexistent-spec") + + assert result is False + + captured = capsys.readouterr() + assert "No existing build found" in captured.out + + def test_confirmation_deletes_build(self, temp_git_repo: Path, monkeypatch, capsys): + """Deletes build when user types 'delete' to confirm.""" + from core.workspace.finalization import discard_existing_build + + spec_name = "test-spec" + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + # Mock input to return "delete" + monkeypatch.setattr("builtins.input", lambda: "delete") + + result = discard_existing_build(temp_git_repo, spec_name) + + assert result is True + captured = capsys.readouterr() + assert "Build deleted" in captured.out + + def test_cancelled_confirmation_returns_false( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Returns False when user doesn't confirm.""" + from core.workspace.finalization import discard_existing_build + + spec_name = "test-spec" + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + # Mock input to return "no" + monkeypatch.setattr("builtins.input", lambda: "no") + + result = discard_existing_build(temp_git_repo, spec_name) + + assert result is False + captured = capsys.readouterr() + assert "Cancelled" in captured.out + + +class TestCheckExistingBuild: + """Tests for check_existing_build function.""" + + def test_no_existing_build_returns_false(self, temp_git_repo: Path): + """Returns False when no existing build.""" + from core.workspace.finalization import check_existing_build + + result = check_existing_build(temp_git_repo, "nonexistent-spec") + + assert result is False + + def test_shows_menu_for_existing_build(self, temp_git_repo: Path, monkeypatch): + """Shows menu when existing build found.""" + from core.workspace.finalization import check_existing_build + + spec_name = "test-spec" + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + # Mock select_menu to return "continue" + def mock_select_menu(title, options, allow_quit): + return "continue" + + monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu) + + result = check_existing_build(temp_git_repo, spec_name) + + assert result is True + + def test_review_choice_reviews_and_continues( + self, temp_git_repo: Path, monkeypatch + ): + """Review choice reviews build then continues.""" + from core.workspace.finalization import check_existing_build + + spec_name = "test-spec" + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + review_called = [] + + def mock_review(project_dir, spec_name): + review_called.append(spec_name) + return True + + def mock_select_menu(title, options, allow_quit): + return "review" + + def mock_input(prompt): + return "" + + monkeypatch.setattr( + "core.workspace.finalization.review_existing_build", mock_review + ) + monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu) + monkeypatch.setattr("builtins.input", mock_input) + + result = check_existing_build(temp_git_repo, spec_name) + + assert result is True + assert spec_name in review_called + + +class TestListAllWorktrees: + """Tests for list_all_worktrees function.""" + + def test_returns_empty_list_when_no_worktrees(self, temp_git_repo: Path): + """Returns empty list when no worktrees exist.""" + from core.workspace.finalization import list_all_worktrees + + result = list_all_worktrees(temp_git_repo) + + assert result == [] + + def test_lists_existing_worktrees(self, temp_git_repo: Path): + """Returns list of existing worktrees.""" + from core.workspace.finalization import list_all_worktrees + + # Create worktrees + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + (worktrees_dir / "spec-001").mkdir() + (worktrees_dir / "spec-002").mkdir() + + result = list_all_worktrees(temp_git_repo) + + assert len(result) == 2 + spec_names = {wt.spec_name for wt in result} + assert "spec-001" in spec_names + assert "spec-002" in spec_names + + +class TestCleanupAllWorktrees: + """Tests for cleanup_all_worktrees function.""" + + def test_no_worktrees_returns_false(self, temp_git_repo: Path, capsys): + """Returns False when no worktrees found.""" + from core.workspace.finalization import cleanup_all_worktrees + + result = cleanup_all_worktrees(temp_git_repo, confirm=False) + + assert result is False + + captured = capsys.readouterr() + assert "No worktrees found" in captured.out + + def test_cleanup_without_confirmation(self, temp_git_repo: Path): + """Cleans up worktrees when confirm=False.""" + from core.workspace.finalization import cleanup_all_worktrees + + # Create worktrees + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + spec1_path = worktrees_dir / "spec-001" + spec1_path.mkdir() + spec2_path = worktrees_dir / "spec-002" + spec2_path.mkdir() + + result = cleanup_all_worktrees(temp_git_repo, confirm=False) + + assert result is True + assert not spec1_path.exists() + assert not spec2_path.exists() + + def test_cleanup_with_confirmation_yes(self, temp_git_repo: Path, monkeypatch): + """Cleans up worktrees when user confirms with 'yes'.""" + from core.workspace.finalization import cleanup_all_worktrees + + # Create worktrees + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + spec1_path = worktrees_dir / "spec-001" + spec1_path.mkdir() + + # Mock input to return "yes" + monkeypatch.setattr("builtins.input", lambda: "yes") + + result = cleanup_all_worktrees(temp_git_repo, confirm=True) + + assert result is True + assert not spec1_path.exists() + + def test_cleanup_with_confirmation_no(self, temp_git_repo: Path, monkeypatch): + """Cancels cleanup when user doesn't confirm.""" + from core.workspace.finalization import cleanup_all_worktrees + + # Create worktrees + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + spec1_path = worktrees_dir / "spec-001" + spec1_path.mkdir() + + # Mock input to return "no" + monkeypatch.setattr("builtins.input", lambda: "no") + + result = cleanup_all_worktrees(temp_git_repo, confirm=True) + + assert result is False + assert spec1_path.exists() # Should still exist + + def test_cleanup_with_confirmation_keyboard_interrupt( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Cancels cleanup when user presses Ctrl+C (KeyboardInterrupt).""" + from core.workspace.finalization import cleanup_all_worktrees + + # Create worktrees + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + spec1_path = worktrees_dir / "spec-001" + spec1_path.mkdir() + + # Mock input to raise KeyboardInterrupt + def mock_input(prompt=""): + raise KeyboardInterrupt() + + monkeypatch.setattr("builtins.input", mock_input) + + result = cleanup_all_worktrees(temp_git_repo, confirm=True) + + assert result is False + assert spec1_path.exists() # Should still exist + + captured = capsys.readouterr() + assert "Cancelled" in captured.out + + +class TestFinalizeWorkspaceBranchCoverage: + """Additional tests for finalize_workspace to cover missing branches.""" + + def test_isolated_mode_merge_choice(self, temp_git_repo: Path, monkeypatch): + """Isolated mode returns MERGE when user selects merge.""" + from core.workspace.finalization import finalize_workspace + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + # Mock select_menu to return "merge" + def mock_select_menu(title, options, allow_quit): + return "merge" + + monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu) + + result = finalize_workspace( + temp_git_repo, + spec_name, + manager=manager, + auto_continue=False, + ) + + assert result == WorkspaceChoice.MERGE + + def test_isolated_mode_review_choice(self, temp_git_repo: Path, monkeypatch): + """Isolated mode returns REVIEW when user selects review.""" + from core.workspace.finalization import finalize_workspace + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + # Mock select_menu to return "review" + def mock_select_menu(title, options, allow_quit): + return "review" + + monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu) + + result = finalize_workspace( + temp_git_repo, + spec_name, + manager=manager, + auto_continue=False, + ) + + assert result == WorkspaceChoice.REVIEW + + def test_isolated_mode_later_choice(self, temp_git_repo: Path, monkeypatch): + """Isolated mode returns LATER when user selects later.""" + from core.workspace.finalization import finalize_workspace + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + # Mock select_menu to return "later" + def mock_select_menu(title, options, allow_quit): + return "later" + + monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu) + + result = finalize_workspace( + temp_git_repo, + spec_name, + manager=manager, + auto_continue=False, + ) + + assert result == WorkspaceChoice.LATER + + +class TestHandleWorkspaceChoiceBranchCoverage: + """Additional tests for handle_workspace_choice to cover missing branches.""" + + def test_choice_test_without_staging_path(self, temp_git_repo: Path, capsys): + """TEST choice shows fallback instructions when staging_path is None.""" + from core.workspace.finalization import handle_workspace_choice + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree directory (but not through manager, so no staging_path) + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager) + + captured = capsys.readouterr() + assert "TEST YOUR FEATURE" in captured.out + # Should show the fallback path + assert ( + str(worktree_path) in captured.out + or f".auto-claude/worktrees/tasks/{spec_name}" in captured.out + ) + + def test_choice_merge_success(self, temp_git_repo: Path, capsys): + """MERGE choice shows success message when merge succeeds.""" + from core.workspace.finalization import handle_workspace_choice + from worktree import WorktreeManager + + # Setup a proper isolated workspace with git worktree + working_dir, manager, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Make changes and commit + (working_dir / "test.py").write_text("test content", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add test"], cwd=working_dir, capture_output=True + ) + + handle_workspace_choice( + WorkspaceChoice.MERGE, temp_git_repo, "test-spec", manager + ) + + captured = capsys.readouterr() + assert "Your feature has been added" in captured.out + + def test_choice_later_without_staging_path(self, temp_git_repo: Path, capsys): + """LATER choice shows fallback path when staging_path is None.""" + from core.workspace.finalization import handle_workspace_choice + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create worktree directory (but not through manager, so no staging_path) + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + handle_workspace_choice( + WorkspaceChoice.LATER, temp_git_repo, spec_name, manager + ) + + captured = capsys.readouterr() + assert "No problem!" in captured.out + # Should show the fallback path + assert ( + str(worktree_path) in captured.out + or f".auto-claude/worktrees/tasks/{spec_name}" in captured.out + ) + + +class TestDiscardExistingBuildBranchCoverage: + """Additional tests for discard_existing_build to cover missing branches.""" + + def test_keyboard_interrupt_cancels_discard( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """KeyboardInterrupt during confirmation returns False.""" + from core.workspace.finalization import discard_existing_build + + spec_name = "test-spec" + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + # Mock input to raise KeyboardInterrupt + def mock_input(prompt=""): + raise KeyboardInterrupt() + + monkeypatch.setattr("builtins.input", mock_input) + + result = discard_existing_build(temp_git_repo, spec_name) + + assert result is False + + captured = capsys.readouterr() + assert "Cancelled" in captured.out + + +class TestCheckExistingBuildBranchCoverage: + """Additional tests for check_existing_build to cover missing branches.""" + + def test_none_choice_exits(self, temp_git_repo: Path, monkeypatch): + """None choice (quit) calls sys.exit(0).""" + import sys + + from core.workspace.finalization import check_existing_build + + spec_name = "test-spec" + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + # Mock select_menu to return None (quit) + def mock_select_menu(title, options, allow_quit): + return None + + monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu) + + # Should raise SystemExit + with pytest.raises(SystemExit) as exc_info: + check_existing_build(temp_git_repo, spec_name) + + assert exc_info.value.code == 0 + + def test_merge_choice_merges_and_returns_false( + self, temp_git_repo: Path, monkeypatch + ): + """Merge choice calls merge_existing_build and returns False.""" + from unittest.mock import MagicMock + + from core.workspace.finalization import check_existing_build + + spec_name = "test-spec" + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + merge_called = [] + + def mock_merge_existing_build(project_dir, spec_name): + merge_called.append(spec_name) + + def mock_select_menu(title, options, allow_quit): + return "merge" + + monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu) + + # Mock the workspace module import + import workspace as ws + + original_merge = getattr(ws, "merge_existing_build", None) + ws.merge_existing_build = mock_merge_existing_build + + try: + result = check_existing_build(temp_git_repo, spec_name) + assert result is False + assert spec_name in merge_called + finally: + if original_merge: + ws.merge_existing_build = original_merge + + def test_fresh_choice_discards_and_returns_false( + self, temp_git_repo: Path, monkeypatch + ): + """Fresh choice discards build and returns False (start fresh).""" + from core.workspace.finalization import check_existing_build + + spec_name = "test-spec" + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_path = worktrees_dir / spec_name + worktree_path.mkdir(parents=True) + + def mock_select_menu(title, options, allow_quit): + return "fresh" + + monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu) + # Mock input to return "delete" for confirmation + monkeypatch.setattr("builtins.input", lambda: "delete") + + result = check_existing_build(temp_git_repo, spec_name) + assert result is False, "Fresh choice should return False" diff --git a/apps/backend/core/workspace/tests/test_git_utils.py b/apps/backend/core/workspace/tests/test_git_utils.py new file mode 100644 index 00000000..f902c2ea --- /dev/null +++ b/apps/backend/core/workspace/tests/test_git_utils.py @@ -0,0 +1,1665 @@ +#!/usr/bin/env python3 +""" +Tests for Workspace Selection and Management +============================================= + +Tests the workspace.py module functionality including: +- Workspace mode selection (isolated vs direct) +- Uncommitted changes detection +- Workspace setup +- Build finalization workflows +""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +# Add parent directory to path so we can import the workspace module +# When co-located at workspace/tests/, we need to add backend to path +# workspace/tests -> workspace -> core -> backend (4 levels up) +_backend = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(_backend)) + +from core.workspace import ( + WorkspaceChoice, + WorkspaceMode, + get_current_branch, + get_existing_build_worktree, + has_uncommitted_changes, + setup_workspace, +) +from worktree import WorktreeError, WorktreeManager + +# Test constant - in the new per-spec architecture, each spec has its own worktree +# named after the spec itself. This constant is used for test assertions. +TEST_SPEC_NAME = "test-spec" + +# ============================================================================= +# TESTS FOR git_utils.py +# ============================================================================= + + +class TestDetectFileRenames: + def test_detects_single_file_rename(self, temp_git_repo: Path): + """Detects a single file rename between two refs.""" + from core.workspace.git_utils import detect_file_renames + + # Create and commit a file + (temp_git_repo / "old_name.txt").write_text("content", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True + ) + + # Get the commit hash + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=temp_git_repo, + capture_output=True, + text=True, + ) + old_commit = result.stdout.strip() + + # Rename the file + (temp_git_repo / "old_name.txt").rename(temp_git_repo / "new_name.txt") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Rename file"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Detect renames + renames = detect_file_renames(temp_git_repo, old_commit, "HEAD") + + assert len(renames) == 1 + assert "old_name.txt" in renames + assert renames["old_name.txt"] == "new_name.txt" + + def test_detects_multiple_file_renames(self, temp_git_repo: Path): + """Detects multiple file renames between two refs.""" + from core.workspace.git_utils import detect_file_renames + + # Create and commit files + (temp_git_repo / "file1.txt").write_text("content1", encoding="utf-8") + (temp_git_repo / "file2.txt").write_text("content2", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add files"], cwd=temp_git_repo, capture_output=True + ) + + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=temp_git_repo, + capture_output=True, + text=True, + ) + old_commit = result.stdout.strip() + + # Rename both files + (temp_git_repo / "file1.txt").rename(temp_git_repo / "renamed1.txt") + (temp_git_repo / "file2.txt").rename(temp_git_repo / "renamed2.txt") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Rename files"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Detect renames + renames = detect_file_renames(temp_git_repo, old_commit, "HEAD") + + assert len(renames) == 2 + assert "file1.txt" in renames + assert renames["file1.txt"] == "renamed1.txt" + assert "file2.txt" in renames + assert renames["file2.txt"] == "renamed2.txt" + + def test_returns_empty_dict_when_no_renames(self, temp_git_repo: Path): + """Returns empty dict when no renames occurred.""" + from core.workspace.git_utils import detect_file_renames + + # Create and commit a file + (temp_git_repo / "test.txt").write_text("content", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True + ) + + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=temp_git_repo, + capture_output=True, + text=True, + ) + old_commit = result.stdout.strip() + + # Modify file (not rename) + (temp_git_repo / "test.txt").write_text("modified content", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Modify file"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Detect renames + renames = detect_file_renames(temp_git_repo, old_commit, "HEAD") + + assert len(renames) == 0 + + def test_returns_empty_dict_on_invalid_refs(self, temp_git_repo: Path): + """Returns empty dict when given invalid refs.""" + from core.workspace.git_utils import detect_file_renames + + renames = detect_file_renames(temp_git_repo, "invalid_ref", "HEAD") + + assert renames == {} + + def test_detects_renames_with_similarity(self, temp_git_repo: Path): + """Detects renames even when file content was slightly modified.""" + from core.workspace.git_utils import detect_file_renames + + # Create and commit a file + (temp_git_repo / "old.txt").write_text("line1\nline2\nline3", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True + ) + + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=temp_git_repo, + capture_output=True, + text=True, + ) + old_commit = result.stdout.strip() + + # Rename and slightly modify + (temp_git_repo / "old.txt").rename(temp_git_repo / "new.txt") + (temp_git_repo / "new.txt").write_text( + "line1\nline2 modified\nline3", encoding="utf-8" + ) + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Rename and modify"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Detect renames + renames = detect_file_renames(temp_git_repo, old_commit, "HEAD") + + # Git may or may not detect rename with similarity threshold + # Just verify the function runs without error + assert isinstance(renames, dict) + + def test_detects_directory_moves(self, temp_git_repo: Path): + """Detects files moved to different directories.""" + from core.workspace.git_utils import detect_file_renames + + # Create directory structure and commit + (temp_git_repo / "src").mkdir() + (temp_git_repo / "src" / "old.py").write_text( + "def foo(): pass", encoding="utf-8" + ) + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True + ) + + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=temp_git_repo, + capture_output=True, + text=True, + ) + old_commit = result.stdout.strip() + + # Create new directory and move file + (temp_git_repo / "lib").mkdir() + (temp_git_repo / "src" / "old.py").rename(temp_git_repo / "lib" / "new.py") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Move file"], cwd=temp_git_repo, capture_output=True + ) + + # Detect renames + renames = detect_file_renames(temp_git_repo, old_commit, "HEAD") + + assert len(renames) == 1 + assert "src/old.py" in renames + assert renames["src/old.py"] == "lib/new.py" + + +class TestApplyPathMapping: + """Tests for apply_path_mapping function.""" + + def test_returns_original_path_when_no_mapping(self): + """Returns original path when no mapping exists.""" + + mappings = {} + result = apply_path_mapping("src/file.py", mappings) + + assert result == "src/file.py" + + def test_returns_mapped_path_when_exact_match(self): + """Returns mapped path when exact match found.""" + + mappings = {"old/path.py": "new/path.py"} + result = apply_path_mapping("old/path.py", mappings) + + assert result == "new/path.py" + + def test_returns_original_path_when_not_in_mappings(self): + """Returns original path when path not in mappings.""" + + mappings = {"other/file.py": "mapped/file.py"} + result = apply_path_mapping("src/file.py", mappings) + + assert result == "src/file.py" + + def test_handles_multiple_mappings(self): + """Correctly applies one of many mappings.""" + + mappings = { + "src/old1.py": "src/new1.py", + "src/old2.py": "src/new2.py", + "src/old3.py": "src/new3.py", + } + + assert apply_path_mapping("src/old1.py", mappings) == "src/new1.py" + assert apply_path_mapping("src/old2.py", mappings) == "src/new2.py" + assert apply_path_mapping("src/old3.py", mappings) == "src/new3.py" + + def test_handles_empty_path(self): + """Handles empty string path.""" + + mappings = {"file.py": "mapped.py"} + result = apply_path_mapping("", mappings) + + assert result == "" + + def test_handles_path_with_special_characters(self): + """Handles paths with special characters.""" + + mappings = {"src/file-with-dashes.py": "src/file_with_underscores.py"} + result = apply_path_mapping("src/file-with-dashes.py", mappings) + + assert result == "src/file_with_underscores.py" + + +class TestGetMergeBase: + """Tests for get_merge_base function.""" + + def test_finds_merge_base_for_diverged_branches(self, temp_git_repo: Path): + """Finds merge-base commit for two diverged branches.""" + from core.workspace.git_utils import get_merge_base + + # Create a file on main + (temp_git_repo / "base.txt").write_text("base content", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Base commit"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Create a feature branch + subprocess.run( + ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True + ) + (temp_git_repo / "feature.txt").write_text("feature content", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Feature commit"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Add a commit to main + subprocess.run( + ["git", "checkout", "main"], cwd=temp_git_repo, capture_output=True + ) + (temp_git_repo / "main.txt").write_text("main content", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Main commit"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Find merge base + merge_base = get_merge_base(temp_git_repo, "main", "feature") + + assert merge_base is not None + assert len(merge_base) == 40 # SHA-1 hash length + + def test_returns_none_for_invalid_ref(self, temp_git_repo: Path): + """Returns None when given invalid ref.""" + from core.workspace.git_utils import get_merge_base + + merge_base = get_merge_base(temp_git_repo, "main", "invalid_branch") + + assert merge_base is None + + def test_finds_merge_base_same_branch(self, temp_git_repo: Path): + """Returns current commit when refs are the same.""" + from core.workspace.git_utils import get_merge_base + + merge_base = get_merge_base(temp_git_repo, "HEAD", "HEAD") + + assert merge_base is not None + assert len(merge_base) == 40 + + def test_finds_merge_base_for_ancestors(self, temp_git_repo: Path): + """Finds merge-base when one ref is ancestor of other.""" + from core.workspace.git_utils import get_merge_base + + # Create initial commit + (temp_git_repo / "base.txt").write_text("base", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True + ) + + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=temp_git_repo, + capture_output=True, + text=True, + ) + base_commit = result.stdout.strip() + + # Add commit on top + (temp_git_repo / "new.txt").write_text("new", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "New"], cwd=temp_git_repo, capture_output=True + ) + + # Merge base of HEAD and its ancestor should be the ancestor + merge_base = get_merge_base(temp_git_repo, "HEAD", base_commit) + + assert merge_base == base_commit + + +class TestGetFileContentFromRef: + """Tests for get_file_content_from_ref function.""" + + def test_gets_file_content_from_commit(self, temp_git_repo: Path): + """Gets file content from a specific commit.""" + from core.workspace.git_utils import get_file_content_from_ref + + # Create and commit a file + (temp_git_repo / "test.txt").write_text("file content", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True + ) + + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=temp_git_repo, + capture_output=True, + text=True, + ) + commit_hash = result.stdout.strip() + + # Get file content + content = get_file_content_from_ref(temp_git_repo, commit_hash, "test.txt") + + assert content == "file content" + + def test_returns_none_for_nonexistent_file(self, temp_git_repo: Path): + """Returns None when file doesn't exist at ref.""" + from core.workspace.git_utils import get_file_content_from_ref + + content = get_file_content_from_ref(temp_git_repo, "HEAD", "nonexistent.txt") + + assert content is None + + def test_returns_none_for_invalid_ref(self, temp_git_repo: Path): + """Returns None when ref doesn't exist.""" + from core.workspace.git_utils import get_file_content_from_ref + + content = get_file_content_from_ref(temp_git_repo, "invalid_ref", "test.txt") + + assert content is None + + def test_gets_file_content_from_branch(self, temp_git_repo: Path): + """Gets file content from a branch name.""" + from core.workspace.git_utils import get_file_content_from_ref + + # Create and commit a file on main + (temp_git_repo / "branch_file.txt").write_text( + "branch content", encoding="utf-8" + ) + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True + ) + + # Get file content from branch + content = get_file_content_from_ref(temp_git_repo, "main", "branch_file.txt") + + assert content == "branch content" + + def test_handles_multiline_file_content(self, temp_git_repo: Path): + """Handles multiline file content correctly.""" + from core.workspace.git_utils import get_file_content_from_ref + + # Create and commit a multiline file + content = "line1\nline2\nline3" + (temp_git_repo / "multiline.txt").write_text(content, encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True + ) + + # Get file content + result = get_file_content_from_ref(temp_git_repo, "HEAD", "multiline.txt") + + assert result == content + + def test_handles_empty_file(self, temp_git_repo: Path): + """Handles empty file correctly.""" + from core.workspace.git_utils import get_file_content_from_ref + + # Create and commit an empty file + (temp_git_repo / "empty.txt").write_text("", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add empty file"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Get file content + content = get_file_content_from_ref(temp_git_repo, "HEAD", "empty.txt") + + assert content == "" + + +class TestGetBinaryFileContentFromRef: + """Tests for get_binary_file_content_from_ref function.""" + + def test_gets_binary_file_content(self, temp_git_repo: Path): + """Gets binary file content from a ref.""" + from core.workspace.git_utils import get_binary_file_content_from_ref + + # Create and commit a binary file + binary_content = b"\x00\x01\x02\x03\x04\x05" + (temp_git_repo / "binary.bin").write_bytes(binary_content) + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add binary file"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Get binary content + content = get_binary_file_content_from_ref(temp_git_repo, "HEAD", "binary.bin") + + assert content == binary_content + + def test_returns_none_for_nonexistent_file(self, temp_git_repo: Path): + """Returns None when file doesn't exist.""" + from core.workspace.git_utils import get_binary_file_content_from_ref + + content = get_binary_file_content_from_ref( + temp_git_repo, "HEAD", "nonexistent.bin" + ) + + assert content is None + + def test_returns_none_for_invalid_ref(self, temp_git_repo: Path): + """Returns None when ref doesn't exist.""" + from core.workspace.git_utils import get_binary_file_content_from_ref + + content = get_binary_file_content_from_ref( + temp_git_repo, "invalid_ref", "test.bin" + ) + + assert content is None + + def test_handles_large_binary_file(self, temp_git_repo: Path): + """Handles larger binary files correctly.""" + from core.workspace.git_utils import get_binary_file_content_from_ref + + # Create and commit a larger binary file + binary_content = bytes(range(256)) * 100 # 25.6 KB + (temp_git_repo / "large.bin").write_bytes(binary_content) + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add large binary file"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Get binary content + content = get_binary_file_content_from_ref(temp_git_repo, "HEAD", "large.bin") + + assert content == binary_content + + def test_handles_zero_byte_file(self, temp_git_repo: Path): + """Handles zero-byte binary files.""" + from core.workspace.git_utils import get_binary_file_content_from_ref + + # Create and commit an empty file + (temp_git_repo / "empty.bin").write_bytes(b"") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add empty binary file"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Get binary content + content = get_binary_file_content_from_ref(temp_git_repo, "HEAD", "empty.bin") + + assert content == b"" + + +class TestGetChangedFilesFromBranch: + """Tests for get_changed_files_from_branch function.""" + + def test_lists_changed_files(self, temp_git_repo: Path): + """Lists all changed files between branches.""" + from core.workspace.git_utils import get_changed_files_from_branch + + # Create a file on main + (temp_git_repo / "base.txt").write_text("base", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True + ) + + # Create feature branch with changes + subprocess.run( + ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True + ) + (temp_git_repo / "new_file.txt").write_text("new", encoding="utf-8") + (temp_git_repo / "modified.txt").write_text("modified", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Feature changes"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Get changed files + files = get_changed_files_from_branch(temp_git_repo, "main", "feature") + + assert len(files) == 2 + file_paths = [f[0] for f in files] + assert "new_file.txt" in file_paths + assert "modified.txt" in file_paths + + def test_excludes_auto_claude_files_by_default(self, temp_git_repo: Path): + """Excludes .auto-claude directory files by default.""" + from core.workspace.git_utils import get_changed_files_from_branch + + # Create base + (temp_git_repo / "base.txt").write_text("base", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True + ) + + # Create feature branch with .auto-claude files + subprocess.run( + ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True + ) + (temp_git_repo / ".auto-claude").mkdir() + (temp_git_repo / ".auto-claude" / "spec.json").write_text( + "spec", encoding="utf-8" + ) + (temp_git_repo / "normal.txt").write_text("normal", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Feature"], cwd=temp_git_repo, capture_output=True + ) + + # Get changed files + files = get_changed_files_from_branch(temp_git_repo, "main", "feature") + + file_paths = [f[0] for f in files] + assert ".auto-claude/spec.json" not in file_paths + assert "normal.txt" in file_paths + + def test_includes_auto_claude_files_when_disabled(self, temp_git_repo: Path): + """Includes .auto-claude files when exclude_auto_claude=False.""" + from core.workspace.git_utils import get_changed_files_from_branch + + # Create base + (temp_git_repo / "base.txt").write_text("base", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True + ) + + # Create feature branch + subprocess.run( + ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True + ) + (temp_git_repo / ".auto-claude").mkdir() + (temp_git_repo / ".auto-claude" / "spec.json").write_text( + "spec", encoding="utf-8" + ) + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Feature"], cwd=temp_git_repo, capture_output=True + ) + + # Get changed files without exclusion + files = get_changed_files_from_branch( + temp_git_repo, "main", "feature", exclude_auto_claude=False + ) + + file_paths = [f[0] for f in files] + assert ".auto-claude/spec.json" in file_paths + + def test_includes_file_status(self, temp_git_repo: Path): + """Includes file status (A, M, D) in results.""" + from core.workspace.git_utils import get_changed_files_from_branch + + # Create base + (temp_git_repo / "file.txt").write_text("original", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True + ) + + # Create feature branch with additions + subprocess.run( + ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True + ) + (temp_git_repo / "added.txt").write_text("added", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add file"], cwd=temp_git_repo, capture_output=True + ) + + # Get changed files + files = get_changed_files_from_branch(temp_git_repo, "main", "feature") + + assert len(files) == 1 + # Status should be 'A' for added + assert files[0][1] in ( + "A", + "M", + ) # Git may report as A or M depending on version + + def test_returns_empty_list_when_no_changes(self, temp_git_repo: Path): + """Returns empty list when there are no changes.""" + from core.workspace.git_utils import get_changed_files_from_branch + + # Create commit on main + (temp_git_repo / "file.txt").write_text("content", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Initial"], cwd=temp_git_repo, capture_output=True + ) + + # Create branch at same commit + subprocess.run( + ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True + ) + + # Get changed files + files = get_changed_files_from_branch(temp_git_repo, "main", "feature") + + assert len(files) == 0 + + def test_excludes_legacy_auto_claude_spec_files(self, temp_git_repo: Path): + """Excludes auto-claude/specs directory files.""" + from core.workspace.git_utils import get_changed_files_from_branch + + # Create base + (temp_git_repo / "base.txt").write_text("base", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Base"], cwd=temp_git_repo, capture_output=True + ) + + # Create feature branch with legacy auto-claude/specs files + subprocess.run( + ["git", "checkout", "-b", "feature"], cwd=temp_git_repo, capture_output=True + ) + (temp_git_repo / "auto-claude").mkdir() + (temp_git_repo / "auto-claude" / "specs").mkdir() + (temp_git_repo / "auto-claude" / "specs" / "spec.md").write_text( + "spec", encoding="utf-8" + ) + (temp_git_repo / "normal.txt").write_text("normal", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Feature"], cwd=temp_git_repo, capture_output=True + ) + + # Get changed files + files = get_changed_files_from_branch(temp_git_repo, "main", "feature") + + file_paths = [f[0] for f in files] + assert "auto-claude/specs/spec.md" not in file_paths + assert "normal.txt" in file_paths + + +class TestIsProcessRunning: + """Tests for is_process_running function.""" + + def test_returns_false_for_nonexistent_pid(self): + """Returns False for a non-existent PID.""" + from core.workspace.git_utils import is_process_running + + # Use a very high PID that's unlikely to exist + result = is_process_running(999999) + + assert result is False + + def test_returns_true_for_current_process(self): + """Returns True for the current process PID.""" + import os + + from core.workspace.git_utils import is_process_running + + current_pid = os.getpid() + result = is_process_running(current_pid) + + assert result is True + + +class TestIsBinaryFile: + """Tests for is_binary_file function.""" + + def test_identifies_image_files(self): + """Identifies image files as binary.""" + from core.workspace.git_utils import is_binary_file + + assert is_binary_file("image.png") is True + assert is_binary_file("photo.jpg") is True + assert is_binary_file("picture.jpeg") is True + assert is_binary_file("graphic.gif") is True + assert is_binary_file("icon.ico") is True + assert is_binary_file("image.webp") is True + assert is_binary_file("image.bmp") is True + assert is_binary_file("image.svg") is True + assert is_binary_file("image.tiff") is True + + def test_identifies_document_files(self): + """Identifies document files as binary.""" + from core.workspace.git_utils import is_binary_file + + assert is_binary_file("doc.pdf") is True + assert is_binary_file("doc.doc") is True + assert is_binary_file("doc.docx") is True + assert is_binary_file("sheet.xls") is True + assert is_binary_file("sheet.xlsx") is True + + def test_identifies_archive_files(self): + """Identifies archive files as binary.""" + from core.workspace.git_utils import is_binary_file + + assert is_binary_file("archive.zip") is True + assert is_binary_file("archive.tar") is True + assert is_binary_file("archive.gz") is True + assert is_binary_file("archive.rar") is True + assert is_binary_file("archive.7z") is True + assert is_binary_file("archive.bz2") is True + + def test_identifies_executable_files(self): + """Identifies executable files as binary.""" + from core.workspace.git_utils import is_binary_file + + assert is_binary_file("program.exe") is True + assert is_binary_file("library.dll") is True + assert is_binary_file("library.so") is True + assert is_binary_file("library.dylib") is True + assert is_binary_file("binary.bin") is True + + def test_identifies_audio_files(self): + """Identifies audio files as binary.""" + from core.workspace.git_utils import is_binary_file + + assert is_binary_file("audio.mp3") is True + assert is_binary_file("audio.wav") is True + assert is_binary_file("audio.ogg") is True + assert is_binary_file("audio.flac") is True + + def test_identifies_video_files(self): + """Identifies video files as binary.""" + from core.workspace.git_utils import is_binary_file + + assert is_binary_file("video.mp4") is True + assert is_binary_file("video.avi") is True + assert is_binary_file("video.mov") is True + assert is_binary_file("video.mkv") is True + + def test_identifies_font_files(self): + """Identifies font files as binary.""" + from core.workspace.git_utils import is_binary_file + + assert is_binary_file("font.woff") is True + assert is_binary_file("font.woff2") is True + assert is_binary_file("font.ttf") is True + assert is_binary_file("font.otf") is True + + def test_returns_false_for_text_files(self): + """Returns False for text files.""" + from core.workspace.git_utils import is_binary_file + + assert is_binary_file("file.txt") is False + assert is_binary_file("file.py") is False + assert is_binary_file("file.js") is False + assert is_binary_file("file.ts") is False + assert is_binary_file("file.md") is False + assert is_binary_file("file.json") is False + assert is_binary_file("file.xml") is False + assert is_binary_file("file.yaml") is False + assert is_binary_file("file.yml") is False + + def test_case_insensitive_extension_check(self): + """Handles uppercase extensions correctly.""" + from core.workspace.git_utils import is_binary_file + + assert is_binary_file("image.PNG") is True + assert is_binary_file("image.JPG") is True + assert is_binary_file("document.PDF") is True + + def test_handles_paths_with_directories(self): + """Handles file paths with directory components.""" + from core.workspace.git_utils import is_binary_file + + assert is_binary_file("path/to/image.png") is True + assert is_binary_file("src/lib/file.py") is False + assert is_binary_file("assets/logo.jpg") is True + + +class TestIsLockFile: + """Tests for is_lock_file function.""" + + def test_identifies_npm_lock_file(self): + """Identifies package-lock.json as lock file.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("package-lock.json") is True + + def test_identifies_pnpm_lock_file(self): + """Identifies pnpm-lock.yaml as lock file.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("pnpm-lock.yaml") is True + + def test_identifies_yarn_lock_file(self): + """Identifies yarn.lock as lock file.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("yarn.lock") is True + + def test_identifies_bun_lock_files(self): + """Identifies bun.lockb and bun.lock as lock files.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("bun.lockb") is True + assert is_lock_file("bun.lock") is True + + def test_identifies_python_lock_files(self): + """Identifies Python lock files.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("Pipfile.lock") is True + assert is_lock_file("poetry.lock") is True + assert is_lock_file("uv.lock") is True + + def test_identifies_rust_lock_file(self): + """Identifies Cargo.lock as lock file.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("Cargo.lock") is True + + def test_identifies_ruby_lock_file(self): + """Identifies Gemfile.lock as lock file.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("Gemfile.lock") is True + + def test_identifies_php_lock_file(self): + """Identifies composer.lock as lock file.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("composer.lock") is True + + def test_identifies_go_lock_file(self): + """Identifies go.sum as lock file.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("go.sum") is True + + def test_returns_false_for_non_lock_files(self): + """Returns False for non-lock files.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("package.json") is False + assert is_lock_file("pyproject.toml") is False + assert is_lock_file("Cargo.toml") is False + assert is_lock_file("Gemfile") is False + assert is_lock_file("file.txt") is False + + def test_handles_paths_with_directories(self): + """Handles file paths with directory components.""" + from core.workspace.git_utils import is_lock_file + + assert is_lock_file("path/to/package-lock.json") is True + assert is_lock_file("src/pnpm-lock.yaml") is True + assert is_lock_file("deps/yarn.lock") is True + + +class TestValidateMergedSyntax: + """Tests for validate_merged_syntax function.""" + + def test_validates_python_syntax_successfully(self, temp_dir: Path): + """Validates correct Python syntax successfully.""" + from core.workspace.git_utils import validate_merged_syntax + + code = "def hello():\n return 'world'\n" + is_valid, error = validate_merged_syntax("test.py", code, temp_dir) + + assert is_valid is True + assert error == "" + + def test_detects_python_syntax_errors(self, temp_dir: Path): + """Detects Python syntax errors.""" + from core.workspace.git_utils import validate_merged_syntax + + code = "def hello(\n return 'world'\n" + is_valid, error = validate_merged_syntax("test.py", code, temp_dir) + + assert is_valid is False + assert "syntax error" in error.lower() + + def test_validates_json_syntax_successfully(self, temp_dir: Path): + """Validates correct JSON syntax successfully.""" + from core.workspace.git_utils import validate_merged_syntax + + code = '{"key": "value", "number": 123}' + is_valid, error = validate_merged_syntax("test.json", code, temp_dir) + + assert is_valid is True + assert error == "" + + def test_detects_json_syntax_errors(self, temp_dir: Path): + """Detects JSON syntax errors.""" + from core.workspace.git_utils import validate_merged_syntax + + code = '{"key": "value", "number"' + is_valid, error = validate_merged_syntax("test.json", code, temp_dir) + + assert is_valid is False + assert "json error" in error.lower() or "syntax" in error.lower() + + def test_skips_validation_for_unknown_extensions(self, temp_dir: Path): + """Skips validation for unknown file types.""" + from core.workspace.git_utils import validate_merged_syntax + + code = "some random content" + is_valid, error = validate_merged_syntax("file.unknown", code, temp_dir) + + assert is_valid is True + assert error == "" + + def test_validates_typescript_with_mocked_esbuild(self, temp_dir: Path): + """Validates TypeScript using esbuild (mocked).""" + from unittest.mock import MagicMock, patch + + from core.workspace.git_utils import validate_merged_syntax + + code = "const x: number = 123;\n" + + # Mock subprocess.run for esbuild + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_result.stderr = "" + + with patch("subprocess.run", return_value=mock_result): + is_valid, error = validate_merged_syntax("test.ts", code, temp_dir) + + # If esbuild is found, should validate + # If not found, should skip validation (return True) + assert is_valid is True + + def test_detects_typescript_syntax_errors_with_mock(self, temp_dir: Path): + """Detects TypeScript syntax errors (mocked esbuild).""" + from unittest.mock import MagicMock, patch + + from core.workspace.git_utils import validate_merged_syntax + + code = "const x: = 123;\n" # Invalid syntax + + # Mock subprocess.run for esbuild to return error + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "✘ [ERROR] Expected expression but found '}'" + + with patch("subprocess.run", return_value=mock_result): + is_valid, error = validate_merged_syntax("test.ts", code, temp_dir) + + assert is_valid is False + assert "syntax error" in error.lower() + + def test_skips_validation_when_esbuild_not_found(self, temp_dir: Path): + """Skips validation when esbuild is not available.""" + from unittest.mock import patch + + from core.workspace.git_utils import validate_merged_syntax + + code = "const x: number = 123;\n" + + # Mock subprocess.run to raise FileNotFoundError + with patch("subprocess.run", side_effect=FileNotFoundError): + is_valid, error = validate_merged_syntax("test.ts", code, temp_dir) + + assert is_valid is True + assert error == "" + + def test_validates_javascript_with_mocked_esbuild(self, temp_dir: Path): + """Validates JavaScript using esbuild (mocked).""" + from unittest.mock import MagicMock, patch + + from core.workspace.git_utils import validate_merged_syntax + + code = "const x = 123;\n" + + # Mock subprocess.run for esbuild + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_result.stderr = "" + + with patch("subprocess.run", return_value=mock_result): + is_valid, error = validate_merged_syntax("test.js", code, temp_dir) + + assert is_valid is True + + def test_validates_jsx_with_mocked_esbuild(self, temp_dir: Path): + """Validates JSX using esbuild (mocked).""" + from unittest.mock import MagicMock, patch + + from core.workspace.git_utils import validate_merged_syntax + + code = "const App = () =>
Hello
;\n" + + # Mock subprocess.run for esbuild + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_result.stderr = "" + + with patch("subprocess.run", return_value=mock_result): + is_valid, error = validate_merged_syntax("test.jsx", code, temp_dir) + + assert is_valid is True + + def test_validates_tsx_with_mocked_esbuild(self, temp_dir: Path): + """Validates TSX using esbuild (mocked).""" + from unittest.mock import MagicMock, patch + + from core.workspace.git_utils import validate_merged_syntax + + code = "const App: React.FC = () =>
Hello
;\n" + + # Mock subprocess.run for esbuild + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_result.stderr = "" + + with patch("subprocess.run", return_value=mock_result): + is_valid, error = validate_merged_syntax("test.tsx", code, temp_dir) + + assert is_valid is True + + def test_handles_python_indentation_errors(self, temp_dir: Path): + """Detects Python indentation errors.""" + from core.workspace.git_utils import validate_merged_syntax + + code = "def hello():\n return 'world'\n return 'bad'\n" + is_valid, error = validate_merged_syntax("test.py", code, temp_dir) + + assert is_valid is False + assert "syntax error" in error.lower() or "indentation" in error.lower() + + def test_validates_empty_python_file(self, temp_dir: Path): + """Validates empty Python file.""" + from core.workspace.git_utils import validate_merged_syntax + + code = "" + is_valid, error = validate_merged_syntax("test.py", code, temp_dir) + + assert is_valid is True + + def test_validates_empty_json_file(self, temp_dir: Path): + """Validates empty JSON file.""" + from core.workspace.git_utils import validate_merged_syntax + + code = "{}" + is_valid, error = validate_merged_syntax("test.json", code, temp_dir) + + # Empty object is valid JSON + assert is_valid is True + + def test_validates_complex_json(self, temp_dir: Path): + """Validates complex nested JSON.""" + from core.workspace.git_utils import validate_merged_syntax + + code = '{"nested": {"key": "value", "array": [1, 2, 3]}}' + is_valid, error = validate_merged_syntax("test.json", code, temp_dir) + + assert is_valid is True + + def test_detects_json_with_trailing_comma(self, temp_dir: Path): + """Detects JSON error with trailing comma.""" + from core.workspace.git_utils import validate_merged_syntax + + code = '{"key": "value",}' + is_valid, error = validate_merged_syntax("test.json", code, temp_dir) + + assert is_valid is False + + def test_handles_esbuild_timeout_gracefully(self, temp_dir: Path): + """Handles esbuild timeout by skipping validation.""" + import subprocess + from unittest.mock import patch + + from core.workspace.git_utils import validate_merged_syntax + + code = "const x = 123;\n" + + # Mock subprocess.run to raise TimeoutExpired + with patch( + "subprocess.run", side_effect=subprocess.TimeoutExpired("esbuild", 15) + ): + is_valid, error = validate_merged_syntax("test.ts", code, temp_dir) + + assert is_valid is True + assert error == "" + + +class TestCreateConflictFileWithGit: + """Tests for create_conflict_file_with_git function.""" + + def test_creates_clean_merge(self, temp_git_repo: Path): + """Creates merged content when there are no conflicts.""" + from core.workspace.git_utils import create_conflict_file_with_git + + main_content = "line1\nline2\nline3" + worktree_content = "line1\nline2\nline3" + base_content = "line1\nline2\nline3" + + merged, had_conflicts = create_conflict_file_with_git( + main_content, worktree_content, base_content, temp_git_repo + ) + + assert had_conflicts is False + assert merged is not None + assert "line1" in merged + + def test_detects_conflicts(self, temp_git_repo: Path): + """Detects conflicts and adds conflict markers.""" + from core.workspace.git_utils import create_conflict_file_with_git + + main_content = "line1\nmain version\nline3" + worktree_content = "line1\nworktree version\nline3" + base_content = "line1\nline2\nline3" + + merged, had_conflicts = create_conflict_file_with_git( + main_content, worktree_content, base_content, temp_git_repo + ) + + assert had_conflicts is True + assert merged is not None + assert "<<<<<<<" in merged or "=======" in merged or ">>>>>>>" in merged + + def test_handles_none_base_content(self, temp_git_repo: Path): + """Handles None as base content.""" + from core.workspace.git_utils import create_conflict_file_with_git + + main_content = "line1\nline2" + worktree_content = "line1\nline2" + + merged, had_conflicts = create_conflict_file_with_git( + main_content, worktree_content, None, temp_git_repo + ) + + assert had_conflicts is False + assert merged is not None + + def test_returns_none_on_error(self, temp_dir: Path): + """Returns (None, False) when git merge-file fails.""" + from unittest.mock import patch + + from core.workspace.git_utils import create_conflict_file_with_git + + # Mock run_git to raise an exception + with patch( + "core.workspace.git_utils.run_git", side_effect=Exception("Git error") + ): + merged, had_conflicts = create_conflict_file_with_git( + "main", "worktree", "base", temp_dir + ) + + assert merged is None + assert had_conflicts is False + + def test_auto_merges_when_only_main_changed(self, temp_git_repo: Path): + """Auto-merges when only main content changed from base.""" + from core.workspace.git_utils import create_conflict_file_with_git + + base_content = "original line" + main_content = "modified line" + worktree_content = "original line" + + merged, had_conflicts = create_conflict_file_with_git( + main_content, worktree_content, base_content, temp_git_repo + ) + + assert had_conflicts is False + assert merged is not None + assert "modified line" in merged + + def test_auto_merges_when_only_worktree_changed(self, temp_git_repo: Path): + """Auto-merges when only worktree content changed from base.""" + from core.workspace.git_utils import create_conflict_file_with_git + + base_content = "original line" + main_content = "original line" + worktree_content = "modified line" + + merged, had_conflicts = create_conflict_file_with_git( + main_content, worktree_content, base_content, temp_git_repo + ) + + assert had_conflicts is False + assert merged is not None + assert "modified line" in merged + + def test_handles_multiline_conflicts(self, temp_git_repo: Path): + """Handles conflicts in multiline content.""" + from core.workspace.git_utils import create_conflict_file_with_git + + main_content = "line1\nline2 main\nline3" + worktree_content = "line1\nline2 worktree\nline3" + base_content = "line1\nline2\nline3" + + merged, had_conflicts = create_conflict_file_with_git( + main_content, worktree_content, base_content, temp_git_repo + ) + + assert had_conflicts is True + assert merged is not None + + def test_handles_empty_contents(self, temp_git_repo: Path): + """Handles empty string contents.""" + from core.workspace.git_utils import create_conflict_file_with_git + + merged, had_conflicts = create_conflict_file_with_git("", "", "", temp_git_repo) + + assert had_conflicts is False + assert merged is not None + + def test_cleanup_temp_files(self, temp_git_repo: Path): + """Cleans up temporary files after merge.""" + import tempfile + from pathlib import Path + + from core.workspace.git_utils import create_conflict_file_with_git + + # Count temp files before + temp_dir = tempfile.gettempdir() + # Run merge + create_conflict_file_with_git("content", "content", "content", temp_git_repo) + + # Note: This is a weak test as other processes may create temp files + # The main assertion is that no exception is raised + assert True # If we got here without exception, cleanup worked + + def test_preserves_newlines_in_merged_content(self, temp_git_repo: Path): + """Preserves newlines in merged content.""" + from core.workspace.git_utils import create_conflict_file_with_git + + content = "line1\nline2\nline3\n" + merged, had_conflicts = create_conflict_file_with_git( + content, content, content, temp_git_repo + ) + + assert had_conflicts is False + assert merged is not None + assert "\n" in merged + + def test_handles_unicode_content(self, temp_git_repo: Path): + """Handles unicode characters in content.""" + from core.workspace.git_utils import create_conflict_file_with_git + + content = "# Comment with émoji 🎉\nline1\n" + merged, had_conflicts = create_conflict_file_with_git( + content, content, content, temp_git_repo + ) + + assert had_conflicts is False + assert merged is not None + assert "émoji" in merged or "🎉" in merged + + def test_conflict_markers_format(self, temp_git_repo: Path): + """Verifies conflict marker format.""" + from core.workspace.git_utils import create_conflict_file_with_git + + main_content = "main version" + worktree_content = "worktree version" + base_content = "base version" + + merged, had_conflicts = create_conflict_file_with_git( + main_content, worktree_content, base_content, temp_git_repo + ) + + if had_conflicts: + # Check for standard git conflict markers + assert "<<<<<<<" in merged + assert "=======" in merged + assert ">>>>>>>" in merged + + +# ============================================================================= +# TESTS FOR MISSING COVERAGE IN git_utils.py AND models.py +# ============================================================================= + +from core.workspace.git_utils import ( + apply_path_mapping, + detect_file_renames, + validate_merged_syntax, +) + + +class TestDetectFileRenamesErrorHandling: + """Tests for error handling in detect_file_renames (lines 214-215).""" + + def test_detect_file_renames_handles_git_command_failure(self, temp_git_repo: Path): + """detect_file_renames returns empty dict when git command fails (line 214-215).""" + from unittest.mock import patch + + with patch("core.workspace.git_utils.run_git") as mock_git: + # Simulate git command failure + mock_git.return_value = type( + "Result", (), {"returncode": 1, "stdout": ""} + )() + + result = detect_file_renames(temp_git_repo, "main", "feature") + + assert result == {} + mock_git.assert_called_once() + + def test_detect_file_renames_handles_exception_during_parsing( + self, temp_git_repo: Path + ): + """detect_file_renames returns empty dict when exception occurs (line 214-215).""" + from unittest.mock import patch + + with patch("core.workspace.git_utils.run_git") as mock_git: + # Simulate an exception during git command execution + mock_git.side_effect = Exception("Git command failed") + + result = detect_file_renames(temp_git_repo, "main", "feature") + + # Should return empty dict on error + assert result == {} + + def test_detect_file_renames_handles_malformed_git_output( + self, temp_git_repo: Path + ): + """detect_file_renames handles malformed git output gracefully (line 214-215).""" + from unittest.mock import patch + + with patch("core.workspace.git_utils.run_git") as mock_git: + # Return success but with malformed output + mock_git.return_value = type( + "Result", (), {"returncode": 0, "stdout": "R\tincomplete\n"} + )() + + result = detect_file_renames(temp_git_repo, "main", "feature") + + # Should handle gracefully and not crash + assert isinstance(result, dict) + + def test_detect_file_renames_returns_empty_dict_on_invalid_refs( + self, temp_git_repo: Path + ): + """detect_file_renames returns empty dict for non-existent refs.""" + result = detect_file_renames( + temp_git_repo, "nonexistent-ref-1", "nonexistent-ref-2" + ) + + # Should return empty dict when refs don't exist + assert result == {} + + +class TestValidateMergedSyntaxErrorHandling: + """Tests for error handling in validate_merged_syntax (lines 450-469, 506-507).""" + + def test_validate_merged_syntax_generic_exception_handling( + self, temp_git_repo: Path + ): + """validate_merged_syntax handles generic exceptions gracefully (lines 506-507).""" + from unittest.mock import patch + + # Test with a TypeScript file that will trigger an exception + with patch("subprocess.run") as mock_run: + # Simulate a generic exception (not TimeoutExpired or FileNotFoundError) + mock_run.side_effect = RuntimeError("Unexpected error") + + is_valid, error = validate_merged_syntax( + "test.ts", "const x: string = 'test';", temp_git_repo + ) + + # Should return True (skip validation) on generic exception + assert is_valid is True + assert error == "" + + def test_validate_merged_syntax_handles_permission_error(self, temp_git_repo: Path): + """validate_merged_syntax handles permission errors during temp file creation.""" + from unittest.mock import patch + + with patch("tempfile.NamedTemporaryFile") as mock_tmp: + # Simulate permission error + mock_tmp.side_effect = PermissionError("Permission denied") + + is_valid, error = validate_merged_syntax( + "test.ts", "const x: string = 'test';", temp_git_repo + ) + + # Should return True on permission error (skip validation) + assert is_valid is True + assert error == "" + + def test_validate_merged_syntax_handles_os_error(self, temp_git_repo: Path): + """validate_merged_syntax handles OS errors gracefully.""" + from unittest.mock import patch + + with patch("tempfile.NamedTemporaryFile") as mock_tmp: + # Simulate OS error + mock_tmp.side_effect = OSError("OS error") + + is_valid, error = validate_merged_syntax( + "test.ts", "const x: string = 'test';", temp_git_repo + ) + + # Should return True on OS error + assert is_valid is True + assert error == "" + + @pytest.mark.slow + def test_validate_merged_syntax_finds_pnpm_esbuild(self, temp_git_repo: Path): + """validate_merged_syntax finds esbuild in pnpm structure (lines 450-455).""" + # Create pnpm-style node_modules structure + pnpm_dir = temp_git_repo / "node_modules" / ".pnpm" + esbuild_version_dir = ( + pnpm_dir / "esbuild@0.19.0" / "node_modules" / "esbuild" / "bin" + ) + esbuild_version_dir.mkdir(parents=True) + + # Create a fake esbuild executable + esbuild_binary = esbuild_version_dir / "esbuild" + if os.name != "nt": + esbuild_binary.write_text( + "#!/bin/sh\necho 'esbuild found'\n", encoding="utf-8" + ) + os.chmod(esbuild_binary, 0o700) + else: + esbuild_binary.write_text("echo esbuild found", encoding="utf-8") + + # This test verifies the pnpm path search logic + # Note: Actual esbuild execution may still be skipped if not properly installed + is_valid, error = validate_merged_syntax( + "test.ts", "const x: string = 'test';", temp_git_repo + ) + + # Should not crash; result depends on whether esbuild actually runs + assert isinstance(is_valid, bool) + assert isinstance(error, str) + + @pytest.mark.slow + def test_validate_merged_syntax_finds_npm_esbuild(self, temp_git_repo: Path): + """validate_merged_syntax finds esbuild in npm structure (lines 459-460).""" + # Create npm-style node_modules structure + npm_bin_dir = temp_git_repo / "node_modules" / ".bin" + npm_bin_dir.mkdir(parents=True) + + # Create a fake esbuild executable + esbuild_binary = npm_bin_dir / "esbuild" + if os.name != "nt": + esbuild_binary.write_text( + "#!/bin/sh\necho 'esbuild found'\n", encoding="utf-8" + ) + os.chmod(esbuild_binary, 0o700) + else: + esbuild_binary.write_text("echo esbuild found", encoding="utf-8") + + # This test verifies the npm path search logic + is_valid, error = validate_merged_syntax( + "test.ts", "const x: string = 'test';", temp_git_repo + ) + + # Should not crash + assert isinstance(is_valid, bool) + assert isinstance(error, str) + + @pytest.mark.slow + def test_validate_merged_syntax_searches_parent_directory( + self, temp_git_repo: Path + ): + """validate_merged_syntax searches parent directory for esbuild (line 462).""" + # Create esbuild in parent directory (apps/frontend sibling structure simulation) + # This simulates the monorepo structure where backend searches frontend's node_modules + parent_dir = temp_git_repo.parent + if parent_dir.exists(): + npm_bin_dir = parent_dir / "node_modules" / ".bin" + npm_bin_dir.mkdir(parents=True, exist_ok=True) + + esbuild_binary = npm_bin_dir / "esbuild" + if os.name != "nt": + esbuild_binary.write_text( + "#!/bin/sh\necho 'esbuild'\n", encoding="utf-8" + ) + os.chmod(esbuild_binary, 0o700) + else: + esbuild_binary.write_text("echo esbuild", encoding="utf-8") + + is_valid, error = validate_merged_syntax( + "test.ts", "const x: string = 'test';", temp_git_repo + ) + + assert isinstance(is_valid, bool) + + def test_validate_merged_syntax_falls_back_to_npx(self, temp_git_repo: Path): + """validate_merged_syntax falls back to npx when esbuild not found (line 469).""" + # Ensure no local esbuild exists + npm_bin = temp_git_repo / "node_modules" / ".bin" + if npm_bin.exists(): + import shutil + + shutil.rmtree(npm_bin) + + # Should fall back to npx and not crash + # Note: npx may or may not be available, but function should handle it + is_valid, error = validate_merged_syntax( + "test.ts", "const x: string = 'test';", temp_git_repo + ) + + # Should return True if npx not available (skip validation) + # or actual validation result if npx is available + assert isinstance(is_valid, bool) + assert isinstance(error, str) + + def test_validate_merged_syntax_npx_fallback_with_mock( + self, temp_git_repo: Path, monkeypatch + ): + """validate_merged_syntax uses npx fallback when esbuild binary not found (lines 466-467).""" + from unittest.mock import MagicMock, patch + + # Mock Path.exists() to ensure no esbuild binary is found anywhere + original_exists = Path.exists + + def mock_exists(self): + """Return False for any esbuild-related paths.""" + path_str = str(self) + # Return False for esbuild binary paths to force npx fallback + if "esbuild" in path_str and ( + "node_modules" in path_str or ".bin" in path_str + ): + return False + # Otherwise use original exists + return original_exists(self) + + # Use Path object directly, not string path + monkeypatch.setattr(Path, "exists", mock_exists) + + # Track the actual subprocess.run calls + run_calls = [] + + def mock_run(args, **kwargs): + """Mock that verifies npx fallback is used.""" + run_calls.append((args, kwargs)) + # Simulate successful npx esbuild execution + + completed = MagicMock() + completed.returncode = 0 + completed.stdout = b"" + completed.stderr = b"" + return completed + + monkeypatch.setattr("subprocess.run", mock_run) + + # Test file with valid TypeScript syntax + test_content = "const x: string = 'test';" + test_file = temp_git_repo / "test.ts" + test_file.write_text(test_content, encoding="utf-8") + + # Call validate_merged_syntax + from core.workspace.git_utils import validate_merged_syntax + + is_valid, error = validate_merged_syntax( + str(test_file), test_content, temp_git_repo + ) + + # Verify npx fallback was used + assert len(run_calls) > 0 + npx_used = any("npx" in str(call[0]) for call in run_calls) + assert npx_used, "npx fallback should be used when esbuild binary not found" + + # Should return True since syntax is valid + assert is_valid is True diff --git a/apps/backend/core/workspace/tests/test_merge.py b/apps/backend/core/workspace/tests/test_merge.py new file mode 100644 index 00000000..0e6dba93 --- /dev/null +++ b/apps/backend/core/workspace/tests/test_merge.py @@ -0,0 +1,1482 @@ +#!/usr/bin/env python3 +""" +Tests for Workspace Merge Operations +===================================== + +Tests the merge functionality including: +- Language inference from file paths +- Code fence stripping +- Simple 3-way merge attempts +- Merge prompt building +- Merge progress callbacks +- AI-assisted merge operations +""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +# Test constant - in the new per-spec architecture, each spec has its own worktree +# named after the spec itself. This constant is used for test assertions. +TEST_SPEC_NAME = "test-spec" + + +class TestInferLanguageFromPath: + def test_python_file(self): + """Correctly identifies Python files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("test.py") == "python" + assert _infer_language_from_path("src/app.py") == "python" + + def test_javascript_file(self): + """Correctly identifies JavaScript files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("test.js") == "javascript" + assert _infer_language_from_path("src/app.js") == "javascript" + + def test_jsx_file(self): + """Correctly identifies JSX files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("App.jsx") == "javascript" + + def test_typescript_file(self): + """Correctly identifies TypeScript files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("test.ts") == "typescript" + + def test_tsx_file(self): + """Correctly identifies TSX files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("App.tsx") == "typescript" + + def test_rust_file(self): + """Correctly identifies Rust files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("main.rs") == "rust" + + def test_go_file(self): + """Correctly identifies Go files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("main.go") == "go" + + def test_java_file(self): + """Correctly identifies Java files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("Main.java") == "java" + + def test_cpp_file(self): + """Correctly identifies C++ files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("main.cpp") == "cpp" + + def test_c_file(self): + """Correctly identifies C files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("main.c") == "c" + + def test_header_file(self): + """Correctly identifies C header files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("header.h") == "c" + + def test_hpp_file(self): + """Correctly identifies C++ header files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("header.hpp") == "cpp" + + def test_ruby_file(self): + """Correctly identifies Ruby files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("app.rb") == "ruby" + + def test_php_file(self): + """Correctly identifies PHP files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("index.php") == "php" + + def test_swift_file(self): + """Correctly identifies Swift files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("App.swift") == "swift" + + def test_kotlin_file(self): + """Correctly identifies Kotlin files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("Main.kt") == "kotlin" + + def test_scala_file(self): + """Correctly identifies Scala files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("Main.scala") == "scala" + + def test_json_file(self): + """Correctly identifies JSON files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("config.json") == "json" + + def test_yaml_file(self): + """Correctly identifies YAML files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("config.yaml") == "yaml" + + def test_yml_file(self): + """Correctly identifies YML files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("config.yml") == "yaml" + + def test_toml_file(self): + """Correctly identifies TOML files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("config.toml") == "toml" + + def test_markdown_file(self): + """Correctly identifies Markdown files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("README.md") == "markdown" + + def test_html_file(self): + """Correctly identifies HTML files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("index.html") == "html" + + def test_css_file(self): + """Correctly identifies CSS files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("style.css") == "css" + + def test_scss_file(self): + """Correctly identifies SCSS files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("style.scss") == "scss" + + def test_sql_file(self): + """Correctly identifies SQL files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("query.sql") == "sql" + + def test_unknown_extension(self): + """Defaults to 'text' for unknown extensions.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("file.unknown") == "text" + + def test_no_extension(self): + """Defaults to 'text' for files without extension.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("Makefile") == "text" + + def test_case_insensitive(self): + """Handles uppercase extensions correctly.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("test.PY") == "python" + assert _infer_language_from_path("test.JS") == "javascript" + + def test_nested_path(self): + """Correctly infers language from nested paths.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("src/components/Button.tsx") == "typescript" + + def test_dockerfile(self): + """Defaults to 'text' for Dockerfile without extension.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("Dockerfile") == "text" + + def test_makefile(self): + """Defaults to 'text' for Makefile without extension.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("Makefile") == "text" + + def test_gitignore(self): + """Defaults to 'text' for .gitignore without extension.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path(".gitignore") == "text" + + def test_env_file(self): + """Defaults to 'text' for .env files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path(".env") == "text" + + def test_config_yaml(self): + """Identifies YAML in config files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("app.config.yaml") == "yaml" + + def test_sh_file(self): + """Defaults to 'text' for shell scripts.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("script.sh") == "text" + + def test_txt_file(self): + """Defaults to 'text' for .txt files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("notes.txt") == "text" + + def test_xml_file(self): + """Defaults to 'text' for .xml files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("config.xml") == "text" + + def test_md_file_in_docs(self): + """Identifies markdown in documentation paths.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("docs/api.md") == "markdown" + + def test_package_json(self): + """Identifies JSON in package files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("package.json") == "json" + + def test_tsconfig_json(self): + """Identifies JSON in TypeScript config files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("tsconfig.json") == "json" + + def test_python_init_file(self): + """Identifies Python in __init__ files.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("package/__init__.py") == "python" + + def test_absolute_path(self): + """Handles absolute paths correctly.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("/usr/local/bin/script.py") == "python" + + def test_windows_path(self): + """Handles Windows paths correctly.""" + from core.workspace import _infer_language_from_path + + assert _infer_language_from_path("C:\\Users\\test\\file.js") == "javascript" + + +class TestStripCodeFences: + """Tests for _strip_code_fences function.""" + + def test_basic_code_fence(self): + """Removes basic markdown code fences.""" + from core.workspace import _strip_code_fences + + content = "```python\ndef hello():\n pass\n```" + result = _strip_code_fences(content) + assert result == "def hello():\n pass" + + def test_code_fence_with_language(self): + """Removes code fence with language specified.""" + from core.workspace import _strip_code_fences + + content = "```javascript\nconst x = 1;\n```" + result = _strip_code_fences(content) + assert result == "const x = 1;" + + def test_no_code_fence(self): + """Returns content unchanged when no code fence present.""" + from core.workspace import _strip_code_fences + + content = "just some text" + result = _strip_code_fences(content) + assert result == content + + def test_code_fence_without_closing_fence(self): + """Handles opening fence without closing fence.""" + from core.workspace import _strip_code_fences + + content = "```python\ndef hello():\n pass" + result = _strip_code_fences(content) + assert result == "def hello():\n pass" + + def test_multiple_lines_fence(self): + """Handles multi-line code with fences.""" + from core.workspace import _strip_code_fences + + content = "```\nline1\nline2\nline3\n```" + result = _strip_code_fences(content) + assert result == "line1\nline2\nline3" + + def test_whitespace_around_fences(self): + """Handles whitespace around code fences.""" + from core.workspace import _strip_code_fences + + content = " ```python\ndef hello():\n ``` " + result = _strip_code_fences(content) + assert "def hello():" in result + + def test_empty_fence(self): + """Handles empty code fence.""" + from core.workspace import _strip_code_fences + + content = "```\n```" + result = _strip_code_fences(content) + assert result == "" + + def test_fence_with_no_language(self): + """Handles fence without language specifier.""" + from core.workspace import _strip_code_fences + + content = "```\ncode here\n```" + result = _strip_code_fences(content) + assert result == "code here" + + def test_code_fence_with_spaces_in_fence_marker(self): + """Handles fence markers with extra spaces.""" + from core.workspace import _strip_code_fences + + content = "``` python\ndef hello():\n pass\n```" + result = _strip_code_fences(content) + assert "def hello():" in result + + def test_nested_fences_not_supported(self): + """Doesn't handle nested fences (edge case).""" + from core.workspace import _strip_code_fences + + content = "```\nouter ``` inner\ncode\n```" + result = _strip_code_fences(content) + # Should strip first fence + assert result.startswith("outer") + + def test_only_fence_at_start(self): + """Only strips fence if at start of content.""" + from core.workspace import _strip_code_fences + + content = "text\n```python\ncode\n```" + result = _strip_code_fences(content) + assert result == content + + def test_preserves_internal_markers(self): + """Preserves triple backticks that aren't fences.""" + from core.workspace import _strip_code_fences + + content = "```python\ncode with ``` in it\n```" + result = _strip_code_fences(content) + assert "code with ``` in it" in result + + def test_multiple_fences_only_first(self): + """Only removes first fence pair.""" + from core.workspace import _strip_code_fences + + content = "```\ncode1\n```\n```\ncode2\n```" + result = _strip_code_fences(content) + # First fence removed, second preserved + assert result.startswith("code1") + + def test_closing_fence_with_extra_text(self): + """Handles closing fence with text after.""" + from core.workspace import _strip_code_fences + + content = "```python\ncode\n``` extra" + result = _strip_code_fences(content) + assert result == "code\n``` extra" + + def test_four_backticks(self): + """Handles four backticks (edge case).""" + from core.workspace import _strip_code_fences + + content = "````python\ncode\n````" + result = _strip_code_fences(content) + # Should strip the fence + assert "code" in result + + def test_unicode_in_code(self): + """Preserves unicode characters in code.""" + from core.workspace import _strip_code_fences + + content = "```python\n# Comment with émoji 🎉\n```" + result = _strip_code_fences(content) + assert "émoji" in result + assert "🎉" in result + + def test_trailing_newlines_preserved(self): + """Preserves internal newlines in code content.""" + from core.workspace import _strip_code_fences + + content = "```python\ncode\n```" + result = _strip_code_fences(content) + assert result == "code" + + def test_single_line_code(self): + """Handles single line code with fences.""" + from core.workspace import _strip_code_fences + + content = "```python\nx = 1\n```" + result = _strip_code_fences(content) + assert result == "x = 1" + + def test_code_with_tabs(self): + """Preserves tabs in code content.""" + from core.workspace import _strip_code_fences + + content = "```python\n\tdef test():\n\t\tpass\n```" + result = _strip_code_fences(content) + assert "\t" in result + + def test_mixed_line_endings(self): + """Handles mixed line endings.""" + from core.workspace import _strip_code_fences + + content = "```python\r\nline1\r\nline2\r\n```" + result = _strip_code_fences(content) + assert "line1" in result + assert "line2" in result + + def test_fence_with_attributes(self): + """Handles fence with extra attributes.""" + from core.workspace import _strip_code_fences + + content = '```python title="test.py"\ncode\n```' + result = _strip_code_fences(content) + assert "code" in result + + def test_leading_spaces_in_content(self): + """Preserves leading spaces in code.""" + from core.workspace import _strip_code_fences + + content = "```python\n indented code\n```" + result = _strip_code_fences(content) + assert " indented code" in result + + def test_code_with_emoji(self): + """Preserves emoji in code content.""" + from core.workspace import _strip_code_fences + + content = "```python\n# 🎉 party time\n```" + result = _strip_code_fences(content) + assert "🎉" in result + + def test_very_long_code_line(self): + """Handles very long code lines.""" + from core.workspace import _strip_code_fences + + long_line = "x" * 1000 + content = f"```\n{long_line}\n```" + result = _strip_code_fences(content) + assert len(result) == 1000 + + +class TestTrySimple3wayMerge: + """Tests for _try_simple_3way_merge function.""" + + def test_both_sides_identical(self): + """Returns content when both sides are identical.""" + from core.workspace import _try_simple_3way_merge + + base = "original" + ours = "modified" + theirs = "modified" + + success, result = _try_simple_3way_merge(base, ours, theirs) + assert success is True + assert result == "modified" + + def test_only_ours_changed(self): + """Returns ours when only ours changed from base.""" + from core.workspace import _try_simple_3way_merge + + base = "original" + ours = "ours modified" + theirs = "original" + + success, result = _try_simple_3way_merge(base, ours, theirs) + assert success is True + assert result == "ours modified" + + def test_only_theirs_changed(self): + """Returns theirs when only theirs changed from base.""" + from core.workspace import _try_simple_3way_merge + + base = "original" + ours = "original" + theirs = "theirs modified" + + success, result = _try_simple_3way_merge(base, ours, theirs) + assert success is True + assert result == "theirs modified" + + def test_both_changed_differently(self): + """Returns False when both changed differently.""" + from core.workspace import _try_simple_3way_merge + + base = "original" + ours = "ours change" + theirs = "theirs change" + + success, result = _try_simple_3way_merge(base, ours, theirs) + assert success is False + assert result is None + + def test_none_base_identical_sides(self): + """Returns ours when base is None and both sides identical.""" + from core.workspace import _try_simple_3way_merge + + base = None + ours = "same" + theirs = "same" + + success, result = _try_simple_3way_merge(base, ours, theirs) + assert success is True + assert result == "same" + + def test_none_base_different_sides(self): + """Returns False when base is None and sides differ.""" + from core.workspace import _try_simple_3way_merge + + base = None + ours = "ours" + theirs = "theirs" + + success, result = _try_simple_3way_merge(base, ours, theirs) + assert success is False + assert result is None + + def test_empty_strings(self): + """Handles empty strings correctly.""" + from core.workspace import _try_simple_3way_merge + + base = "" + ours = "" + theirs = "" + + success, result = _try_simple_3way_merge(base, ours, theirs) + assert success is True + assert result == "" + + def test_multiline_content(self): + """Handles multiline content correctly.""" + from core.workspace import _try_simple_3way_merge + + base = "line1\nline2" + ours = "line1\nline2" + theirs = "line1\nline2\nline3" + + success, result = _try_simple_3way_merge(base, ours, theirs) + assert success is True + assert result == "line1\nline2\nline3" + + def test_whitespace_differences(self): + """Treats whitespace differences as changes.""" + from core.workspace import _try_simple_3way_merge + + base = "text" + ours = "text " + theirs = "text" + + success, result = _try_simple_3way_merge(base, ours, theirs) + # Different from base means ours is the change + assert success is True + assert result == "text " + + def test_all_same(self): + """Returns True when all three are the same.""" + from core.workspace import _try_simple_3way_merge + + content = "same content" + success, result = _try_simple_3way_merge(content, content, content) + assert success is True + assert result == content + + def test_newline_differences(self): + """Handles trailing newline differences.""" + from core.workspace import _try_simple_3way_merge + + base = "text" + ours = "text\n" + theirs = "text" + + success, result = _try_simple_3way_merge(base, ours, theirs) + # Different from base means ours is the change + assert success is True + assert result == "text\n" + + +class TestBuildMergePrompt: + """Tests for _build_merge_prompt function.""" + + def test_basic_prompt_structure(self): + """Creates prompt with all required sections.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "test.py", + "base content", + "main content", + "worktree content", + "spec-001", + ) + + assert "FILE: test.py" in prompt + assert "TASK: spec-001" in prompt + assert "OURS" in prompt + assert "THEIRS" in prompt + assert "main content" in prompt + assert "worktree content" in prompt + + def test_includes_language_from_file(self): + """Infers and includes language in code fence.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "test.py", + "base", + "main", + "worktree", + "spec", + ) + + assert "```python" in prompt + + def test_with_base_content(self): + """Includes BASE section when base content provided.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.js", + "base content", + "main", + "worktree", + "spec", + ) + + assert "BASE (common ancestor" in prompt + assert "base content" in prompt + + def test_without_base_content(self): + """Handles None base content gracefully.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.ts", + None, + "main", + "worktree", + "spec", + ) + + assert "BASE" not in prompt or "common ancestor" not in prompt + + def test_truncates_large_base_content(self): + """Truncates base content over 10000 characters.""" + from core.workspace import _build_merge_prompt + + large_base = "x" * 15000 + prompt = _build_merge_prompt( + "file.py", + large_base, + "main", + "worktree", + "spec", + ) + + assert "(truncated)" in prompt + assert len(prompt) < len(large_base) + 1000 + + def test_truncates_large_main_content(self): + """Truncates main content over 15000 characters.""" + from core.workspace import _build_merge_prompt + + large_main = "y" * 20000 + prompt = _build_merge_prompt( + "file.py", + "base", + large_main, + "worktree", + "spec", + ) + + assert "(truncated)" in prompt + + def test_truncates_large_worktree_content(self): + """Truncates worktree content over 15000 characters.""" + from core.workspace import _build_merge_prompt + + large_worktree = "z" * 20000 + prompt = _build_merge_prompt( + "file.py", + "base", + "main", + large_worktree, + "spec", + ) + + assert "(truncated)" in prompt + + def test_typescript_language(self): + """Uses typescript for .ts files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.ts", + None, + "main", + "worktree", + "spec", + ) + + assert "```typescript" in prompt + + def test_javascript_language(self): + """Uses javascript for .js files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.js", + None, + "main", + "worktree", + "spec", + ) + + assert "```javascript" in prompt + + def test_json_language(self): + """Uses json for .json files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "config.json", + None, + "main", + "worktree", + "spec", + ) + + assert "```json" in prompt + + def test_spec_name_included(self): + """Includes spec name in prompt.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.py", + None, + "main", + "worktree", + "my-spec-name", + ) + + assert "TASK: my-spec-name" in prompt + + def test_merge_instruction(self): + """Includes merge instruction.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.py", + None, + "main", + "worktree", + "spec", + ) + + assert "3-way code merge" in prompt or "combine changes" in prompt.lower() + + def test_output_instruction(self): + """Includes instruction to output only code.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.py", + None, + "main", + "worktree", + "spec", + ) + + assert "OUTPUT THE MERGED CODE ONLY" in prompt or "no explanations" in prompt + + def test_no_markdown_fences_instruction(self): + """Includes instruction about no markdown fences.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.py", + None, + "main", + "worktree", + "spec", + ) + + assert "no markdown fences" in prompt + + def test_ours_section_description(self): + """Describes OURS correctly.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.py", + None, + "main content", + "worktree", + "spec", + ) + + assert "OURS (current main branch" in prompt + + def test_theirs_section_description(self): + """Describes THEIRS correctly.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.py", + None, + "main", + "worktree content", + "spec", + ) + + assert "THEIRS (task worktree" in prompt + + def test_special_characters_in_content(self): + """Handles special characters in content.""" + from core.workspace import _build_merge_prompt + + content = "code with 'quotes' and \"double quotes\" and \n newlines" + prompt = _build_merge_prompt( + "file.py", + None, + content, + content, + "spec", + ) + + assert "quotes" in prompt + assert "\n" in prompt or "newlines" in prompt + + def test_empty_contents(self): + """Handles empty contents gracefully.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.py", + "", + "", + "", + "spec", + ) + + # Should still have structure + assert "FILE:" in prompt + assert "OURS" in prompt + assert "THEIRS" in prompt + + def test_markdown_language(self): + """Uses markdown for .md files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "README.md", + None, + "main", + "worktree", + "spec", + ) + + assert "```markdown" in prompt + + def test_yaml_language(self): + """Uses yaml for .yml files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "config.yml", + None, + "main", + "worktree", + "spec", + ) + + assert "```yaml" in prompt + + def test_cpp_language(self): + """Uses cpp for .cpp files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "main.cpp", + None, + "main", + "worktree", + "spec", + ) + + assert "```cpp" in prompt + + def test_rust_language(self): + """Uses rust for .rs files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "main.rs", + None, + "main", + "worktree", + "spec", + ) + + assert "```rust" in prompt + + def test_go_language(self): + """Uses go for .go files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "main.go", + None, + "main", + "worktree", + "spec", + ) + + assert "```go" in prompt + + def test_ruby_language(self): + """Uses ruby for .rb files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "app.rb", + None, + "main", + "worktree", + "spec", + ) + + assert "```ruby" in prompt + + def test_java_language(self): + """Uses java for .java files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "Main.java", + None, + "main", + "worktree", + "spec", + ) + + assert "```java" in prompt + + def test_sql_language(self): + """Uses sql for .sql files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "query.sql", + None, + "main", + "worktree", + "spec", + ) + + assert "```sql" in prompt + + def test_html_language(self): + """Uses html for .html files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "index.html", + None, + "main", + "worktree", + "spec", + ) + + assert "```html" in prompt + + def test_css_language(self): + """Uses css for .css files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "style.css", + None, + "main", + "worktree", + "spec", + ) + + assert "```css" in prompt + + def test_scss_language(self): + """Uses scss for .scss files.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "style.scss", + None, + "main", + "worktree", + "spec", + ) + + assert "```scss" in prompt + + def test_text_language_for_unknown(self): + """Uses text for unknown extensions.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.unknown", + None, + "main", + "worktree", + "spec", + ) + + assert "```text" in prompt + + def test_truncates_both_large_contents(self): + """Truncates both main and worktree when large.""" + from core.workspace import _build_merge_prompt + + large_main = "x" * 20000 + large_worktree = "y" * 20000 + prompt = _build_merge_prompt( + "file.py", + None, + large_main, + large_worktree, + "spec", + ) + + # Should have truncation markers + assert prompt.count("(truncated)") >= 2 + + def test_preserves_small_base_content(self): + """Does not truncate small base content.""" + from core.workspace import _build_merge_prompt + + base = "small base" + prompt = _build_merge_prompt( + "file.py", + base, + "main", + "worktree", + "spec", + ) + + assert "small base" in prompt + assert "(truncated)" not in prompt + + def test_spec_name_with_special_chars(self): + """Handles spec names with special characters.""" + from core.workspace import _build_merge_prompt + + prompt = _build_merge_prompt( + "file.py", + None, + "main", + "worktree", + "spec-001_feature", + ) + + assert "spec-001_feature" in prompt + + +class TestCreateMergeProgressCallback: + """Tests for _create_merge_progress_callback function.""" + + def test_returns_callable_when_piped(self, monkeypatch): + """Returns emit_progress when stdout is not a TTY.""" + from core.workspace import _create_merge_progress_callback + from merge.progress import emit_progress + + # Mock sys.stdout.isatty to return False + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + + callback = _create_merge_progress_callback() + assert callback is not None + assert callback == emit_progress + + def test_returns_none_when_tty(self, monkeypatch): + """Returns None when stdout is a TTY.""" + from core.workspace import _create_merge_progress_callback + + # Mock sys.stdout.isatty to return True + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + + callback = _create_merge_progress_callback() + assert callback is None + + def test_callback_emits_progress_json(self, monkeypatch, capsys): + """Emits proper progress JSON when callback is used.""" + from core.workspace import _create_merge_progress_callback + from merge.progress import MergeProgressStage + + # Mock sys.stdout.isatty to return False + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + + callback = _create_merge_progress_callback() + if callback: + callback( + MergeProgressStage.ANALYZING, + 50, + "Test message", + {"test_key": "test_value"}, + ) + + captured = capsys.readouterr() + assert '"type": "progress"' in captured.out + assert '"percent": 50' in captured.out + assert '"message": "Test message"' in captured.out + + def test_multiple_callbacks_different_stages(self, monkeypatch, capsys): + """Handles multiple callback calls with different stages.""" + from core.workspace import _create_merge_progress_callback + from merge.progress import MergeProgressStage + + # Mock sys.stdout.isatty to return False + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + + callback = _create_merge_progress_callback() + if callback: + callback(MergeProgressStage.ANALYZING, 0, "Starting") + callback(MergeProgressStage.COMPLETE, 100, "Done") + + captured = capsys.readouterr() + assert "Starting" in captured.out + assert "Done" in captured.out + assert '"percent": 0' in captured.out + assert '"percent": 100' in captured.out + + +# Helper classes for AI merge tests +class TextBlock: + """Mock TextBlock for testing AI merge responses.""" + + def __init__(self, text: str): + self.text = text + # Set __name__ for type checking + self.__class__.__name__ = "TextBlock" + + +class AssistantMessage: + """Mock AssistantMessage for testing AI merge responses.""" + + def __init__(self, content: list): + self.content = content + # Set __name__ for type checking + self.__class__.__name__ = "AssistantMessage" + + +class MockClientBase: + """Base mock client class that implements async context manager.""" + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def query(self, prompt): + return None + + +class TestAttemptAiMerge: + """Tests for _attempt_ai_merge function with extensive mocking.""" + + def test_successful_merge_returns_true_and_content(self, temp_git_repo: Path): + """Successful AI merge returns (True, merged_content, "").""" + import asyncio + from unittest.mock import patch + + from core.workspace import ParallelMergeTask, _attempt_ai_merge + + task = ParallelMergeTask( + file_path="test.py", + main_content="def foo():\n pass", + worktree_content="def bar():\n pass", + base_content=None, + spec_name="spec-001", + project_dir=temp_git_repo, + ) + + # Create a mock client class that properly implements async context manager + class MockClient(MockClientBase): + def __init__(self): + self.query_calls = [] + + async def query(self, prompt): + self.query_calls.append(prompt) + return None + + async def receive_response(self): + mock_msg = AssistantMessage([TextBlock("def merged():\n pass")]) + yield mock_msg + + mock_client = MockClient() + + with patch("core.simple_client.create_simple_client", return_value=mock_client): + with patch( + "core.workspace.git_utils.validate_merged_syntax", + return_value=(True, ""), + ): + result = asyncio.run( + _attempt_ai_merge( + task, + "test prompt", + model="claude-haiku-4-5-20251001", + max_thinking_tokens=1024, + ) + ) + + assert result[0] is True + assert result[1] == "def merged():\n pass" + assert result[2] == "" + + def test_ai_returns_natural_language_returns_error(self, temp_git_repo: Path): + """AI returning natural language instead of code returns error.""" + import asyncio + from unittest.mock import patch + + from core.workspace import ParallelMergeTask, _attempt_ai_merge + + task = ParallelMergeTask( + file_path="test.py", + main_content="main", + worktree_content="worktree", + base_content=None, + spec_name="spec-001", + project_dir=temp_git_repo, + ) + + # Create a mock client that returns natural language + class MockClient(MockClientBase): + async def receive_response(self): + msg = AssistantMessage( + [TextBlock("I need to see more context to merge this properly.")] + ) + yield msg + + mock_client = MockClient() + + with patch("core.simple_client.create_simple_client", return_value=mock_client): + result = asyncio.run( + _attempt_ai_merge( + task, + "test prompt", + model="claude-haiku-4-5-20251001", + max_thinking_tokens=1024, + ) + ) + + assert result[0] is False + assert result[1] is None + assert "explanation instead of code" in result[2].lower() + + def test_invalid_syntax_after_merge_returns_error(self, temp_git_repo: Path): + """Invalid syntax after merge returns (False, None, error).""" + import asyncio + from unittest.mock import patch + + from core.workspace import ParallelMergeTask, _attempt_ai_merge + + task = ParallelMergeTask( + file_path="test.py", + main_content="main", + worktree_content="worktree", + base_content=None, + spec_name="spec-001", + project_dir=temp_git_repo, + ) + + # Create a mock client that returns invalid Python + class MockClient(MockClientBase): + async def receive_response(self): + msg = AssistantMessage([TextBlock("def merged(:\n pass")]) + yield msg + + mock_client = MockClient() + + with patch("core.simple_client.create_simple_client", return_value=mock_client): + result = asyncio.run( + _attempt_ai_merge( + task, + "test prompt", + model="claude-haiku-4-5-20251001", + max_thinking_tokens=1024, + ) + ) + + assert result[0] is False + assert result[1] is None + assert "syntax" in result[2].lower() + + def test_empty_ai_response_returns_error(self, temp_git_repo: Path): + """Empty AI response returns (False, None, error).""" + import asyncio + from unittest.mock import patch + + from core.workspace import ParallelMergeTask, _attempt_ai_merge + + task = ParallelMergeTask( + file_path="test.py", + main_content="main", + worktree_content="worktree", + base_content=None, + spec_name="spec-001", + project_dir=temp_git_repo, + ) + + # Create a mock client that returns empty response + class MockClient(MockClientBase): + response_text = "" + + async def receive_response(self): + # Empty generator - yields nothing + return + yield + + mock_client = MockClient() + + with patch("core.simple_client.create_simple_client", return_value=mock_client): + result = asyncio.run( + _attempt_ai_merge( + task, + "test prompt", + model="claude-haiku-4-5-20251001", + max_thinking_tokens=1024, + ) + ) + + assert result[0] is False + assert result[1] is None + assert "empty response" in result[2].lower() + + def test_code_fence_stripping_is_applied(self, temp_git_repo: Path): + """Code fence stripping is applied to AI response.""" + import asyncio + from unittest.mock import patch + + from core.workspace import ParallelMergeTask, _attempt_ai_merge + + task = ParallelMergeTask( + file_path="test.py", + main_content="main", + worktree_content="worktree", + base_content=None, + spec_name="spec-001", + project_dir=temp_git_repo, + ) + + # Create a mock client that returns code with fences + class MockClient(MockClientBase): + async def receive_response(self): + # Use markdown-style code fences (backticks) + block = TextBlock("```python\ndef merged():\n pass\n```") + msg = AssistantMessage([block]) + yield msg + + mock_client = MockClient() + + with patch("core.simple_client.create_simple_client", return_value=mock_client): + with patch( + "core.workspace.git_utils.validate_merged_syntax", + return_value=(True, ""), + ): + result = asyncio.run( + _attempt_ai_merge( + task, + "test prompt", + model="claude-haiku-4-5-20251001", + max_thinking_tokens=1024, + ) + ) + + assert result[0] is True + # Code fences should be stripped + assert not result[1].startswith("```") + assert "def merged():" in result[1] + + def test_response_with_code_patterns_passes_natural_language_check( + self, temp_git_repo: Path + ): + """Response with code patterns passes natural language check.""" + import asyncio + from unittest.mock import patch + + from core.workspace import ParallelMergeTask, _attempt_ai_merge + + task = ParallelMergeTask( + file_path="test.py", + main_content="main", + worktree_content="worktree", + base_content=None, + spec_name="spec-001", + project_dir=temp_git_repo, + ) + + # Create a mock client that returns valid code + class MockClient(MockClientBase): + async def receive_response(self): + # Response that has "i need to" but also has code patterns + block = TextBlock( + "# I need to handle edge cases\ndef merged():\n pass\n" + ) + msg = AssistantMessage([block]) + yield msg + + mock_client = MockClient() + + with patch("core.simple_client.create_simple_client", return_value=mock_client): + with patch( + "core.workspace.git_utils.validate_merged_syntax", + return_value=(True, ""), + ): + result = asyncio.run( + _attempt_ai_merge( + task, + "test prompt", + model="claude-haiku-4-5-20251001", + max_thinking_tokens=1024, + ) + ) + + # Should pass because it has code patterns (def) + assert result[0] is True + assert "def merged():" in result[1] diff --git a/apps/backend/core/workspace/tests/test_models.py b/apps/backend/core/workspace/tests/test_models.py new file mode 100644 index 00000000..d11d79b6 --- /dev/null +++ b/apps/backend/core/workspace/tests/test_models.py @@ -0,0 +1,638 @@ +#!/usr/bin/env python3 +""" +Tests for Workspace Models +========================== + +Tests the workspace.py module models including: +- WorkspaceMode enum +- WorkspaceChoice enum +- ParallelMergeTask +- ParallelMergeResult +- MergeLock and MergeLockError +- SpecNumberLock and SpecNumberLockError +""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +# Add parent directory to path so we can import the workspace module +# When co-located at workspace/tests/, we need to add backend to path +# workspace/tests -> workspace -> core -> backend (4 levels up) +_backend = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(_backend)) + +from core.workspace.models import ( + MergeLock, + MergeLockError, + ParallelMergeResult, + ParallelMergeTask, + SpecNumberLock, + SpecNumberLockError, +) +from worktree import WorktreeError, WorktreeManager + +# Test constant - in the new per-spec architecture, each spec has its own worktree +# named after the spec itself. This constant is used for test assertions. +TEST_SPEC_NAME = "test-spec" + + +class TestWorkspaceMode: + """Tests for WorkspaceMode enum.""" + + def test_isolated_mode(self): + """ISOLATED mode value is correct.""" + from core.workspace.models import WorkspaceMode + + assert WorkspaceMode.ISOLATED.value == "isolated" + + def test_direct_mode(self): + """DIRECT mode value is correct.""" + from core.workspace.models import WorkspaceMode + + assert WorkspaceMode.DIRECT.value == "direct" + + +class TestWorkspaceChoice: + """Tests for WorkspaceChoice enum.""" + + def test_merge_choice(self): + """MERGE choice value is correct.""" + from core.workspace.models import WorkspaceChoice + + assert WorkspaceChoice.MERGE.value == "merge" + + def test_review_choice(self): + """REVIEW choice value is correct.""" + from core.workspace.models import WorkspaceChoice + + assert WorkspaceChoice.REVIEW.value == "review" + + def test_test_choice(self): + """TEST choice value is correct.""" + from core.workspace.models import WorkspaceChoice + + assert WorkspaceChoice.TEST.value == "test" + + def test_later_choice(self): + """LATER choice value is correct.""" + from core.workspace.models import WorkspaceChoice + + assert WorkspaceChoice.LATER.value == "later" + + +class TestParallelMergeTask: + """Tests for ParallelMergeTask dataclass.""" + + def test_create_merge_task(self): + """ParallelMergeTask can be instantiated with all fields.""" + task = ParallelMergeTask( + file_path="src/example.py", + main_content="main content", + worktree_content="worktree content", + base_content="base content", + spec_name="test-spec", + project_dir=Path("/project"), + ) + + assert task.file_path == "src/example.py" + assert task.main_content == "main content" + assert task.worktree_content == "worktree content" + assert task.base_content == "base content" + assert task.spec_name == "test-spec" + assert task.project_dir == Path("/project") + + def test_merge_task_with_none_base(self): + """ParallelMergeTask can have None for base_content.""" + task = ParallelMergeTask( + file_path="src/example.py", + main_content="main content", + worktree_content="worktree content", + base_content=None, + spec_name="test-spec", + project_dir=Path("/project"), + ) + + assert task.base_content is None + + def test_merge_task_field_assignment(self): + """ParallelMergeTask fields can be reassigned.""" + task = ParallelMergeTask( + file_path="src/example.py", + main_content="main", + worktree_content="worktree", + base_content=None, + spec_name="spec-1", + project_dir=Path("/project"), + ) + + task.file_path = "src/updated.py" + task.main_content = "updated main" + task.worktree_content = "updated worktree" + task.base_content = "updated base" + task.spec_name = "spec-2" + task.project_dir = Path("/updated") + + assert task.file_path == "src/updated.py" + assert task.main_content == "updated main" + assert task.worktree_content == "updated worktree" + assert task.base_content == "updated base" + assert task.spec_name == "spec-2" + assert task.project_dir == Path("/updated") + + +class TestParallelMergeResult: + """Tests for ParallelMergeResult dataclass.""" + + def test_create_successful_result(self): + """ParallelMergeResult can represent a successful merge.""" + result = ParallelMergeResult( + file_path="src/example.py", + merged_content="merged content", + success=True, + error=None, + was_auto_merged=True, + ) + + assert result.file_path == "src/example.py" + assert result.merged_content == "merged content" + assert result.success is True + assert result.error is None + assert result.was_auto_merged is True + + def test_create_failed_result(self): + """ParallelMergeResult can represent a failed merge.""" + result = ParallelMergeResult( + file_path="src/example.py", + merged_content=None, + success=False, + error="Merge conflict occurred", + was_auto_merged=False, + ) + + assert result.file_path == "src/example.py" + assert result.merged_content is None + assert result.success is False + assert result.error == "Merge conflict occurred" + assert result.was_auto_merged is False + + def test_result_default_values(self): + """ParallelMergeResult has correct default values.""" + result = ParallelMergeResult( + file_path="src/example.py", + merged_content="content", + success=True, + ) + + assert result.error is None + assert result.was_auto_merged is False + + def test_result_field_assignment(self): + """ParallelMergeResult fields can be reassigned.""" + result = ParallelMergeResult( + file_path="src/example.py", + merged_content="merged", + success=True, + error=None, + was_auto_merged=False, + ) + + result.file_path = "src/updated.py" + result.merged_content = "updated merged" + result.success = False + result.error = "New error" + result.was_auto_merged = True + + assert result.file_path == "src/updated.py" + assert result.merged_content == "updated merged" + assert result.success is False + assert result.error == "New error" + assert result.was_auto_merged is True + + +class TestMergeLockError: + """Tests for MergeLockError exception.""" + + def test_merge_lock_error_creation(self): + """MergeLockError can be instantiated with a message.""" + error = MergeLockError("Could not acquire lock") + assert str(error) == "Could not acquire lock" + + def test_merge_lock_error_is_exception(self): + """MergeLockError is an Exception subclass.""" + error = MergeLockError("test") + assert isinstance(error, Exception) + assert isinstance(error, MergeLockError) + + def test_raise_merge_lock_error(self): + """MergeLockError can be raised and caught.""" + with pytest.raises(MergeLockError) as exc_info: + raise MergeLockError("Lock timeout") + assert str(exc_info.value) == "Lock timeout" + + +class TestMergeLock: + """Tests for MergeLock context manager.""" + + def test_merge_lock_initialization(self, temp_git_repo: Path): + """MergeLock initializes with correct paths.""" + lock = MergeLock(temp_git_repo, "test-spec") + + assert lock.project_dir == temp_git_repo + assert lock.spec_name == "test-spec" + assert lock.lock_dir == temp_git_repo / ".auto-claude" / ".locks" + assert lock.lock_file == lock.lock_dir / "merge-test-spec.lock" + assert lock.acquired is False + + def test_merge_lock_acquire_and_release(self, temp_git_repo: Path): + """MergeLock can be acquired and released.""" + lock = MergeLock(temp_git_repo, "test-spec") + + with lock: + assert lock.acquired is True + assert lock.lock_file.exists() + + # After context, lock should be released + assert lock.lock_file.exists() is False + + def test_merge_lock_creates_lock_dir(self, temp_git_repo: Path): + """MergeLock creates lock directory if it doesn't exist.""" + lock = MergeLock(temp_git_repo, "test-spec") + + # Remove lock dir if it exists + if lock.lock_dir.exists(): + lock.lock_dir.rmdir() + + with lock: + assert lock.lock_dir.exists() + + def test_merge_lock_writes_pid(self, temp_git_repo: Path): + """MergeLock writes current PID to lock file.""" + lock = MergeLock(temp_git_repo, "test-spec") + + with lock: + pid_content = lock.lock_file.read_text(encoding="utf-8").strip() + assert pid_content == str(os.getpid()) + + @pytest.mark.slow + def test_merge_lock_timeout_on_contention(self, temp_git_repo: Path): + """MergeLock raises MergeLockError when lock is held by another process.""" + lock1 = MergeLock(temp_git_repo, "test-spec") + + # Acquire first lock + lock1.__enter__() + + try: + # Create a second lock for the same spec + lock2 = MergeLock(temp_git_repo, "test-spec") + + # This should timeout because lock1 holds the lock + with pytest.raises(MergeLockError) as exc_info: + lock2.__enter__() + + assert "Could not acquire merge lock" in str(exc_info.value) + assert "test-spec" in str(exc_info.value) + assert "after 30s" in str(exc_info.value) + finally: + lock1.__exit__(None, None, None) + + def test_merge_lock_removes_stale_lock(self, temp_git_repo: Path): + """MergeLock removes stale lock from dead process.""" + lock1 = MergeLock(temp_git_repo, "test-spec") + + with lock1: + # Write a fake PID that doesn't exist + fake_pid = 999999 + lock1.lock_file.write_text(str(fake_pid), encoding="utf-8") + + # Create a new lock - it should remove the stale lock + lock2 = MergeLock(temp_git_repo, "test-spec") + with lock2: + assert lock2.acquired is True + + def test_merge_lock_handles_invalid_pid(self, temp_git_repo: Path): + """MergeLock handles invalid PID in lock file.""" + lock1 = MergeLock(temp_git_repo, "test-spec") + + with lock1: + # Write invalid content to lock file + lock1.lock_file.write_text("invalid-pid", encoding="utf-8") + + # Create a new lock - it should remove the invalid lock + lock2 = MergeLock(temp_git_repo, "test-spec") + with lock2: + assert lock2.acquired is True + + def test_merge_lock_cleanup_on_exception(self, temp_git_repo: Path): + """MergeLock releases lock even if exception occurs in context.""" + lock = MergeLock(temp_git_repo, "test-spec") + + try: + with lock: + assert lock.acquired is True + raise ValueError("Test exception") + except ValueError: + pass + + # Lock should be released despite exception + assert lock.lock_file.exists() is False + + def test_merge_lock_idempotent_release(self, temp_git_repo: Path): + """MergeLock __exit__ can be called multiple times safely.""" + lock = MergeLock(temp_git_repo, "test-spec") + + with lock: + pass + + # Call __exit__ again - should not raise + lock.__exit__(None, None, None) + lock.__exit__(None, None, None) + + def test_merge_lock_different_specs_dont_conflict(self, temp_git_repo: Path): + """MergeLock for different specs can be held simultaneously.""" + lock1 = MergeLock(temp_git_repo, "spec-1") + lock2 = MergeLock(temp_git_repo, "spec-2") + + with lock1: + with lock2: + assert lock1.acquired is True + assert lock2.acquired is True + assert lock1.lock_file != lock2.lock_file + + +class TestSpecNumberLockError: + """Tests for SpecNumberLockError exception.""" + + def test_spec_number_lock_error_creation(self): + """SpecNumberLockError can be instantiated with a message.""" + error = SpecNumberLockError("Could not acquire spec numbering lock") + assert str(error) == "Could not acquire spec numbering lock" + + def test_spec_number_lock_error_is_exception(self): + """SpecNumberLockError is an Exception subclass.""" + error = SpecNumberLockError("test") + assert isinstance(error, Exception) + assert isinstance(error, SpecNumberLockError) + + def test_raise_spec_number_lock_error(self): + """SpecNumberLockError can be raised and caught.""" + with pytest.raises(SpecNumberLockError) as exc_info: + raise SpecNumberLockError("Lock timeout") + assert str(exc_info.value) == "Lock timeout" + + +class TestSpecNumberLock: + """Tests for SpecNumberLock context manager.""" + + def test_spec_number_lock_initialization(self, temp_git_repo: Path): + """SpecNumberLock initializes with correct paths.""" + lock = SpecNumberLock(temp_git_repo) + + assert lock.project_dir == temp_git_repo + assert lock.lock_dir == temp_git_repo / ".auto-claude" / ".locks" + assert lock.lock_file == lock.lock_dir / "spec-numbering.lock" + assert lock.acquired is False + assert lock._global_max is None + + def test_spec_number_lock_acquire_and_release(self, temp_git_repo: Path): + """SpecNumberLock can be acquired and released.""" + lock = SpecNumberLock(temp_git_repo) + + with lock: + assert lock.acquired is True + assert lock.lock_file.exists() + + # After context, lock should be released + assert lock.lock_file.exists() is False + + def test_spec_number_lock_creates_lock_dir(self, temp_git_repo: Path): + """SpecNumberLock creates lock directory if it doesn't exist.""" + lock = SpecNumberLock(temp_git_repo) + + # Remove lock dir if it exists + if lock.lock_dir.exists(): + lock.lock_dir.rmdir() + + with lock: + assert lock.lock_dir.exists() + + def test_spec_number_lock_writes_pid(self, temp_git_repo: Path): + """SpecNumberLock writes current PID to lock file.""" + lock = SpecNumberLock(temp_git_repo) + + with lock: + pid_content = lock.lock_file.read_text(encoding="utf-8").strip() + assert pid_content == str(os.getpid()) + + def test_get_next_spec_number_no_existing_specs(self, temp_git_repo: Path): + """get_next_spec_number returns 1 when no specs exist.""" + lock = SpecNumberLock(temp_git_repo) + + with lock: + next_num = lock.get_next_spec_number() + assert next_num == 1 + + def test_get_next_spec_number_with_existing_specs(self, temp_git_repo: Path): + """get_next_spec_number returns max existing spec number + 1.""" + # Create spec directories + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + (specs_dir / "001-first").mkdir() + (specs_dir / "003-third").mkdir() + + lock = SpecNumberLock(temp_git_repo) + + with lock: + next_num = lock.get_next_spec_number() + assert next_num == 4 + + def test_get_next_spec_number_caches_result(self, temp_git_repo: Path): + """get_next_spec_number caches the global max.""" + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + (specs_dir / "005-test").mkdir() + + lock = SpecNumberLock(temp_git_repo) + + with lock: + next_num1 = lock.get_next_spec_number() + next_num2 = lock.get_next_spec_number() + + # Should return the same value (cached) + assert next_num1 == next_num2 == 6 + assert lock._global_max == 5 + + def test_get_next_spec_number_requires_lock(self, temp_git_repo: Path): + """get_next_spec_number raises SpecNumberLockError if lock not acquired.""" + lock = SpecNumberLock(temp_git_repo) + + with pytest.raises(SpecNumberLockError) as exc_info: + lock.get_next_spec_number() + + assert "Lock must be acquired" in str(exc_info.value) + + def test_get_next_spec_number_scans_worktrees(self, temp_git_repo: Path): + """get_next_spec_number scans all worktree spec directories.""" + # Create main project specs + main_specs = temp_git_repo / ".auto-claude" / "specs" + main_specs.mkdir(parents=True) + (main_specs / "002-main").mkdir() + + # Create worktree with specs + worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks" + worktrees_dir.mkdir(parents=True) + worktree_spec_dir = worktrees_dir / "test-worktree" / ".auto-claude" / "specs" + worktree_spec_dir.mkdir(parents=True) + (worktree_spec_dir / "005-worktree").mkdir() + + lock = SpecNumberLock(temp_git_repo) + + with lock: + next_num = lock.get_next_spec_number() + # Should find max of 2 and 5, return 6 + assert next_num == 6 + + def test_scan_specs_dir_nonexistent(self, temp_git_repo: Path): + """_scan_specs_dir returns 0 for nonexistent directory.""" + lock = SpecNumberLock(temp_git_repo) + + with lock: + # Use a path inside temp_dir that doesn't exist + nonexistent = temp_git_repo / "this_does_not_exist_specs" + result = lock._scan_specs_dir(nonexistent) + assert result == 0 + + def test_scan_specs_dir_ignores_invalid_names(self, temp_git_repo: Path): + """_scan_specs_dir ignores directories with invalid spec names.""" + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + (specs_dir / "001-valid").mkdir() + (specs_dir / "invalid-name").mkdir() + (specs_dir / "abc").mkdir() + (specs_dir / "100-valid").mkdir() + + lock = SpecNumberLock(temp_git_repo) + + with lock: + result = lock._scan_specs_dir(specs_dir) + # Should only count 001 and 100 + assert result == 100 + + @pytest.mark.slow + def test_spec_number_lock_timeout_on_contention(self, temp_git_repo: Path): + """SpecNumberLock raises SpecNumberLockError when lock is held.""" + lock1 = SpecNumberLock(temp_git_repo) + + # Acquire first lock + lock1.__enter__() + + try: + # Create a second lock + lock2 = SpecNumberLock(temp_git_repo) + + # This should timeout because lock1 holds the lock + with pytest.raises(SpecNumberLockError) as exc_info: + lock2.__enter__() + + assert "Could not acquire spec numbering lock" in str(exc_info.value) + assert "after 30s" in str(exc_info.value) + finally: + lock1.__exit__(None, None, None) + + def test_spec_number_lock_removes_stale_lock(self, temp_git_repo: Path): + """SpecNumberLock removes stale lock from dead process.""" + lock1 = SpecNumberLock(temp_git_repo) + + with lock1: + # Write a fake PID that doesn't exist + fake_pid = 999999 + lock1.lock_file.write_text(str(fake_pid), encoding="utf-8") + + # Create a new lock - it should remove the stale lock + lock2 = SpecNumberLock(temp_git_repo) + with lock2: + assert lock2.acquired is True + + def test_spec_number_lock_handles_invalid_pid(self, temp_git_repo: Path): + """SpecNumberLock handles invalid PID in lock file.""" + lock1 = SpecNumberLock(temp_git_repo) + + with lock1: + # Write invalid content to lock file + lock1.lock_file.write_text("invalid-pid", encoding="utf-8") + + # Create a new lock - it should remove the invalid lock + lock2 = SpecNumberLock(temp_git_repo) + with lock2: + assert lock2.acquired is True + + def test_spec_number_lock_cleanup_on_exception(self, temp_git_repo: Path): + """SpecNumberLock releases lock even if exception occurs in context.""" + lock = SpecNumberLock(temp_git_repo) + + try: + with lock: + assert lock.acquired is True + raise ValueError("Test exception") + except ValueError: + pass + + # Lock should be released despite exception + assert lock.lock_file.exists() is False + + def test_spec_number_lock_idempotent_release(self, temp_git_repo: Path): + """SpecNumberLock __exit__ can be called multiple times safely.""" + lock = SpecNumberLock(temp_git_repo) + + with lock: + pass + + # Call __exit__ again - should not raise + lock.__exit__(None, None, None) + lock.__exit__(None, None, None) + + def test_spec_number_lock_returns_self(self, temp_git_repo: Path): + """SpecNumberLock __enter__ returns self.""" + lock = SpecNumberLock(temp_git_repo) + + with lock as entered_lock: + assert entered_lock is lock + + def test_merge_success_returns_true(self, temp_git_repo: Path): + """Successful merge returns True (ACS-163 verification).""" + manager = WorktreeManager(temp_git_repo) + manager.setup() + + # Create a worktree with non-conflicting changes + worker_info = manager.create_worktree("worker-spec") + (worker_info.path / "worker-file.txt").write_text( + "worker content", encoding="utf-8" + ) + subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Worker commit"], + cwd=worker_info.path, + capture_output=True, + ) + + # Merge should succeed + result = manager.merge_worktree("worker-spec", delete_after=False) + + assert result is True + + # Verify the file was merged into base branch + subprocess.run( + ["git", "checkout", manager.base_branch], + cwd=temp_git_repo, + capture_output=True, + ) + assert (temp_git_repo / "worker-file.txt").exists(), ( + "Merged file should exist in base branch" + ) + merged_content = (temp_git_repo / "worker-file.txt").read_text(encoding="utf-8") + assert merged_content == "worker content", ( + "Merged file should have worktree content" + ) diff --git a/tests/test_workspace.py b/apps/backend/core/workspace/tests/test_rebase.py similarity index 51% rename from tests/test_workspace.py rename to apps/backend/core/workspace/tests/test_rebase.py index a1291b78..dcddff6f 100644 --- a/tests/test_workspace.py +++ b/apps/backend/core/workspace/tests/test_rebase.py @@ -1,539 +1,31 @@ #!/usr/bin/env python3 """ -Tests for Workspace Selection and Management -============================================= +Tests for Workspace Rebase Operations +====================================== -Tests the workspace.py module functionality including: -- Workspace mode selection (isolated vs direct) -- Uncommitted changes detection -- Workspace setup -- Build finalization workflows +Tests the rebase functionality including: +- Rebase detection (_check_git_conflicts) +- Spec branch rebase operations +- Rebase integration tests +- Rebase error handling """ +import json +import os +import shutil import subprocess +import sys from pathlib import Path import pytest -from workspace import ( - WorkspaceChoice, - WorkspaceMode, - get_current_branch, - get_existing_build_worktree, - has_uncommitted_changes, - setup_workspace, -) -from worktree import WorktreeManager +from worktree import WorktreeError, WorktreeManager # Test constant - in the new per-spec architecture, each spec has its own worktree # named after the spec itself. This constant is used for test assertions. TEST_SPEC_NAME = "test-spec" -class TestWorkspaceMode: - """Tests for WorkspaceMode enum.""" - - def test_isolated_mode(self): - """ISOLATED mode value is correct.""" - assert WorkspaceMode.ISOLATED.value == "isolated" - - def test_direct_mode(self): - """DIRECT mode value is correct.""" - assert WorkspaceMode.DIRECT.value == "direct" - - -class TestWorkspaceChoice: - """Tests for WorkspaceChoice enum.""" - - def test_merge_choice(self): - """MERGE choice value is correct.""" - assert WorkspaceChoice.MERGE.value == "merge" - - def test_review_choice(self): - """REVIEW choice value is correct.""" - assert WorkspaceChoice.REVIEW.value == "review" - - def test_test_choice(self): - """TEST choice value is correct.""" - assert WorkspaceChoice.TEST.value == "test" - - def test_later_choice(self): - """LATER choice value is correct.""" - assert WorkspaceChoice.LATER.value == "later" - - -class TestHasUncommittedChanges: - """Tests for uncommitted changes detection.""" - - def test_clean_repo_no_changes(self, temp_git_repo: Path): - """Clean repo returns False.""" - result = has_uncommitted_changes(temp_git_repo) - assert result is False - - def test_untracked_file_has_changes(self, temp_git_repo: Path): - """Untracked file counts as changes.""" - (temp_git_repo / "new_file.txt").write_text("content") - - result = has_uncommitted_changes(temp_git_repo) - assert result is True - - def test_modified_file_has_changes(self, temp_git_repo: Path): - """Modified tracked file counts as changes.""" - (temp_git_repo / "README.md").write_text("modified content") - - result = has_uncommitted_changes(temp_git_repo) - assert result is True - - def test_staged_file_has_changes(self, temp_git_repo: Path): - """Staged file counts as changes.""" - (temp_git_repo / "README.md").write_text("modified") - subprocess.run(["git", "add", "README.md"], cwd=temp_git_repo, capture_output=True) - - result = has_uncommitted_changes(temp_git_repo) - assert result is True - - -class TestGetCurrentBranch: - """Tests for current branch detection.""" - - def test_gets_main_branch(self, temp_git_repo: Path): - """Gets the main/master branch.""" - branch = get_current_branch(temp_git_repo) - - # Could be main or master depending on git config - assert branch in ["main", "master"] - - def test_gets_feature_branch(self, temp_git_repo: Path): - """Gets feature branch name.""" - subprocess.run( - ["git", "checkout", "-b", "feature/test-branch"], - cwd=temp_git_repo, capture_output=True - ) - - branch = get_current_branch(temp_git_repo) - assert branch == "feature/test-branch" - - -class TestGetExistingBuildWorktree: - """Tests for existing build worktree detection.""" - - def test_no_existing_worktree(self, temp_git_repo: Path): - """Returns None when no worktree exists.""" - result = get_existing_build_worktree(temp_git_repo, "test-spec") - assert result is None - - def test_existing_worktree(self, temp_git_repo: Path): - """Returns path when worktree exists.""" - # Create the worktree directory structure (per-spec architecture) - worktree_path = temp_git_repo / ".worktrees" / TEST_SPEC_NAME - worktree_path.mkdir(parents=True) - - result = get_existing_build_worktree(temp_git_repo, TEST_SPEC_NAME) - assert result == worktree_path - - -class TestSetupWorkspace: - """Tests for workspace setup.""" - - def test_setup_direct_mode(self, temp_git_repo: Path): - """Direct mode returns project dir and no manager.""" - working_dir, manager, _ = setup_workspace( - temp_git_repo, - "test-spec", - WorkspaceMode.DIRECT, - ) - - assert working_dir == temp_git_repo - assert manager is None - - def test_setup_isolated_mode(self, temp_git_repo: Path): - """Isolated mode creates worktree and returns manager.""" - working_dir, manager, _ = setup_workspace( - temp_git_repo, - TEST_SPEC_NAME, - WorkspaceMode.ISOLATED, - ) - - assert working_dir != temp_git_repo - assert manager is not None - assert working_dir.exists() - # Per-spec architecture: worktree is named after the spec - assert working_dir.name == TEST_SPEC_NAME - - def test_setup_isolated_creates_worktrees_dir(self, temp_git_repo: Path): - """Isolated mode creates worktrees directory.""" - setup_workspace( - temp_git_repo, - "test-spec", - WorkspaceMode.ISOLATED, - ) - - assert (temp_git_repo / ".auto-claude" / "worktrees" / "tasks").exists() - - -class TestWorkspaceUtilities: - """Tests for workspace utility functions.""" - - def test_per_spec_worktree_naming(self, temp_git_repo: Path): - """Per-spec architecture uses spec name for worktree directory.""" - spec_name = "my-spec-001" - working_dir, manager, _ = setup_workspace( - temp_git_repo, - spec_name, - WorkspaceMode.ISOLATED, - ) - - # Worktree should be named after the spec - assert working_dir.name == spec_name - # New path: .auto-claude/worktrees/tasks/{spec_name} - assert working_dir.parent.name == "tasks" - - -class TestWorkspaceIntegration: - """Integration tests for workspace management.""" - - def test_isolated_workflow(self, temp_git_repo: Path): - """Full isolated workflow: setup -> work -> finalize.""" - # Setup isolated workspace - working_dir, manager, _ = setup_workspace( - temp_git_repo, - "test-spec", - WorkspaceMode.ISOLATED, - ) - - # Make changes in workspace - (working_dir / "feature.py").write_text("# New feature\n") - - # Verify changes are in workspace - assert (working_dir / "feature.py").exists() - - # Verify changes are NOT in main project - assert not (temp_git_repo / "feature.py").exists() - - def test_direct_workflow(self, temp_git_repo: Path): - """Full direct workflow: setup -> work.""" - # Setup direct workspace - working_dir, manager, _ = setup_workspace( - temp_git_repo, - "test-spec", - WorkspaceMode.DIRECT, - ) - - # Working dir is the project dir - assert working_dir == temp_git_repo - - # Make changes directly - (working_dir / "feature.py").write_text("# New feature\n") - - # Changes are in main project - assert (temp_git_repo / "feature.py").exists() - - def test_isolated_merge(self, temp_git_repo: Path): - """Can merge isolated workspace back to main.""" - # Setup - working_dir, manager, _ = setup_workspace( - temp_git_repo, - "test-spec", - WorkspaceMode.ISOLATED, - ) - - # Make changes and commit using git directly - (working_dir / "feature.py").write_text("# New feature\n") - subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "Add feature"], - cwd=working_dir, capture_output=True - ) - - # Merge back using merge_worktree - result = manager.merge_worktree("test-spec", delete_after=False) - - assert result is True - - # Check changes are in main - subprocess.run( - ["git", "checkout", manager.base_branch], - cwd=temp_git_repo, capture_output=True - ) - assert (temp_git_repo / "feature.py").exists() - - -class TestWorkspaceCleanup: - """Tests for workspace cleanup.""" - - def test_cleanup_after_merge(self, temp_git_repo: Path): - """Workspace is cleaned up after merge with delete_after=True.""" - working_dir, manager, _ = setup_workspace( - temp_git_repo, - "test-spec", - WorkspaceMode.ISOLATED, - ) - - # Commit changes using git directly - (working_dir / "test.py").write_text("test") - subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "Test"], - cwd=working_dir, capture_output=True - ) - - # Merge with cleanup - manager.merge_worktree("test-spec", delete_after=True) - - # Workspace should be removed - assert not working_dir.exists() - - def test_workspace_preserved_after_merge_no_delete(self, temp_git_repo: Path): - """Workspace preserved after merge with delete_after=False.""" - working_dir, manager, _ = setup_workspace( - temp_git_repo, - "test-spec", - WorkspaceMode.ISOLATED, - ) - - # Commit changes using git directly - (working_dir / "test.py").write_text("test") - subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "Test"], - cwd=working_dir, capture_output=True - ) - - # Merge without cleanup - manager.merge_worktree("test-spec", delete_after=False) - - # Workspace should still exist - assert working_dir.exists() - - -class TestWorkspaceReuse: - """Tests for reusing existing workspaces.""" - - def test_reuse_existing_workspace(self, temp_git_repo: Path): - """Can reuse existing workspace on second setup.""" - # First setup - working_dir1, manager1, _ = setup_workspace( - temp_git_repo, - "test-spec", - WorkspaceMode.ISOLATED, - ) - - # Add a marker file - (working_dir1 / "marker.txt").write_text("marker") - - # Second setup (should reuse) - working_dir2, manager2, _ = setup_workspace( - temp_git_repo, - "test-spec", - WorkspaceMode.ISOLATED, - ) - - # Should be the same directory - assert working_dir1 == working_dir2 - - # Marker should still exist - assert (working_dir2 / "marker.txt").exists() - - -class TestWorkspaceErrors: - """Tests for workspace error handling.""" - - def test_setup_non_git_directory(self, temp_dir: Path): - """Handles non-git directories gracefully.""" - with pytest.raises(Exception): - # This should fail because temp_dir is not a git repo - setup_workspace( - temp_dir, - "test-spec", - WorkspaceMode.ISOLATED, - ) - - -class TestPerSpecWorktreeName: - """Tests for per-spec worktree naming (new architecture).""" - - def test_worktree_named_after_spec(self, temp_git_repo: Path): - """Worktree is named after the spec.""" - spec_name = "spec-1" - working_dir, _, _ = setup_workspace( - temp_git_repo, - spec_name, - WorkspaceMode.ISOLATED, - ) - - # Per-spec architecture: worktree directory matches spec name - assert working_dir.name == spec_name - - def test_different_specs_get_different_worktrees(self, temp_git_repo: Path): - """Different specs create separate worktrees.""" - working_dir1, _, _ = setup_workspace( - temp_git_repo, - "spec-1", - WorkspaceMode.ISOLATED, - ) - - working_dir2, _, _ = setup_workspace( - temp_git_repo, - "spec-2", - WorkspaceMode.ISOLATED, - ) - - # Each spec has its own worktree - assert working_dir1.name == "spec-1" - assert working_dir2.name == "spec-2" - assert working_dir1 != working_dir2 - - def test_worktree_path_in_worktrees_dir(self, temp_git_repo: Path): - """Worktree is created in worktrees directory.""" - working_dir, _, _ = setup_workspace( - temp_git_repo, - "test-spec", - WorkspaceMode.ISOLATED, - ) - - # New path: .auto-claude/worktrees/tasks/{spec_name} - assert "worktrees" in str(working_dir) - assert working_dir.parent.name == "tasks" - - -class TestConflictInfoDisplay: - """Tests for conflict info display function (ACS-179).""" - - def test_print_conflict_info_with_string_list(self, capsys): - """print_conflict_info handles string list of file paths (ACS-179).""" - from core.workspace.display import print_conflict_info - - result = { - "conflicts": ["file1.txt", "file2.py", "file3.js"] - } - - print_conflict_info(result) - - captured = capsys.readouterr() - assert "3 file" in captured.out - assert "file1.txt" in captured.out - assert "file2.py" in captured.out - assert "file3.js" in captured.out - assert "git add" in captured.out - - def test_print_conflict_info_with_dict_list(self, capsys): - """print_conflict_info handles dict list with file/reason/severity (ACS-179).""" - from core.workspace.display import print_conflict_info - - result = { - "conflicts": [ - {"file": "file1.txt", "reason": "Syntax error", "severity": "high"}, - {"file": "file2.py", "reason": "Merge conflict", "severity": "medium"}, - {"file": "file3.js", "reason": "Unknown error", "severity": "low"}, - ] - } - - print_conflict_info(result) - - captured = capsys.readouterr() - assert "3 file" in captured.out - assert "file1.txt" in captured.out - assert "file2.py" in captured.out - assert "file3.js" in captured.out - assert "Syntax error" in captured.out - assert "Merge conflict" in captured.out - # Verify severity emoji indicators - assert "🔴" in captured.out # High severity - assert "🟡" in captured.out # Medium severity - - def test_print_conflict_info_mixed_formats(self, capsys): - """print_conflict_info handles mixed string and dict conflicts (ACS-179).""" - from core.workspace.display import print_conflict_info - - result = { - "conflicts": [ - "simple-file.txt", - {"file": "complex-file.py", "reason": "AI merge failed", "severity": "high"}, - ] - } - - print_conflict_info(result) - - captured = capsys.readouterr() - assert "2 file" in captured.out - assert "simple-file.txt" in captured.out - assert "complex-file.py" in captured.out - assert "AI merge failed" in captured.out - - -class TestMergeErrorHandling: - """Tests for merge error handling (ACS-163).""" - - def test_merge_failure_returns_false_immediately(self, temp_git_repo: Path): - """Failed merge returns False without falling through (ACS-163).""" - manager = WorktreeManager(temp_git_repo) - manager.setup() - - # Create a worktree with changes - worker_info = manager.create_worktree("worker-spec") - (worker_info.path / "worker-file.txt").write_text("worker content") - subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "Worker commit"], - cwd=worker_info.path, capture_output=True - ) - - # Create a conflicting change on main - subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True) - (temp_git_repo / "worker-file.txt").write_text("main content") - subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "Main commit"], - cwd=temp_git_repo, capture_output=True - ) - - # Merge should fail (conflict) and return False - # This tests the fix for ACS-163 where failed merge would fall through - result = manager.merge_worktree("worker-spec", delete_after=False) - - # Should return False on merge conflict - assert result is False - - # Verify side effects: base branch content is unchanged - subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True) - base_content = (temp_git_repo / "worker-file.txt").read_text() - assert base_content == "main content", "Base branch should be unchanged after failed merge" - - # Verify worktree still exists (delete_after=False) - assert worker_info.path.exists(), "Worktree should still exist after failed merge" - - # Verify worktree content is unchanged - worktree_content = (worker_info.path / "worker-file.txt").read_text() - assert worktree_content == "worker content", "Worktree content should be unchanged" - - def test_merge_success_returns_true(self, temp_git_repo: Path): - """Successful merge returns True (ACS-163 verification).""" - manager = WorktreeManager(temp_git_repo) - manager.setup() - - # Create a worktree with non-conflicting changes - worker_info = manager.create_worktree("worker-spec") - (worker_info.path / "worker-file.txt").write_text("worker content") - subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "Worker commit"], - cwd=worker_info.path, capture_output=True - ) - - # Merge should succeed - result = manager.merge_worktree("worker-spec", delete_after=False) - - assert result is True - - # Verify the file was merged into base branch - subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True) - assert (temp_git_repo / "worker-file.txt").exists(), "Merged file should exist in base branch" - merged_content = (temp_git_repo / "worker-file.txt").read_text() - assert merged_content == "worker content", "Merged file should have worktree content" - - class TestRebaseDetection: - """Tests for automatic rebase detection (ACS-224).""" - def test_check_git_conflicts_detects_branch_behind(self, temp_git_repo: Path): """_check_git_conflicts detects when spec branch is behind base branch (ACS-224).""" from core.workspace import _check_git_conflicts @@ -547,7 +39,7 @@ class TestRebaseDetection: ) # Add a commit to spec branch - (temp_git_repo / "spec-file.txt").write_text("spec content") + (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Spec commit"], @@ -561,7 +53,7 @@ class TestRebaseDetection: cwd=temp_git_repo, capture_output=True, ) - (temp_git_repo / "main-file.txt").write_text("main content") + (temp_git_repo / "main-file.txt").write_text("main content", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Main commit after spec"], @@ -574,7 +66,9 @@ class TestRebaseDetection: assert result is not None assert result.get("needs_rebase") is True, "Should detect branch is behind" - assert result.get("commits_behind") == 1, "Should count commits behind correctly" + assert result.get("commits_behind") == 1, ( + "Should count commits behind correctly" + ) assert result.get("spec_branch") == spec_branch def test_check_git_conflicts_no_commits_behind(self, temp_git_repo: Path): @@ -588,7 +82,7 @@ class TestRebaseDetection: cwd=temp_git_repo, capture_output=True, ) - (temp_git_repo / "spec-file.txt").write_text("spec content") + (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Spec commit"], @@ -624,7 +118,7 @@ class TestRebaseDetection: ) # Add a commit to spec branch - (temp_git_repo / "spec-file.txt").write_text("spec content") + (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Spec commit"], @@ -639,7 +133,9 @@ class TestRebaseDetection: capture_output=True, ) for i in range(3): - (temp_git_repo / f"main-file-{i}.txt").write_text(f"main content {i}") + (temp_git_repo / f"main-file-{i}.txt").write_text( + f"main content {i}", encoding="utf-8" + ) subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", f"Main commit {i}"], @@ -671,7 +167,7 @@ class TestRebaseSpecBranch: ) # Add a commit to spec branch - (temp_git_repo / "spec-file.txt").write_text("spec content") + (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Spec commit"], @@ -685,7 +181,7 @@ class TestRebaseSpecBranch: cwd=temp_git_repo, capture_output=True, ) - (temp_git_repo / "main-file.txt").write_text("main content") + (temp_git_repo / "main-file.txt").write_text("main content", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Main commit"], @@ -726,7 +222,9 @@ class TestRebaseSpecBranch: ).stdout assert "Main commit" in log, "Spec branch should have main commit after rebase" - def test_rebase_spec_branch_with_conflicts_aborts_cleanly(self, temp_git_repo: Path): + def test_rebase_spec_branch_with_conflicts_aborts_cleanly( + self, temp_git_repo: Path + ): """_rebase_spec_branch handles conflicts by aborting and returning False (ACS-224).""" from core.workspace import _rebase_spec_branch @@ -739,7 +237,7 @@ class TestRebaseSpecBranch: ) # Create a file that will conflict - (temp_git_repo / "conflict.txt").write_text("spec version") + (temp_git_repo / "conflict.txt").write_text("spec version", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Spec conflict"], @@ -753,7 +251,7 @@ class TestRebaseSpecBranch: cwd=temp_git_repo, capture_output=True, ) - (temp_git_repo / "conflict.txt").write_text("main version") + (temp_git_repo / "conflict.txt").write_text("main version", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Main conflict"], @@ -823,7 +321,7 @@ class TestRebaseSpecBranch: cwd=temp_git_repo, capture_output=True, ) - (temp_git_repo / "spec-file.txt").write_text("spec content") + (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Spec commit"], @@ -842,7 +340,9 @@ class TestRebaseSpecBranch: # (branch already up-to-date is a success condition) result = _rebase_spec_branch(temp_git_repo, "test-spec", "main") - assert result is True, "Rebase should return True when branch is already up-to-date" + assert result is True, ( + "Rebase should return True when branch is already up-to-date" + ) class TestRebaseIntegration: @@ -859,7 +359,9 @@ class TestRebaseIntegration: worker_info = manager.create_worktree("test-spec") # Add a file in spec worktree and commit - (worker_info.path / "spec-file.txt").write_text("spec content") + (worker_info.path / "spec-file.txt").write_text( + "spec content", encoding="utf-8" + ) subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True) subprocess.run( ["git", "commit", "-m", "Spec commit"], @@ -874,7 +376,7 @@ class TestRebaseIntegration: capture_output=True, ) for i in range(2): - (temp_git_repo / f"main-{i}.txt").write_text(f"main {i}") + (temp_git_repo / f"main-{i}.txt").write_text(f"main {i}", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", f"Main {i}"], @@ -906,7 +408,7 @@ class TestRebaseIntegration: ) # Add a commit to spec - (temp_git_repo / "spec.txt").write_text("spec") + (temp_git_repo / "spec.txt").write_text("spec", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Spec"], @@ -920,7 +422,7 @@ class TestRebaseIntegration: cwd=temp_git_repo, capture_output=True, ) - (temp_git_repo / "main.txt").write_text("main") + (temp_git_repo / "main.txt").write_text("main", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Main"], @@ -966,7 +468,7 @@ class TestRebaseErrorHandling: cwd=temp_git_repo, capture_output=True, ) - (temp_git_repo / "spec-file.txt").write_text("spec content") + (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Spec commit"], @@ -1007,7 +509,6 @@ class TestRebaseErrorHandling: def test_check_git_conflicts_handles_corrupted_repo(self, temp_git_repo: Path): """_check_git_conflicts handles corrupted repo metadata gracefully (ACS-224).""" - import shutil from core.workspace import _check_git_conflicts @@ -1018,7 +519,7 @@ class TestRebaseErrorHandling: cwd=temp_git_repo, capture_output=True, ) - (temp_git_repo / "spec-file.txt").write_text("spec content") + (temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Spec commit"], diff --git a/apps/backend/core/workspace/tests/test_setup.py b/apps/backend/core/workspace/tests/test_setup.py new file mode 100644 index 00000000..b74556cb --- /dev/null +++ b/apps/backend/core/workspace/tests/test_setup.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +Tests for Workspace Setup Operations +===================================== + +Tests the setup functionality including: +- Spec copy to workspace operations +- Timeline hook installation +- Timeline tracking initialization +""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +# Test constant - in the new per-spec architecture, each spec has its own worktree +# named after the spec itself. This constant is used for test assertions. +TEST_SPEC_NAME = "test-spec" + + +class TestCopySpecToWorktree: + """Tests for copy_spec_to_worktree function.""" + + def test_copies_spec_files_to_worktree(self, temp_git_repo: Path): + """Copies spec directory to worktree .auto-claude/specs/ location.""" + from core.workspace.setup import copy_spec_to_worktree + + # Create source spec directory + source_spec = temp_git_repo / "specs" / "test-spec" + source_spec.mkdir(parents=True) + (source_spec / "spec.md").write_text("# Test Spec", encoding="utf-8") + (source_spec / "requirements.json").write_text("{}", encoding="utf-8") + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Copy spec + result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec") + + # Verify path is correct + expected = worktree_path / ".auto-claude" / "specs" / "test-spec" + assert result == expected + + # Verify files were copied + assert (expected / "spec.md").exists() + assert (expected / "requirements.json").exists() + assert (expected / "spec.md").read_text(encoding="utf-8") == "# Test Spec" + + def test_overwrites_existing_spec_in_worktree(self, temp_git_repo: Path): + """Overwrites spec files if they already exist in worktree.""" + from core.workspace.setup import copy_spec_to_worktree + + # Create source spec + source_spec = temp_git_repo / "specs" / "test-spec" + source_spec.mkdir(parents=True) + (source_spec / "spec.md").write_text("# New Spec", encoding="utf-8") + + # Create worktree with existing spec + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + existing_spec = worktree_path / ".auto-claude" / "specs" / "test-spec" + existing_spec.mkdir(parents=True) + (existing_spec / "spec.md").write_text("# Old Spec", encoding="utf-8") + + # Copy spec + result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec") + + # Verify new content was copied + assert (result / "spec.md").read_text(encoding="utf-8") == "# New Spec" + + def test_creates_parent_directories(self, temp_git_repo: Path): + """Creates .auto-claude/specs directory if it doesn't exist.""" + from core.workspace.setup import copy_spec_to_worktree + + source_spec = temp_git_repo / "specs" / "test-spec" + source_spec.mkdir(parents=True) + (source_spec / "spec.md").write_text("# Test", encoding="utf-8") + + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec") + + # Parent directories should be created + assert result.exists() + assert (result.parent).exists() + + +class TestEnsureTimelineHookInstalled: + """Tests for ensure_timeline_hook_installed function.""" + + def test_skips_if_not_git_repo(self, temp_dir: Path): + """Skips hook installation if directory is not a git repo.""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Should not raise exception + ensure_timeline_hook_installed(temp_dir) + + def test_skips_if_hook_already_installed(self, temp_git_repo: Path, monkeypatch): + """Skips if FileTimelineTracker hook is already installed.""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Create hooks directory + hooks_dir = temp_git_repo / ".git" / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + + # Create hook with FileTimelineTracker marker + hook_file = hooks_dir / "post-commit" + hook_file.write_text( + "#!/bin/sh\n# FileTimelineTracker hook\necho 'tracked'", encoding="utf-8" + ) + + # Mock install_hook to track if it was called + install_called = [] + + def mock_install_hook(project_dir): + install_called.append(True) + + monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook) + + ensure_timeline_hook_installed(temp_git_repo) + + # install_hook should not be called + assert len(install_called) == 0 + + def test_installs_hook_if_missing(self, temp_git_repo: Path): + """Installs hook if it doesn't exist.""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Create hooks directory but no hook file + hooks_dir = temp_git_repo / ".git" / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + + # This test verifies the function runs without error + # The actual install_hook call is hard to mock because it's imported locally + # In production, the real install_hook would be called + ensure_timeline_hook_installed(temp_git_repo) + + # Verify hooks directory exists (function ran) + assert hooks_dir.exists() + + +class TestInitializeTimelineTracking: + """Tests for initialize_timeline_tracking function.""" + + def test_with_implementation_plan(self, temp_git_repo: Path, monkeypatch): + """Initializes tracking with files from implementation plan.""" + from core.workspace.setup import initialize_timeline_tracking + + # Create source spec with implementation plan + spec_name = "test-spec" + source_spec = temp_git_repo / ".auto-claude" / "specs" / spec_name + source_spec.mkdir(parents=True) + + plan = { + "title": "Test Feature", + "description": "Test description", + "phases": [ + { + "subtasks": [ + {"files": ["app/main.py", "app/utils.py"]}, + {"files": ["tests/test_main.py"]}, + ] + } + ], + } + (source_spec / "implementation_plan.json").write_text( + json.dumps(plan), encoding="utf-8" + ) + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name + ) + worktree_path.mkdir(parents=True) + + # Mock FileTimelineTracker + mock_tracker_calls = [] + + class MockTracker: + def __init__(self, project_dir): + pass + + def on_task_start( + self, + task_id, + files_to_modify, + branch_point_commit, + task_intent, + task_title, + ): + mock_tracker_calls.append( + { + "task_id": task_id, + "files": files_to_modify, + "branch": branch_point_commit, + "intent": task_intent, + "title": task_title, + } + ) + + monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", MockTracker) + + initialize_timeline_tracking( + temp_git_repo, spec_name, worktree_path, source_spec + ) + + # Verify tracker was called with correct parameters + assert len(mock_tracker_calls) == 1 + call = mock_tracker_calls[0] + assert call["task_id"] == spec_name + assert set(call["files"]) == { + "app/main.py", + "app/utils.py", + "tests/test_main.py", + } + assert call["title"] == "Test Feature" + assert call["intent"] == "Test description" + + def test_without_implementation_plan(self, temp_git_repo: Path, monkeypatch): + """Initializes tracking retroactively from worktree if no plan.""" + from core.workspace.setup import initialize_timeline_tracking + + spec_name = "test-spec" + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name + ) + worktree_path.mkdir(parents=True) + + # Mock FileTimelineTracker + mock_calls = [] + + class MockTracker: + def __init__(self, project_dir): + pass + + def initialize_from_worktree( + self, task_id, worktree_path, task_intent, task_title + ): + mock_calls.append( + { + "task_id": task_id, + "worktree": worktree_path, + "intent": task_intent, + "title": task_title, + } + ) + + monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", MockTracker) + + initialize_timeline_tracking(temp_git_repo, spec_name, worktree_path, None) + + # Should use retroactive initialization + assert len(mock_calls) == 1 + assert mock_calls[0]["task_id"] == spec_name + + def test_handles_exception_gracefully( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Logs warning but doesn't raise exception on error.""" + from core.workspace.setup import initialize_timeline_tracking + + spec_name = "test-spec" + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name + ) + worktree_path.mkdir(parents=True) + + # Mock FileTimelineTracker to raise exception + class FailingTracker: + def __init__(self, project_dir): + raise Exception("Tracker init failed") + + monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", FailingTracker) + + # Should not raise + initialize_timeline_tracking(temp_git_repo, spec_name, worktree_path, None) + + # Should print warning + captured = capsys.readouterr() + assert "Timeline tracking" in captured.out or "Note:" in captured.out diff --git a/apps/backend/core/workspace/tests/test_workspace.py b/apps/backend/core/workspace/tests/test_workspace.py new file mode 100644 index 00000000..d2d0e57e --- /dev/null +++ b/apps/backend/core/workspace/tests/test_workspace.py @@ -0,0 +1,2293 @@ +#!/usr/bin/env python3 +""" +Tests for Workspace Selection and Management +============================================= + +Tests the workspace.py module functionality including: +- Workspace mode selection (isolated vs direct) +- Uncommitted changes detection +- Workspace setup +- Build finalization workflows +""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +# Add parent directory to path so we can import the workspace module +# When co-located at workspace/tests/, we need to add backend to path +# workspace/tests -> workspace -> core -> backend (4 levels up) +_backend = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(_backend)) + +from core.workspace import ( + WorkspaceChoice, + WorkspaceMode, + get_current_branch, + get_existing_build_worktree, + has_uncommitted_changes, + setup_workspace, +) +from core.workspace.models import ( + MergeLock, + MergeLockError, + SpecNumberLock, + SpecNumberLockError, +) +from worktree import WorktreeError, WorktreeManager + +# Test constant - in the new per-spec architecture, each spec has its own worktree +# named after the spec itself. This constant is used for test assertions. +TEST_SPEC_NAME = "test-spec" + + +class TestHasUncommittedChanges: + """Tests for uncommitted changes detection.""" + + def test_clean_repo_no_changes(self, temp_git_repo: Path): + """Clean repo returns False.""" + result = has_uncommitted_changes(temp_git_repo) + assert result is False + + def test_untracked_file_has_changes(self, temp_git_repo: Path): + """Untracked file counts as changes.""" + (temp_git_repo / "new_file.txt").write_text("content", encoding="utf-8") + + result = has_uncommitted_changes(temp_git_repo) + assert result is True + + def test_modified_file_has_changes(self, temp_git_repo: Path): + """Modified tracked file counts as changes.""" + (temp_git_repo / "README.md").write_text("modified content", encoding="utf-8") + + result = has_uncommitted_changes(temp_git_repo) + assert result is True + + def test_staged_file_has_changes(self, temp_git_repo: Path): + """Staged file counts as changes.""" + (temp_git_repo / "README.md").write_text("modified", encoding="utf-8") + subprocess.run( + ["git", "add", "README.md"], cwd=temp_git_repo, capture_output=True + ) + + result = has_uncommitted_changes(temp_git_repo) + assert result is True + + +class TestGetCurrentBranch: + """Tests for current branch detection.""" + + def test_gets_main_branch(self, temp_git_repo: Path): + """Gets the main/master branch.""" + branch = get_current_branch(temp_git_repo) + + # Could be main or master depending on git config + assert branch in ["main", "master"] + + def test_gets_feature_branch(self, temp_git_repo: Path): + """Gets feature branch name.""" + subprocess.run( + ["git", "checkout", "-b", "feature/test-branch"], + cwd=temp_git_repo, + capture_output=True, + ) + + branch = get_current_branch(temp_git_repo) + assert branch == "feature/test-branch" + + +class TestGetExistingBuildWorktree: + """Tests for existing build worktree detection.""" + + def test_no_existing_worktree(self, temp_git_repo: Path): + """Returns None when no worktree exists.""" + result = get_existing_build_worktree(temp_git_repo, "test-spec") + assert result is None + + def test_existing_worktree(self, temp_git_repo: Path): + """Returns path when worktree exists.""" + # Create the worktree directory structure (per-spec architecture) + worktree_path = temp_git_repo / ".worktrees" / TEST_SPEC_NAME + worktree_path.mkdir(parents=True) + + result = get_existing_build_worktree(temp_git_repo, TEST_SPEC_NAME) + assert result == worktree_path + + +class TestSetupWorkspace: + """Tests for workspace setup.""" + + def test_setup_direct_mode(self, temp_git_repo: Path): + """Direct mode returns project dir and no manager.""" + working_dir, manager, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.DIRECT, + ) + + assert working_dir == temp_git_repo + assert manager is None + + def test_setup_isolated_mode(self, temp_git_repo: Path): + """Isolated mode creates worktree and returns manager.""" + working_dir, manager, _ = setup_workspace( + temp_git_repo, + TEST_SPEC_NAME, + WorkspaceMode.ISOLATED, + ) + + assert working_dir != temp_git_repo + assert manager is not None + assert working_dir.exists() + # Per-spec architecture: worktree is named after the spec + assert working_dir.name == TEST_SPEC_NAME + + def test_setup_isolated_creates_worktrees_dir(self, temp_git_repo: Path): + """Isolated mode creates worktrees directory.""" + setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + assert (temp_git_repo / ".auto-claude" / "worktrees" / "tasks").exists() + + +class TestWorkspaceUtilities: + """Tests for workspace utility functions.""" + + def test_per_spec_worktree_naming(self, temp_git_repo: Path): + """Per-spec architecture uses spec name for worktree directory.""" + spec_name = "my-spec-001" + working_dir, manager, _ = setup_workspace( + temp_git_repo, + spec_name, + WorkspaceMode.ISOLATED, + ) + + # Worktree should be named after the spec + assert working_dir.name == spec_name + # New path: .auto-claude/worktrees/tasks/{spec_name} + assert working_dir.parent.name == "tasks" + + +class TestWorkspaceIntegration: + """Integration tests for workspace management.""" + + def test_isolated_workflow(self, temp_git_repo: Path): + """Full isolated workflow: setup -> work -> finalize.""" + # Setup isolated workspace + working_dir, manager, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Make changes in workspace + (working_dir / "feature.py").write_text("# New feature\n", encoding="utf-8") + + # Verify changes are in workspace + assert (working_dir / "feature.py").exists() + + # Verify changes are NOT in main project + assert not (temp_git_repo / "feature.py").exists() + + def test_direct_workflow(self, temp_git_repo: Path): + """Full direct workflow: setup -> work.""" + # Setup direct workspace + working_dir, manager, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.DIRECT, + ) + + # Working dir is the project dir + assert working_dir == temp_git_repo + + # Make changes directly + (working_dir / "feature.py").write_text("# New feature\n", encoding="utf-8") + + # Changes are in main project + assert (temp_git_repo / "feature.py").exists() + + def test_isolated_merge(self, temp_git_repo: Path): + """Can merge isolated workspace back to main.""" + # Setup + working_dir, manager, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Make changes and commit using git directly + (working_dir / "feature.py").write_text("# New feature\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add feature"], cwd=working_dir, capture_output=True + ) + + # Merge back using merge_worktree + result = manager.merge_worktree("test-spec", delete_after=False) + + assert result is True + + # Check changes are in main + subprocess.run( + ["git", "checkout", manager.base_branch], + cwd=temp_git_repo, + capture_output=True, + ) + assert (temp_git_repo / "feature.py").exists() + + +class TestWorkspaceCleanup: + """Tests for workspace cleanup.""" + + def test_cleanup_after_merge(self, temp_git_repo: Path): + """Workspace is cleaned up after merge with delete_after=True.""" + working_dir, manager, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Commit changes using git directly + (working_dir / "test.py").write_text("test", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Test"], cwd=working_dir, capture_output=True + ) + + # Merge with cleanup + manager.merge_worktree("test-spec", delete_after=True) + + # Workspace should be removed + assert not working_dir.exists() + + def test_workspace_preserved_after_merge_no_delete(self, temp_git_repo: Path): + """Workspace preserved after merge with delete_after=False.""" + working_dir, manager, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Commit changes using git directly + (working_dir / "test.py").write_text("test", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Test"], cwd=working_dir, capture_output=True + ) + + # Merge without cleanup + manager.merge_worktree("test-spec", delete_after=False) + + # Workspace should still exist + assert working_dir.exists() + + +class TestWorkspaceReuse: + """Tests for reusing existing workspaces.""" + + def test_reuse_existing_workspace(self, temp_git_repo: Path): + """Can reuse existing workspace on second setup.""" + # First setup + working_dir1, manager1, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Add a marker file + (working_dir1 / "marker.txt").write_text("marker", encoding="utf-8") + + # Second setup (should reuse) + working_dir2, manager2, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Should be the same directory + assert working_dir1 == working_dir2 + + # Marker should still exist + assert (working_dir2 / "marker.txt").exists() + + +class TestWorkspaceErrors: + """Tests for workspace error handling.""" + + def test_setup_non_git_directory(self, temp_dir: Path): + """Handles non-git directories gracefully.""" + # This should fail because temp_dir is not a git repo + with pytest.raises( + (OSError, ValueError, subprocess.CalledProcessError, WorktreeError) + ): + setup_workspace( + temp_dir, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + +class TestPerSpecWorktreeName: + """Tests for per-spec worktree naming (new architecture).""" + + def test_worktree_named_after_spec(self, temp_git_repo: Path): + """Worktree is named after the spec.""" + spec_name = "spec-1" + working_dir, _, _ = setup_workspace( + temp_git_repo, + spec_name, + WorkspaceMode.ISOLATED, + ) + + # Per-spec architecture: worktree directory matches spec name + assert working_dir.name == spec_name + + def test_different_specs_get_different_worktrees(self, temp_git_repo: Path): + """Different specs create separate worktrees.""" + working_dir1, _, _ = setup_workspace( + temp_git_repo, + "spec-1", + WorkspaceMode.ISOLATED, + ) + + working_dir2, _, _ = setup_workspace( + temp_git_repo, + "spec-2", + WorkspaceMode.ISOLATED, + ) + + # Each spec has its own worktree + assert working_dir1.name == "spec-1" + assert working_dir2.name == "spec-2" + assert working_dir1 != working_dir2 + + def test_worktree_path_in_worktrees_dir(self, temp_git_repo: Path): + """Worktree is created in worktrees directory.""" + working_dir, _, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # New path: .auto-claude/worktrees/tasks/{spec_name} + assert "worktrees" in str(working_dir) + assert working_dir.parent.name == "tasks" + + +class TestConflictInfoDisplay: + """Tests for conflict info display function (ACS-179).""" + + def test_print_conflict_info_with_string_list(self, capsys): + """print_conflict_info handles string list of file paths (ACS-179).""" + from core.workspace.display import print_conflict_info + + result = {"conflicts": ["file1.txt", "file2.py", "file3.js"]} + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "3 file" in captured.out + assert "file1.txt" in captured.out + assert "file2.py" in captured.out + assert "file3.js" in captured.out + assert "git add" in captured.out + + def test_print_conflict_info_with_dict_list(self, capsys): + """print_conflict_info handles dict list with file/reason/severity (ACS-179).""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + {"file": "file1.txt", "reason": "Syntax error", "severity": "high"}, + {"file": "file2.py", "reason": "Merge conflict", "severity": "medium"}, + {"file": "file3.js", "reason": "Unknown error", "severity": "low"}, + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "3 file" in captured.out + assert "file1.txt" in captured.out + assert "file2.py" in captured.out + assert "file3.js" in captured.out + assert "Syntax error" in captured.out + assert "Merge conflict" in captured.out + # Verify severity emoji indicators + assert "🔴" in captured.out # High severity + assert "🟡" in captured.out # Medium severity + + def test_print_conflict_info_mixed_formats(self, capsys): + """print_conflict_info handles mixed string and dict conflicts (ACS-179).""" + from core.workspace.display import print_conflict_info + + result = { + "conflicts": [ + "simple-file.txt", + { + "file": "complex-file.py", + "reason": "AI merge failed", + "severity": "high", + }, + ] + } + + print_conflict_info(result) + + captured = capsys.readouterr() + assert "2 file" in captured.out + assert "simple-file.txt" in captured.out + assert "complex-file.py" in captured.out + assert "AI merge failed" in captured.out + + +class TestMergeErrorHandling: + """Tests for merge error handling (ACS-163).""" + + def test_merge_failure_returns_false_immediately(self, temp_git_repo: Path): + """Failed merge returns False without falling through (ACS-163).""" + manager = WorktreeManager(temp_git_repo) + manager.setup() + + # Create a worktree with changes + worker_info = manager.create_worktree("worker-spec") + (worker_info.path / "worker-file.txt").write_text( + "worker content", encoding="utf-8" + ) + subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Worker commit"], + cwd=worker_info.path, + capture_output=True, + ) + + # Create a conflicting change on main + subprocess.run( + ["git", "checkout", manager.base_branch], + cwd=temp_git_repo, + capture_output=True, + ) + (temp_git_repo / "worker-file.txt").write_text("main content", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Main commit"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Merge should fail (conflict) and return False + # This tests the fix for ACS-163 where failed merge would fall through + result = manager.merge_worktree("worker-spec", delete_after=False) + + # Should return False on merge conflict + assert result is False + + # Verify side effects: base branch content is unchanged + subprocess.run( + ["git", "checkout", manager.base_branch], + cwd=temp_git_repo, + capture_output=True, + ) + base_content = (temp_git_repo / "worker-file.txt").read_text(encoding="utf-8") + assert base_content == "main content", ( + "Base branch should be unchanged after failed merge" + ) + + +class TestMergeLockExceptionHandling: + """Tests for exception handling in MergeLock.__exit__ (lines 136-137).""" + + def test_merge_lock_exit_handles_already_deleted_lock(self, temp_git_repo: Path): + """MergeLock.__exit__ handles lock file already being deleted (lines 136-137).""" + lock = MergeLock(temp_git_repo, "test-spec") + + with lock: + assert lock.acquired is True + # Delete the lock file manually before context exits + lock.lock_file.unlink() + + # Should exit cleanly even though lock file was already deleted + assert lock.lock_file.exists() is False + + +class TestSpecNumberLockExceptionHandling: + """Tests for exception handling in SpecNumberLock.__exit__ (lines 225-226).""" + + def test_spec_number_lock_exit_handles_already_deleted_lock( + self, temp_git_repo: Path + ): + """SpecNumberLock.__exit__ handles lock file already being deleted (lines 225-226).""" + lock = SpecNumberLock(temp_git_repo) + + with lock: + assert lock.acquired is True + # Delete the lock file manually before context exits + lock.lock_file.unlink() + + # Should exit cleanly even though lock file was already deleted + assert lock.lock_file.exists() is False + + +class TestScanSpecsDirValueErrorHandling: + """Tests for ValueError handling in _scan_specs_dir (lines 272-273).""" + + def test_scan_specs_dir_handles_non_numeric_prefix(self, temp_git_repo: Path): + """_scan_specs_dir handles directories with non-numeric prefix (lines 272-273).""" + lock = SpecNumberLock(temp_git_repo) + + # Create specs directory with invalid names + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + + # Create directories with various invalid prefixes + (specs_dir / "abc-invalid").mkdir() + (specs_dir / "xyz-test").mkdir() + (specs_dir / "--bad").mkdir() + + with lock: + result = lock._scan_specs_dir(specs_dir) + + # Should ignore directories with non-numeric prefixes and return 0 + assert result == 0 + + def test_scan_specs_dir_handles_partial_numeric_prefix(self, temp_git_repo: Path): + """_scan_specs_dir handles directories with partial numeric prefix (lines 272-273).""" + lock = SpecNumberLock(temp_git_repo) + + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + + # Create directories with partial numeric prefixes + (specs_dir / "12-invalid").mkdir() # Only 2 digits + (specs_dir / "1-bad").mkdir() # Only 1 digit + (specs_dir / "001-valid").mkdir() # Valid + + with lock: + result = lock._scan_specs_dir(specs_dir) + + # Should only count the valid 3-digit prefix + assert result == 1 + + def test_scan_specs_dir_handles_empty_directory_name(self, temp_git_repo: Path): + """_scan_specs_dir handles empty directory names gracefully (lines 272-273).""" + lock = SpecNumberLock(temp_git_repo) + + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + + # Create directory that's just dashes (would cause issues with [:3]) + (specs_dir / "---").mkdir() + + with lock: + result = lock._scan_specs_dir(specs_dir) + + # Should handle gracefully without crashing + assert result == 0 + + def test_scan_specs_dir_handles_very_long_numeric_prefix(self, temp_git_repo: Path): + """_scan_specs_dir handles directories with long numeric strings (lines 272-273).""" + lock = SpecNumberLock(temp_git_repo) + + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + + # Create directory with high spec number (tests parsing first 3 digits) + # The glob pattern "[0-9][0-9][0-9]-*" matches exactly 3 digits, so use 999 + (specs_dir / "999-high-spec").mkdir() + + with lock: + result = lock._scan_specs_dir(specs_dir) + + # Should parse the first 3 digits as number + assert result == 999 + + def test_scan_specs_dir_handles_mixed_valid_invalid(self, temp_git_repo: Path): + """_scan_specs_dir handles mix of valid and invalid spec directories (lines 272-273).""" + lock = SpecNumberLock(temp_git_repo) + + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + + # Mix of valid and invalid directories + (specs_dir / "001-first").mkdir() + (specs_dir / "invalid-name").mkdir() + (specs_dir / "005-second").mkdir() + (specs_dir / "abc").mkdir() + (specs_dir / "010-third").mkdir() + + with lock: + result = lock._scan_specs_dir(specs_dir) + + # Should only count valid directories and return max + assert result == 10 + + +# ============================================================================= +# TESTS FOR WORKSPACE SETUP (core.workspace.setup) - MISSING COVERAGE +# ============================================================================= + + +class TestChooseWorkspace: + """Tests for choose_workspace function (lines 52-146).""" + + def test_force_isolated_mode(self, temp_git_repo: Path, monkeypatch): + """Returns ISOLATED mode when force_isolated is True (lines 75-76).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import choose_workspace + + # Mock has_uncommitted_changes to avoid its side effects + monkeypatch.setattr( + "core.workspace.setup.has_uncommitted_changes", lambda x: False + ) + + result = choose_workspace( + temp_git_repo, + "test-spec", + force_isolated=True, + ) + + assert result == WorkspaceMode.ISOLATED + + def test_force_direct_mode(self, temp_git_repo: Path, monkeypatch): + """Returns DIRECT mode when force_direct is True (lines 77-78).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import choose_workspace + + # Mock has_uncommitted_changes to avoid its side effects + monkeypatch.setattr( + "core.workspace.setup.has_uncommitted_changes", lambda x: False + ) + + result = choose_workspace( + temp_git_repo, + "test-spec", + force_direct=True, + ) + + assert result == WorkspaceMode.DIRECT + + def test_auto_continue_defaults_to_isolated( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Auto-continue mode defaults to isolated for safety (lines 81-83).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import choose_workspace + + # Mock has_uncommitted_changes to avoid its side effects + monkeypatch.setattr( + "core.workspace.setup.has_uncommitted_changes", lambda x: False + ) + + result = choose_workspace( + temp_git_repo, + "test-spec", + auto_continue=True, + ) + + assert result == WorkspaceMode.ISOLATED + captured = capsys.readouterr() + assert "Auto-continue" in captured.out + + def test_unsaved_work_triggers_isolated(self, temp_git_repo: Path, monkeypatch): + """Uncommitted changes trigger isolated mode (lines 86-110).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import choose_workspace + + # Mock has_uncommitted_changes to return True + monkeypatch.setattr( + "core.workspace.setup.has_uncommitted_changes", lambda x: True + ) + + # Mock input to simulate Enter key press + monkeypatch.setattr("builtins.input", lambda x: None) + + result = choose_workspace( + temp_git_repo, + "test-spec", + ) + + assert result == WorkspaceMode.ISOLATED + + def test_unsaved_work_with_keyboard_interrupt( + self, temp_git_repo: Path, monkeypatch + ): + """KeyboardInterrupt during unsaved work prompt exits cleanly (lines 105-108).""" + import sys + + from core.workspace.setup import choose_workspace + + # Mock has_uncommitted_changes to return True + monkeypatch.setattr( + "core.workspace.setup.has_uncommitted_changes", lambda x: True + ) + + # Mock input to raise KeyboardInterrupt + def mock_input(prompt): + raise KeyboardInterrupt() + + monkeypatch.setattr("builtins.input", mock_input) + + # Should exit via sys.exit(0) + with pytest.raises(SystemExit) as exc_info: + choose_workspace(temp_git_repo, "test-spec") + + assert exc_info.value.code == 0 + + +class TestDebugModuleFallback: + """Tests for debug module fallback functions (lines 35-43).""" + + def test_fallback_debug_function(self, monkeypatch): + """Fallback debug function does nothing when module is unavailable.""" + # Remove debug from sys.modules if present + import sys + + debug_module = sys.modules.pop("debug", None) + + try: + # Re-import setup.py to trigger the fallback + monkeypatch.setattr(sys, "modules", {**sys.modules}) + if "core.workspace.setup" in sys.modules: + del sys.modules["core.workspace.setup"] + + # Import fresh - should use fallback + import core.workspace.setup as setup_module + + # Fallback debug functions should be no-ops + setup_module.debug("test", "message") + setup_module.debug_warning("test", "warning") + + # Should not raise any exceptions + assert True + finally: + # Restore debug module if it existed + if debug_module is not None: + sys.modules["debug"] = debug_module + + def test_fallback_debug_warning_function(self, monkeypatch): + """Fallback debug_warning function does nothing when module is unavailable.""" + import sys + + # Remove debug from sys.modules if present + debug_module = sys.modules.pop("debug", None) + + try: + # Force reimport to use fallback + if "core.workspace.setup" in sys.modules: + del sys.modules["core.workspace.setup"] + + from core.workspace.setup import debug_warning + + # Fallback function should be a no-op + debug_warning("test_module", "test_warning") + + # Should not raise any exceptions + assert True + finally: + if debug_module is not None: + sys.modules["debug"] = debug_module + + +class TestSymlinkBrokenSymlinkDetection: + """Tests for broken symlink detection (lines 242-247).""" + + @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific symlink test") + def test_skips_broken_symlinks(self, temp_git_repo: Path): + """Skips creating symlink if broken symlink already exists (lines 242-247).""" + from core.workspace.setup import symlink_node_modules_to_worktree + + # Create node_modules in project + node_modules = temp_git_repo / "node_modules" + node_modules.mkdir() + (node_modules / "test.txt").write_text("test", encoding="utf-8") + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Create a broken symlink (pointing to non-existent path) + non_existent_path = temp_git_repo / "non_existent_path" + os.symlink( + non_existent_path, worktree_path / "node_modules", target_is_directory=False + ) + + # Verify symlink is broken + assert (worktree_path / "node_modules").is_symlink() + assert not (worktree_path / "node_modules").exists() + + # Should skip the broken symlink + symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path) + + # node_modules should not be in symlinked list + assert "node_modules" not in symlinked + + +class TestWindowsJunctionFailure: + """Tests for Windows junction creation failure (lines 256-262).""" + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") + def test_handles_mklink_failure(self, temp_git_repo: Path, monkeypatch, capsys): + """Handles mklink /J failure gracefully (lines 256-262).""" + from unittest.mock import patch + + from core.workspace.setup import symlink_node_modules_to_worktree + + # Create node_modules in project + node_modules = temp_git_repo / "node_modules" + node_modules.mkdir() + (node_modules / "test.txt").write_text("test", encoding="utf-8") + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Mock subprocess.run to simulate mklink failure + def mock_subprocess_run(cmd, capture_output=False, text=False): + result = type( + "obj", (object,), {"returncode": 1, "stderr": "Access denied"} + )() + return result + + with patch("subprocess.run", side_effect=mock_subprocess_run): + with monkeypatch.context() as m: + m.setattr("sys.platform", "win32") + symlinked = symlink_node_modules_to_worktree( + temp_git_repo, worktree_path + ) + + # Should handle failure gracefully + assert "node_modules" not in symlinked + + +class TestSymlinkOSErrorHandling: + """Tests for OSError handling in symlink creation (lines 269-278).""" + + @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test") + def test_handles_oserror_on_symlink_creation( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Handles OSError when symlink creation fails (lines 269-281).""" + from unittest.mock import patch + + from core.workspace.setup import symlink_node_modules_to_worktree + + # Create node_modules in project + node_modules = temp_git_repo / "node_modules" + node_modules.mkdir() + (node_modules / "test.txt").write_text("test", encoding="utf-8") + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Mock os.symlink to raise OSError + def mock_symlink(src, dst): + raise OSError("Filesystem does not support symlinks") + + with patch("os.symlink", side_effect=mock_symlink): + symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path) + + # Should handle error gracefully + assert "node_modules" not in symlinked + + # Check warning message was printed + captured = capsys.readouterr() + assert "Warning" in captured.out or "node_modules" in captured.out + + +class TestEnvFilesPrintStatus: + """Tests for env files copy print status (line 373).""" + + def test_prints_status_when_env_files_copied(self, temp_git_repo: Path, capsys): + """Prints status message when env files are copied (line 373-375).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import setup_workspace + + # Create .env file in project root + (temp_git_repo / ".env").write_text("TEST=1", encoding="utf-8") + + # Setup isolated workspace - .env should be copied + setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + captured = capsys.readouterr() + assert "Environment files copied" in captured.out + + +class TestSymlinkedModulesPrintStatus: + """Tests for symlinked modules print status (line 383).""" + + @pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific symlink test") + def test_prints_status_when_modules_symlinked(self, temp_git_repo: Path, capsys): + """Prints status message when node_modules are symlinked (line 383).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import setup_workspace + + # Create backend/.venv to trigger Python virtual environment detection + # This is a common pattern in this monorepo + backend_venv = temp_git_repo / "apps" / "backend" / ".venv" + backend_venv.mkdir(parents=True) + (backend_venv / "lib").mkdir() + + # Create node_modules at root + node_modules = temp_git_repo / "node_modules" + node_modules.mkdir() + (node_modules / "package.json").write_text("{}", encoding="utf-8") + + # Create apps/frontend/node_modules + frontend_node_modules = temp_git_repo / "apps" / "frontend" / "node_modules" + frontend_node_modules.mkdir(parents=True) + (frontend_node_modules / "react").mkdir() + + # Setup isolated workspace - node_modules should be symlinked + setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + captured = capsys.readouterr() + assert "Dependencies linked" in captured.out + + +class TestSecurityFilesCopy: + """Tests for security files copy with error handling (lines 395-407).""" + + def test_copies_security_files(self, temp_git_repo: Path): + """Copies security configuration files to worktree (lines 389-406).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import setup_workspace + from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME + + # Create security files + allowlist_file = temp_git_repo / ALLOWLIST_FILENAME + allowlist_file.write_text("allowlist content", encoding="utf-8") + + profile_file = temp_git_repo / PROFILE_FILENAME + profile_file.write_text('{"profile": "data"}', encoding="utf-8") + + # Commit changes + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add security files"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Setup workspace + worktree_path, _, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Verify files were copied + assert (worktree_path / ALLOWLIST_FILENAME).exists() + assert (worktree_path / PROFILE_FILENAME).exists() + assert (worktree_path / ALLOWLIST_FILENAME).read_text( + encoding="utf-8" + ) == "allowlist content" + + def test_handles_security_file_copy_error( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Handles OSError when copying security files (lines 399-406).""" + from unittest.mock import patch + + from core.workspace.setup import copy_env_files_to_worktree + from security.constants import ALLOWLIST_FILENAME + + # Create security file + allowlist_file = temp_git_repo / ALLOWLIST_FILENAME + allowlist_file.write_text("content", encoding="utf-8") + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Mock shutil.copy2 to raise PermissionError + def mock_copy2(src, dst): + if ALLOWLIST_FILENAME in str(src): + raise PermissionError("Access denied") + return shutil.copy2(src, dst) + + with patch("shutil.copy2", side_effect=mock_copy2): + # This should handle the error gracefully + copy_env_files_to_worktree(temp_git_repo, worktree_path) + + # Function should complete without raising + assert True + + +class TestSecurityProfileInheritance: + """Tests for security profile inheritance marking (lines 413-428).""" + + def test_marks_profile_as_inherited(self, temp_git_repo: Path): + """Marks security profile with inherited_from field (lines 416-428).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import setup_workspace + from security.constants import PROFILE_FILENAME + + # Create security profile + profile_data = {"profile": "test-profile", "project_type": "python"} + profile_file = temp_git_repo / PROFILE_FILENAME + profile_file.write_text(json.dumps(profile_data, indent=2), encoding="utf-8") + + # Commit changes + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add profile"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Setup workspace + worktree_path, _, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Verify profile was marked as inherited + worktree_profile = worktree_path / PROFILE_FILENAME + assert worktree_profile.exists() + + with open(worktree_profile, encoding="utf-8") as f: + worktree_profile_data = json.load(f) + + assert "inherited_from" in worktree_profile_data + assert str(temp_git_repo.resolve()) in worktree_profile_data["inherited_from"] + + def test_handles_corrupt_profile_json(self, temp_git_repo: Path, capsys): + """Handles JSON decode error when reading profile (line 427-428).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import setup_workspace + from security.constants import PROFILE_FILENAME + + # Create corrupt profile file + profile_file = temp_git_repo / PROFILE_FILENAME + profile_file.write_text("{invalid json content", encoding="utf-8") + + # Commit changes + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add corrupt profile"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Setup workspace - should handle error gracefully + worktree_path, _, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Verify worktree was created despite corrupt profile + assert worktree_path.exists() + + +class TestSpecCopyInSetupWorkspace: + """Tests for spec copy in setup_workspace (lines 441-445).""" + + def test_copies_spec_to_workspace(self, temp_git_repo: Path): + """Copies spec files to workspace when source_spec_dir is provided (lines 441-445).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import setup_workspace + + # Create source spec directory + source_spec = temp_git_repo / "external-specs" / "test-spec" + source_spec.mkdir(parents=True) + (source_spec / "spec.md").write_text("# Test Spec", encoding="utf-8") + (source_spec / "requirements.json").write_text("{}", encoding="utf-8") + + # Setup workspace with source spec + worktree_path, _, localized_spec = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + source_spec_dir=source_spec, + ) + + # Verify spec was copied + assert localized_spec is not None + assert localized_spec.exists() + assert (localized_spec / "spec.md").exists() + assert (localized_spec / "requirements.json").exists() + + def test_skips_spec_copy_when_source_not_exists(self, temp_git_repo: Path): + """Skips spec copy when source_spec_dir does not exist (lines 441-445).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import setup_workspace + + # Setup workspace with non-existent source spec + non_existent_spec = temp_git_repo / "non-existent-spec" + + worktree_path, _, localized_spec = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + source_spec_dir=non_existent_spec, + ) + + # localized_spec should be None + assert localized_spec is None + + +class TestTimelineHookNotGitRepo: + """Tests for ensure_timeline_hook_installed with non-git directory (line 477).""" + + def test_returns_early_when_not_git_repo(self, temp_dir: Path): + """Returns early when directory is not a git repository (line 477).""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Should not raise any exception + ensure_timeline_hook_installed(temp_dir) + + # Function should return without doing anything + assert True + + +class TestTimelineHookWorktreeGitFile: + """Tests for worktree .git file handling (lines 480-485).""" + + def test_handles_worktree_git_file(self, temp_git_repo: Path): + """Handles worktree where .git is a file, not directory (lines 480-485).""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Create a worktree-style .git file + git_dir = temp_git_repo / ".git" + git_dir_content = "gitdir: .git/worktrees/test\n" + + # Save original .git directory + git_backup = temp_git_repo / ".git.backup" + if git_dir.is_dir(): + shutil.move(str(git_dir), str(git_backup)) + + try: + # Create .git as a file (worktree style) + git_dir.write_text(git_dir_content, encoding="utf-8") + + # Should handle this gracefully + ensure_timeline_hook_installed(temp_git_repo) + + assert True + finally: + # Restore original .git + if git_backup.exists(): + if git_dir.exists(): + git_dir.unlink() + shutil.move(str(git_backup), str(git_dir)) + + def test_handles_invalid_git_file_content(self, temp_git_repo: Path): + """Handles .git file with invalid content (lines 481-485).""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Create a .git file with invalid content + git_dir = temp_git_repo / ".git" + git_backup = temp_git_repo / ".git.backup" + + # Save original + if git_dir.is_dir(): + shutil.move(str(git_dir), str(git_backup)) + + try: + # Write invalid content (doesn't start with "gitdir:") + git_dir.write_text("invalid content", encoding="utf-8") + + # Should return early without error + ensure_timeline_hook_installed(temp_git_repo) + + assert True + finally: + if git_backup.exists(): + if git_dir.exists(): + git_dir.unlink() + shutil.move(str(git_backup), str(git_dir)) + + +class TestTimelineHookExistsCheck: + """Tests for hook exists check (lines 490-493).""" + + def test_skips_when_hook_already_exists(self, temp_git_repo: Path, monkeypatch): + """Skips installation when hook already exists with FileTimelineTracker (lines 490-493).""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Create hooks directory and hook file with FileTimelineTracker marker + hooks_dir = temp_git_repo / ".git" / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + + hook_file = hooks_dir / "post-commit" + hook_content = """#!/bin/sh +# FileTimelineTracker hook +git log -1 +""" + hook_file.write_text(hook_content, encoding="utf-8") + + # Track if install_hook was called + install_called = [] + + def mock_install_hook(project_dir): + install_called.append(True) + + monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook) + + ensure_timeline_hook_installed(temp_git_repo) + + # install_hook should NOT have been called + assert len(install_called) == 0 + + +class TestTimelineHookExceptionHandling: + """Tests for exception handling in ensure_timeline_hook_installed (lines 501-503).""" + + def test_handles_exception_gracefully(self, temp_git_repo: Path, monkeypatch): + """Handles exceptions during hook installation gracefully (lines 501-503).""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Mock install_hook to raise an exception + def mock_install_hook(project_dir): + raise RuntimeError("Hook installation failed") + + monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook) + + # Should not raise exception - should handle it via debug_warning + ensure_timeline_hook_installed(temp_git_repo) + + # Test passes if no exception was raised + assert True + + +class TestInitializeTimelineTrackingNoSourceSpec: + """Tests for initialize_timeline_tracking without source spec (lines 563-569).""" + + def test_initializes_from_worktree_without_plan(self, temp_git_repo: Path): + """Initializes tracking from worktree when no implementation plan exists (lines 563-569).""" + from core.workspace.setup import initialize_timeline_tracking + + # Create worktree with some changes + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + (worktree_path / "test.py").write_text("# Test file", encoding="utf-8") + + # Call without source_spec_dir + initialize_timeline_tracking( + project_dir=temp_git_repo, + spec_name="test-spec", + worktree_path=worktree_path, + source_spec_dir=None, + ) + + # Should complete without error + assert True + + +class TestInitializeTimelineTrackingWithNoFiles: + """Tests for initialize_timeline_tracking with no files to track.""" + + def test_handles_no_files_in_plan(self, temp_git_repo: Path): + """Handles implementation plan with no files to modify (lines 546-561).""" + from core.workspace.setup import initialize_timeline_tracking + + # Create source spec with empty implementation plan + source_spec = temp_git_repo / ".auto-claude" / "specs" / "test-spec" + source_spec.mkdir(parents=True) + + plan = {"title": "Empty Plan", "description": "No files", "phases": []} + (source_spec / "implementation_plan.json").write_text( + json.dumps(plan), encoding="utf-8" + ) + + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Should handle empty plan gracefully + initialize_timeline_tracking( + project_dir=temp_git_repo, + spec_name="test-spec", + worktree_path=worktree_path, + source_spec_dir=source_spec, + ) + + assert True + + +class TestFinalizationWorkspaceCdPathFallbacks: + """Tests for finalization cd path fallback when get_existing_build_worktree returns None (lines 176, 247).""" + + def test_test_choice_fallback_to_default_path( + self, temp_git_repo: Path, capsys, monkeypatch + ): + """Tests TEST choice shows default .auto-claude path when worktree not found (lines 172-180).""" + from core.workspace.finalization import handle_workspace_choice + from worktree import WorktreeManager + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Mock get_existing_build_worktree to return None (no worktree found) + def mock_get_existing_build_worktree(project_dir, spec_name): + return None + + monkeypatch.setattr( + "core.workspace.finalization.get_existing_build_worktree", + mock_get_existing_build_worktree, + ) + + handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager) + + captured = capsys.readouterr() + # Should show the default .auto-claude/worktrees/tasks/{spec_name} path + assert ".auto-claude/worktrees/tasks/test-spec" in captured.out + + def test_later_choice_fallback_to_default_path( + self, temp_git_repo: Path, capsys, monkeypatch + ): + """Tests LATER choice shows default path when worktree not found (lines 243-251).""" + from core.workspace.finalization import handle_workspace_choice + from worktree import WorktreeManager + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Mock get_existing_build_worktree to return None + def mock_get_existing_build_worktree(project_dir, spec_name): + return None + + monkeypatch.setattr( + "core.workspace.finalization.get_existing_build_worktree", + mock_get_existing_build_worktree, + ) + + handle_workspace_choice( + WorkspaceChoice.LATER, temp_git_repo, spec_name, manager + ) + + captured = capsys.readouterr() + # Should show the default .auto-claude/worktrees/tasks/{spec_name} path + assert ".auto-claude/worktrees/tasks/test-spec" in captured.out + + +class TestFinalizationWorkspaceCdPathWithExistingBuild: + """Tests for finalization cd path when get_existing_build_worktree returns a path (lines 174, 245).""" + + def test_test_choice_shows_existing_worktree_path( + self, temp_git_repo: Path, capsys, monkeypatch + ): + """Tests TEST choice shows worktree path when staging_path is None and get_existing_build_worktree returns path (line 174).""" + from core.workspace.finalization import handle_workspace_choice + from worktree import WorktreeManager + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create a worktree directory (plain directory, not a git worktree) + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name + ) + worktree_path.mkdir(parents=True) + + # Mock manager.get_worktree_info to return None (simulating no valid worktree info) + # This ensures staging_path will be None + monkeypatch.setattr(manager, "get_worktree_info", lambda spec_name: None) + + # Mock get_existing_build_worktree to return the worktree path + def mock_get_existing_build_worktree(project_dir, spec_name): + return worktree_path + + monkeypatch.setattr( + "core.workspace.finalization.get_existing_build_worktree", + mock_get_existing_build_worktree, + ) + + handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager) + + captured = capsys.readouterr() + # Should show the actual worktree path (via line 174) + assert str(worktree_path) in captured.out + + def test_later_choice_shows_existing_worktree_path( + self, temp_git_repo: Path, capsys, monkeypatch + ): + """Tests LATER choice shows worktree path when staging_path is None and get_existing_build_worktree returns path (line 245).""" + from core.workspace.finalization import handle_workspace_choice + from worktree import WorktreeManager + + manager = WorktreeManager(temp_git_repo) + spec_name = "test-spec" + + # Create a worktree directory (plain directory, not a git worktree) + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name + ) + worktree_path.mkdir(parents=True) + + # Mock manager.get_worktree_info to return None (simulating no valid worktree info) + # This ensures staging_path will be None + monkeypatch.setattr(manager, "get_worktree_info", lambda spec_name: None) + + # Mock get_existing_build_worktree to return the worktree path + def mock_get_existing_build_worktree(project_dir, spec_name): + return worktree_path + + monkeypatch.setattr( + "core.workspace.finalization.get_existing_build_worktree", + mock_get_existing_build_worktree, + ) + + handle_workspace_choice( + WorkspaceChoice.LATER, temp_git_repo, spec_name, manager + ) + + captured = capsys.readouterr() + # Should show the actual worktree path (via line 245) + assert str(worktree_path) in captured.out + + +class TestChooseWorkspaceMenuSelection: + """Tests for choose_workspace menu selection (lines 113-146).""" + + def test_shows_menu_with_isolated_and_direct_options( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Shows menu with isolated and direct options when no uncommitted changes (lines 113-146).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import choose_workspace + + # Mock has_uncommitted_changes to return False + monkeypatch.setattr( + "core.workspace.setup.has_uncommitted_changes", lambda x: False + ) + + # Mock select_menu to return "direct" choice + def mock_select_menu(title, options, allow_quit=False): + from ui import MenuOption + + # Verify the options are correct + assert len(options) == 2 + assert options[0].key == "isolated" + assert options[1].key == "direct" + assert "Separate workspace" in options[0].label + assert "Right here" in options[1].label + return "direct" + + monkeypatch.setattr("core.workspace.setup.select_menu", mock_select_menu) + + result = choose_workspace( + temp_git_repo, + "test-spec", + ) + + assert result == WorkspaceMode.DIRECT + captured = capsys.readouterr() + assert "Working directly in your project" in captured.out + + def test_menu_selects_isolated_returns_isolated_mode( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Menu returns isolated mode when isolated option is selected (lines 139-146).""" + from core.workspace.models import WorkspaceMode + from core.workspace.setup import choose_workspace + + # Mock has_uncommitted_changes to return False + monkeypatch.setattr( + "core.workspace.setup.has_uncommitted_changes", lambda x: False + ) + + # Mock select_menu to return "isolated" + monkeypatch.setattr( + "core.workspace.setup.select_menu", + lambda title, options, allow_quit=False: "isolated", + ) + + result = choose_workspace( + temp_git_repo, + "test-spec", + ) + + assert result == WorkspaceMode.ISOLATED + captured = capsys.readouterr() + assert "Using a separate workspace for safety" in captured.out + + def test_menu_with_none_choice_exits(self, temp_git_repo: Path, monkeypatch): + """Menu with None choice (user quit) exits via sys.exit(0) (lines 134-137).""" + from core.workspace.setup import choose_workspace + + # Mock has_uncommitted_changes to return False + monkeypatch.setattr( + "core.workspace.setup.has_uncommitted_changes", lambda x: False + ) + + # Mock select_menu to return None (user quit) + monkeypatch.setattr( + "core.workspace.setup.select_menu", + lambda title, options, allow_quit=False: None, + ) + + # Should exit via sys.exit(0) + with pytest.raises(SystemExit) as exc_info: + choose_workspace(temp_git_repo, "test-spec") + + assert exc_info.value.code == 0 + + +class TestWindowsJunctionCreation: + """Tests for Windows-specific junction creation in symlink_node_modules_to_worktree (lines 256-262).""" + + @pytest.mark.skipif( + sys.platform != "win32", + reason="Windows junction creation only applies on Windows", + ) + def test_creates_junction_on_windows(self, temp_git_repo: Path, monkeypatch): + """Creates junction on Windows using mklink /J command (lines 256-262).""" + from core.workspace.setup import symlink_node_modules_to_worktree + + # Create source node_modules directory + source_node_modules = temp_git_repo / "node_modules" + source_node_modules.mkdir() + (source_node_modules / "test-package").mkdir() + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Mock subprocess.run to simulate mklink /J + mock_results = [] + + def mock_subprocess_run(cmd, capture_output=False, text=False, **kwargs): + mock_results.append(cmd) + result = type("MockResult", (), {"returncode": 0, "stderr": ""})() + return result + + monkeypatch.setattr("subprocess.run", mock_subprocess_run) + + # Call the function + symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path) + + # Verify mklink /J command was called + assert len(mock_results) > 0 + cmd = mock_results[0] + assert "cmd" in cmd + assert "/c" in cmd + assert "mklink" in cmd + assert "/J" in cmd + + @pytest.mark.skipif( + sys.platform != "win32", + reason="Windows junction creation only applies on Windows", + ) + def test_handles_junction_creation_failure( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Handles mklink /J failure gracefully (lines 261-262, 269-281).""" + from core.workspace.setup import symlink_node_modules_to_worktree + + # Create source node_modules directory + source_node_modules = temp_git_repo / "node_modules" + source_node_modules.mkdir() + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Mock subprocess.run to simulate mklink failure + def mock_subprocess_run(cmd, capture_output=False, text=False, **kwargs): + result = type( + "MockResult", (), {"returncode": 1, "stderr": "Access denied"} + )() + return result + + monkeypatch.setattr("subprocess.run", mock_subprocess_run) + + # Call the function - should handle error gracefully + symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path) + + # Should return empty list (no successful symlinks) + assert len(symlinked) == 0 + + captured = capsys.readouterr() + # Should show warning + assert "Warning" in captured.out or "TypeScript" in captured.out + + def test_creates_relative_symlink_on_non_windows( + self, temp_git_repo: Path, monkeypatch + ): + """Creates relative symlink on non-Windows platforms (lines 264-266).""" + from core.workspace.setup import symlink_node_modules_to_worktree + + # Skip on actual Windows + if sys.platform == "win32": + pytest.skip("Test for non-Windows platforms") + + # Create source node_modules directory + source_node_modules = temp_git_repo / "node_modules" + source_node_modules.mkdir() + (source_node_modules / "test-package").mkdir() + + # Create worktree + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Call the function + symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path) + + # Verify symlink was created + assert len(symlinked) > 0 + target_path = worktree_path / symlinked[0] + assert target_path.is_symlink() + + +class TestSecurityFileCopyErrorInSetupWorkspace: + """Tests for security file copy error handling in setup_workspace (lines 402-403).""" + + def test_handles_security_file_copy_oserror_in_setup( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Handles OSError when copying security files in setup_workspace (lines 402-406).""" + from unittest.mock import patch + + from core.workspace.models import WorkspaceMode + from core.workspace.setup import setup_workspace + from security.constants import ALLOWLIST_FILENAME + + # Create security file + allowlist_file = temp_git_repo / ALLOWLIST_FILENAME + allowlist_file.write_text("content", encoding="utf-8") + + # Commit changes + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add allowlist"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Track if warning was printed + print_calls = [] + + original_print = ( + __builtins__["print"] + if isinstance(__builtins__, dict) + else __builtins__.print + ) + + def mock_print(*args, **kwargs): + print_calls.append((args, kwargs)) + return original_print(*args, **kwargs) + + # Mock shutil.copy2 to raise OSError for security files + def mock_copy2(src, dst): + if ALLOWLIST_FILENAME in str(src): + raise OSError("Permission denied") + return shutil.copy2(src, dst) + + monkeypatch.setattr("builtins.print", mock_print) + + with patch("shutil.copy2", side_effect=mock_copy2): + # Setup workspace - should handle error gracefully + worktree_path, _, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Verify worktree was created despite copy error + assert worktree_path.exists() + + def test_handles_permission_error_on_security_copy( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Handles PermissionError when copying security files (lines 402-406).""" + from unittest.mock import patch + + from core.workspace.models import WorkspaceMode + from core.workspace.setup import setup_workspace + from security.constants import PROFILE_FILENAME + + # Create security profile + profile_file = temp_git_repo / PROFILE_FILENAME + profile_file.write_text('{"profile": "data"}', encoding="utf-8") + + # Commit changes + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add profile"], + cwd=temp_git_repo, + capture_output=True, + ) + + # Mock shutil.copy2 to raise PermissionError for profile file + def mock_copy2(src, dst): + if PROFILE_FILENAME in str(src): + raise PermissionError("Access denied") + return shutil.copy2(src, dst) + + with patch("shutil.copy2", side_effect=mock_copy2): + # Setup workspace - should handle error gracefully + worktree_path, _, _ = setup_workspace( + temp_git_repo, + "test-spec", + WorkspaceMode.ISOLATED, + ) + + # Verify worktree was created despite permission error + assert worktree_path.exists() + + # Verify warning was printed + + +class TestMergeLockExceptionHandlingUnlink: + """Tests for MergeLock __exit__ exception handling during unlink (lines 136-137).""" + + def test_merge_lock_exit_handles_unlink_exception(self, temp_git_repo: Path): + """MergeLock.__exit__ handles exceptions when unlink() fails (lines 136-137).""" + from unittest.mock import patch + + lock = MergeLock(temp_git_repo, "test-spec") + + # Enter the lock context + lock.__enter__() + assert lock.acquired is True + assert lock.lock_file.exists() + + # Mock unlink to raise an exception + with patch.object(Path, "unlink", side_effect=OSError("Device read-only")): + # __exit__ should not raise despite unlink failure + lock.__exit__(None, None, None) + + # Lock should still be marked as acquired because cleanup failed silently + assert lock.acquired is True + + def test_merge_lock_exit_handles_permission_error(self, temp_git_repo: Path): + """MergeLock.__exit__ handles PermissionError when unlink() fails.""" + from unittest.mock import patch + + lock = MergeLock(temp_git_repo, "test-spec") + + lock.__enter__() + assert lock.acquired is True + + # Mock unlink to raise PermissionError + with patch.object(Path, "unlink", side_effect=PermissionError("Access denied")): + # Should not raise + lock.__exit__(None, None, None) + + def test_merge_lock_exit_handles_lock_file_becoming_directory( + self, temp_git_repo: Path + ): + """MergeLock.__exit__ handles when lock file becomes a directory (race condition).""" + lock = MergeLock(temp_git_repo, "test-spec") + + lock.__enter__() + assert lock.acquired is True + + # Simulate race: lock file becomes a directory + lock.lock_file.unlink() + lock.lock_file.mkdir() + + # unlink() on a directory raises OSError/IsADirectoryError + # __exit__ should handle this gracefully + lock.__exit__(None, None, None) + + # Cleanup the directory + lock.lock_file.rmdir() + + +class TestSpecNumberLockExceptionHandlingUnlink: + """Tests for SpecNumberLock __exit__ exception handling during unlink (lines 225-226).""" + + def test_spec_number_lock_exit_handles_unlink_exception(self, temp_git_repo: Path): + """SpecNumberLock.__exit__ handles exceptions when unlink() fails (lines 225-226).""" + from unittest.mock import patch + + lock = SpecNumberLock(temp_git_repo) + + lock.__enter__() + assert lock.acquired is True + assert lock.lock_file.exists() + + # Mock unlink to raise an exception + with patch.object(Path, "unlink", side_effect=OSError("Device read-only")): + # __exit__ should not raise despite unlink failure + lock.__exit__(None, None, None) + + def test_spec_number_lock_exit_handles_permission_error(self, temp_git_repo: Path): + """SpecNumberLock.__exit__ handles PermissionError when unlink() fails.""" + from unittest.mock import patch + + lock = SpecNumberLock(temp_git_repo) + + lock.__enter__() + assert lock.acquired is True + + # Mock unlink to raise PermissionError + with patch.object(Path, "unlink", side_effect=PermissionError("Access denied")): + # Should not raise + lock.__exit__(None, None, None) + + def test_spec_number_lock_exit_handles_lock_file_becoming_directory( + self, temp_git_repo: Path + ): + """SpecNumberLock.__exit__ handles when lock file becomes a directory (race condition).""" + lock = SpecNumberLock(temp_git_repo) + + lock.__enter__() + assert lock.acquired is True + + # Simulate race: lock file becomes a directory + lock.lock_file.unlink() + lock.lock_file.mkdir() + + # unlink() on a directory raises OSError/IsADirectoryError + # __exit__ should handle this gracefully + lock.__exit__(None, None, None) + + # Cleanup the directory + lock.lock_file.rmdir() + + +class TestSpecNumberLockScanExceptionHandling: + """Tests for _scan_specs_dir exception handling (lines 272-273).""" + + def test_scan_specs_dir_handles_invalid_folder_names(self, temp_git_repo: Path): + """_scan_specs_dir handles folders with non-numeric prefixes (lines 272-273).""" + lock = SpecNumberLock(temp_git_repo) + + # Create specs with invalid names that trigger ValueError + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + + # These will cause ValueError when trying to int(folder.name[:3]) + invalid_names = ["abc", "xyz", "invalid-name"] + for name in invalid_names: + (specs_dir / name).mkdir() + + # Create valid specs + (specs_dir / "001-valid").mkdir() + (specs_dir / "100-another").mkdir() + + with lock: + # Should not raise ValueError, should skip invalid folders + result = lock._scan_specs_dir(specs_dir) + + # Should only count valid specs + assert result == 100 + + def test_scan_specs_dir_handles_malformed_number_prefix(self, temp_git_repo: Path): + """_scan_specs_dir handles folder names with non-digit characters in prefix.""" + lock = SpecNumberLock(temp_git_repo) + + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + + # Create folder that starts with digits but has non-digit in prefix + # This will fail the int() conversion + (specs_dir / "1a-bad").mkdir() + (specs_dir / "9!-bad").mkdir() + + # Create valid specs + (specs_dir / "050-good").mkdir() + + with lock: + # Should handle malformed prefixes gracefully + result = lock._scan_specs_dir(specs_dir) + + # Should only count valid specs + assert result == 50 + + def test_scan_specs_dir_handles_short_folder_names(self, temp_git_repo: Path): + """_scan_specs_dir handles folder names shorter than 3 characters.""" + lock = SpecNumberLock(temp_git_repo) + + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + + # Edge case: folder with less than 3 chars + # name[:3] will be less than 3 chars, but int() may still work if it's numeric + (specs_dir / "12").mkdir() + + # Edge case: very large number + (specs_dir / "999-very-large").mkdir() + + # Valid specs + (specs_dir / "001-first").mkdir() + + with lock: + result = lock._scan_specs_dir(specs_dir) + + # Should handle all cases and return max + assert result == 999 + + def test_scan_specs_dir_handles_unexpected_folder_names( + self, temp_git_repo: Path, monkeypatch + ): + """_scan_specs_dir handles ValueError when glob returns unexpected folder names (lines 272-273).""" + lock = SpecNumberLock(temp_git_repo) + + specs_dir = temp_git_repo / ".auto-claude" / "specs" + specs_dir.mkdir(parents=True) + + # Create a folder that matches the glob pattern visually + # but we'll mock glob to return a folder that triggers ValueError + (specs_dir / "001-valid").mkdir() + + # Create fake Path objects that will cause ValueError in int() + from pathlib import Path + from unittest.mock import MagicMock + + fake_folder = MagicMock() + fake_folder.name = "XYZ-invalid" # Non-numeric prefix + + # Mock glob to return both valid and invalid folders + original_glob = specs_dir.glob + + def mock_glob(pattern): + # Return the actual valid folder plus our fake one + real_results = list(original_glob(pattern)) + return real_results + [fake_folder] + + monkeypatch.setattr(Path, "glob", lambda self, pattern: mock_glob(pattern)) + + with lock: + # Should not raise ValueError, should skip invalid folder + result = lock._scan_specs_dir(specs_dir) + + # Should still find the valid spec + assert result == 1 + + +class TestSetupDebugFallback: + """Tests for debug fallback functions in setup.py (lines 35-43).""" + + def test_debug_fallback_no_op(self, monkeypatch): + """Fallback debug function is no-op when debug module not available (lines 39-40).""" + # Remove debug module from sys.modules to trigger fallback + import importlib + import sys + + debug_module = sys.modules.pop("debug", None) + + # Force reload of setup module to trigger fallback path + if "core.workspace.setup" in sys.modules: + del sys.modules["core.workspace.setup"] + + try: + from core.workspace.setup import debug, debug_warning + + # Both functions should be no-ops (don't raise) + debug("test", "message") + debug_warning("test", "warning") + + # No exception means fallback is working + assert True + finally: + # Restore debug module + if debug_module is not None: + sys.modules["debug"] = debug_module + # Force reload again to restore normal state + if "core.workspace.setup" in sys.modules: + del sys.modules["core.workspace.setup"] + import importlib + + importlib.reload(importlib.import_module("core.workspace.setup")) + + def test_debug_import_error_creates_fallback(self, monkeypatch): + """ImportError in debug import creates fallback functions (lines 35-43).""" + import builtins + import importlib + import sys + + # Save original debug module and import function + original_debug = sys.modules.get("debug") + original_import = builtins.__import__ + + # Create a custom import that blocks 'debug' module + def debug_blocking_import(name, *args, **kwargs): + if name == "debug": + raise ImportError("debug module not found (simulated)") + return original_import(name, *args, **kwargs) + + try: + # Block debug import and remove from sys.modules + monkeypatch.setattr(builtins, "__import__", debug_blocking_import) + if "debug" in sys.modules: + del sys.modules["debug"] + + # Also remove setup module and related modules to force re-import + for module_name in list(sys.modules.keys()): + if module_name.startswith("core.workspace.setup"): + del sys.modules[module_name] + + # Re-import setup module - it should create fallback functions + setup_module = importlib.import_module("core.workspace.setup") + + # Check that debug functions exist and are callables + assert hasattr(setup_module, "debug") + assert hasattr(setup_module, "debug_warning") + assert callable(setup_module.debug) + assert callable(setup_module.debug_warning) + + # They should be no-ops (accept any args without error) + setup_module.debug("module", "message", "extra") + setup_module.debug_warning("module", "warning", key="value") + finally: + # Restore debug module + if original_debug is not None: + sys.modules["debug"] = original_debug + # Restore setup module + if "core.workspace.setup" in sys.modules: + del sys.modules["core.workspace.setup"] + importlib.reload(importlib.import_module("core.workspace.setup")) + + +class TestWindowsJunctionErrorHandling: + """Tests for Windows junction creation error handling (lines 256-262).""" + + def test_windows_junction_creation_error_handling( + self, temp_git_repo: Path, monkeypatch, capsys + ): + """Handles OSError when mklink fails on Windows (lines 256-262, 269-281).""" + from core.workspace.setup import symlink_node_modules_to_worktree + + # Only test on Windows or when we can mock the platform + if sys.platform != "win32": + # Mock platform to simulate Windows + monkeypatch.setattr("sys.platform", "win32") + + # Create source node_modules directory + source_node_modules = temp_git_repo / "node_modules" + source_node_modules.mkdir() + (source_node_modules / "test-package").mkdir() + + # Create worktree path + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Mock subprocess.run to simulate mklink failure + original_run = subprocess.run + + def mock_subprocess_run(cmd, **kwargs): + if "mklink" in " ".join(cmd): + # Simulate mklink failure + return subprocess.CompletedProcess( + cmd, returncode=1, stderr="Access is denied" + ) + return original_run(cmd, **kwargs) + + monkeypatch.setattr("subprocess.run", mock_subprocess_run) + + # Call the function - should handle error gracefully + symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path) + + # Verify no symlinks were created (due to error) + assert len(symlinked) == 0 + + # Verify warning was printed + captured = capsys.readouterr() + assert "Warning" in captured.out or "warning" in captured.out.lower() + + def test_windows_junction_osexception_continues( + self, temp_git_repo: Path, monkeypatch + ): + """Continues after OSError in junction creation (lines 261-262, 269-281).""" + from core.workspace.setup import symlink_node_modules_to_worktree + + # Mock Windows platform + original_platform = sys.platform + monkeypatch.setattr("sys.platform", "win32") + + try: + # Create source and worktree directories + source_node_modules = temp_git_repo / "node_modules" + source_node_modules.mkdir() + + worktree_path = ( + temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec" + ) + worktree_path.mkdir(parents=True) + + # Create a second source to test that function continues after error + source_frontend_modules = ( + temp_git_repo / "apps" / "frontend" / "node_modules" + ) + source_frontend_modules.mkdir(parents=True) + + # Mock subprocess.run to fail on first, succeed on second + call_count = [0] + original_run = subprocess.run + + def mock_subprocess_run(cmd, **kwargs): + call_count[0] += 1 + if "mklink" in " ".join(cmd) and call_count[0] == 1: + # First mklink fails + raise OSError("mklink /J failed") + elif "mklink" in " ".join(cmd): + # Second succeeds + return subprocess.CompletedProcess(cmd, returncode=0, stderr="") + return original_run(cmd, **kwargs) + + monkeypatch.setattr("subprocess.run", mock_subprocess_run) + + # Call the function - should continue after first error + symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path) + + # At least one symlink should have succeeded (or both failed gracefully) + # The important thing is the function didn't crash + assert isinstance(symlinked, list) + finally: + monkeypatch.setattr("sys.platform", original_platform) + + +class TestTimelineHookInstallationEdgeCases: + """Tests for timeline hook installation edge cases (lines 461-503).""" + + def setup_method(self): + """Reset the global hook check flag before each test.""" + import core.workspace.setup as setup_module + + setup_module._git_hook_check_done = False + + def test_hook_installation_skips_when_no_git_dir(self, temp_dir: Path, monkeypatch): + """Skips hook installation when .git directory doesn't exist (line 477).""" + from core.workspace.setup import ensure_timeline_hook_installed + + # temp_dir is not a git repo + assert not (temp_dir / ".git").exists() + + # Should return early without error + ensure_timeline_hook_installed(temp_dir) + + # No .git directory should have been created + assert not (temp_dir / ".git").exists() + + def test_hook_installation_handles_worktree_invalid_git_file(self, tmp_path): + """Handles worktrees with invalid .git file content (lines 481-485).""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Create a fake worktree directory (not a real git repo) + fake_worktree = tmp_path / "fake_worktree" + fake_worktree.mkdir() + + # Create .git as a FILE with invalid content (worktree style) + git_file = fake_worktree / ".git" + git_file.write_text( + "invalid content that doesn't start with gitdir:", encoding="utf-8" + ) + + # Should handle gracefully and return early + ensure_timeline_hook_installed(fake_worktree) + + # Verify the file wasn't modified + assert "invalid content" in git_file.read_text(encoding="utf-8") + + def test_hook_installation_worktree_gitdir_extraction(self, tmp_path): + """Extracts gitdir from worktree .git file correctly (lines 481-483).""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Create a fake worktree structure + # First, create the actual git dir in a different location + actual_git_dir = tmp_path / "actual_git_dir" + actual_git_dir.mkdir() + (actual_git_dir / "hooks").mkdir() + + # Create a fake worktree directory with .git as a FILE + fake_worktree = tmp_path / "fake_worktree" + fake_worktree.mkdir() + git_file = fake_worktree / ".git" + git_file.write_text(f"gitdir: {actual_git_dir}", encoding="utf-8") + + # Should correctly extract gitdir path + ensure_timeline_hook_installed(fake_worktree) + + # Verify the actual git dir has hooks directory + assert (actual_git_dir / "hooks").exists() + + def test_hook_installation_skips_when_hook_already_installed( + self, tmp_path, monkeypatch + ): + """Skips installation when FileTimelineTracker hook already exists (lines 491-493).""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Create a git directory structure + git_dir = tmp_path / ".git" + git_dir.mkdir() + hooks_dir = git_dir / "hooks" + hooks_dir.mkdir() + + # Create an existing hook with FileTimelineTracker marker + hook_path = hooks_dir / "post-commit" + hook_path.write_text( + "#!/bin/bash\n# FileTimelineTracker hook\necho 'Timeline tracking'\n", + encoding="utf-8", + ) + + # Mock install_hook to verify it's NOT called + install_hook_called = [] + + def mock_install_hook(project_dir): + install_hook_called.append(project_dir) + + # Patch the import location where install_hook is used + monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook) + + # Should skip installation + ensure_timeline_hook_installed(tmp_path) + + # install_hook should NOT have been called + assert len(install_hook_called) == 0 + + def test_hook_installation_handles_exceptions_gracefully( + self, tmp_path, monkeypatch + ): + """Handles exceptions during hook installation gracefully (lines 501-503).""" + from core.workspace.setup import ensure_timeline_hook_installed + + # Create a git directory structure + git_dir = tmp_path / ".git" + git_dir.mkdir() + hooks_dir = git_dir / "hooks" + hooks_dir.mkdir() + + # Mock install_hook to raise an exception + def mock_install_hook(project_dir): + raise RuntimeError("Simulated installation failure") + + # Patch the import location where install_hook is used + monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook) + + # Should handle exception gracefully (not crash) + ensure_timeline_hook_installed(tmp_path) + + # Function should complete without raising an exception diff --git a/apps/backend/integrations/graphiti/test_graphiti_memory.py b/apps/backend/integrations/graphiti/test_graphiti_memory.py deleted file mode 100644 index b2a9a875..00000000 --- a/apps/backend/integrations/graphiti/test_graphiti_memory.py +++ /dev/null @@ -1,720 +0,0 @@ -#!/usr/bin/env python3 -""" -Test Script for Memory Integration with LadybugDB -================================================= - -This script tests the memory layer (graph + semantic search) to verify -data is being saved and retrieved correctly from LadybugDB (embedded Kuzu). - -LadybugDB is an embedded graph database - no Docker required! - -Usage: - # Set environment variables first (or in .env file): - export GRAPHITI_ENABLED=true - export GRAPHITI_EMBEDDER_PROVIDER=ollama # or: openai, voyage, azure_openai, google - - # For Ollama (recommended - free, local): - export OLLAMA_EMBEDDING_MODEL=embeddinggemma - export OLLAMA_EMBEDDING_DIM=768 - - # For OpenAI: - export OPENAI_API_KEY=sk-... - - # Run the test: - cd auto-claude - python integrations/graphiti/test_graphiti_memory.py - - # Or run specific tests: - python integrations/graphiti/test_graphiti_memory.py --test connection - python integrations/graphiti/test_graphiti_memory.py --test save - python integrations/graphiti/test_graphiti_memory.py --test search - python integrations/graphiti/test_graphiti_memory.py --test ollama -""" - -import argparse -import asyncio -import json -import os -import sys -from datetime import datetime, timezone -from pathlib import Path - -# Add auto-claude to path -auto_claude_dir = Path(__file__).parent.parent.parent -sys.path.insert(0, str(auto_claude_dir)) - -# Load .env file -try: - from dotenv import load_dotenv - - env_file = auto_claude_dir / ".env" - if env_file.exists(): - load_dotenv(env_file) - print(f"Loaded .env from {env_file}") -except ImportError: - print("Note: python-dotenv not installed, using environment variables only") - - -def apply_ladybug_monkeypatch(): - """Apply LadybugDB monkeypatch for embedded database support.""" - try: - import real_ladybug - - sys.modules["kuzu"] = real_ladybug - return True - except ImportError: - pass - - # Try native kuzu as fallback - try: - import kuzu # noqa: F401 - - return True - except ImportError: - return False - - -def print_header(title: str): - """Print a section header.""" - print("\n" + "=" * 60) - print(f" {title}") - print("=" * 60 + "\n") - - -def print_result(label: str, value: str, success: bool = True): - """Print a result line.""" - status = "✅" if success else "❌" - print(f" {status} {label}: {value}") - - -def print_info(message: str): - """Print an info line.""" - print(f" ℹ️ {message}") - - -async def test_ladybugdb_connection(db_path: str, database: str) -> bool: - """Test basic LadybugDB connection.""" - print_header("1. Testing LadybugDB Connection") - - print(f" Database path: {db_path}") - print(f" Database name: {database}") - print() - - if not apply_ladybug_monkeypatch(): - print_result("LadybugDB", "Not installed (pip install real-ladybug)", False) - return False - - print_result("LadybugDB", "Installed", True) - - try: - import kuzu # This is real_ladybug via monkeypatch - - # Ensure parent directory exists (database will create its own structure) - full_path = Path(db_path) / database - full_path.parent.mkdir(parents=True, exist_ok=True) - - # Create database and connection - db = kuzu.Database(str(full_path)) - conn = kuzu.Connection(db) - - # Test basic query - result = conn.execute("RETURN 1 + 1 as test") - df = result.get_as_df() - test_value = df["test"].iloc[0] if len(df) > 0 else None - - if test_value == 2: - print_result("Connection", "SUCCESS - Database responds correctly", True) - return True - else: - print_result("Connection", f"Unexpected result: {test_value}", False) - return False - - except Exception as e: - print_result("Connection", f"FAILED: {e}", False) - return False - - -async def test_save_episode(db_path: str, database: str) -> tuple[str, str]: - """Test saving an episode to the graph.""" - print_header("2. Testing Episode Save") - - try: - from integrations.graphiti.config import GraphitiConfig - from integrations.graphiti.queries_pkg.client import GraphitiClient - - # Create config - config = GraphitiConfig.from_env() - config.db_path = db_path - config.database = database - config.enabled = True - - print(f" Embedder provider: {config.embedder_provider}") - print() - - # Initialize client - client = GraphitiClient(config) - initialized = await client.initialize() - - if not initialized: - print_result("Client Init", "Failed to initialize", False) - return None, None - - print_result("Client Init", "SUCCESS", True) - - # Create test episode data - test_data = { - "type": "test_episode", - "timestamp": datetime.now(timezone.utc).isoformat(), - "test_field": "Hello from LadybugDB test!", - "test_number": 42, - "embedder": config.embedder_provider, - } - - episode_name = f"test_episode_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - group_id = "ladybug_test_group" - - print(f" Episode name: {episode_name}") - print(f" Group ID: {group_id}") - print(f" Data: {json.dumps(test_data, indent=4)}") - print() - - # Save using Graphiti - from graphiti_core.nodes import EpisodeType - - print(" Saving episode...") - await client.graphiti.add_episode( - name=episode_name, - episode_body=json.dumps(test_data), - source=EpisodeType.text, - source_description="Test episode from test_graphiti_memory.py", - reference_time=datetime.now(timezone.utc), - group_id=group_id, - ) - - print_result("Episode Save", "SUCCESS", True) - - await client.close() - return episode_name, group_id - - except ImportError as e: - print_result("Import", f"Missing dependency: {e}", False) - return None, None - except Exception as e: - print_result("Episode Save", f"FAILED: {e}", False) - import traceback - - traceback.print_exc() - return None, None - - -async def test_keyword_search(db_path: str, database: str) -> bool: - """Test keyword search (works without embeddings).""" - print_header("3. Testing Keyword Search") - - if not apply_ladybug_monkeypatch(): - print_result("LadybugDB", "Not installed", False) - return False - - try: - import kuzu - - full_path = Path(db_path) / database - if not full_path.exists(): - print_info("Database doesn't exist yet - run save test first") - return True - - db = kuzu.Database(str(full_path)) - conn = kuzu.Connection(db) - - # Search for test episodes - search_query = "test" - print(f" Search query: '{search_query}'") - print() - - query = f""" - MATCH (e:Episodic) - WHERE toLower(e.name) CONTAINS '{search_query}' - OR toLower(e.content) CONTAINS '{search_query}' - RETURN e.name as name, e.content as content - LIMIT 5 - """ - - try: - result = conn.execute(query) - df = result.get_as_df() - - print(f" Found {len(df)} results:") - for _, row in df.iterrows(): - name = row.get("name", "unknown")[:50] - content = str(row.get("content", ""))[:60] - print(f" - {name}: {content}...") - - print_result("Keyword Search", f"Found {len(df)} results", True) - return True - - except Exception as e: - if "Episodic" in str(e) and "not exist" in str(e).lower(): - print_info("Episodic table doesn't exist yet - run save test first") - return True - raise - - except Exception as e: - print_result("Keyword Search", f"FAILED: {e}", False) - return False - - -async def test_semantic_search(db_path: str, database: str, group_id: str) -> bool: - """Test semantic search using embeddings.""" - print_header("4. Testing Semantic Search") - - if not group_id: - print_info("Skipping - no group_id from save test") - return True - - try: - from integrations.graphiti.config import GraphitiConfig - from integrations.graphiti.queries_pkg.client import GraphitiClient - - # Create config - config = GraphitiConfig.from_env() - config.db_path = db_path - config.database = database - config.enabled = True - - if not config.embedder_provider: - print_info("No embedder configured - semantic search requires embeddings") - return True - - print(f" Embedder: {config.embedder_provider}") - print() - - # Initialize client - client = GraphitiClient(config) - initialized = await client.initialize() - - if not initialized: - print_result("Client Init", "Failed", False) - return False - - # Search - query = "test episode hello LadybugDB" - print(f" Query: '{query}'") - print(f" Group ID: {group_id}") - print() - - print(" Searching...") - results = await client.graphiti.search( - query=query, - group_ids=[group_id], - num_results=10, - ) - - print(f" Found {len(results)} results:") - for i, result in enumerate(results[:5]): - # Print available attributes - if hasattr(result, "fact") and result.fact: - print(f" {i + 1}. [fact] {str(result.fact)[:80]}...") - elif hasattr(result, "content") and result.content: - print(f" {i + 1}. [content] {str(result.content)[:80]}...") - elif hasattr(result, "name"): - print(f" {i + 1}. [name] {str(result.name)[:80]}...") - - await client.close() - - if results: - print_result( - "Semantic Search", f"SUCCESS - Found {len(results)} results", True - ) - else: - print_result( - "Semantic Search", "No results (may need time for embedding)", False - ) - - return len(results) > 0 - - except Exception as e: - print_result("Semantic Search", f"FAILED: {e}", False) - import traceback - - traceback.print_exc() - return False - - -async def test_ollama_embeddings() -> bool: - """Test Ollama embedding generation directly.""" - print_header("5. Testing Ollama Embeddings") - - ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "embeddinggemma") - ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") - - print(f" Model: {ollama_model}") - print(f" Base URL: {ollama_base_url}") - print() - - try: - import requests - - # Check Ollama status - print(" Checking Ollama status...") - try: - resp = requests.get(f"{ollama_base_url}/api/tags", timeout=5) - if resp.status_code != 200: - print_result( - "Ollama", f"Not responding (status {resp.status_code})", False - ) - return False - - models = [m["name"] for m in resp.json().get("models", [])] - embedding_models = [ - m for m in models if "embed" in m.lower() or "gemma" in m.lower() - ] - print_result("Ollama", f"Running with {len(models)} models", True) - print(f" Embedding models: {embedding_models}") - - except requests.exceptions.ConnectionError: - print_result("Ollama", "Not running - start with 'ollama serve'", False) - return False - - # Test embedding generation - print() - print(" Generating test embedding...") - - test_text = ( - "This is a test embedding for Auto Claude memory system using LadybugDB." - ) - - resp = requests.post( - f"{ollama_base_url}/api/embeddings", - json={"model": ollama_model, "prompt": test_text}, - timeout=30, - ) - - if resp.status_code == 200: - data = resp.json() - embedding = data.get("embedding", []) - print_result("Embedding", f"SUCCESS - {len(embedding)} dimensions", True) - print(f" First 5 values: {embedding[:5]}") - - # Verify dimension matches config - expected_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", 768)) - if len(embedding) == expected_dim: - print_result("Dimension", f"Matches expected ({expected_dim})", True) - else: - print_result( - "Dimension", - f"Mismatch! Got {len(embedding)}, expected {expected_dim}", - False, - ) - print_info( - f"Update OLLAMA_EMBEDDING_DIM={len(embedding)} in your config" - ) - - return True - else: - print_result( - "Embedding", f"FAILED: {resp.status_code} - {resp.text}", False - ) - return False - - except ImportError: - print_result("requests", "Not installed (pip install requests)", False) - return False - except Exception as e: - print_result("Ollama Embeddings", f"FAILED: {e}", False) - return False - - -async def test_graphiti_memory_class(db_path: str, database: str) -> bool: - """Test the GraphitiMemory wrapper class.""" - print_header("6. Testing GraphitiMemory Class") - - try: - from integrations.graphiti.memory import GraphitiMemory - - # Create temporary directories for testing - test_spec_dir = Path("/tmp/graphiti_test_spec") - test_spec_dir.mkdir(parents=True, exist_ok=True) - - test_project_dir = Path("/tmp/graphiti_test_project") - test_project_dir.mkdir(parents=True, exist_ok=True) - - print(f" Spec dir: {test_spec_dir}") - print(f" Project dir: {test_project_dir}") - print() - - # Override database path via environment - os.environ["GRAPHITI_DB_PATH"] = db_path - os.environ["GRAPHITI_DATABASE"] = database - - # Create memory instance - memory = GraphitiMemory(test_spec_dir, test_project_dir) - - print(f" Is enabled: {memory.is_enabled}") - print(f" Group ID: {memory.group_id}") - print() - - if not memory.is_enabled: - print_info("GraphitiMemory not enabled - check GRAPHITI_ENABLED=true") - return True - - # Initialize - print(" Initializing...") - init_result = await memory.initialize() - - if not init_result: - print_result("Initialize", "Failed", False) - return False - - print_result("Initialize", "SUCCESS", True) - - # Test save_session_insights - print() - print(" Testing save_session_insights...") - insights = { - "subtasks_completed": ["test-subtask-1"], - "discoveries": { - "files_understood": {"test.py": "Test file"}, - "patterns_found": ["Pattern: LadybugDB works!"], - "gotchas_encountered": [], - }, - "what_worked": ["Using embedded database"], - "what_failed": [], - "recommendations_for_next_session": ["Continue testing"], - } - - save_result = await memory.save_session_insights( - session_num=1, insights=insights - ) - print_result( - "save_session_insights", "SUCCESS" if save_result else "FAILED", save_result - ) - - # Test save_pattern - print() - print(" Testing save_pattern...") - pattern_result = await memory.save_pattern( - "LadybugDB pattern: Embedded graph database works without Docker" - ) - print_result( - "save_pattern", "SUCCESS" if pattern_result else "FAILED", pattern_result - ) - - # Test get_relevant_context - print() - print(" Testing get_relevant_context...") - await asyncio.sleep(1) # Brief wait for processing - - context = await memory.get_relevant_context("LadybugDB embedded database") - print(f" Found {len(context)} context items") - - for item in context[:3]: - item_type = item.get("type", "unknown") - content = str(item.get("content", ""))[:60] - print(f" - [{item_type}] {content}...") - - print_result("get_relevant_context", f"Found {len(context)} items", True) - - # Get status - print() - print(" Status summary:") - status = memory.get_status_summary() - for key, value in status.items(): - print(f" {key}: {value}") - - await memory.close() - print_result("GraphitiMemory", "All tests passed", True) - return True - - except ImportError as e: - print_result("Import", f"Missing: {e}", False) - return False - except Exception as e: - print_result("GraphitiMemory", f"FAILED: {e}", False) - import traceback - - traceback.print_exc() - return False - - -async def test_database_contents(db_path: str, database: str) -> bool: - """Show what's in the database (debug).""" - print_header("7. Database Contents (Debug)") - - if not apply_ladybug_monkeypatch(): - print_result("LadybugDB", "Not installed", False) - return False - - try: - import kuzu - - full_path = Path(db_path) / database - if not full_path.exists(): - print_info(f"Database doesn't exist at {full_path}") - return True - - db = kuzu.Database(str(full_path)) - conn = kuzu.Connection(db) - - # Get table info - print(" Checking tables...") - - tables_to_check = ["Episodic", "Entity", "Community"] - - for table in tables_to_check: - try: - result = conn.execute(f"MATCH (n:{table}) RETURN count(n) as count") - df = result.get_as_df() - count = df["count"].iloc[0] if len(df) > 0 else 0 - print(f" {table}: {count} nodes") - except Exception as e: - if "not exist" in str(e).lower() or "cannot" in str(e).lower(): - print(f" {table}: (table not created yet)") - else: - print(f" {table}: Error - {e}") - - # Show sample episodic nodes - print() - print(" Sample Episodic nodes:") - try: - result = conn.execute(""" - MATCH (e:Episodic) - RETURN e.name as name, e.created_at as created - ORDER BY e.created_at DESC - LIMIT 5 - """) - df = result.get_as_df() - - if len(df) == 0: - print(" (none)") - else: - for _, row in df.iterrows(): - print(f" - {row.get('name', 'unknown')}") - except Exception as e: - if "Episodic" in str(e): - print(" (table not created yet)") - else: - print(f" Error: {e}") - - print_result("Database Contents", "Displayed", True) - return True - - except Exception as e: - print_result("Database Contents", f"FAILED: {e}", False) - return False - - -async def main(): - """Run all tests.""" - parser = argparse.ArgumentParser(description="Test Memory System with LadybugDB") - parser.add_argument( - "--test", - choices=[ - "all", - "connection", - "save", - "keyword", - "semantic", - "ollama", - "memory", - "contents", - ], - default="all", - help="Which test to run", - ) - parser.add_argument( - "--db-path", - default=os.path.expanduser("~/.auto-claude/memories"), - help="Database path", - ) - parser.add_argument( - "--database", - default="test_memory", - help="Database name (use 'test_memory' for testing)", - ) - - args = parser.parse_args() - - print("\n" + "=" * 60) - print(" MEMORY SYSTEM TEST SUITE (LadybugDB)") - print("=" * 60) - - # Configuration check - print_header("0. Configuration Check") - - print(f" Database path: {args.db_path}") - print(f" Database name: {args.database}") - print() - - # Check environment - graphiti_enabled = os.environ.get("GRAPHITI_ENABLED", "").lower() == "true" - embedder_provider = os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", "") - - print_result("GRAPHITI_ENABLED", str(graphiti_enabled), graphiti_enabled) - print_result( - "GRAPHITI_EMBEDDER_PROVIDER", - embedder_provider or "(not set)", - bool(embedder_provider), - ) - - if embedder_provider == "ollama": - ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "") - ollama_dim = os.environ.get("OLLAMA_EMBEDDING_DIM", "") - print_result( - "OLLAMA_EMBEDDING_MODEL", ollama_model or "(not set)", bool(ollama_model) - ) - print_result( - "OLLAMA_EMBEDDING_DIM", ollama_dim or "(not set)", bool(ollama_dim) - ) - elif embedder_provider == "openai": - has_key = bool(os.environ.get("OPENAI_API_KEY")) - print_result("OPENAI_API_KEY", "Set" if has_key else "Not set", has_key) - - # Run tests based on selection - test = args.test - group_id = None - - if test in ["all", "connection"]: - await test_ladybugdb_connection(args.db_path, args.database) - - if test in ["all", "ollama"]: - await test_ollama_embeddings() - - if test in ["all", "save"]: - _, group_id = await test_save_episode(args.db_path, args.database) - if group_id: - print("\n Waiting 2 seconds for embedding processing...") - await asyncio.sleep(2) - - if test in ["all", "keyword"]: - await test_keyword_search(args.db_path, args.database) - - if test in ["all", "semantic"]: - await test_semantic_search( - args.db_path, args.database, group_id or "ladybug_test_group" - ) - - if test in ["all", "memory"]: - await test_graphiti_memory_class(args.db_path, args.database) - - if test in ["all", "contents"]: - await test_database_contents(args.db_path, args.database) - - print_header("TEST SUMMARY") - print(" Tests completed. Check the results above for any failures.") - print() - print(" Quick commands:") - print(" # Run all tests:") - print(" python integrations/graphiti/test_graphiti_memory.py") - print() - print(" # Test just Ollama embeddings:") - print(" python integrations/graphiti/test_graphiti_memory.py --test ollama") - print() - print(" # Test with production database:") - print( - " python integrations/graphiti/test_graphiti_memory.py --database auto_claude_memory" - ) - print() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/apps/backend/integrations/graphiti/test_ollama_embedding_memory.py b/apps/backend/integrations/graphiti/test_ollama_embedding_memory.py deleted file mode 100644 index cc5b1efa..00000000 --- a/apps/backend/integrations/graphiti/test_ollama_embedding_memory.py +++ /dev/null @@ -1,862 +0,0 @@ -#!/usr/bin/env python3 -""" -Test Script for Ollama Embedding Memory Integration -==================================================== - -This test validates that the memory system works correctly with local Ollama -embedding models (like embeddinggemma, nomic-embed-text) for creating and -retrieving memories in the hybrid RAG system. - -The test covers: -1. Ollama embedding generation (direct API test) -2. Creating memories with Ollama embeddings via GraphitiMemory -3. Retrieving memories via semantic search -4. Verifying the full create → store → retrieve cycle - -Prerequisites: - 1. Install Ollama: https://ollama.ai/ - 2. Pull an embedding model: - ollama pull embeddinggemma # 768 dimensions (lightweight) - ollama pull nomic-embed-text # 768 dimensions (good quality) - 3. Pull an LLM model (for knowledge graph construction): - ollama pull deepseek-r1:7b # or llama3.2:3b, mistral:7b - 4. Start Ollama server: ollama serve - 5. Configure environment: - export GRAPHITI_ENABLED=true - export GRAPHITI_LLM_PROVIDER=ollama - export GRAPHITI_EMBEDDER_PROVIDER=ollama - export OLLAMA_LLM_MODEL=deepseek-r1:7b - export OLLAMA_EMBEDDING_MODEL=embeddinggemma - export OLLAMA_EMBEDDING_DIM=768 - -NOTE: graphiti-core internally uses an OpenAI reranker for search ranking. - For full offline operation, set a dummy key: export OPENAI_API_KEY=dummy - The reranker will fail at search time, but embedding creation works. - For production, use OpenAI API key for best search quality. - -Usage: - cd apps/backend - python integrations/graphiti/test_ollama_embedding_memory.py - - # Run specific tests: - python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings - python integrations/graphiti/test_ollama_embedding_memory.py --test create - python integrations/graphiti/test_ollama_embedding_memory.py --test retrieve - python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle -""" - -import argparse -import asyncio -import os -import shutil -import sys -import tempfile -from datetime import datetime -from pathlib import Path - -# Add auto-claude to path -auto_claude_dir = Path(__file__).parent.parent.parent -sys.path.insert(0, str(auto_claude_dir)) - -# Load .env file -try: - from dotenv import load_dotenv - - env_file = auto_claude_dir / ".env" - if env_file.exists(): - load_dotenv(env_file) - print(f"Loaded .env from {env_file}") -except ImportError: - print("Note: python-dotenv not installed, using environment variables only") - - -# ============================================================================ -# Helper Functions -# ============================================================================ - - -def print_header(title: str): - """Print a section header.""" - print("\n" + "=" * 70) - print(f" {title}") - print("=" * 70 + "\n") - - -def print_result(label: str, value: str, success: bool = True): - """Print a result line.""" - status = "PASS" if success else "FAIL" - print(f" [{status}] {label}: {value}") - - -def print_info(message: str): - """Print an info line.""" - print(f" INFO: {message}") - - -def print_step(step: int, message: str): - """Print a step indicator.""" - print(f"\n Step {step}: {message}") - - -def apply_ladybug_monkeypatch(): - """Apply LadybugDB monkeypatch for embedded database support.""" - try: - import real_ladybug - - sys.modules["kuzu"] = real_ladybug - return True - except ImportError: - pass - - # Try native kuzu as fallback - try: - import kuzu # noqa: F401 - - return True - except ImportError: - return False - - -# ============================================================================ -# Test 1: Ollama Embedding Generation -# ============================================================================ - - -async def test_ollama_embeddings() -> bool: - """ - Test Ollama embedding generation directly via API. - - This validates that Ollama is running and can generate embeddings - with the configured model. - """ - print_header("Test 1: Ollama Embedding Generation") - - ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "embeddinggemma") - ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") - expected_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", "768")) - - print(f" Ollama Model: {ollama_model}") - print(f" Base URL: {ollama_base_url}") - print(f" Expected Dimension: {expected_dim}") - print() - - try: - import requests - except ImportError: - print_result("requests library", "Not installed - pip install requests", False) - return False - - # Step 1: Check Ollama is running - print_step(1, "Checking Ollama server status") - try: - resp = requests.get(f"{ollama_base_url}/api/tags", timeout=10) - if resp.status_code != 200: - print_result( - "Ollama server", - f"Not responding (status {resp.status_code})", - False, - ) - return False - - models = resp.json().get("models", []) - model_names = [m.get("name", "") for m in models] - print_result("Ollama server", f"Running with {len(models)} models", True) - - # Check if embedding model is available - embedding_model_found = any( - ollama_model in name or ollama_model.split(":")[0] in name - for name in model_names - ) - if not embedding_model_found: - print_info(f"Model '{ollama_model}' not found. Available: {model_names}") - print_info(f"Pull it with: ollama pull {ollama_model}") - - except requests.exceptions.ConnectionError: - print_result( - "Ollama server", - "Not running - start with 'ollama serve'", - False, - ) - return False - - # Step 2: Generate test embedding - print_step(2, "Generating test embeddings") - - test_texts = [ - "This is a test memory about implementing OAuth authentication.", - "The user prefers using TypeScript for frontend development.", - "A gotcha discovered: always validate JWT tokens on the server side.", - ] - - embeddings = [] - for i, text in enumerate(test_texts): - resp = requests.post( - f"{ollama_base_url}/api/embeddings", - json={"model": ollama_model, "prompt": text}, - timeout=60, - ) - - if resp.status_code != 200: - print_result( - f"Embedding {i + 1}", - f"Failed: {resp.status_code} - {resp.text[:100]}", - False, - ) - return False - - data = resp.json() - embedding = data.get("embedding", []) - embeddings.append(embedding) - - print_result( - f"Embedding {i + 1}", - f"Generated {len(embedding)} dimensions", - True, - ) - - # Step 3: Validate embedding dimensions - print_step(3, "Validating embedding dimensions") - - for i, embedding in enumerate(embeddings): - if len(embedding) != expected_dim: - print_result( - f"Embedding {i + 1} dimension", - f"Mismatch! Got {len(embedding)}, expected {expected_dim}", - False, - ) - print_info(f"Update OLLAMA_EMBEDDING_DIM={len(embedding)} in your config") - return False - print_result( - f"Embedding {i + 1} dimension", f"{len(embedding)} matches expected", True - ) - - # Step 4: Test embedding similarity (basic sanity check) - print_step(4, "Testing embedding similarity") - - def cosine_similarity(a, b): - """Calculate cosine similarity between two vectors.""" - dot_product = sum(x * y for x, y in zip(a, b)) - norm_a = sum(x * x for x in a) ** 0.5 - norm_b = sum(x * x for x in b) ** 0.5 - return dot_product / (norm_a * norm_b) if norm_a and norm_b else 0 - - # Generate embedding for a similar query - query = "OAuth authentication implementation" - resp = requests.post( - f"{ollama_base_url}/api/embeddings", - json={"model": ollama_model, "prompt": query}, - timeout=60, - ) - query_embedding = resp.json().get("embedding", []) - - similarities = [cosine_similarity(query_embedding, emb) for emb in embeddings] - - print(f" Query: '{query}'") - print(" Similarities to test texts:") - for i, (text, sim) in enumerate(zip(test_texts, similarities)): - print(f" {i + 1}. {sim:.4f} - '{text[:50]}...'") - - # First text (about OAuth) should have highest similarity to OAuth query - if similarities[0] > similarities[1] and similarities[0] > similarities[2]: - print_result("Semantic similarity", "OAuth query matches OAuth text best", True) - else: - print_info("Similarity ordering may vary - embeddings are still working") - - print() - print_result("Ollama Embeddings", "All tests passed", True) - return True - - -# ============================================================================ -# Test 2: Memory Creation with Ollama -# ============================================================================ - - -async def test_memory_creation(test_db_path: Path) -> tuple[Path, Path, bool]: - """ - Test creating memories using GraphitiMemory with Ollama embeddings. - - Returns: - Tuple of (spec_dir, project_dir, success) - """ - print_header("Test 2: Memory Creation with Ollama Embeddings") - - # Create test directories - spec_dir = test_db_path / "test_spec" - project_dir = test_db_path / "test_project" - spec_dir.mkdir(parents=True, exist_ok=True) - project_dir.mkdir(parents=True, exist_ok=True) - - print(f" Spec dir: {spec_dir}") - print(f" Project dir: {project_dir}") - print(f" Database path: {test_db_path}") - print() - - # Override database path for testing - os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db") - os.environ["GRAPHITI_DATABASE"] = "test_ollama_memory" - - try: - from integrations.graphiti.memory import GraphitiMemory - except ImportError as e: - print_result("Import GraphitiMemory", f"Failed: {e}", False) - return spec_dir, project_dir, False - - # Step 1: Initialize GraphitiMemory - print_step(1, "Initializing GraphitiMemory") - - memory = GraphitiMemory(spec_dir, project_dir) - print(f" Is enabled: {memory.is_enabled}") - print(f" Group ID: {memory.group_id}") - - if not memory.is_enabled: - print_result( - "GraphitiMemory", - "Not enabled - check GRAPHITI_ENABLED=true", - False, - ) - return spec_dir, project_dir, False - - init_result = await memory.initialize() - if not init_result: - print_result("Initialize", "Failed to initialize", False) - return spec_dir, project_dir, False - - print_result("Initialize", "SUCCESS", True) - - # Step 2: Save session insights - print_step(2, "Saving session insights") - - session_insights = { - "subtasks_completed": ["implement-oauth-login", "add-jwt-validation"], - "discoveries": { - "files_understood": { - "auth/oauth.py": "OAuth 2.0 flow implementation with Google/GitHub", - "auth/jwt.py": "JWT token generation and validation utilities", - }, - "patterns_found": [ - "Pattern: Use refresh tokens for long-lived sessions", - "Pattern: Store tokens in httpOnly cookies for security", - ], - "gotchas_encountered": [ - "Gotcha: Always validate JWT signature on server side", - "Gotcha: OAuth state parameter prevents CSRF attacks", - ], - }, - "what_worked": [ - "Using PyJWT for token handling", - "Separating OAuth providers into individual modules", - ], - "what_failed": [], - "recommendations_for_next_session": [ - "Consider adding refresh token rotation", - "Add rate limiting to auth endpoints", - ], - } - - save_result = await memory.save_session_insights( - session_num=1, insights=session_insights - ) - print_result( - "save_session_insights", "SUCCESS" if save_result else "FAILED", save_result - ) - - # Step 3: Save patterns - print_step(3, "Saving code patterns") - - patterns = [ - "OAuth implementation uses authorization code flow for web apps", - "JWT tokens include user ID, roles, and expiration in payload", - "Token refresh happens automatically when access token expires", - ] - - for i, pattern in enumerate(patterns): - result = await memory.save_pattern(pattern) - print_result(f"save_pattern {i + 1}", "SUCCESS" if result else "FAILED", result) - - # Step 4: Save gotchas - print_step(4, "Saving gotchas (pitfalls)") - - gotchas = [ - "Never store config values in frontend code or files checked into git", - "API redirect URIs must exactly match the registered URIs", - "Cache expiration times should be short for performance (15 min default)", - ] - - for i, gotcha in enumerate(gotchas): - result = await memory.save_gotcha(gotcha) - print_result(f"save_gotcha {i + 1}", "SUCCESS" if result else "FAILED", result) - - # Step 5: Save codebase discoveries - print_step(5, "Saving codebase discoveries") - - discoveries = { - "api/routes/users.py": "User management API endpoints (list, create, update)", - "middleware/logging.py": "Request logging middleware for all routes", - "models/user.py": "User model with profile data and role management", - "services/notifications.py": "Notification service integrations (email, SMS, push)", - } - - discovery_result = await memory.save_codebase_discoveries(discoveries) - print_result( - "save_codebase_discoveries", - "SUCCESS" if discovery_result else "FAILED", - discovery_result, - ) - - # Brief wait for embedding processing - print() - print_info("Waiting 3 seconds for embedding processing...") - await asyncio.sleep(3) - - await memory.close() - - print() - print_result("Memory Creation", "All memories saved successfully", True) - return spec_dir, project_dir, True - - -# ============================================================================ -# Test 3: Memory Retrieval with Semantic Search -# ============================================================================ - - -async def test_memory_retrieval(spec_dir: Path, project_dir: Path) -> bool: - """ - Test retrieving memories using semantic search with Ollama embeddings. - - This validates that saved memories can be found via semantic similarity. - """ - print_header("Test 3: Memory Retrieval with Semantic Search") - - try: - from integrations.graphiti.memory import GraphitiMemory - except ImportError as e: - print_result("Import GraphitiMemory", f"Failed: {e}", False) - return False - - # Step 1: Initialize memory (reconnect) - print_step(1, "Reconnecting to GraphitiMemory") - - memory = GraphitiMemory(spec_dir, project_dir) - init_result = await memory.initialize() - - if not init_result: - print_result("Initialize", "Failed to reconnect", False) - return False - - print_result("Initialize", "Reconnected successfully", True) - - # Step 2: Semantic search for API-related content - print_step(2, "Searching for API-related memories") - - api_query = "How do the API endpoints work in this project?" - results = await memory.get_relevant_context(api_query, num_results=5) - - print(f" Query: '{api_query}'") - print(f" Found {len(results)} results:") - - api_found = False - for i, result in enumerate(results): - content = result.get("content", "")[:100] - result_type = result.get("type", "unknown") - score = result.get("score", 0) - print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...") - if "api" in content.lower() or "routes" in content.lower(): - api_found = True - - if api_found: - print_result("API search", "Found API-related content", True) - else: - print_info("API content may not be in top results - checking other queries") - - # Step 3: Search for middleware-related content - print_step(3, "Searching for middleware patterns") - - middleware_query = "middleware and request handling best practices" - results = await memory.get_relevant_context(middleware_query, num_results=5) - - print(f" Query: '{middleware_query}'") - print(f" Found {len(results)} results:") - - middleware_found = False - for i, result in enumerate(results): - content = result.get("content", "")[:100] - result_type = result.get("type", "unknown") - score = result.get("score", 0) - print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...") - if "middleware" in content.lower() or "routes" in content.lower(): - middleware_found = True - - print_result( - "Middleware search", - "Found middleware-related content" if middleware_found else "No direct matches", - middleware_found or len(results) > 0, - ) - - # Step 4: Get session history - print_step(4, "Retrieving session history") - - history = await memory.get_session_history(limit=3) - print(f" Found {len(history)} session records:") - - for i, session in enumerate(history): - session_num = session.get("session_number", "?") - subtasks = session.get("subtasks_completed", []) - print(f" Session {session_num}: {len(subtasks)} subtasks completed") - for subtask in subtasks[:3]: - print(f" - {subtask}") - - print_result( - "Session history", f"Retrieved {len(history)} sessions", len(history) > 0 - ) - - # Step 5: Get status summary - print_step(5, "Memory status summary") - - status = memory.get_status_summary() - for key, value in status.items(): - print(f" {key}: {value}") - - await memory.close() - - print() - all_passed = len(results) > 0 and len(history) > 0 - print_result( - "Memory Retrieval", - "All retrieval tests passed" if all_passed else "Some tests had issues", - all_passed, - ) - return all_passed - - -# ============================================================================ -# Test 4: Full Create → Store → Retrieve Cycle -# ============================================================================ - - -async def test_full_cycle(test_db_path: Path) -> bool: - """ - Test the complete memory lifecycle: - 1. Create unique test data - 2. Store in graph database with Ollama embeddings - 3. Search and retrieve via semantic similarity - 4. Verify retrieved data matches what was stored - """ - print_header("Test 4: Full Create-Store-Retrieve Cycle") - - # Create fresh test directories - spec_dir = test_db_path / "cycle_test_spec" - project_dir = test_db_path / "cycle_test_project" - spec_dir.mkdir(parents=True, exist_ok=True) - project_dir.mkdir(parents=True, exist_ok=True) - - # Override database path for testing - os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db") - os.environ["GRAPHITI_DATABASE"] = "test_full_cycle" - - try: - from integrations.graphiti.memory import GraphitiMemory - except ImportError as e: - print_result("Import", f"Failed: {e}", False) - return False - - # Step 1: Create unique test content - print_step(1, "Creating unique test content") - - unique_id = datetime.now().strftime("%Y%m%d_%H%M%S") - unique_pattern = ( - f"Unique pattern {unique_id}: Use dependency injection for database connections" - ) - unique_gotcha = f"Unique gotcha {unique_id}: Always close database connections in finally blocks" - - print(f" Unique ID: {unique_id}") - print(f" Pattern: {unique_pattern[:60]}...") - print(f" Gotcha: {unique_gotcha[:60]}...") - - # Step 2: Store the content - print_step(2, "Storing content in memory system") - - memory = GraphitiMemory(spec_dir, project_dir) - init_result = await memory.initialize() - - if not init_result: - print_result("Initialize", "Failed", False) - return False - - print_result("Initialize", "SUCCESS", True) - - pattern_result = await memory.save_pattern(unique_pattern) - print_result( - "save_pattern", "SUCCESS" if pattern_result else "FAILED", pattern_result - ) - - gotcha_result = await memory.save_gotcha(unique_gotcha) - print_result("save_gotcha", "SUCCESS" if gotcha_result else "FAILED", gotcha_result) - - # Wait for embedding processing - print() - print_info("Waiting 4 seconds for embedding processing and indexing...") - await asyncio.sleep(4) - - # Step 3: Search for the unique content - print_step(3, "Searching for unique content") - - # Search for the pattern - pattern_query = "dependency injection database connections" - pattern_results = await memory.get_relevant_context(pattern_query, num_results=5) - - print(f" Query: '{pattern_query}'") - print(f" Found {len(pattern_results)} results") - - pattern_found = False - for result in pattern_results: - content = result.get("content", "") - if unique_id in content: - pattern_found = True - print(f" MATCH: {content[:80]}...") - - print_result( - "Pattern retrieval", - f"Found unique pattern (ID: {unique_id})" - if pattern_found - else "Unique pattern not in top results", - pattern_found, - ) - - # Search for the gotcha - gotcha_query = "database connection cleanup finally block" - gotcha_results = await memory.get_relevant_context(gotcha_query, num_results=5) - - print(f" Query: '{gotcha_query}'") - print(f" Found {len(gotcha_results)} results") - - gotcha_found = False - for result in gotcha_results: - content = result.get("content", "") - if unique_id in content: - gotcha_found = True - print(f" MATCH: {content[:80]}...") - - print_result( - "Gotcha retrieval", - f"Found unique gotcha (ID: {unique_id})" - if gotcha_found - else "Unique gotcha not in top results", - gotcha_found, - ) - - # Step 4: Verify semantic similarity works - print_step(4, "Verifying semantic similarity") - - # Search with semantically similar but different wording - alt_query = "closing connections properly in error handling" - alt_results = await memory.get_relevant_context(alt_query, num_results=3) - - print(f" Alternative query: '{alt_query}'") - print(f" Found {len(alt_results)} semantically similar results:") - - for i, result in enumerate(alt_results): - content = result.get("content", "")[:80] - score = result.get("score", 0) - print(f" {i + 1}. (score: {score:.4f}) {content}...") - - semantic_works = len(alt_results) > 0 - print_result( - "Semantic similarity", - "Working - found related content" if semantic_works else "No results", - semantic_works, - ) - - await memory.close() - - # Summary - print() - cycle_passed = ( - pattern_result - and gotcha_result - and (pattern_found or gotcha_found or len(alt_results) > 0) - ) - print_result( - "Full Cycle Test", - "Create-Store-Retrieve cycle verified" - if cycle_passed - else "Some steps had issues", - cycle_passed, - ) - - return cycle_passed - - -# ============================================================================ -# Main Entry Point -# ============================================================================ - - -async def main(): - """Run Ollama embedding memory tests.""" - parser = argparse.ArgumentParser( - description="Test Ollama Embedding Memory Integration" - ) - parser.add_argument( - "--test", - choices=["all", "embeddings", "create", "retrieve", "full-cycle"], - default="all", - help="Which test to run", - ) - parser.add_argument( - "--keep-db", - action="store_true", - help="Keep test database after completion (default: cleanup)", - ) - - args = parser.parse_args() - - print("\n" + "=" * 70) - print(" OLLAMA EMBEDDING MEMORY TEST SUITE") - print("=" * 70) - - # Configuration check - print_header("Configuration Check") - - config_items = { - "GRAPHITI_ENABLED": os.environ.get("GRAPHITI_ENABLED", ""), - "GRAPHITI_LLM_PROVIDER": os.environ.get("GRAPHITI_LLM_PROVIDER", ""), - "GRAPHITI_EMBEDDER_PROVIDER": os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", ""), - "OLLAMA_LLM_MODEL": os.environ.get("OLLAMA_LLM_MODEL", ""), - "OLLAMA_EMBEDDING_MODEL": os.environ.get("OLLAMA_EMBEDDING_MODEL", ""), - "OLLAMA_EMBEDDING_DIM": os.environ.get("OLLAMA_EMBEDDING_DIM", ""), - "OLLAMA_BASE_URL": os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434"), - "OPENAI_API_KEY": "(set)" - if os.environ.get("OPENAI_API_KEY") - else "(not set - needed for reranker)", - } - - all_configured = True - required_keys = [ - "GRAPHITI_ENABLED", - "GRAPHITI_LLM_PROVIDER", - "GRAPHITI_EMBEDDER_PROVIDER", - "OLLAMA_LLM_MODEL", - "OLLAMA_EMBEDDING_MODEL", - ] - - for key, value in config_items.items(): - is_optional = key in [ - "OLLAMA_BASE_URL", - "OPENAI_API_KEY", - "OLLAMA_EMBEDDING_DIM", - ] - is_set = bool(value) if not is_optional else True - display_value = value or "(not set)" - if key == "OPENAI_API_KEY": - display_value = value # Already formatted above - is_set = True # Optional for testing - print_result(key, display_value, is_set) - if key in required_keys and not bool(os.environ.get(key)): - all_configured = False - - if not all_configured: - print() - print(" Missing required configuration. Please set:") - print(" export GRAPHITI_ENABLED=true") - print(" export GRAPHITI_LLM_PROVIDER=ollama") - print(" export GRAPHITI_EMBEDDER_PROVIDER=ollama") - print(" export OLLAMA_LLM_MODEL=deepseek-r1:7b") - print(" export OLLAMA_EMBEDDING_MODEL=embeddinggemma") - print(" export OLLAMA_EMBEDDING_DIM=768") - print(" export OPENAI_API_KEY=dummy # For graphiti-core reranker") - print() - return - - # Check LadybugDB - if not apply_ladybug_monkeypatch(): - print() - print_result("LadybugDB", "Not installed - pip install real-ladybug", False) - return - - print_result("LadybugDB", "Installed", True) - - # Create temp directory for test database - test_db_path = Path(tempfile.mkdtemp(prefix="ollama_memory_test_")) - print() - print_info(f"Test database: {test_db_path}") - - # Run tests - test = args.test - results = {} - - try: - if test in ["all", "embeddings"]: - results["embeddings"] = await test_ollama_embeddings() - - spec_dir = None - project_dir = None - - if test in ["all", "create"]: - spec_dir, project_dir, results["create"] = await test_memory_creation( - test_db_path - ) - - if test in ["all", "retrieve"]: - if spec_dir and project_dir: - results["retrieve"] = await test_memory_retrieval(spec_dir, project_dir) - else: - print_info( - "Skipping retrieve test - no spec/project dir from create test" - ) - - if test in ["all", "full-cycle"]: - results["full-cycle"] = await test_full_cycle(test_db_path) - - finally: - # Cleanup unless --keep-db specified - if not args.keep_db and test_db_path.exists(): - print() - print_info(f"Cleaning up test database: {test_db_path}") - shutil.rmtree(test_db_path, ignore_errors=True) - - # Summary - print_header("TEST SUMMARY") - - all_passed = True - for test_name, passed in results.items(): - status = "PASSED" if passed else "FAILED" - print(f" {test_name}: {status}") - if not passed: - all_passed = False - - print() - if all_passed: - print(" All tests PASSED!") - print() - print(" The memory system is working correctly with Ollama embeddings.") - print(" Memories can be created and retrieved using semantic search.") - else: - print(" Some tests FAILED. Check the output above for details.") - print() - print(" Common issues:") - print(" - Ollama not running: ollama serve") - print(" - Model not pulled: ollama pull embeddinggemma") - print(" - Wrong dimension: Update OLLAMA_EMBEDDING_DIM to match model") - - print() - print(" Commands:") - print(" # Run all tests:") - print(" python integrations/graphiti/test_ollama_embedding_memory.py") - print() - print(" # Run specific test:") - print( - " python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings" - ) - print( - " python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle" - ) - print() - print(" # Keep database for inspection:") - print(" python integrations/graphiti/test_ollama_embedding_memory.py --keep-db") - print() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/apps/backend/integrations/graphiti/test_provider_naming.py b/apps/backend/integrations/graphiti/test_provider_naming.py deleted file mode 100644 index 4fce56b7..00000000 --- a/apps/backend/integrations/graphiti/test_provider_naming.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -""" -Quick test to demonstrate provider-specific database naming. - -Shows how Auto Claude automatically generates provider-specific database names -to prevent embedding dimension mismatches. -""" - -import os -import sys -from pathlib import Path - -# Add auto-claude to path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) - -from integrations.graphiti.config import GraphitiConfig - - -def test_provider_naming(): - """Demonstrate provider-specific database naming.""" - - print("\n" + "=" * 70) - print(" PROVIDER-SPECIFIC DATABASE NAMING") - print("=" * 70 + "\n") - - providers = [ - ("openai", None, None), - ("ollama", "embeddinggemma", 768), - ("ollama", "qwen3-embedding:0.6b", 1024), - ("voyage", None, None), - ("google", None, None), - ] - - for provider, model, dim in providers: - # Create config - config = GraphitiConfig.from_env() - config.embedder_provider = provider - - if provider == "ollama" and model: - config.ollama_embedding_model = model - if dim: - config.ollama_embedding_dim = dim - - # Get naming info - dimension = config.get_embedding_dimension() - signature = config.get_provider_signature() - db_name = config.get_provider_specific_database_name("auto_claude_memory") - - print(f"Provider: {provider}") - if model: - print(f" Model: {model}") - print(f" Embedding Dimension: {dimension}") - print(f" Provider Signature: {signature}") - print(f" Database Name: {db_name}") - print(f" Full Path: ~/.auto-claude/memories/{db_name}/") - print() - - print("=" * 70) - print("\nKey Benefits:") - print(" ✅ No dimension mismatch errors") - print(" ✅ Each provider uses its own database") - print(" ✅ Can switch providers without conflicts") - print(" ✅ Migration utility available for data transfer") - print() - - -if __name__ == "__main__": - test_provider_naming() diff --git a/apps/backend/runners/github/services/review_tools.py b/apps/backend/runners/github/services/review_tools.py index 6853833f..c318d571 100644 --- a/apps/backend/runners/github/services/review_tools.py +++ b/apps/backend/runners/github/services/review_tools.py @@ -15,18 +15,18 @@ from dataclasses import dataclass from pathlib import Path try: - from ...analysis.test_discovery import TestDiscovery from ...core.client import create_client from ..context_gatherer import PRContext from ..models import PRReviewFinding, ReviewSeverity from .category_utils import map_category except (ImportError, ValueError, SystemError): - from analysis.test_discovery import TestDiscovery from category_utils import map_category from context_gatherer import PRContext from core.client import create_client from models import PRReviewFinding, ReviewSeverity +# TestDiscovery was removed - tests are now co-located in their respective modules + logger = logging.getLogger(__name__) @@ -367,48 +367,58 @@ async def run_tests( """ logger.info("[Orchestrator] Running tests...") + # Determine test command based on project configuration + # Try common test commands in order of preference + test_commands = [ + "pytest --cov=.", # Python with coverage + "pytest", # Python + "npm test", # Node.js + "npm run test", # Node.js (script form) + "python -m pytest", # Python alternative + ] + try: - # Discover test framework - discovery = TestDiscovery() - test_info = discovery.discover(project_dir) - - if not test_info.has_tests: - logger.warning("[Orchestrator] No tests found") - return TestResult(executed=False, passed=False, error="No tests found") - - # Get test command - test_cmd = test_info.test_command - if not test_cmd: - return TestResult( - executed=False, passed=False, error="No test command available" + # Execute tests with timeout - try common commands + for test_cmd in test_commands: + logger.info(f"[Orchestrator] Attempting: {test_cmd}") + proc = await asyncio.create_subprocess_shell( + test_cmd, + cwd=project_dir, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) - # Execute tests with timeout - logger.info(f"[Orchestrator] Executing: {test_cmd}") - proc = await asyncio.create_subprocess_shell( - test_cmd, - cwd=project_dir, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - try: - stdout, stderr = await asyncio.wait_for( - proc.communicate(), - timeout=300.0, # 5 min max - ) - except asyncio.TimeoutError: - logger.error("[Orchestrator] Tests timed out after 5 minutes") - proc.kill() - return TestResult(executed=True, passed=False, error="Timeout after 5min") - - passed = proc.returncode == 0 - logger.info(f"[Orchestrator] Tests {'passed' if passed else 'failed'}") + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=300.0, # 5 min max + ) + # If command not found (127) or not executable (126), try next command + # For any other exit code (including test failures), the test framework exists + if proc.returncode in (126, 127): + # Command not found or not executable - try next one + continue + # Test ran (may have passed or failed) - return result + passed = proc.returncode == 0 + logger.info(f"[Orchestrator] Tests {'passed' if passed else 'failed'}") + return TestResult( + executed=True, + passed=passed, + error=None if passed else stderr.decode("utf-8")[:500], + ) + except asyncio.TimeoutError: + # Command timed out - kill it and try next command + proc.kill() + await proc.wait() # Ensure process is fully terminated + continue + except FileNotFoundError: + # Command not found - try next one + continue + # If no test command worked + logger.warning("[Orchestrator] No test command could be executed") return TestResult( - executed=True, - passed=passed, - error=None if passed else stderr.decode("utf-8")[:500], + executed=False, passed=False, error="No test command available" ) except Exception as e: diff --git a/apps/backend/runners/github/test_bot_detection.py b/apps/backend/runners/github/test_bot_detection.py deleted file mode 100644 index e8811502..00000000 --- a/apps/backend/runners/github/test_bot_detection.py +++ /dev/null @@ -1,707 +0,0 @@ -""" -Tests for Bot Detection Module -================================ - -Tests the BotDetector class to ensure it correctly prevents infinite loops. -""" - -import json -import subprocess -import sys -from datetime import datetime, timedelta -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -# Use direct file import to avoid package import issues -_github_dir = Path(__file__).parent -if str(_github_dir) not in sys.path: - sys.path.insert(0, str(_github_dir)) - -from bot_detection import BotDetectionState, BotDetector - - -@pytest.fixture -def temp_state_dir(tmp_path): - """Create temporary state directory.""" - state_dir = tmp_path / "github" - state_dir.mkdir() - return state_dir - - -@pytest.fixture -def mock_bot_detector(temp_state_dir): - """Create bot detector with mocked bot username.""" - with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"): - detector = BotDetector( - state_dir=temp_state_dir, - bot_token="fake-token", - review_own_prs=False, - ) - return detector - - -class TestBotDetectionState: - """Test BotDetectionState data class.""" - - def test_save_and_load(self, temp_state_dir): - """Test saving and loading state.""" - state = BotDetectionState( - reviewed_commits={ - "123": ["abc123", "def456"], - "456": ["ghi789"], - }, - last_review_times={ - "123": "2025-01-01T10:00:00", - "456": "2025-01-01T11:00:00", - }, - ) - - # Save - state.save(temp_state_dir) - - # Load - loaded = BotDetectionState.load(temp_state_dir) - - assert loaded.reviewed_commits == state.reviewed_commits - assert loaded.last_review_times == state.last_review_times - - def test_load_nonexistent(self, temp_state_dir): - """Test loading when file doesn't exist.""" - loaded = BotDetectionState.load(temp_state_dir) - - assert loaded.reviewed_commits == {} - assert loaded.last_review_times == {} - - -class TestBotDetectorInit: - """Test BotDetector initialization.""" - - def test_init_with_token(self, temp_state_dir): - """Test initialization with bot token.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps({"login": "my-bot"}), - ) - - detector = BotDetector( - state_dir=temp_state_dir, - bot_token="ghp_test123", - review_own_prs=False, - ) - - assert detector.bot_username == "my-bot" - assert detector.review_own_prs is False - - def test_init_without_token(self, temp_state_dir): - """Test initialization without bot token.""" - detector = BotDetector( - state_dir=temp_state_dir, - bot_token=None, - review_own_prs=True, - ) - - assert detector.bot_username is None - assert detector.review_own_prs is True - - -class TestBotDetection: - """Test bot detection methods.""" - - def test_is_bot_pr(self, mock_bot_detector): - """Test detecting bot-authored PRs.""" - bot_pr = {"author": {"login": "test-bot"}} - human_pr = {"author": {"login": "alice"}} - - assert mock_bot_detector.is_bot_pr(bot_pr) is True - assert mock_bot_detector.is_bot_pr(human_pr) is False - - def test_is_bot_commit(self, mock_bot_detector): - """Test detecting bot-authored commits.""" - bot_commit = {"author": {"login": "test-bot"}} - human_commit = {"author": {"login": "alice"}} - bot_committer = { - "committer": {"login": "test-bot"}, - "author": {"login": "alice"}, - } - - assert mock_bot_detector.is_bot_commit(bot_commit) is True - assert mock_bot_detector.is_bot_commit(human_commit) is False - assert mock_bot_detector.is_bot_commit(bot_committer) is True - - def test_get_last_commit_sha(self, mock_bot_detector): - """Test extracting last commit SHA.""" - # GitHub API returns commits in chronological order (oldest first, newest last) - # So commits[-1] is the LATEST commit - commits = [ - {"oid": "abc123"}, # Oldest commit - {"oid": "def456"}, # Latest commit - ] - - sha = mock_bot_detector.get_last_commit_sha(commits) - assert sha == "def456" # Should return the LAST (latest) commit - - # Test with sha field instead of oid - commits_with_sha = [{"sha": "xyz789"}] - sha = mock_bot_detector.get_last_commit_sha(commits_with_sha) - assert sha == "xyz789" - - # Empty commits - assert mock_bot_detector.get_last_commit_sha([]) is None - - -class TestCoolingOff: - """Test cooling off period. - - Note: COOLING_OFF_MINUTES is currently set to 1 minute for testing large PRs. - """ - - def test_within_cooling_off(self, mock_bot_detector): - """Test PR within cooling off period.""" - # Set last review to 30 seconds ago (within 1 minute cooling off) - half_min_ago = datetime.now() - timedelta(seconds=30) - mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat() - - is_cooling, reason = mock_bot_detector.is_within_cooling_off(123) - - assert is_cooling is True - assert "Cooling off" in reason - - def test_outside_cooling_off(self, mock_bot_detector): - """Test PR outside cooling off period.""" - # Set last review to 2 minutes ago (outside 1 minute cooling off) - two_min_ago = datetime.now() - timedelta(minutes=2) - mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat() - - is_cooling, reason = mock_bot_detector.is_within_cooling_off(123) - - assert is_cooling is False - assert reason == "" - - def test_no_previous_review(self, mock_bot_detector): - """Test PR with no previous review.""" - is_cooling, reason = mock_bot_detector.is_within_cooling_off(999) - - assert is_cooling is False - assert reason == "" - - -class TestReviewedCommits: - """Test reviewed commit tracking.""" - - def test_has_reviewed_commit(self, mock_bot_detector): - """Test checking if commit was reviewed.""" - mock_bot_detector.state.reviewed_commits["123"] = ["abc123", "def456"] - - assert mock_bot_detector.has_reviewed_commit(123, "abc123") is True - assert mock_bot_detector.has_reviewed_commit(123, "xyz789") is False - assert mock_bot_detector.has_reviewed_commit(999, "abc123") is False - - def test_mark_reviewed(self, mock_bot_detector, temp_state_dir): - """Test marking PR as reviewed.""" - mock_bot_detector.mark_reviewed(123, "abc123") - - # Check state - assert "123" in mock_bot_detector.state.reviewed_commits - assert "abc123" in mock_bot_detector.state.reviewed_commits["123"] - assert "123" in mock_bot_detector.state.last_review_times - - # Check persistence - loaded = BotDetectionState.load(temp_state_dir) - assert "123" in loaded.reviewed_commits - assert "abc123" in loaded.reviewed_commits["123"] - - def test_mark_reviewed_multiple(self, mock_bot_detector): - """Test marking same PR reviewed multiple times.""" - mock_bot_detector.mark_reviewed(123, "abc123") - mock_bot_detector.mark_reviewed(123, "def456") - - commits = mock_bot_detector.state.reviewed_commits["123"] - assert len(commits) == 2 - assert "abc123" in commits - assert "def456" in commits - - -class TestShouldSkipReview: - """Test main should_skip_pr_review logic.""" - - def test_skip_bot_pr(self, mock_bot_detector): - """Test skipping bot-authored PR.""" - pr_data = {"author": {"login": "test-bot"}} - commits = [{"author": {"login": "test-bot"}, "oid": "abc123"}] - - should_skip, reason = mock_bot_detector.should_skip_pr_review( - pr_number=123, - pr_data=pr_data, - commits=commits, - ) - - assert should_skip is True - assert "bot user" in reason - - def test_skip_bot_commit(self, mock_bot_detector): - """Test skipping PR with bot commit as the latest commit.""" - pr_data = {"author": {"login": "alice"}} - # GitHub API returns commits in chronological order (oldest first, newest last) - # So commits[-1] is the LATEST commit - which is the bot commit - commits = [ - {"author": {"login": "alice"}, "oid": "abc123"}, # Oldest commit (by alice) - { - "author": {"login": "test-bot"}, - "oid": "def456", - }, # Latest commit (by bot) - ] - - should_skip, reason = mock_bot_detector.should_skip_pr_review( - pr_number=123, - pr_data=pr_data, - commits=commits, - ) - - assert should_skip is True - assert "bot" in reason.lower() - - def test_skip_cooling_off(self, mock_bot_detector): - """Test skipping during cooling off period.""" - # Set last review to 30 seconds ago (within 1 minute cooling off) - half_min_ago = datetime.now() - timedelta(seconds=30) - mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat() - - pr_data = {"author": {"login": "alice"}} - commits = [{"author": {"login": "alice"}, "oid": "abc123"}] - - should_skip, reason = mock_bot_detector.should_skip_pr_review( - pr_number=123, - pr_data=pr_data, - commits=commits, - ) - - assert should_skip is True - assert "Cooling off" in reason - - def test_skip_already_reviewed(self, mock_bot_detector): - """Test skipping already-reviewed commit.""" - mock_bot_detector.state.reviewed_commits["123"] = ["abc123"] - - pr_data = {"author": {"login": "alice"}} - commits = [{"author": {"login": "alice"}, "oid": "abc123"}] - - should_skip, reason = mock_bot_detector.should_skip_pr_review( - pr_number=123, - pr_data=pr_data, - commits=commits, - ) - - assert should_skip is True - assert "Already reviewed" in reason - - def test_allow_review(self, mock_bot_detector): - """Test allowing review when all checks pass.""" - pr_data = {"author": {"login": "alice"}} - commits = [{"author": {"login": "alice"}, "oid": "abc123"}] - - should_skip, reason = mock_bot_detector.should_skip_pr_review( - pr_number=123, - pr_data=pr_data, - commits=commits, - ) - - assert should_skip is False - assert reason == "" - - def test_allow_review_own_prs(self, temp_state_dir): - """Test allowing review when review_own_prs is True.""" - with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"): - detector = BotDetector( - state_dir=temp_state_dir, - bot_token="fake-token", - review_own_prs=True, # Allow bot to review own PRs - ) - - pr_data = {"author": {"login": "test-bot"}} - commits = [{"author": {"login": "test-bot"}, "oid": "abc123"}] - - should_skip, reason = detector.should_skip_pr_review( - pr_number=123, - pr_data=pr_data, - commits=commits, - ) - - # Should not skip even though it's bot's own PR - assert should_skip is False - - -class TestStateManagement: - """Test state management methods.""" - - def test_clear_pr_state(self, mock_bot_detector, temp_state_dir): - """Test clearing PR state.""" - # Set up state - mock_bot_detector.mark_reviewed(123, "abc123") - mock_bot_detector.mark_reviewed(456, "def456") - - # Clear one PR - mock_bot_detector.clear_pr_state(123) - - # Check in-memory state - assert "123" not in mock_bot_detector.state.reviewed_commits - assert "123" not in mock_bot_detector.state.last_review_times - assert "456" in mock_bot_detector.state.reviewed_commits - - # Check persistence - loaded = BotDetectionState.load(temp_state_dir) - assert "123" not in loaded.reviewed_commits - assert "456" in loaded.reviewed_commits - - def test_get_stats(self, mock_bot_detector): - """Test getting detector statistics.""" - mock_bot_detector.mark_reviewed(123, "abc123") - mock_bot_detector.mark_reviewed(123, "def456") - mock_bot_detector.mark_reviewed(456, "ghi789") - - stats = mock_bot_detector.get_stats() - - assert stats["bot_username"] == "test-bot" - assert stats["review_own_prs"] is False - assert stats["total_prs_tracked"] == 2 - assert stats["total_reviews_performed"] == 3 - assert stats["cooling_off_minutes"] == 1 # Currently set to 1 for testing - - -class TestEdgeCases: - """Test edge cases and error handling.""" - - def test_no_commits(self, mock_bot_detector): - """Test handling PR with no commits.""" - pr_data = {"author": {"login": "alice"}} - commits = [] - - should_skip, reason = mock_bot_detector.should_skip_pr_review( - pr_number=123, - pr_data=pr_data, - commits=commits, - ) - - # Should not skip (no bot commit to detect) - assert should_skip is False - - def test_malformed_commit_data(self, mock_bot_detector): - """Test handling malformed commit data.""" - pr_data = {"author": {"login": "alice"}} - commits = [ - {"author": {"login": "alice"}}, # Missing oid/sha - {}, # Empty commit - ] - - # Should not crash - should_skip, reason = mock_bot_detector.should_skip_pr_review( - pr_number=123, - pr_data=pr_data, - commits=commits, - ) - - assert should_skip is False - - def test_invalid_last_review_time(self, mock_bot_detector): - """Test handling invalid timestamp in state.""" - mock_bot_detector.state.last_review_times["123"] = "invalid-timestamp" - - is_cooling, reason = mock_bot_detector.is_within_cooling_off(123) - - # Should not crash, should return False - assert is_cooling is False - - -class TestGhExecutableDetection: - """Test gh executable detection in bot_detector._get_bot_username.""" - - def test_get_bot_username_with_gh_not_found(self, temp_state_dir): - """Test _get_bot_username when gh CLI is not found.""" - with patch("bot_detection.get_gh_executable", return_value=None): - detector = BotDetector( - state_dir=temp_state_dir, - bot_token="fake-token", - review_own_prs=False, - ) - - # Should not crash, username should be None - assert detector.bot_username is None - - def test_get_bot_username_with_detected_gh(self, temp_state_dir): - """Test _get_bot_username when gh CLI is found.""" - mock_gh_path = str(temp_state_dir / "gh") - with patch("bot_detection.get_gh_executable", return_value=mock_gh_path): - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps({"login": "test-bot-user"}), - ) - - detector = BotDetector( - state_dir=temp_state_dir, - bot_token="fake-token", - review_own_prs=False, - ) - - # Should use the detected gh path - assert detector.bot_username == "test-bot-user" - - # Verify subprocess was called with the correct gh path - mock_run.assert_called_once() - called_cmd_list = mock_run.call_args[0][0] - assert called_cmd_list[0] == mock_gh_path - assert called_cmd_list[1:] == ["api", "user"] - - def test_get_bot_username_uses_get_gh_executable_return_value(self, temp_state_dir): - """Test that _get_bot_username uses the path returned by get_gh_executable.""" - # Note: GITHUB_CLI_PATH env var is tested by get_gh_executable's own tests - # This test verifies _get_bot_username uses whatever get_gh_executable returns - mock_gh_path = str(temp_state_dir / "gh") - - with patch("bot_detection.get_gh_executable", return_value=mock_gh_path): - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps({"login": "env-bot-user"}), - ) - - detector = BotDetector( - state_dir=temp_state_dir, - bot_token="fake-token", - review_own_prs=False, - ) - - # Verify the command was run with the path from get_gh_executable - assert detector.bot_username == "env-bot-user" - - # Verify subprocess was called with the correct path - mock_run.assert_called_once() - called_cmd_list = mock_run.call_args[0][0] - assert called_cmd_list[0] == mock_gh_path - assert called_cmd_list[1:] == ["api", "user"] - - def test_get_bot_username_with_api_error(self, temp_state_dir): - """Test _get_bot_username when gh api command fails.""" - mock_gh_path = str(temp_state_dir / "gh") - with patch("bot_detection.get_gh_executable", return_value=mock_gh_path): - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=1, - stderr="Authentication failed", - ) - - detector = BotDetector( - state_dir=temp_state_dir, - bot_token="invalid-token", - review_own_prs=False, - ) - - # Should handle error gracefully, username should be None - assert detector.bot_username is None - - def test_get_bot_username_with_subprocess_timeout(self, temp_state_dir): - """Test _get_bot_username when subprocess times out.""" - mock_gh_path = str(temp_state_dir / "gh") - with patch("bot_detection.get_gh_executable", return_value=mock_gh_path): - with patch( - "subprocess.run", side_effect=subprocess.TimeoutExpired("gh", 5) - ): - detector = BotDetector( - state_dir=temp_state_dir, - bot_token="fake-token", - review_own_prs=False, - ) - - # Should handle timeout gracefully, username should be None - assert detector.bot_username is None - - def test_get_bot_username_without_token(self, temp_state_dir): - """Test _get_bot_username when no bot token is provided.""" - with patch("subprocess.run") as mock_run: - detector = BotDetector( - state_dir=temp_state_dir, - bot_token=None, - review_own_prs=False, - ) - - # Should return None without trying to call gh - assert detector.bot_username is None - # Verify subprocess.run was not called (no gh CLI invocation) - mock_run.assert_not_called() - - -class TestInProgressTracking: - """Test in-progress review tracking.""" - - def test_mark_review_started(self, mock_bot_detector, temp_state_dir): - """Test marking review as started.""" - mock_bot_detector.mark_review_started(123) - - # Check state - assert "123" in mock_bot_detector.state.in_progress_reviews - start_time_str = mock_bot_detector.state.in_progress_reviews["123"] - start_time = datetime.fromisoformat(start_time_str) - - # Should be very recent (within last 5 seconds) - time_diff = datetime.now() - start_time - assert time_diff.total_seconds() < 5 - - # Check persistence - loaded = BotDetectionState.load(temp_state_dir) - assert "123" in loaded.in_progress_reviews - - def test_mark_review_finished_success(self, mock_bot_detector, temp_state_dir): - """Test marking review as finished successfully.""" - mock_bot_detector.mark_review_started(123) - assert "123" in mock_bot_detector.state.in_progress_reviews - - mock_bot_detector.mark_review_finished(123, success=True) - - # In-progress state should be cleared - assert "123" not in mock_bot_detector.state.in_progress_reviews - - # Check persistence - loaded = BotDetectionState.load(temp_state_dir) - assert "123" not in loaded.in_progress_reviews - - def test_mark_review_finished_error(self, mock_bot_detector): - """Test marking review as finished with error.""" - mock_bot_detector.mark_review_started(123) - mock_bot_detector.mark_review_finished(123, success=False) - - # In-progress state should be cleared - assert "123" not in mock_bot_detector.state.in_progress_reviews - - def test_is_review_in_progress_active(self, mock_bot_detector): - """Test detecting active in-progress review.""" - mock_bot_detector.mark_review_started(123) - - is_in_progress, reason = mock_bot_detector.is_review_in_progress(123) - - assert is_in_progress is True - assert "already in progress" in reason.lower() - - def test_is_review_in_progress_not_started(self, mock_bot_detector): - """Test checking in-progress when review not started.""" - is_in_progress, reason = mock_bot_detector.is_review_in_progress(999) - - assert is_in_progress is False - assert reason == "" - - def test_is_review_in_progress_stale(self, mock_bot_detector): - """Test detecting stale in-progress review.""" - # Set review start time to 31 minutes ago (past timeout) - stale_time = datetime.now() - timedelta(minutes=31) - mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat() - - is_in_progress, reason = mock_bot_detector.is_review_in_progress(123) - - # Should detect as stale and clear it - assert is_in_progress is False - assert reason == "" - # Should be removed from state - assert "123" not in mock_bot_detector.state.in_progress_reviews - - def test_is_review_in_progress_invalid_timestamp(self, mock_bot_detector): - """Test handling invalid timestamp in in-progress state.""" - mock_bot_detector.state.in_progress_reviews["123"] = "invalid-timestamp" - - is_in_progress, reason = mock_bot_detector.is_review_in_progress(123) - - # Should clear invalid state - assert is_in_progress is False - assert reason == "" - assert "123" not in mock_bot_detector.state.in_progress_reviews - - def test_should_skip_review_in_progress(self, mock_bot_detector): - """Test skipping PR when review is in progress.""" - mock_bot_detector.mark_review_started(123) - - pr_data = {"author": {"login": "alice"}} - commits = [{"author": {"login": "alice"}, "oid": "abc123"}] - - should_skip, reason = mock_bot_detector.should_skip_pr_review( - pr_number=123, - pr_data=pr_data, - commits=commits, - ) - - assert should_skip is True - assert "already in progress" in reason.lower() - - def test_mark_reviewed_clears_in_progress(self, mock_bot_detector): - """Test that mark_reviewed also clears in-progress state.""" - mock_bot_detector.mark_review_started(123) - assert "123" in mock_bot_detector.state.in_progress_reviews - - mock_bot_detector.mark_reviewed(123, "abc123") - - # In-progress should be cleared - assert "123" not in mock_bot_detector.state.in_progress_reviews - # Reviewed state should be set - assert "123" in mock_bot_detector.state.reviewed_commits - assert "abc123" in mock_bot_detector.state.reviewed_commits["123"] - - def test_clear_pr_state_clears_in_progress(self, mock_bot_detector): - """Test that clear_pr_state also clears in-progress state.""" - mock_bot_detector.mark_review_started(123) - mock_bot_detector.mark_reviewed(123, "abc123") - - assert ( - "123" in mock_bot_detector.state.in_progress_reviews or True - ) # May be cleared by mark_reviewed - assert "123" in mock_bot_detector.state.reviewed_commits - - # Start another review - mock_bot_detector.mark_review_started(123) - assert "123" in mock_bot_detector.state.in_progress_reviews - - mock_bot_detector.clear_pr_state(123) - - # Everything should be cleared - assert "123" not in mock_bot_detector.state.in_progress_reviews - assert "123" not in mock_bot_detector.state.reviewed_commits - assert "123" not in mock_bot_detector.state.last_review_times - - def test_get_stats_includes_in_progress(self, mock_bot_detector): - """Test that get_stats includes in-progress count.""" - mock_bot_detector.mark_review_started(123) - mock_bot_detector.mark_review_started(456) - mock_bot_detector.mark_reviewed(789, "abc123") - - stats = mock_bot_detector.get_stats() - - assert stats["in_progress_reviews"] == 2 - assert stats["total_prs_tracked"] == 1 # Only 789 is tracked as reviewed - assert stats["in_progress_timeout_minutes"] == 30 - - def test_cleanup_stale_prs_removes_stale_in_progress(self, mock_bot_detector): - """Test that cleanup_stale_prs removes stale in-progress reviews.""" - # Add a stale in-progress review (32 minutes ago) - stale_time = datetime.now() - timedelta(minutes=32) - mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat() - - # Add an active in-progress review (5 minutes ago) - active_time = datetime.now() - timedelta(minutes=5) - mock_bot_detector.state.in_progress_reviews["456"] = active_time.isoformat() - - # Add a stale reviewed PR (40 days ago) - stale_review_time = datetime.now() - timedelta(days=40) - mock_bot_detector.state.reviewed_commits["789"] = ["abc123"] - mock_bot_detector.state.last_review_times["789"] = stale_review_time.isoformat() - - cleaned = mock_bot_detector.cleanup_stale_prs(max_age_days=30) - - # Should remove stale in-progress and stale reviewed PR - assert cleaned == 2 # 1 stale in-progress + 1 stale reviewed - assert "123" not in mock_bot_detector.state.in_progress_reviews - assert ( - "456" in mock_bot_detector.state.in_progress_reviews - ) # Active one remains - assert "789" not in mock_bot_detector.state.reviewed_commits - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/apps/backend/runners/github/test_context_gatherer.py b/apps/backend/runners/github/test_context_gatherer.py deleted file mode 100644 index 19ed4498..00000000 --- a/apps/backend/runners/github/test_context_gatherer.py +++ /dev/null @@ -1,213 +0,0 @@ -""" -Unit tests for PR Context Gatherer -=================================== - -Tests the context gathering functionality without requiring actual GitHub API calls. -""" - -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from context_gatherer import ChangedFile, PRContext, PRContextGatherer - - -@pytest.mark.asyncio -async def test_gather_basic_pr_context(tmp_path): - """Test gathering basic PR context.""" - # Create a temporary project directory - project_dir = tmp_path / "project" - project_dir.mkdir() - - # Mock the subprocess calls - pr_metadata = { - "number": 123, - "title": "Add new feature", - "body": "This PR adds a new feature", - "author": {"login": "testuser"}, - "baseRefName": "main", - "headRefName": "feature/new-feature", - "files": [ - { - "path": "src/app.ts", - "status": "modified", - "additions": 10, - "deletions": 5, - } - ], - "additions": 10, - "deletions": 5, - "changedFiles": 1, - "labels": [{"name": "feature"}], - } - - with patch("subprocess.run") as mock_run: - # Mock metadata fetch - mock_run.return_value = MagicMock( - returncode=0, stdout='{"number": 123, "title": "Add new feature"}' - ) - - gatherer = PRContextGatherer(project_dir, 123) - - # We can't fully test without real git, but we can verify the structure - assert gatherer.pr_number == 123 - assert gatherer.project_dir == project_dir - - -def test_normalize_status(): - """Test file status normalization.""" - gatherer = PRContextGatherer(Path("/tmp"), 1) - - assert gatherer._normalize_status("added") == "added" - assert gatherer._normalize_status("ADD") == "added" - assert gatherer._normalize_status("modified") == "modified" - assert gatherer._normalize_status("mod") == "modified" - assert gatherer._normalize_status("deleted") == "deleted" - assert gatherer._normalize_status("renamed") == "renamed" - - -def test_find_test_files(tmp_path): - """Test finding related test files.""" - # Create a project structure - project_dir = tmp_path / "project" - src_dir = project_dir / "src" - src_dir.mkdir(parents=True) - - # Create source file - source_file = src_dir / "utils.ts" - source_file.write_text("export const add = (a, b) => a + b;", encoding="utf-8") - - # Create test file - test_file = src_dir / "utils.test.ts" - test_file.write_text("import { add } from './utils';", encoding="utf-8") - - gatherer = PRContextGatherer(project_dir, 1) - - # Find test files for the source file - source_path = Path("src/utils.ts") - test_files = gatherer._find_test_files(source_path) - - assert "src/utils.test.ts" in test_files - - -def test_resolve_import_path(tmp_path): - """Test resolving relative import paths.""" - # Create a project structure - project_dir = tmp_path / "project" - src_dir = project_dir / "src" - src_dir.mkdir(parents=True) - - # Create imported file - utils_file = src_dir / "utils.ts" - utils_file.write_text("export const helper = () => {};", encoding="utf-8") - - # Create importing file - app_file = src_dir / "app.ts" - app_file.write_text("import { helper } from './utils';", encoding="utf-8") - - gatherer = PRContextGatherer(project_dir, 1) - - # Resolve import path - source_path = Path("src/app.ts") - resolved = gatherer._resolve_import_path("./utils", source_path) - - assert resolved == "src/utils.ts" - - -def test_detect_repo_structure_monorepo(tmp_path): - """Test detecting monorepo structure.""" - # Create monorepo structure - project_dir = tmp_path / "project" - project_dir.mkdir() - - apps_dir = project_dir / "apps" - apps_dir.mkdir() - - (apps_dir / "frontend").mkdir() - (apps_dir / "backend").mkdir() - - # Create package.json with workspaces - package_json = project_dir / "package.json" - package_json.write_text('{"workspaces": ["apps/*"]}', encoding="utf-8") - - gatherer = PRContextGatherer(project_dir, 1) - - structure = gatherer._detect_repo_structure() - - assert "Monorepo Apps" in structure - assert "frontend" in structure - assert "backend" in structure - assert "Workspaces" in structure - - -def test_detect_repo_structure_python(tmp_path): - """Test detecting Python project structure.""" - project_dir = tmp_path / "project" - project_dir.mkdir() - - # Create pyproject.toml - pyproject = project_dir / "pyproject.toml" - pyproject.write_text("[tool.poetry]\nname = 'test'", encoding="utf-8") - - gatherer = PRContextGatherer(project_dir, 1) - - structure = gatherer._detect_repo_structure() - - assert "Python Project" in structure - - -def test_find_config_files(tmp_path): - """Test finding configuration files.""" - project_dir = tmp_path / "project" - src_dir = project_dir / "src" - src_dir.mkdir(parents=True) - - # Create config files - (src_dir / "tsconfig.json").write_text("{}", encoding="utf-8") - (src_dir / "package.json").write_text("{}", encoding="utf-8") - - gatherer = PRContextGatherer(project_dir, 1) - - config_files = gatherer._find_config_files(Path("src")) - - assert "src/tsconfig.json" in config_files - assert "src/package.json" in config_files - - -def test_get_file_extension(): - """Test file extension mapping for syntax highlighting.""" - gatherer = PRContextGatherer(Path("/tmp"), 1) - - assert gatherer._get_file_extension("app.ts") == "typescript" - assert gatherer._get_file_extension("utils.tsx") == "typescript" - assert gatherer._get_file_extension("script.js") == "javascript" - assert gatherer._get_file_extension("script.jsx") == "javascript" - assert gatherer._get_file_extension("main.py") == "python" - assert gatherer._get_file_extension("config.json") == "json" - assert gatherer._get_file_extension("readme.md") == "markdown" - assert gatherer._get_file_extension("config.yml") == "yaml" - - -def test_find_imports_typescript(tmp_path): - """Test finding imports in TypeScript code.""" - project_dir = tmp_path / "project" - project_dir.mkdir() - - content = """ -import { Component } from 'react'; -import { helper } from './utils'; -import { config } from '../config'; -import external from 'lodash'; -""" - - gatherer = PRContextGatherer(project_dir, 1) - source_path = Path("src/app.tsx") - - imports = gatherer._find_imports(content, source_path) - - # Should only include relative imports - assert len(imports) >= 0 # Depends on whether files actually exist - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/apps/backend/runners/github/test_enhanced_pr_review.py b/apps/backend/runners/github/test_enhanced_pr_review.py deleted file mode 100644 index 87c11a43..00000000 --- a/apps/backend/runners/github/test_enhanced_pr_review.py +++ /dev/null @@ -1,582 +0,0 @@ -#!/usr/bin/env python3 -""" -Validation tests for the Enhanced PR Review System. - -These tests validate: -1. Model serialization/deserialization -2. Verdict generation logic -3. Risk assessment calculation -4. AI comment parsing -5. Structural issue parsing -6. Summary generation -""" - -import json -import sys -from dataclasses import asdict - -from context_gatherer import AI_BOT_PATTERNS, AIBotComment - -# Direct imports (avoid parent __init__.py issues) -from models import ( - AICommentTriage, - AICommentVerdict, - MergeVerdict, - PRReviewFinding, - PRReviewResult, - ReviewCategory, - ReviewPass, - ReviewSeverity, - StructuralIssue, -) - - -def test_merge_verdict_enum(): - """Test MergeVerdict enum values.""" - print("Testing MergeVerdict enum...") - - assert MergeVerdict.READY_TO_MERGE.value == "ready_to_merge" - assert MergeVerdict.MERGE_WITH_CHANGES.value == "merge_with_changes" - assert MergeVerdict.NEEDS_REVISION.value == "needs_revision" - assert MergeVerdict.BLOCKED.value == "blocked" - - # Test string conversion - assert MergeVerdict("ready_to_merge") == MergeVerdict.READY_TO_MERGE - assert MergeVerdict("blocked") == MergeVerdict.BLOCKED - - print(" ✅ MergeVerdict enum: PASS") - - -def test_ai_comment_verdict_enum(): - """Test AICommentVerdict enum values.""" - print("Testing AICommentVerdict enum...") - - assert AICommentVerdict.CRITICAL.value == "critical" - assert AICommentVerdict.IMPORTANT.value == "important" - assert AICommentVerdict.NICE_TO_HAVE.value == "nice_to_have" - assert AICommentVerdict.TRIVIAL.value == "trivial" - assert AICommentVerdict.FALSE_POSITIVE.value == "false_positive" - - print(" ✅ AICommentVerdict enum: PASS") - - -def test_review_pass_enum(): - """Test ReviewPass enum includes new passes.""" - print("Testing ReviewPass enum...") - - assert ReviewPass.STRUCTURAL.value == "structural" - assert ReviewPass.AI_COMMENT_TRIAGE.value == "ai_comment_triage" - - # Ensure all 6 passes exist - passes = [p.value for p in ReviewPass] - assert len(passes) == 6 - assert "quick_scan" in passes - assert "security" in passes - assert "quality" in passes - assert "deep_analysis" in passes - assert "structural" in passes - assert "ai_comment_triage" in passes - - print(" ✅ ReviewPass enum: PASS") - - -def test_ai_bot_patterns(): - """Test AI bot detection patterns.""" - print("Testing AI bot patterns...") - - # Check known patterns exist - assert "coderabbitai" in AI_BOT_PATTERNS - assert "greptile" in AI_BOT_PATTERNS - assert "copilot" in AI_BOT_PATTERNS - assert "sourcery-ai" in AI_BOT_PATTERNS - - # Check pattern -> name mapping - assert AI_BOT_PATTERNS["coderabbitai"] == "CodeRabbit" - assert AI_BOT_PATTERNS["greptile"] == "Greptile" - assert AI_BOT_PATTERNS["copilot"] == "GitHub Copilot" - - # Check we have a reasonable number of patterns - assert len(AI_BOT_PATTERNS) >= 15, ( - f"Expected at least 15 patterns, got {len(AI_BOT_PATTERNS)}" - ) - - print(f" ✅ AI bot patterns ({len(AI_BOT_PATTERNS)} patterns): PASS") - - -def test_ai_bot_comment_dataclass(): - """Test AIBotComment dataclass.""" - print("Testing AIBotComment dataclass...") - - comment = AIBotComment( - comment_id=12345, - author="coderabbitai[bot]", - tool_name="CodeRabbit", - body="This function has a potential SQL injection vulnerability.", - file="src/db/queries.py", - line=42, - created_at="2024-01-15T10:30:00Z", - ) - - assert comment.comment_id == 12345 - assert comment.tool_name == "CodeRabbit" - assert "SQL injection" in comment.body - assert comment.file == "src/db/queries.py" - assert comment.line == 42 - - print(" ✅ AIBotComment dataclass: PASS") - - -def test_ai_comment_triage_dataclass(): - """Test AICommentTriage dataclass.""" - print("Testing AICommentTriage dataclass...") - - triage = AICommentTriage( - comment_id=12345, - tool_name="CodeRabbit", - original_comment="SQL injection vulnerability detected", - verdict=AICommentVerdict.CRITICAL, - reasoning="Verified - user input is directly concatenated into SQL query", - response_comment="✅ Verified: Critical security issue - must fix before merge", - ) - - assert triage.verdict == AICommentVerdict.CRITICAL - assert triage.tool_name == "CodeRabbit" - assert "Verified" in triage.reasoning - - print(" ✅ AICommentTriage dataclass: PASS") - - -def test_structural_issue_dataclass(): - """Test StructuralIssue dataclass.""" - print("Testing StructuralIssue dataclass...") - - issue = StructuralIssue( - id="struct-1", - issue_type="feature_creep", - severity=ReviewSeverity.HIGH, - title="PR includes unrelated authentication refactor", - description="The PR titled 'Fix payment bug' also refactors auth middleware.", - impact="Bundles unrelated changes, harder to review and revert.", - suggestion="Split into two PRs: one for payment fix, one for auth refactor.", - ) - - assert issue.issue_type == "feature_creep" - assert issue.severity == ReviewSeverity.HIGH - assert "unrelated" in issue.title.lower() - - print(" ✅ StructuralIssue dataclass: PASS") - - -def test_pr_review_result_new_fields(): - """Test PRReviewResult has all new fields.""" - print("Testing PRReviewResult new fields...") - - result = PRReviewResult( - pr_number=123, - repo="owner/repo", - success=True, - findings=[], - summary="Test summary", - overall_status="approve", - # New fields - verdict=MergeVerdict.READY_TO_MERGE, - verdict_reasoning="No blocking issues found", - blockers=[], - risk_assessment={ - "complexity": "low", - "security_impact": "none", - "scope_coherence": "good", - }, - structural_issues=[], - ai_comment_triages=[], - quick_scan_summary={"purpose": "Test PR", "complexity": "low"}, - ) - - assert result.verdict == MergeVerdict.READY_TO_MERGE - assert result.verdict_reasoning == "No blocking issues found" - assert result.blockers == [] - assert result.risk_assessment["complexity"] == "low" - assert result.structural_issues == [] - assert result.ai_comment_triages == [] - - print(" ✅ PRReviewResult new fields: PASS") - - -def test_pr_review_result_serialization(): - """Test PRReviewResult serializes and deserializes correctly.""" - print("Testing PRReviewResult serialization...") - - # Create a complex result - finding = PRReviewFinding( - id="finding-1", - severity=ReviewSeverity.HIGH, - category=ReviewCategory.SECURITY, - title="SQL Injection", - description="User input not sanitized", - file="src/db.py", - line=42, - ) - - structural = StructuralIssue( - id="struct-1", - issue_type="feature_creep", - severity=ReviewSeverity.MEDIUM, - title="Unrelated changes", - description="Extra refactoring", - impact="Harder to review", - suggestion="Split PR", - ) - - triage = AICommentTriage( - comment_id=999, - tool_name="CodeRabbit", - original_comment="Missing null check", - verdict=AICommentVerdict.TRIVIAL, - reasoning="Value is guaranteed non-null by upstream validation", - ) - - result = PRReviewResult( - pr_number=456, - repo="test/repo", - success=True, - findings=[finding], - summary="Test", - overall_status="comment", - verdict=MergeVerdict.MERGE_WITH_CHANGES, - verdict_reasoning="1 high-priority issue", - blockers=["Security: SQL Injection (src/db.py:42)"], - risk_assessment={ - "complexity": "medium", - "security_impact": "medium", - "scope_coherence": "mixed", - }, - structural_issues=[structural], - ai_comment_triages=[triage], - quick_scan_summary={"purpose": "Test", "complexity": "medium"}, - ) - - # Serialize to dict - data = result.to_dict() - - # Check serialized data - assert data["verdict"] == "merge_with_changes" - assert data["blockers"] == ["Security: SQL Injection (src/db.py:42)"] - assert len(data["structural_issues"]) == 1 - assert len(data["ai_comment_triages"]) == 1 - assert data["structural_issues"][0]["issue_type"] == "feature_creep" - assert data["ai_comment_triages"][0]["verdict"] == "trivial" - - # Deserialize back - loaded = PRReviewResult.from_dict(data) - - assert loaded.verdict == MergeVerdict.MERGE_WITH_CHANGES - assert loaded.verdict_reasoning == "1 high-priority issue" - assert len(loaded.structural_issues) == 1 - assert loaded.structural_issues[0].issue_type == "feature_creep" - assert len(loaded.ai_comment_triages) == 1 - assert loaded.ai_comment_triages[0].verdict == AICommentVerdict.TRIVIAL - - print(" ✅ PRReviewResult serialization: PASS") - - -def test_verdict_generation_logic(): - """Test verdict generation produces correct verdicts.""" - print("Testing verdict generation logic...") - - # Test case 1: No issues -> READY_TO_MERGE - findings = [] - structural = [] - triages = [] - - # Simulate verdict logic - critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL] - high = [f for f in findings if f.severity == ReviewSeverity.HIGH] - security_critical = [f for f in critical if f.category == ReviewCategory.SECURITY] - structural_blockers = [ - s - for s in structural - if s.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH) - ] - ai_critical = [t for t in triages if t.verdict == AICommentVerdict.CRITICAL] - - blockers = [] - for f in security_critical: - blockers.append(f"Security: {f.title}") - for f in critical: - if f not in security_critical: - blockers.append(f"Critical: {f.title}") - for s in structural_blockers: - blockers.append(f"Structure: {s.title}") - for t in ai_critical: - blockers.append(f"{t.tool_name}: {t.original_comment[:50]}") - - if blockers: - if security_critical: - verdict = MergeVerdict.BLOCKED - elif len(critical) > 0: - verdict = MergeVerdict.BLOCKED - else: - verdict = MergeVerdict.NEEDS_REVISION - elif high: - verdict = MergeVerdict.MERGE_WITH_CHANGES - else: - verdict = MergeVerdict.READY_TO_MERGE - - assert verdict == MergeVerdict.READY_TO_MERGE - assert len(blockers) == 0 - print(" ✓ Case 1: No issues -> READY_TO_MERGE") - - # Test case 2: Security critical -> BLOCKED - findings = [ - PRReviewFinding( - id="sec-1", - severity=ReviewSeverity.CRITICAL, - category=ReviewCategory.SECURITY, - title="SQL Injection", - description="Test", - file="test.py", - line=1, - ) - ] - - critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL] - security_critical = [f for f in critical if f.category == ReviewCategory.SECURITY] - - blockers = [] - for f in security_critical: - blockers.append(f"Security: {f.title}") - - if blockers and security_critical: - verdict = MergeVerdict.BLOCKED - - assert verdict == MergeVerdict.BLOCKED - assert len(blockers) == 1 - assert "SQL Injection" in blockers[0] - print(" ✓ Case 2: Security critical -> BLOCKED") - - # Test case 3: High severity only -> MERGE_WITH_CHANGES - findings = [ - PRReviewFinding( - id="q-1", - severity=ReviewSeverity.HIGH, - category=ReviewCategory.QUALITY, - title="Missing error handling", - description="Test", - file="test.py", - line=1, - ) - ] - - critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL] - high = [f for f in findings if f.severity == ReviewSeverity.HIGH] - security_critical = [f for f in critical if f.category == ReviewCategory.SECURITY] - - blockers = [] - if not blockers and high: - verdict = MergeVerdict.MERGE_WITH_CHANGES - - assert verdict == MergeVerdict.MERGE_WITH_CHANGES - print(" ✓ Case 3: High severity only -> MERGE_WITH_CHANGES") - - print(" ✅ Verdict generation logic: PASS") - - -def test_risk_assessment_logic(): - """Test risk assessment calculation.""" - print("Testing risk assessment logic...") - - # Test complexity levels - def calculate_complexity(additions, deletions): - total = additions + deletions - if total > 500: - return "high" - elif total > 200: - return "medium" - else: - return "low" - - assert calculate_complexity(50, 20) == "low" - assert calculate_complexity(150, 100) == "medium" - assert calculate_complexity(400, 200) == "high" - print(" ✓ Complexity calculation") - - # Test security impact levels - def calculate_security_impact(findings): - security = [f for f in findings if f.category == ReviewCategory.SECURITY] - if any(f.severity == ReviewSeverity.CRITICAL for f in security): - return "critical" - elif any(f.severity == ReviewSeverity.HIGH for f in security): - return "medium" - elif security: - return "low" - else: - return "none" - - assert calculate_security_impact([]) == "none" - - findings_low = [ - PRReviewFinding( - id="s1", - severity=ReviewSeverity.LOW, - category=ReviewCategory.SECURITY, - title="Test", - description="", - file="", - line=1, - ) - ] - assert calculate_security_impact(findings_low) == "low" - - findings_critical = [ - PRReviewFinding( - id="s2", - severity=ReviewSeverity.CRITICAL, - category=ReviewCategory.SECURITY, - title="Test", - description="", - file="", - line=1, - ) - ] - assert calculate_security_impact(findings_critical) == "critical" - print(" ✓ Security impact calculation") - - print(" ✅ Risk assessment logic: PASS") - - -def test_json_parsing_robustness(): - """Test JSON parsing handles edge cases.""" - print("Testing JSON parsing robustness...") - - import re - - def parse_json_array(text): - """Simulate the JSON parsing from AI response.""" - try: - json_match = re.search(r"```json\s*(\[.*?\])\s*```", text, re.DOTALL) - if json_match: - return json.loads(json_match.group(1)) - except (json.JSONDecodeError, ValueError): - pass - return [] - - # Test valid JSON - valid = """ -Here is my analysis: -```json -[{"id": "f1", "title": "Test"}] -``` -Done. -""" - result = parse_json_array(valid) - assert len(result) == 1 - assert result[0]["id"] == "f1" - print(" ✓ Valid JSON parsing") - - # Test empty array - empty = """ -```json -[] -``` -""" - result = parse_json_array(empty) - assert result == [] - print(" ✓ Empty array parsing") - - # Test no JSON block - no_json = "This response has no JSON block." - result = parse_json_array(no_json) - assert result == [] - print(" ✓ No JSON block handling") - - # Test malformed JSON - malformed = """ -```json -[{"id": "f1", "title": "Missing close bracket" -``` -""" - result = parse_json_array(malformed) - assert result == [] - print(" ✓ Malformed JSON handling") - - print(" ✅ JSON parsing robustness: PASS") - - -def test_confidence_threshold(): - """Test 80% confidence threshold filtering.""" - print("Testing confidence threshold...") - - CONFIDENCE_THRESHOLD = 0.80 - - findings_data = [ - {"id": "f1", "confidence": 0.95, "title": "High confidence"}, - {"id": "f2", "confidence": 0.80, "title": "At threshold"}, - {"id": "f3", "confidence": 0.79, "title": "Below threshold"}, - {"id": "f4", "confidence": 0.50, "title": "Low confidence"}, - {"id": "f5", "title": "No confidence field"}, # Should default to 0.85 - ] - - filtered = [] - for f in findings_data: - confidence = float(f.get("confidence", 0.85)) - if confidence >= CONFIDENCE_THRESHOLD: - filtered.append(f) - - assert len(filtered) == 3 - assert filtered[0]["id"] == "f1" # 0.95 >= 0.80 - assert filtered[1]["id"] == "f2" # 0.80 >= 0.80 - assert filtered[2]["id"] == "f5" # 0.85 (default) >= 0.80 - - print( - f" ✓ Filtered {len(findings_data) - len(filtered)}/{len(findings_data)} findings below threshold" - ) - print(" ✅ Confidence threshold: PASS") - - -def run_all_tests(): - """Run all validation tests.""" - print("\n" + "=" * 60) - print("Enhanced PR Review System - Validation Tests") - print("=" * 60 + "\n") - - tests = [ - test_merge_verdict_enum, - test_ai_comment_verdict_enum, - test_review_pass_enum, - test_ai_bot_patterns, - test_ai_bot_comment_dataclass, - test_ai_comment_triage_dataclass, - test_structural_issue_dataclass, - test_pr_review_result_new_fields, - test_pr_review_result_serialization, - test_verdict_generation_logic, - test_risk_assessment_logic, - test_json_parsing_robustness, - test_confidence_threshold, - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - test() - passed += 1 - except Exception as e: - print(f" ❌ {test.__name__}: FAILED") - print(f" Error: {e}") - failed += 1 - - print("\n" + "=" * 60) - print(f"Results: {passed} passed, {failed} failed") - print("=" * 60) - - if failed > 0: - sys.exit(1) - else: - print("\n✅ All validation tests passed! System is ready for production.\n") - sys.exit(0) - - -if __name__ == "__main__": - run_all_tests() diff --git a/apps/backend/runners/github/test_file_lock.py b/apps/backend/runners/github/test_file_lock.py deleted file mode 100644 index fe6c5ea8..00000000 --- a/apps/backend/runners/github/test_file_lock.py +++ /dev/null @@ -1,333 +0,0 @@ -""" -Test File Locking for Concurrent Operations -=========================================== - -Demonstrates file locking preventing data corruption in concurrent scenarios. -""" - -import asyncio -import json -import tempfile -import time -from pathlib import Path - -from file_lock import ( - FileLock, - FileLockTimeout, - locked_json_read, - locked_json_update, - locked_json_write, - locked_read, - locked_write, -) - - -async def test_basic_file_lock(): - """Test basic file locking mechanism.""" - print("\n=== Test 1: Basic File Lock ===") - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = Path(tmpdir) / "test.txt" - test_file.write_text("initial content", encoding="utf-8") - - # Acquire lock and hold it - async with FileLock(test_file, timeout=5.0): - print("✓ Lock acquired successfully") - # Do work while holding lock - await asyncio.sleep(0.1) - print("✓ Lock held during work") - - print("✓ Lock released automatically") - - -async def test_locked_write(): - """Test atomic locked write operations.""" - print("\n=== Test 2: Locked Write ===") - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = Path(tmpdir) / "data.json" - - # Write data with locking - data = {"count": 0, "items": ["a", "b", "c"]} - async with locked_write(test_file, timeout=5.0) as f: - json.dump(data, f, indent=2) - - print(f"✓ Written to {test_file.name}") - - # Verify data was written correctly - with open(test_file, encoding="utf-8") as f: - loaded = json.load(f) - assert loaded == data - print(f"✓ Data verified: {loaded}") - - -async def test_locked_json_helpers(): - """Test JSON helper functions.""" - print("\n=== Test 3: JSON Helpers ===") - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = Path(tmpdir) / "data.json" - - # Write JSON - data = {"users": [], "total": 0} - await locked_json_write(test_file, data, timeout=5.0) - print(f"✓ JSON written: {data}") - - # Read JSON - loaded = await locked_json_read(test_file, timeout=5.0) - assert loaded == data - print(f"✓ JSON read: {loaded}") - - -async def test_locked_json_update(): - """Test atomic read-modify-write updates.""" - print("\n=== Test 4: Atomic Updates ===") - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = Path(tmpdir) / "counter.json" - - # Initialize counter - await locked_json_write(test_file, {"count": 0}, timeout=5.0) - print("✓ Counter initialized to 0") - - # Define update function - def increment_counter(data): - data["count"] += 1 - return data - - # Perform 5 atomic updates - for i in range(5): - await locked_json_update(test_file, increment_counter, timeout=5.0) - - # Verify final count - final = await locked_json_read(test_file, timeout=5.0) - assert final["count"] == 5 - print(f"✓ Counter incremented 5 times: {final}") - - -async def test_concurrent_updates_without_lock(): - """Demonstrate data corruption WITHOUT file locking.""" - print("\n=== Test 5: Concurrent Updates WITHOUT Locking (UNSAFE) ===") - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = Path(tmpdir) / "unsafe.json" - - # Initialize counter - test_file.write_text(json.dumps({"count": 0}), encoding="utf-8") - - async def unsafe_increment(): - """Increment without locking - RACE CONDITION!""" - # Read - with open(test_file, encoding="utf-8") as f: - data = json.load(f) - - # Simulate some processing - await asyncio.sleep(0.01) - - # Write - data["count"] += 1 - with open(test_file, "w", encoding="utf-8") as f: - json.dump(data, f) - - # Run 10 concurrent increments - await asyncio.gather(*[unsafe_increment() for _ in range(10)]) - - # Check final count - with open(test_file, encoding="utf-8") as f: - final = json.load(f) - - print("✗ Expected count: 10") - print(f"✗ Actual count: {final['count']} (CORRUPTED due to race condition)") - print( - f"✗ Lost updates: {10 - final['count']} (multiple processes overwrote each other)" - ) - - -async def test_concurrent_updates_with_lock(): - """Demonstrate data integrity WITH file locking.""" - print("\n=== Test 6: Concurrent Updates WITH Locking (SAFE) ===") - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = Path(tmpdir) / "safe.json" - - # Initialize counter - await locked_json_write(test_file, {"count": 0}, timeout=5.0) - - async def safe_increment(): - """Increment with locking - NO RACE CONDITION!""" - - def increment(data): - # Simulate some processing - time.sleep(0.01) - data["count"] += 1 - return data - - await locked_json_update(test_file, increment, timeout=5.0) - - # Run 10 concurrent increments - await asyncio.gather(*[safe_increment() for _ in range(10)]) - - # Check final count - final = await locked_json_read(test_file, timeout=5.0) - - assert final["count"] == 10 - print("✓ Expected count: 10") - print(f"✓ Actual count: {final['count']} (CORRECT with file locking)") - print("✓ No data corruption - all updates applied successfully") - - -async def test_lock_timeout(): - """Test lock timeout behavior.""" - print("\n=== Test 7: Lock Timeout ===") - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = Path(tmpdir) / "timeout.json" - test_file.write_text(json.dumps({"data": "test"}), encoding="utf-8") - - # Acquire lock and hold it - lock1 = FileLock(test_file, timeout=1.0) - await lock1.__aenter__() - print("✓ First lock acquired") - - try: - # Try to acquire second lock with short timeout - lock2 = FileLock(test_file, timeout=0.5) - await lock2.__aenter__() - print("✗ Second lock acquired (should have timed out!)") - except FileLockTimeout as e: - print(f"✓ Second lock timed out as expected: {e}") - finally: - await lock1.__aexit__(None, None, None) - print("✓ First lock released") - - -async def test_index_update_pattern(): - """Test the index update pattern used in models.py.""" - print("\n=== Test 8: Index Update Pattern (Production Pattern) ===") - - with tempfile.TemporaryDirectory() as tmpdir: - index_file = Path(tmpdir) / "index.json" - - # Simulate multiple PR reviews updating the index concurrently - async def add_review(pr_number: int, status: str): - """Add or update a PR review in the index.""" - - def update_index(current_data): - if current_data is None: - current_data = {"reviews": [], "last_updated": None} - - reviews = current_data.get("reviews", []) - existing = next( - (r for r in reviews if r["pr_number"] == pr_number), None - ) - - entry = { - "pr_number": pr_number, - "status": status, - "timestamp": time.time(), - } - - if existing: - reviews = [ - entry if r["pr_number"] == pr_number else r for r in reviews - ] - else: - reviews.append(entry) - - current_data["reviews"] = reviews - current_data["last_updated"] = time.time() - - return current_data - - await locked_json_update(index_file, update_index, timeout=5.0) - - # Simulate 5 concurrent review updates - print("Simulating 5 concurrent PR review updates...") - await asyncio.gather( - add_review(101, "approved"), - add_review(102, "changes_requested"), - add_review(103, "commented"), - add_review(104, "approved"), - add_review(105, "approved"), - ) - - # Verify all reviews were recorded - final_index = await locked_json_read(index_file, timeout=5.0) - assert len(final_index["reviews"]) == 5 - print("✓ All 5 reviews recorded correctly") - print(f"✓ Index state: {len(final_index['reviews'])} reviews") - - # Update an existing review - await add_review(102, "approved") # Change status - updated_index = await locked_json_read(index_file, timeout=5.0) - assert len(updated_index["reviews"]) == 5 # Still 5, not 6 - review_102 = next(r for r in updated_index["reviews"] if r["pr_number"] == 102) - assert review_102["status"] == "approved" - print("✓ Review #102 updated from 'changes_requested' to 'approved'") - print("✓ No duplicate entries created") - - -async def test_atomic_write_failure(): - """Test that failed writes don't corrupt existing files.""" - print("\n=== Test 9: Atomic Write Failure Handling ===") - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = Path(tmpdir) / "important.json" - - # Write initial data - initial_data = {"important": "data", "version": 1} - await locked_json_write(test_file, initial_data, timeout=5.0) - print(f"✓ Initial data written: {initial_data}") - - # Try to write invalid data that will fail - try: - async with locked_write(test_file, timeout=5.0) as f: - f.write("{invalid json") - # Simulate an error during write - raise Exception("Simulated write failure") - except Exception as e: - print(f"✓ Write failed as expected: {e}") - - # Verify original data is intact (atomic write rolled back) - current_data = await locked_json_read(test_file, timeout=5.0) - assert current_data == initial_data - print(f"✓ Original data intact after failed write: {current_data}") - print( - "✓ Atomic write prevented corruption (temp file discarded, original preserved)" - ) - - -async def main(): - """Run all tests.""" - print("=" * 70) - print("File Locking Tests - Preventing Concurrent Operation Corruption") - print("=" * 70) - - tests = [ - test_basic_file_lock, - test_locked_write, - test_locked_json_helpers, - test_locked_json_update, - test_concurrent_updates_without_lock, - test_concurrent_updates_with_lock, - test_lock_timeout, - test_index_update_pattern, - test_atomic_write_failure, - ] - - for test in tests: - try: - await test() - except Exception as e: - print(f"✗ Test failed: {e}") - import traceback - - traceback.print_exc() - - print("\n" + "=" * 70) - print("All Tests Completed!") - print("=" * 70) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/apps/backend/runners/github/test_gh_client.py b/apps/backend/runners/github/test_gh_client.py deleted file mode 100644 index 0bed21b9..00000000 --- a/apps/backend/runners/github/test_gh_client.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -Tests for GHClient timeout and retry functionality. -""" - -import asyncio -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from gh_client import GHClient, GHCommandError, GHTimeoutError - - -class TestGHClient: - """Test suite for GHClient.""" - - @pytest.fixture - def client(self, tmp_path): - """Create a test client.""" - return GHClient( - project_dir=tmp_path, - default_timeout=2.0, - max_retries=3, - ) - - @pytest.mark.asyncio - async def test_timeout_raises_error(self, client): - """Test that commands timeout after max retries.""" - # Use a command that will timeout (sleep longer than timeout) - with pytest.raises(GHTimeoutError) as exc_info: - await client.run(["api", "/repos/nonexistent/repo"], timeout=0.1) - - assert "timed out after 3 attempts" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_invalid_command_raises_error(self, client): - """Test that invalid commands raise GHCommandError.""" - with pytest.raises(GHCommandError): - await client.run(["invalid-command"]) - - @pytest.mark.asyncio - async def test_successful_command(self, client): - """Test successful command execution.""" - # This test requires gh CLI to be installed - try: - result = await client.run(["--version"]) - assert result.returncode == 0 - assert "gh version" in result.stdout - assert result.attempts == 1 - except Exception: - pytest.skip("gh CLI not available") - - @pytest.mark.asyncio - async def test_convenience_methods_timeout_protection(self, client): - """Test that convenience methods have timeout protection.""" - # These will fail because repo doesn't exist, but should not hang - with pytest.raises((GHCommandError, GHTimeoutError)): - await client.pr_list() - - with pytest.raises((GHCommandError, GHTimeoutError)): - await client.issue_list() - - -class TestGHClientGhExecutableDetection: - """Test suite for GHClient gh executable detection.""" - - @pytest.fixture - def client(self, tmp_path): - """Create a test client.""" - return GHClient( - project_dir=tmp_path, - default_timeout=2.0, - max_retries=3, - ) - - @pytest.mark.asyncio - async def test_run_raises_error_when_gh_not_found(self, client): - """Test that run() raises GHCommandError when gh is not found.""" - with patch("gh_client.get_gh_executable", return_value=None): - with pytest.raises(GHCommandError) as exc_info: - await client.run(["--version"]) - - assert "not found" in str(exc_info.value) - # Test verifies error message mentions GitHub CLI for user guidance - assert "GitHub CLI" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_run_uses_detected_gh_executable(self, client): - """Test that run() uses the detected gh executable path.""" - mock_exec = "/custom/path/to/gh" - - with patch("gh_client.get_gh_executable", return_value=mock_exec): - with patch("asyncio.create_subprocess_exec") as mock_subprocess: - # Mock the subprocess to return immediately - mock_proc = MagicMock() - mock_proc.communicate = AsyncMock( - return_value=(b"gh version 2.0.0\n", b"") - ) - mock_proc.returncode = 0 - mock_subprocess.return_value = mock_proc - - await client.run(["--version"]) - - # Verify the correct gh path was used - mock_subprocess.assert_called_once() - called_cmd = mock_subprocess.call_args[0][0] - assert called_cmd == mock_exec - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/apps/backend/runners/github/test_permissions.py b/apps/backend/runners/github/test_permissions.py deleted file mode 100644 index 38c8ac4c..00000000 --- a/apps/backend/runners/github/test_permissions.py +++ /dev/null @@ -1,393 +0,0 @@ -""" -Unit Tests for GitHub Permission System -======================================= - -Tests for GitHubPermissionChecker and permission verification. -""" - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from permissions import GitHubPermissionChecker, PermissionCheckResult, PermissionError - - -class MockGitHubClient: - """Mock GitHub API client for testing.""" - - def __init__(self): - self.get = AsyncMock() - self._get_headers = AsyncMock() - - -@pytest.fixture -def mock_gh_client(): - """Create a mock GitHub client.""" - return MockGitHubClient() - - -@pytest.fixture -def permission_checker(mock_gh_client): - """Create a permission checker instance.""" - return GitHubPermissionChecker( - gh_client=mock_gh_client, - repo="owner/test-repo", - allowed_roles=["OWNER", "MEMBER", "COLLABORATOR"], - allow_external_contributors=False, - ) - - -@pytest.mark.asyncio -async def test_verify_token_scopes_success(permission_checker, mock_gh_client): - """Test successful token scope verification.""" - mock_gh_client._get_headers.return_value = { - "X-OAuth-Scopes": "repo, read:org, admin:repo_hook" - } - - # Should not raise - await permission_checker.verify_token_scopes() - - -@pytest.mark.asyncio -async def test_verify_token_scopes_minimum(permission_checker, mock_gh_client): - """Test token with minimum scopes (repo only) triggers warning.""" - mock_gh_client._get_headers.return_value = {"X-OAuth-Scopes": "repo"} - - # Should warn but not raise (for non-org repos) - await permission_checker.verify_token_scopes() - - -@pytest.mark.asyncio -async def test_verify_token_scopes_insufficient(permission_checker, mock_gh_client): - """Test insufficient token scopes raises error.""" - mock_gh_client._get_headers.return_value = {"X-OAuth-Scopes": "read:user"} - - with pytest.raises(PermissionError, match="missing required scopes"): - await permission_checker.verify_token_scopes() - - -@pytest.mark.asyncio -async def test_check_label_adder_success(permission_checker, mock_gh_client): - """Test successfully finding who added a label.""" - mock_gh_client.get.side_effect = [ - # Issue events - [ - { - "event": "labeled", - "label": {"name": "auto-fix"}, - "actor": {"login": "alice"}, - }, - { - "event": "commented", - "actor": {"login": "bob"}, - }, - ], - # Collaborator permission check for alice - {"permission": "write"}, - ] - - username, role = await permission_checker.check_label_adder(123, "auto-fix") - - assert username == "alice" - assert role == "COLLABORATOR" - mock_gh_client.get.assert_any_call("/repos/owner/test-repo/issues/123/events") - - -@pytest.mark.asyncio -async def test_check_label_adder_not_found(permission_checker, mock_gh_client): - """Test error when label not found in events.""" - mock_gh_client.get.return_value = [ - { - "event": "labeled", - "label": {"name": "bug"}, - "actor": {"login": "alice"}, - }, - ] - - with pytest.raises(PermissionError, match="Label 'auto-fix' not found"): - await permission_checker.check_label_adder(123, "auto-fix") - - -@pytest.mark.asyncio -async def test_get_user_role_owner(permission_checker, mock_gh_client): - """Test getting role for repository owner.""" - role = await permission_checker.get_user_role("owner") - - assert role == "OWNER" - # Should use cache, no API calls needed - assert mock_gh_client.get.call_count == 0 - - -@pytest.mark.asyncio -async def test_get_user_role_collaborator(permission_checker, mock_gh_client): - """Test getting role for collaborator with write access.""" - mock_gh_client.get.return_value = {"permission": "write"} - - role = await permission_checker.get_user_role("alice") - - assert role == "COLLABORATOR" - mock_gh_client.get.assert_called_with( - "/repos/owner/test-repo/collaborators/alice/permission" - ) - - -@pytest.mark.asyncio -async def test_get_user_role_org_member(permission_checker, mock_gh_client): - """Test getting role for organization member.""" - mock_gh_client.get.side_effect = [ - # Not a collaborator - Exception("Not a collaborator"), - # Repo info (org-owned) - {"owner": {"type": "Organization"}}, - # Org membership check - {"state": "active"}, - ] - - role = await permission_checker.get_user_role("bob") - - assert role == "MEMBER" - - -@pytest.mark.asyncio -async def test_get_user_role_contributor(permission_checker, mock_gh_client): - """Test getting role for external contributor.""" - mock_gh_client.get.side_effect = [ - # Not a collaborator - Exception("Not a collaborator"), - # Repo info (user-owned, not org) - {"owner": {"type": "User"}}, - # Contributors list - [ - {"login": "alice"}, - {"login": "charlie"}, # The user we're checking - ], - ] - - role = await permission_checker.get_user_role("charlie") - - assert role == "CONTRIBUTOR" - - -@pytest.mark.asyncio -async def test_get_user_role_none(permission_checker, mock_gh_client): - """Test getting role for user with no relationship to repo.""" - mock_gh_client.get.side_effect = [ - # Not a collaborator - Exception("Not a collaborator"), - # Repo info - {"owner": {"type": "User"}}, - # Contributors list (user not in it) - [{"login": "alice"}], - ] - - role = await permission_checker.get_user_role("stranger") - - assert role == "NONE" - - -@pytest.mark.asyncio -async def test_get_user_role_caching(permission_checker, mock_gh_client): - """Test that user roles are cached.""" - mock_gh_client.get.return_value = {"permission": "write"} - - # First call - role1 = await permission_checker.get_user_role("alice") - assert role1 == "COLLABORATOR" - - # Second call should use cache - role2 = await permission_checker.get_user_role("alice") - assert role2 == "COLLABORATOR" - - # Only one API call should have been made - assert mock_gh_client.get.call_count == 1 - - -@pytest.mark.asyncio -async def test_is_allowed_for_autofix_owner(permission_checker, mock_gh_client): - """Test auto-fix permission for owner.""" - result = await permission_checker.is_allowed_for_autofix("owner") - - assert result.allowed is True - assert result.username == "owner" - assert result.role == "OWNER" - assert result.reason is None - - -@pytest.mark.asyncio -async def test_is_allowed_for_autofix_collaborator(permission_checker, mock_gh_client): - """Test auto-fix permission for collaborator.""" - mock_gh_client.get.return_value = {"permission": "write"} - - result = await permission_checker.is_allowed_for_autofix("alice") - - assert result.allowed is True - assert result.username == "alice" - assert result.role == "COLLABORATOR" - - -@pytest.mark.asyncio -async def test_is_allowed_for_autofix_denied(permission_checker, mock_gh_client): - """Test auto-fix permission denied for unauthorized user.""" - mock_gh_client.get.side_effect = [ - Exception("Not a collaborator"), - {"owner": {"type": "User"}}, - [], # Not in contributors - ] - - result = await permission_checker.is_allowed_for_autofix("stranger") - - assert result.allowed is False - assert result.username == "stranger" - assert result.role == "NONE" - assert "not in allowed roles" in result.reason - - -@pytest.mark.asyncio -async def test_is_allowed_for_autofix_contributor_allowed(mock_gh_client): - """Test auto-fix permission for contributor when external contributors allowed.""" - checker = GitHubPermissionChecker( - gh_client=mock_gh_client, - repo="owner/test-repo", - allow_external_contributors=True, - ) - - mock_gh_client.get.side_effect = [ - Exception("Not a collaborator"), - {"owner": {"type": "User"}}, - [{"login": "charlie"}], # Is a contributor - ] - - result = await checker.is_allowed_for_autofix("charlie") - - assert result.allowed is True - assert result.role == "CONTRIBUTOR" - - -@pytest.mark.asyncio -async def test_check_org_membership_true(permission_checker, mock_gh_client): - """Test successful org membership check.""" - mock_gh_client.get.side_effect = [ - # Repo info - {"owner": {"type": "Organization"}}, - # Org membership - {"state": "active"}, - ] - - is_member = await permission_checker.check_org_membership("alice") - - assert is_member is True - - -@pytest.mark.asyncio -async def test_check_org_membership_false(permission_checker, mock_gh_client): - """Test failed org membership check.""" - mock_gh_client.get.side_effect = [ - # Repo info - {"owner": {"type": "Organization"}}, - # Org membership check fails - Exception("Not a member"), - ] - - is_member = await permission_checker.check_org_membership("stranger") - - assert is_member is False - - -@pytest.mark.asyncio -async def test_check_org_membership_non_org_repo(permission_checker, mock_gh_client): - """Test org membership check for non-org repo returns True.""" - mock_gh_client.get.return_value = {"owner": {"type": "User"}} - - is_member = await permission_checker.check_org_membership("anyone") - - assert is_member is True - - -@pytest.mark.asyncio -async def test_check_team_membership_true(permission_checker, mock_gh_client): - """Test successful team membership check.""" - mock_gh_client.get.return_value = {"state": "active"} - - is_member = await permission_checker.check_team_membership("alice", "developers") - - assert is_member is True - mock_gh_client.get.assert_called_with( - "/orgs/owner/teams/developers/memberships/alice" - ) - - -@pytest.mark.asyncio -async def test_check_team_membership_false(permission_checker, mock_gh_client): - """Test failed team membership check.""" - mock_gh_client.get.side_effect = Exception("Not a team member") - - is_member = await permission_checker.check_team_membership("bob", "developers") - - assert is_member is False - - -@pytest.mark.asyncio -async def test_verify_automation_trigger_allowed(permission_checker, mock_gh_client): - """Test complete automation trigger verification (allowed).""" - mock_gh_client.get.side_effect = [ - # Issue events - [ - { - "event": "labeled", - "label": {"name": "auto-fix"}, - "actor": {"login": "alice"}, - } - ], - # Collaborator permission - {"permission": "write"}, - ] - - result = await permission_checker.verify_automation_trigger(123, "auto-fix") - - assert result.allowed is True - assert result.username == "alice" - assert result.role == "COLLABORATOR" - - -@pytest.mark.asyncio -async def test_verify_automation_trigger_denied(permission_checker, mock_gh_client): - """Test complete automation trigger verification (denied).""" - mock_gh_client.get.side_effect = [ - # Issue events - [ - { - "event": "labeled", - "label": {"name": "auto-fix"}, - "actor": {"login": "stranger"}, - } - ], - # Not a collaborator - Exception("Not a collaborator"), - # Repo info - {"owner": {"type": "User"}}, - # Not in contributors - [], - ] - - result = await permission_checker.verify_automation_trigger(123, "auto-fix") - - assert result.allowed is False - assert result.username == "stranger" - assert result.role == "NONE" - - -def test_log_permission_denial(permission_checker, caplog): - """Test permission denial logging.""" - import logging - - caplog.set_level(logging.WARNING) - - permission_checker.log_permission_denial( - action="auto-fix", - username="stranger", - role="NONE", - issue_number=123, - ) - - assert "PERMISSION DENIED" in caplog.text - assert "stranger" in caplog.text - assert "auto-fix" in caplog.text diff --git a/apps/backend/runners/github/test_rate_limiter.py b/apps/backend/runners/github/test_rate_limiter.py deleted file mode 100644 index 281f68f4..00000000 --- a/apps/backend/runners/github/test_rate_limiter.py +++ /dev/null @@ -1,506 +0,0 @@ -""" -Tests for Rate Limiter -====================== - -Comprehensive test suite for rate limiting system covering: -- Token bucket algorithm -- GitHub API rate limiting -- AI cost tracking -- Decorator functionality -- Exponential backoff -- Edge cases -""" - -import asyncio -import time - -import pytest -from rate_limiter import ( - CostLimitExceeded, - CostTracker, - RateLimiter, - RateLimitExceeded, - TokenBucket, - check_rate_limit, - rate_limited, -) - - -class TestTokenBucket: - """Test token bucket algorithm.""" - - def test_initial_state(self): - """Bucket starts full.""" - bucket = TokenBucket(capacity=100, refill_rate=10.0) - assert bucket.available() == 100 - - def test_try_acquire_success(self): - """Can acquire tokens when available.""" - bucket = TokenBucket(capacity=100, refill_rate=10.0) - assert bucket.try_acquire(10) is True - assert bucket.available() == 90 - - def test_try_acquire_failure(self): - """Cannot acquire when insufficient tokens.""" - bucket = TokenBucket(capacity=100, refill_rate=10.0) - bucket.try_acquire(100) - assert bucket.try_acquire(1) is False - assert bucket.available() == 0 - - @pytest.mark.asyncio - async def test_acquire_waits(self): - """Acquire waits for refill when needed.""" - bucket = TokenBucket(capacity=10, refill_rate=10.0) # 10 tokens/sec - bucket.try_acquire(10) # Empty the bucket - - start = time.monotonic() - result = await bucket.acquire(1) # Should wait ~0.1s for 1 token - elapsed = time.monotonic() - start - - assert result is True - assert elapsed >= 0.05 # At least some delay - assert elapsed < 0.5 # But not too long - - @pytest.mark.asyncio - async def test_acquire_timeout(self): - """Acquire respects timeout.""" - bucket = TokenBucket(capacity=10, refill_rate=1.0) # 1 token/sec - bucket.try_acquire(10) # Empty the bucket - - start = time.monotonic() - result = await bucket.acquire(100, timeout=0.1) # Need 100s, timeout 0.1s - elapsed = time.monotonic() - start - - assert result is False - assert elapsed < 0.5 # Should timeout quickly - - def test_refill_over_time(self): - """Tokens refill at correct rate.""" - bucket = TokenBucket(capacity=100, refill_rate=100.0) # 100 tokens/sec - bucket.try_acquire(50) # Take 50 - assert bucket.available() == 50 - - time.sleep(0.5) # Wait 0.5s = 50 tokens - available = bucket.available() - assert 95 <= available <= 100 # Should be near full - - def test_time_until_available(self): - """Calculate wait time correctly.""" - bucket = TokenBucket(capacity=100, refill_rate=10.0) - bucket.try_acquire(100) # Empty - - wait = bucket.time_until_available(10) - assert 0.9 <= wait <= 1.1 # Should be ~1s for 10 tokens at 10/s - - -class TestCostTracker: - """Test AI cost tracking.""" - - def test_calculate_cost_sonnet(self): - """Calculate cost for Sonnet model.""" - cost = CostTracker.calculate_cost( - input_tokens=1_000_000, - output_tokens=1_000_000, - model="claude-sonnet-4-5-20250929", - ) - # $3 input + $15 output = $18 for 1M each - assert cost == 18.0 - - def test_calculate_cost_opus(self): - """Calculate cost for Opus model.""" - cost = CostTracker.calculate_cost( - input_tokens=1_000_000, - output_tokens=1_000_000, - model="claude-opus-4-5-20251101", - ) - # $15 input + $75 output = $90 for 1M each - assert cost == 90.0 - - def test_calculate_cost_haiku(self): - """Calculate cost for Haiku model.""" - cost = CostTracker.calculate_cost( - input_tokens=1_000_000, - output_tokens=1_000_000, - model="claude-haiku-4-5-20251001", - ) - # $0.80 input + $4 output = $4.80 for 1M each - assert cost == 4.80 - - def test_calculate_cost_unknown_model(self): - """Unknown model uses default pricing.""" - cost = CostTracker.calculate_cost( - input_tokens=1_000_000, - output_tokens=1_000_000, - model="unknown-model", - ) - # Default: $3 input + $15 output = $18 - assert cost == 18.0 - - def test_add_operation_under_limit(self): - """Can add operation under budget.""" - tracker = CostTracker(cost_limit=10.0) - cost = tracker.add_operation( - input_tokens=100_000, # $0.30 - output_tokens=50_000, # $0.75 - model="claude-sonnet-4-5-20250929", - operation_name="test", - ) - assert 1.0 <= cost <= 1.1 - assert tracker.total_cost == cost - assert len(tracker.operations) == 1 - - def test_add_operation_exceeds_limit(self): - """Cannot add operation that exceeds budget.""" - tracker = CostTracker(cost_limit=1.0) - with pytest.raises(CostLimitExceeded): - tracker.add_operation( - input_tokens=1_000_000, # $3 - exceeds $1 limit - output_tokens=0, - model="claude-sonnet-4-5-20250929", - ) - - def test_remaining_budget(self): - """Remaining budget calculated correctly.""" - tracker = CostTracker(cost_limit=10.0) - tracker.add_operation( - input_tokens=100_000, - output_tokens=50_000, - model="claude-sonnet-4-5-20250929", - ) - remaining = tracker.remaining_budget() - assert 8.9 <= remaining <= 9.1 - - def test_usage_report(self): - """Usage report generated.""" - tracker = CostTracker(cost_limit=10.0) - tracker.add_operation( - input_tokens=100_000, - output_tokens=50_000, - model="claude-sonnet-4-5-20250929", - operation_name="operation1", - ) - report = tracker.usage_report() - assert "Total Cost:" in report - assert "Budget:" in report - assert "operation1" in report - - -class TestRateLimiter: - """Test RateLimiter singleton.""" - - def setup_method(self): - """Reset singleton before each test.""" - RateLimiter.reset_instance() - - def test_singleton_pattern(self): - """Only one instance exists.""" - limiter1 = RateLimiter.get_instance() - limiter2 = RateLimiter.get_instance() - assert limiter1 is limiter2 - - @pytest.mark.asyncio - async def test_acquire_github(self): - """Can acquire GitHub tokens.""" - limiter = RateLimiter.get_instance(github_limit=10) - assert await limiter.acquire_github() is True - assert limiter.github_requests == 1 - - @pytest.mark.asyncio - async def test_acquire_github_rate_limited(self): - """GitHub rate limiting works.""" - limiter = RateLimiter.get_instance( - github_limit=2, - github_refill_rate=0.0, # No refill - ) - assert await limiter.acquire_github() is True - assert await limiter.acquire_github() is True - # Third should timeout immediately - assert await limiter.acquire_github(timeout=0.1) is False - assert limiter.github_rate_limited == 1 - - def test_check_github_available(self): - """Check GitHub availability without consuming.""" - limiter = RateLimiter.get_instance(github_limit=100) - available, msg = limiter.check_github_available() - assert available is True - assert "100" in msg - - def test_track_ai_cost(self): - """Track AI costs.""" - limiter = RateLimiter.get_instance(cost_limit=10.0) - cost = limiter.track_ai_cost( - input_tokens=100_000, - output_tokens=50_000, - model="claude-sonnet-4-5-20250929", - operation_name="test", - ) - assert cost > 0 - assert limiter.cost_tracker.total_cost == cost - - def test_track_ai_cost_exceeds_limit(self): - """Cost limit enforcement.""" - limiter = RateLimiter.get_instance(cost_limit=1.0) - with pytest.raises(CostLimitExceeded): - limiter.track_ai_cost( - input_tokens=1_000_000, - output_tokens=1_000_000, - model="claude-sonnet-4-5-20250929", - ) - - def test_check_cost_available(self): - """Check cost availability.""" - limiter = RateLimiter.get_instance(cost_limit=10.0) - available, msg = limiter.check_cost_available() - assert available is True - assert "$10" in msg - - def test_record_github_error(self): - """Record GitHub errors.""" - limiter = RateLimiter.get_instance() - limiter.record_github_error() - assert limiter.github_errors == 1 - - def test_statistics(self): - """Statistics collection.""" - limiter = RateLimiter.get_instance() - stats = limiter.statistics() - assert "github" in stats - assert "cost" in stats - assert "runtime_seconds" in stats - - def test_report(self): - """Report generation.""" - limiter = RateLimiter.get_instance() - report = limiter.report() - assert "Rate Limiter Report" in report - assert "GitHub API:" in report - assert "AI Cost:" in report - - -class TestRateLimitedDecorator: - """Test @rate_limited decorator.""" - - def setup_method(self): - """Reset singleton before each test.""" - RateLimiter.reset_instance() - - @pytest.mark.asyncio - async def test_decorator_success(self): - """Decorator allows successful calls.""" - - @rate_limited(operation_type="github") - async def test_func(): - return "success" - - result = await test_func() - assert result == "success" - - @pytest.mark.asyncio - async def test_decorator_rate_limited(self): - """Decorator handles rate limiting.""" - limiter = RateLimiter.get_instance( - github_limit=1, - github_refill_rate=0.0, # No refill - ) - - @rate_limited(operation_type="github", max_retries=0) - async def test_func(): - # Consume token manually first - if limiter.github_requests == 0: - await limiter.acquire_github() - return "success" - - # First call succeeds - result = await test_func() - assert result == "success" - - # Second call should fail (no tokens, no retry) - with pytest.raises(RateLimitExceeded): - await test_func() - - @pytest.mark.asyncio - async def test_decorator_retries(self): - """Decorator retries on rate limit.""" - limiter = RateLimiter.get_instance( - github_limit=1, - github_refill_rate=10.0, # Fast refill for test - ) - call_count = 0 - - @rate_limited(operation_type="github", max_retries=2, base_delay=0.1) - async def test_func(): - nonlocal call_count - call_count += 1 - if call_count == 1: - # Consume all tokens - await limiter.acquire_github() - raise Exception("403 rate limit exceeded") - return "success" - - result = await test_func() - assert result == "success" - assert call_count == 2 # Initial + 1 retry - - @pytest.mark.asyncio - async def test_decorator_cost_limit_no_retry(self): - """Cost limit is not retried.""" - limiter = RateLimiter.get_instance(cost_limit=0.1) - - @rate_limited(operation_type="github") - async def test_func(): - # Exceed cost limit - limiter.track_ai_cost( - input_tokens=1_000_000, - output_tokens=1_000_000, - model="claude-sonnet-4-5-20250929", - ) - return "success" - - with pytest.raises(CostLimitExceeded): - await test_func() - - -class TestCheckRateLimit: - """Test check_rate_limit helper.""" - - def setup_method(self): - """Reset singleton before each test.""" - RateLimiter.reset_instance() - - @pytest.mark.asyncio - async def test_check_github_success(self): - """Check passes when available.""" - RateLimiter.get_instance(github_limit=100) - await check_rate_limit(operation_type="github") # Should not raise - - @pytest.mark.asyncio - async def test_check_github_failure(self): - """Check fails when rate limited.""" - limiter = RateLimiter.get_instance( - github_limit=0, # No tokens - github_refill_rate=0.0, - ) - with pytest.raises(RateLimitExceeded): - await check_rate_limit(operation_type="github") - - @pytest.mark.asyncio - async def test_check_cost_success(self): - """Check passes when budget available.""" - RateLimiter.get_instance(cost_limit=10.0) - await check_rate_limit(operation_type="cost") # Should not raise - - @pytest.mark.asyncio - async def test_check_cost_failure(self): - """Check fails when budget exceeded.""" - limiter = RateLimiter.get_instance(cost_limit=0.01) - limiter.cost_tracker.total_cost = 10.0 # Manually exceed - with pytest.raises(CostLimitExceeded): - await check_rate_limit(operation_type="cost") - - -class TestIntegration: - """Integration tests simulating real usage.""" - - def setup_method(self): - """Reset singleton before each test.""" - RateLimiter.reset_instance() - - @pytest.mark.asyncio - async def test_github_workflow(self): - """Simulate GitHub automation workflow.""" - limiter = RateLimiter.get_instance( - github_limit=10, - github_refill_rate=10.0, - cost_limit=5.0, - ) - - @rate_limited(operation_type="github") - async def fetch_pr(): - return {"number": 123} - - @rate_limited(operation_type="github") - async def fetch_diff(): - return {"files": []} - - # Simulate workflow - pr = await fetch_pr() - assert pr["number"] == 123 - - diff = await fetch_diff() - assert "files" in diff - - # Track AI review - limiter.track_ai_cost( - input_tokens=5000, - output_tokens=2000, - model="claude-sonnet-4-5-20250929", - operation_name="PR review", - ) - - # Check stats - stats = limiter.statistics() - assert stats["github"]["total_requests"] >= 2 - assert stats["cost"]["total_cost"] > 0 - - @pytest.mark.asyncio - async def test_burst_handling(self): - """Handle burst of requests.""" - limiter = RateLimiter.get_instance( - github_limit=5, - github_refill_rate=5.0, - ) - - @rate_limited(operation_type="github", max_retries=1, base_delay=0.1) - async def api_call(n: int): - return n - - # Make 10 calls (will hit limit at 5, then wait for refill) - results = [] - for i in range(10): - result = await api_call(i) - results.append(result) - - assert len(results) == 10 - assert results == list(range(10)) - - @pytest.mark.asyncio - async def test_cost_tracking_multiple_models(self): - """Track costs across different models.""" - limiter = RateLimiter.get_instance(cost_limit=100.0) - - # Sonnet for review - limiter.track_ai_cost( - input_tokens=10_000, - output_tokens=5_000, - model="claude-sonnet-4-5-20250929", - operation_name="PR review", - ) - - # Haiku for triage - limiter.track_ai_cost( - input_tokens=5_000, - output_tokens=2_000, - model="claude-haiku-4-5-20251001", - operation_name="Issue triage", - ) - - # Opus for complex analysis - limiter.track_ai_cost( - input_tokens=20_000, - output_tokens=10_000, - model="claude-opus-4-5-20251101", - operation_name="Architecture review", - ) - - stats = limiter.statistics() - assert stats["cost"]["operations"] == 3 - assert stats["cost"]["total_cost"] < 100.0 - - report = limiter.cost_tracker.usage_report() - assert "PR review" in report - assert "Issue triage" in report - assert "Architecture review" in report - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/apps/backend/scripts/test_memory_save.py b/apps/backend/scripts/test_memory_save.py deleted file mode 100644 index 52715671..00000000 --- a/apps/backend/scripts/test_memory_save.py +++ /dev/null @@ -1,379 +0,0 @@ -#!/usr/bin/env python3 -""" -Memory Save Verification Script -================================ - -Tests the memory save functionality with Graphiti enabled. -Run with DEBUG=true SENTRY_DEV=true to verify Sentry events. - -Usage: - cd apps/backend - DEBUG=true python scripts/test_memory_save.py - - # With Sentry enabled (for Sentry event verification): - DEBUG=true SENTRY_DEV=true python scripts/test_memory_save.py -""" - -import asyncio -import logging -import os -import sys -import tempfile -from pathlib import Path - -# Add the backend directory to the path so we can import modules -SCRIPT_DIR = Path(__file__).resolve().parent -BACKEND_DIR = SCRIPT_DIR.parent -if str(BACKEND_DIR) not in sys.path: - sys.path.insert(0, str(BACKEND_DIR)) - -# Configure logging -logging.basicConfig( - level=logging.DEBUG if os.environ.get("DEBUG") else logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -async def test_memory_imports(): - """Test that all memory-related imports work correctly.""" - print("\n=== Testing Memory System Imports ===") - - errors = [] - - # Test memory_manager imports - try: - from agents.memory_manager import ( - debug_memory_system_status, - get_graphiti_context, - save_session_memory, - ) - - print("[OK] agents.memory_manager imports successful") - except ImportError as e: - errors.append(f"agents.memory_manager: {e}") - print(f"[FAIL] agents.memory_manager: {e}") - - # Test graphiti_helpers imports - try: - from memory.graphiti_helpers import ( - get_graphiti_memory, - is_graphiti_memory_enabled, - save_to_graphiti_async, - ) - - print("[OK] memory.graphiti_helpers imports successful") - except ImportError as e: - errors.append(f"memory.graphiti_helpers: {e}") - print(f"[FAIL] memory.graphiti_helpers: {e}") - - # Test graphiti_config imports - try: - from graphiti_config import ( - get_graphiti_status, - is_graphiti_enabled, - ) - - print("[OK] graphiti_config imports successful") - except ImportError as e: - errors.append(f"graphiti_config: {e}") - print(f"[FAIL] graphiti_config: {e}") - - # Test sentry imports - try: - from core.sentry import ( - capture_exception, - capture_message, - init_sentry, - ) - from core.sentry import ( - is_enabled as sentry_is_enabled, - ) - - print("[OK] core.sentry imports successful") - except ImportError as e: - errors.append(f"core.sentry: {e}") - print(f"[FAIL] core.sentry: {e}") - - # Test graphiti queries_pkg imports - try: - from integrations.graphiti.queries_pkg.client import GraphitiClient - from integrations.graphiti.queries_pkg.graphiti import GraphitiMemory - from integrations.graphiti.queries_pkg.queries import GraphitiQueries - from integrations.graphiti.queries_pkg.search import GraphitiSearch - - print("[OK] integrations.graphiti.queries_pkg imports successful") - except ImportError as e: - errors.append(f"integrations.graphiti.queries_pkg: {e}") - print(f"[FAIL] integrations.graphiti.queries_pkg: {e}") - - if errors: - print(f"\n[FAIL] {len(errors)} import error(s) found") - return False - else: - print("\n[OK] All imports successful") - return True - - -async def test_graphiti_status(): - """Test Graphiti configuration status.""" - print("\n=== Testing Graphiti Status ===") - - try: - from graphiti_config import get_graphiti_status, is_graphiti_enabled - - enabled = is_graphiti_enabled() - status = get_graphiti_status() - - print(f"Graphiti Enabled: {enabled}") - print(f"Graphiti Available: {status.get('available')}") - print(f" Host: {status.get('host')}") - print(f" Port: {status.get('port')}") - print(f" Database: {status.get('database')}") - print(f" LLM Provider: {status.get('llm_provider')}") - print(f" Embedder Provider: {status.get('embedder_provider')}") - - if not status.get("available"): - print(f" Reason: {status.get('reason')}") - print(f" Errors: {status.get('errors')}") - - return enabled - except Exception as e: - print(f"[FAIL] Error checking Graphiti status: {e}") - return False - - -async def test_sentry_status(): - """Test Sentry configuration status. - - Returns True if: - - Sentry is enabled and ready, OR - - Sentry is properly disabled due to configuration (no DSN, not dev mode, SDK not installed) - - Only returns False if there's an unexpected error. - """ - print("\n=== Testing Sentry Status ===") - - try: - from core.sentry import init_sentry, is_enabled, is_initialized - - # Check if SENTRY_DEV is set - sentry_dev = os.environ.get("SENTRY_DEV", "").lower() in ("true", "1", "yes") - sentry_dsn = os.environ.get("SENTRY_DSN", "") - - print(f"SENTRY_DSN set: {bool(sentry_dsn)}") - print(f"SENTRY_DEV: {sentry_dev}") - - # Initialize Sentry - init_sentry(component="memory-test") - - print(f"Sentry Initialized: {is_initialized()}") - print(f"Sentry Enabled: {is_enabled()}") - - if is_enabled(): - print("[OK] Sentry is enabled and ready to capture events") - else: - # Sentry being disabled is OK - it just means configuration requires it - if not sentry_dsn: - print("[INFO] Sentry disabled - no SENTRY_DSN configured (expected)") - elif not sentry_dev: - print( - "[INFO] Sentry disabled in dev mode - set SENTRY_DEV=true to enable" - ) - else: - print("[INFO] Sentry disabled - sentry-sdk may not be installed") - print( - "[OK] Sentry integration configured correctly (disabled by configuration)" - ) - - # Return True even if disabled - we're testing that the integration works, - # not that Sentry is necessarily enabled - return True - except Exception as e: - print(f"[FAIL] Error checking Sentry status: {e}") - return False - - -async def test_memory_save_flow(): - """Test the memory save flow end-to-end.""" - print("\n=== Testing Memory Save Flow ===") - - # Create temporary directories for testing - with tempfile.TemporaryDirectory() as tmp_dir: - tmp_path = Path(tmp_dir) - spec_dir = tmp_path / "test_spec" - spec_dir.mkdir(parents=True) - project_dir = tmp_path / "project" - project_dir.mkdir(parents=True) - - print(f"Test spec_dir: {spec_dir}") - print(f"Test project_dir: {project_dir}") - - try: - from agents.memory_manager import save_session_memory - - # Test memory save with sample data - subtask_id = "test-subtask-1" - session_num = 1 - success = True - subtasks_completed = ["test-subtask-1"] - discoveries = { - "files_understood": {"test.py": "Test file for memory verification"}, - "patterns_found": ["Test pattern: Always verify imports"], - "gotchas_encountered": ["Test gotcha: Check async/await usage"], - } - - print("\nSaving test session memory...") - print(f" subtask_id: {subtask_id}") - print(f" session_num: {session_num}") - print(f" success: {success}") - - result, storage_type = await save_session_memory( - spec_dir=spec_dir, - project_dir=project_dir, - subtask_id=subtask_id, - session_num=session_num, - success=success, - subtasks_completed=subtasks_completed, - discoveries=discoveries, - ) - - print("\nMemory Save Result:") - print(f" Success: {result}") - print(f" Storage Type: {storage_type}") - - if result: - print(f"[OK] Memory save succeeded using {storage_type} storage") - - # Verify file was created if file-based - if storage_type == "file": - memory_file = ( - spec_dir - / "memory" - / "session_insights" - / f"session_{session_num:03d}.json" - ) - if memory_file.exists(): - print(f"[OK] Memory file created: {memory_file}") - else: - print(f"[WARN] Memory file not found: {memory_file}") - - return True - else: - print("[FAIL] Memory save failed") - return False - - except Exception as e: - print(f"[FAIL] Error during memory save test: {e}") - import traceback - - traceback.print_exc() - - # Test Sentry capture - try: - from core.sentry import capture_exception, is_enabled - - if is_enabled(): - capture_exception( - e, - operation="test_memory_save", - context="verification_script", - ) - print("[INFO] Exception captured to Sentry") - except Exception: - pass - - return False - - -async def test_sentry_capture(): - """Test that Sentry capture works (only if Sentry is enabled).""" - print("\n=== Testing Sentry Capture ===") - - try: - from core.sentry import ( - capture_exception, - capture_message, - is_enabled, - ) - - if not is_enabled(): - print("[SKIP] Sentry not enabled - skipping capture test") - print(" Set SENTRY_DSN and SENTRY_DEV=true to test Sentry capture") - return True - - # Test capture_message - print("Sending test message to Sentry...") - capture_message( - "Memory save verification script test message", - level="info", - test_type="verification", - component="memory-test", - ) - print("[OK] Test message sent to Sentry") - - # Test capture_exception - print("Sending test exception to Sentry...") - try: - raise ValueError("Test exception for memory save verification") - except Exception as e: - capture_exception( - e, - operation="test_exception", - context="verification_script", - ) - print("[OK] Test exception sent to Sentry") - - print("\n[INFO] Check your Sentry dashboard for the test events") - return True - - except Exception as e: - print(f"[FAIL] Error testing Sentry capture: {e}") - return False - - -async def main(): - """Run all memory save verification tests.""" - print("=" * 60) - print("Memory Save Verification Script") - print("=" * 60) - print(f"DEBUG: {os.environ.get('DEBUG', 'not set')}") - print(f"SENTRY_DEV: {os.environ.get('SENTRY_DEV', 'not set')}") - print(f"GRAPHITI_ENABLED: {os.environ.get('GRAPHITI_ENABLED', 'not set')}") - - results = {} - - # Run tests - results["imports"] = await test_memory_imports() - results["graphiti_status"] = await test_graphiti_status() - results["sentry_status"] = await test_sentry_status() - results["memory_save"] = await test_memory_save_flow() - results["sentry_capture"] = await test_sentry_capture() - - # Summary - print("\n" + "=" * 60) - print("Test Summary") - print("=" * 60) - - passed = 0 - failed = 0 - for test_name, result in results.items(): - status = "[OK]" if result else "[FAIL]" - print(f" {status} {test_name}") - if result: - passed += 1 - else: - failed += 1 - - print(f"\nTotal: {passed} passed, {failed} failed") - - if failed > 0: - print("\n[FAIL] Some tests failed") - sys.exit(1) - else: - print("\n[OK] All tests passed") - sys.exit(0) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/apps/backend/test_discovery.py b/apps/backend/test_discovery.py deleted file mode 100644 index 00f16fe0..00000000 --- a/apps/backend/test_discovery.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Backward compatibility shim - import from analysis.test_discovery instead.""" - -from analysis.test_discovery import ( - FRAMEWORK_PATTERNS, - TestDiscovery, - TestDiscoveryResult, - TestFramework, - discover_tests, - get_test_command, - get_test_frameworks, -) - -__all__ = [ - "TestFramework", - "TestDiscoveryResult", - "TestDiscovery", - "discover_tests", - "get_test_command", - "get_test_frameworks", - "FRAMEWORK_PATTERNS", -] diff --git a/package-lock.json b/package-lock.json index 311e1536..78d1453e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -262,6 +262,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -826,6 +827,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -869,6 +871,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -908,6 +911,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2194,6 +2198,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2215,6 +2220,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.4.0.tgz", "integrity": "sha512-jn0phJ+hU7ZuvaoZE/8/Euw3gvHJrn2yi+kXrymwObEPVPjtwCmkvXDRQCWli+fCTTF/aSOtXaLr7CLIvv3LQg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2227,6 +2233,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.4.0.tgz", "integrity": "sha512-KtcyFHssTn5ZgDu6SXmUznS80OFs/wN7y6MyFRRcKU6TOw8hNcGxKvt8hsdaLJfhzUszNSjURetq5Qpkad14Gw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2242,6 +2249,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -2644,6 +2652,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.4.0.tgz", "integrity": "sha512-RWvGLj2lMDZd7M/5tjkI/2VHMpXebLgPKvBUd9LRasEWR2xAynDwEYZuLvY9P2NGG73HF07jbbgWX2C9oavcQg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.4.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2660,6 +2669,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.4.0.tgz", "integrity": "sha512-WH0xXkz/OHORDLKqaxcUZS0X+t1s7gGlumr2ebiEgNZQl2b0upK2cdoD0tatf7l8iP74woGJ/Kmxe82jdvcWRw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.4.0", "@opentelemetry/resources": "2.4.0", @@ -2677,6 +2687,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -4871,6 +4882,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5173,6 +5185,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5183,6 +5196,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5452,6 +5466,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5484,6 +5499,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5954,6 +5970,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6924,6 +6941,7 @@ "integrity": "sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.4.0", "builder-util": "26.3.4", @@ -7081,6 +7099,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", @@ -8439,6 +8458,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -8765,6 +8785,7 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -11251,6 +11272,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11441,6 +11463,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11450,6 +11473,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12445,7 +12469,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -12624,6 +12649,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12782,6 +12808,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13102,6 +13129,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -13694,6 +13722,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14158,6 +14187,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/tests/test_discovery.py b/tests/test_discovery.py deleted file mode 100644 index b922e486..00000000 --- a/tests/test_discovery.py +++ /dev/null @@ -1,578 +0,0 @@ -#!/usr/bin/env python3 -""" -Tests for the test_discovery module. - -Tests cover: -- Framework detection for various languages -- Package manager detection -- Test directory discovery -- Test file detection -- Command extraction -""" - -import json -import tempfile -from pathlib import Path - -import pytest - -# Add auto-claude to path for imports -import sys -sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend")) - -from test_discovery import ( - TestFramework, - TestDiscoveryResult, - TestDiscovery, - discover_tests, - get_test_command, - get_test_frameworks, -) - - -# ============================================================================= -# FIXTURES -# ============================================================================= - - -@pytest.fixture -def temp_dir(): - """Create a temporary directory for tests.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield Path(tmpdir) - - -@pytest.fixture -def discovery(): - """Create a TestDiscovery instance.""" - return TestDiscovery() - - -# ============================================================================= -# PACKAGE MANAGER DETECTION -# ============================================================================= - - -class TestPackageManagerDetection: - """Tests for package manager detection.""" - - def test_detect_npm(self, discovery, temp_dir): - """Test npm detection via package-lock.json.""" - (temp_dir / "package-lock.json").write_text("{}") - result = discovery.discover(temp_dir) - assert result.package_manager == "npm" - - def test_detect_yarn(self, discovery, temp_dir): - """Test yarn detection via yarn.lock.""" - (temp_dir / "yarn.lock").write_text("") - result = discovery.discover(temp_dir) - assert result.package_manager == "yarn" - - def test_detect_pnpm(self, discovery, temp_dir): - """Test pnpm detection via pnpm-lock.yaml.""" - (temp_dir / "pnpm-lock.yaml").write_text("") - result = discovery.discover(temp_dir) - assert result.package_manager == "pnpm" - - def test_detect_bun(self, discovery, temp_dir): - """Test bun detection via bun.lockb.""" - (temp_dir / "bun.lockb").write_bytes(b"") - result = discovery.discover(temp_dir) - assert result.package_manager == "bun" - - def test_detect_bun_text_lockfile(self, discovery, temp_dir): - """Test bun detection via bun.lock (text format, Bun 1.2.0+).""" - (temp_dir / "bun.lock").write_text("") - result = discovery.discover(temp_dir) - assert result.package_manager == "bun" - - def test_detect_uv(self, discovery, temp_dir): - """Test uv detection via uv.lock.""" - (temp_dir / "uv.lock").write_text("") - result = discovery.discover(temp_dir) - assert result.package_manager == "uv" - - def test_detect_poetry(self, discovery, temp_dir): - """Test poetry detection via poetry.lock.""" - (temp_dir / "poetry.lock").write_text("") - result = discovery.discover(temp_dir) - assert result.package_manager == "poetry" - - def test_detect_cargo(self, discovery, temp_dir): - """Test cargo detection via Cargo.lock.""" - (temp_dir / "Cargo.lock").write_text("") - result = discovery.discover(temp_dir) - assert result.package_manager == "cargo" - - def test_detect_bundler(self, discovery, temp_dir): - """Test bundler detection via Gemfile.lock.""" - (temp_dir / "Gemfile.lock").write_text("") - result = discovery.discover(temp_dir) - assert result.package_manager == "bundler" - - -# ============================================================================= -# JAVASCRIPT FRAMEWORK DETECTION -# ============================================================================= - - -class TestJSFrameworkDetection: - """Tests for JavaScript test framework detection.""" - - def test_detect_jest_from_dependencies(self, discovery, temp_dir): - """Test Jest detection from package.json dependencies.""" - pkg = {"devDependencies": {"jest": "^29.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discovery.discover(temp_dir) - - assert len(result.frameworks) > 0 - framework_names = [f.name for f in result.frameworks] - assert "jest" in framework_names - - def test_detect_jest_version(self, discovery, temp_dir): - """Test Jest version extraction.""" - pkg = {"devDependencies": {"jest": "^29.5.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discovery.discover(temp_dir) - jest = next(f for f in result.frameworks if f.name == "jest") - assert jest.version == "29.5.0" - - def test_detect_jest_config_file(self, discovery, temp_dir): - """Test Jest config file detection.""" - pkg = {"devDependencies": {"jest": "^29.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - (temp_dir / "jest.config.js").write_text("module.exports = {}") - - result = discovery.discover(temp_dir) - jest = next(f for f in result.frameworks if f.name == "jest") - assert jest.config_file == "jest.config.js" - - def test_detect_vitest(self, discovery, temp_dir): - """Test Vitest detection.""" - pkg = {"devDependencies": {"vitest": "^1.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "vitest" in framework_names - - def test_detect_playwright(self, discovery, temp_dir): - """Test Playwright detection.""" - pkg = {"devDependencies": {"@playwright/test": "^1.40.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "playwright" in framework_names - - playwright = next(f for f in result.frameworks if f.name == "playwright") - assert playwright.type == "e2e" - - def test_detect_cypress(self, discovery, temp_dir): - """Test Cypress detection.""" - pkg = {"devDependencies": {"cypress": "^13.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "cypress" in framework_names - - cypress = next(f for f in result.frameworks if f.name == "cypress") - assert cypress.type == "e2e" - - def test_detect_from_test_script(self, discovery, temp_dir): - """Test framework detection from npm test script.""" - pkg = { - "scripts": {"test": "vitest run"}, - "devDependencies": {}, - } - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discovery.discover(temp_dir) - - # Should infer from script - framework_names = [f.name for f in result.frameworks] - assert "vitest" in framework_names or "npm_test" in framework_names - - def test_ignore_empty_test_script(self, discovery, temp_dir): - """Test that default empty test script is ignored.""" - pkg = { - "scripts": {"test": 'echo "Error: no test specified" && exit 1'}, - "devDependencies": {}, - } - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discovery.discover(temp_dir) - assert len(result.frameworks) == 0 - - -# ============================================================================= -# PYTHON FRAMEWORK DETECTION -# ============================================================================= - - -class TestPythonFrameworkDetection: - """Tests for Python test framework detection.""" - - def test_detect_pytest_from_requirements(self, discovery, temp_dir): - """Test pytest detection from requirements.txt.""" - (temp_dir / "requirements.txt").write_text("pytest==7.4.0\n") - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "pytest" in framework_names - - def test_detect_pytest_from_pyproject(self, discovery, temp_dir): - """Test pytest detection from pyproject.toml.""" - pyproject = """ -[project] -dependencies = ["pytest>=7.0.0"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -""" - (temp_dir / "pyproject.toml").write_text(pyproject) - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "pytest" in framework_names - - def test_detect_pytest_from_conftest(self, discovery, temp_dir): - """Test pytest detection from conftest.py presence.""" - (temp_dir / "conftest.py").write_text("import pytest\n") - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "pytest" in framework_names - - def test_detect_pytest_from_tests_conftest(self, discovery, temp_dir): - """Test pytest detection from tests/conftest.py.""" - tests_dir = temp_dir / "tests" - tests_dir.mkdir() - (tests_dir / "conftest.py").write_text("import pytest\n") - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "pytest" in framework_names - - def test_detect_pytest_ini(self, discovery, temp_dir): - """Test pytest.ini config file detection.""" - (temp_dir / "pytest.ini").write_text("[pytest]\ntestpaths = tests\n") - (temp_dir / "requirements.txt").write_text("pytest\n") - - result = discovery.discover(temp_dir) - - pytest_fw = next(f for f in result.frameworks if f.name == "pytest") - assert pytest_fw.config_file == "pytest.ini" - - -# ============================================================================= -# OTHER LANGUAGE FRAMEWORK DETECTION -# ============================================================================= - - -class TestOtherLanguageFrameworks: - """Tests for Rust, Go, and Ruby framework detection.""" - - def test_detect_cargo_test(self, discovery, temp_dir): - """Test Rust cargo test detection.""" - (temp_dir / "Cargo.toml").write_text('[package]\nname = "test"') - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "cargo_test" in framework_names - - cargo = next(f for f in result.frameworks if f.name == "cargo_test") - assert cargo.command == "cargo test" - - def test_detect_go_test(self, discovery, temp_dir): - """Test Go test detection.""" - (temp_dir / "go.mod").write_text("module test") - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "go_test" in framework_names - - go = next(f for f in result.frameworks if f.name == "go_test") - assert go.command == "go test ./..." - - def test_detect_rspec(self, discovery, temp_dir): - """Test RSpec detection.""" - (temp_dir / "Gemfile").write_text('gem "rspec"') - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "rspec" in framework_names - - def test_detect_rspec_with_dotfile(self, discovery, temp_dir): - """Test RSpec detection via .rspec file.""" - (temp_dir / "Gemfile").write_text('gem "rails"') - (temp_dir / ".rspec").write_text("--format documentation\n") - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "rspec" in framework_names - - rspec = next(f for f in result.frameworks if f.name == "rspec") - assert rspec.config_file == ".rspec" - - def test_detect_minitest(self, discovery, temp_dir): - """Test Minitest detection.""" - (temp_dir / "Gemfile").write_text('gem "minitest"') - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "minitest" in framework_names - - -# ============================================================================= -# TEST DIRECTORY DETECTION -# ============================================================================= - - -class TestDirectoryDetection: - """Tests for test directory detection.""" - - def test_find_tests_directory(self, discovery, temp_dir): - """Test finding 'tests' directory.""" - (temp_dir / "tests").mkdir() - - result = discovery.discover(temp_dir) - - assert "tests" in result.test_directories - - def test_find_test_directory(self, discovery, temp_dir): - """Test finding 'test' directory.""" - (temp_dir / "test").mkdir() - - result = discovery.discover(temp_dir) - - assert "test" in result.test_directories - - def test_find_spec_directory(self, discovery, temp_dir): - """Test finding 'spec' directory.""" - (temp_dir / "spec").mkdir() - - result = discovery.discover(temp_dir) - - assert "spec" in result.test_directories - - def test_find_dunder_tests_directory(self, discovery, temp_dir): - """Test finding '__tests__' directory.""" - (temp_dir / "__tests__").mkdir() - - result = discovery.discover(temp_dir) - - assert "__tests__" in result.test_directories - - -# ============================================================================= -# TEST FILE DETECTION -# ============================================================================= - - -class TestFileDetection: - """Tests for test file detection.""" - - def test_detect_python_test_files(self, discovery, temp_dir): - """Test detecting Python test files.""" - tests_dir = temp_dir / "tests" - tests_dir.mkdir() - (tests_dir / "test_main.py").write_text("def test_example(): pass") - - result = discovery.discover(temp_dir) - - assert result.has_tests is True - - def test_detect_js_test_files(self, discovery, temp_dir): - """Test detecting JavaScript test files.""" - src_dir = temp_dir / "src" - src_dir.mkdir() - (src_dir / "app.test.js").write_text("test('example', () => {})") - - result = discovery.discover(temp_dir) - - assert result.has_tests is True - - def test_detect_ts_test_files(self, discovery, temp_dir): - """Test detecting TypeScript test files.""" - (temp_dir / "component.spec.ts").write_text("describe('test', () => {})") - - result = discovery.discover(temp_dir) - - assert result.has_tests is True - - def test_no_tests_in_empty_project(self, discovery, temp_dir): - """Test that empty project has no tests.""" - result = discovery.discover(temp_dir) - - assert result.has_tests is False - - -# ============================================================================= -# SERIALIZATION -# ============================================================================= - - -class TestSerialization: - """Tests for result serialization.""" - - def test_to_dict(self, discovery, temp_dir): - """Test converting result to dictionary.""" - pkg = {"devDependencies": {"jest": "^29.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discovery.discover(temp_dir) - result_dict = discovery.to_dict(result) - - assert isinstance(result_dict, dict) - assert "frameworks" in result_dict - assert "test_command" in result_dict - assert "test_directories" in result_dict - assert "has_tests" in result_dict - - def test_framework_dict_structure(self, discovery, temp_dir): - """Test framework dictionary structure.""" - pkg = {"devDependencies": {"jest": "^29.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - (temp_dir / "jest.config.js").write_text("{}") - - result = discovery.discover(temp_dir) - result_dict = discovery.to_dict(result) - - assert len(result_dict["frameworks"]) > 0 - framework = result_dict["frameworks"][0] - - assert "name" in framework - assert "type" in framework - assert "command" in framework - assert "config_file" in framework - - def test_json_serializable(self, discovery, temp_dir): - """Test that result is JSON serializable.""" - pkg = {"devDependencies": {"jest": "^29.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discovery.discover(temp_dir) - result_dict = discovery.to_dict(result) - - # Should not raise - json_str = json.dumps(result_dict) - assert isinstance(json_str, str) - - -# ============================================================================= -# CONVENIENCE FUNCTIONS -# ============================================================================= - - -class TestConvenienceFunctions: - """Tests for convenience functions.""" - - def test_discover_tests(self, temp_dir): - """Test discover_tests function.""" - pkg = {"devDependencies": {"jest": "^29.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discover_tests(temp_dir) - - assert isinstance(result, TestDiscoveryResult) - - def test_get_test_command(self, temp_dir): - """Test get_test_command function.""" - pkg = {"devDependencies": {"jest": "^29.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - cmd = get_test_command(temp_dir) - - assert "jest" in cmd - - def test_get_test_frameworks(self, temp_dir): - """Test get_test_frameworks function.""" - pkg = {"devDependencies": {"jest": "^29.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - frameworks = get_test_frameworks(temp_dir) - - assert isinstance(frameworks, list) - assert "jest" in frameworks - - -# ============================================================================= -# EDGE CASES -# ============================================================================= - - -class TestEdgeCases: - """Tests for edge cases.""" - - def test_invalid_package_json(self, discovery, temp_dir): - """Test handling of invalid package.json.""" - (temp_dir / "package.json").write_text("not valid json") - - # Should not raise - result = discovery.discover(temp_dir) - assert isinstance(result, TestDiscoveryResult) - - def test_nonexistent_directory(self, discovery): - """Test handling of non-existent directory.""" - fake_dir = Path("/nonexistent/path") - - # Should not raise - result = discovery.discover(fake_dir) - assert isinstance(result, TestDiscoveryResult) - assert len(result.frameworks) == 0 - - def test_multiple_frameworks(self, discovery, temp_dir): - """Test detecting multiple frameworks.""" - pkg = { - "devDependencies": { - "jest": "^29.0.0", - "@playwright/test": "^1.40.0", - } - } - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result = discovery.discover(temp_dir) - - framework_names = [f.name for f in result.frameworks] - assert "jest" in framework_names - assert "playwright" in framework_names - - def test_caching(self, discovery, temp_dir): - """Test that results are cached.""" - pkg = {"devDependencies": {"jest": "^29.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - # First call - result1 = discovery.discover(temp_dir) - - # Second call should use cache - result2 = discovery.discover(temp_dir) - - assert result1 is result2 - - def test_clear_cache(self, discovery, temp_dir): - """Test cache clearing.""" - pkg = {"devDependencies": {"jest": "^29.0.0"}} - (temp_dir / "package.json").write_text(json.dumps(pkg)) - - result1 = discovery.discover(temp_dir) - discovery.clear_cache() - result2 = discovery.discover(temp_dir) - - assert result1 is not result2