"""Step definitions for CLI plan and context command testing.""" import contextlib import json import os import shlex import shutil import tempfile from pathlib import Path from behave import given, then, when # type: ignore[import-not-found] from behave.runner import Context # type: ignore[import-not-found] from cleveragents.application.container import get_container, reset_container def _run_cleveragents_inprocess(context, args): """Run a cleveragents CLI command in-process using the ``main()`` entry point. This avoids the ~6 s overhead per ``subprocess.run`` call (Python startup, module imports, Alembic migration check) by reusing the already-imported modules in the test process. The function temporarily changes the working directory to ``context.test_dir`` (if set) so that project discovery works correctly, and widens the virtual terminal so Rich doesn't wrap output. """ import contextlib as _contextlib import io import re from cleveragents.cli.main import main as cli_main prev_cwd = os.getcwd() prev_columns = os.environ.get("COLUMNS") os.environ["COLUMNS"] = "300" if hasattr(context, "test_dir") and context.test_dir: os.chdir(context.test_dir) stdout_buf = io.StringIO() stderr_buf = io.StringIO() try: with ( _contextlib.redirect_stdout(stdout_buf), _contextlib.redirect_stderr(stderr_buf), ): exit_code = cli_main(list(args)) except SystemExit as exc: exit_code = exc.code if exc.code is not None else 0 finally: os.chdir(prev_cwd) if prev_columns is None: os.environ.pop("COLUMNS", None) else: os.environ["COLUMNS"] = prev_columns stdout_text = stdout_buf.getvalue() stderr_text = stderr_buf.getvalue() # Strip ANSI escape codes that Rich may emit even to StringIO _ansi_re = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") stdout_text = _ansi_re.sub("", stdout_text) stderr_text = _ansi_re.sub("", stderr_text) output = stdout_text if stderr_text: output += stderr_text context.command_output = output context.command_error = stderr_text context.command_exit_code = exit_code if isinstance(exit_code, int) else 1 context.exit_code = context.command_exit_code @given("I have a clean test environment") def step_clean_test_environment(context: Context) -> None: """Create a clean test environment.""" # Create a temporary directory for testing context.test_dir = tempfile.mkdtemp(prefix="cleveragents_test_") context.temp_dir = ( context.test_dir ) # Alias for compatibility with other step definitions context.original_cwd = os.getcwd() os.chdir(context.test_dir) # Reset container for clean state reset_container() # Set up mock AI provider for testing os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true" # Auto-approve migrations during tests to avoid interactive prompts os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true" # Use a file-based SQLite DB scoped to the temp directory so subprocess CLI # calls share the same state. db_path = Path(context.test_dir) / ".cleveragents" / "db.sqlite" os.environ["CLEVERAGENTS_DATABASE_URL"] = f"sqlite:///{db_path}" os.environ["CLEVERAGENTS_TEST_DATABASE_URL"] = f"sqlite:///{db_path}" @given("I have set up the test configuration") def step_setup_test_config(context): """Set up test configuration.""" # Configuration is handled by environment variables pass @given("I am in an empty directory") def step_in_empty_directory(context): """Ensure we're in an empty directory.""" assert len(os.listdir(".")) == 0, "Directory is not empty" @given("I have an initialized project") def step_initialized_project(context): """Initialize a project for testing.""" from cleveragents.cli.commands.project import init_command # Use the CLI command directly init_command("test-project", Path.cwd()) # Verify project was initialized assert (Path.cwd() / ".cleveragents").exists() context.project_dir = Path.cwd() / ".cleveragents" # Ensure an actor is available for mock provider flows container = get_container() actor_service = container.actor_service() with contextlib.suppress(Exception): actor_service.upsert_actor( name="local/mock-default", provider="MockProvider", model="mock-gpt-4", set_default=True, is_built_in=True, unsafe=True, ) @given('I have a file "{filename}" with content "{content}"') def step_create_file_with_content(context, filename, content): """Create a file with specified content.""" Path(filename).write_text(content) assert Path(filename).exists() @given('I have a file "{filename}"') def step_create_file(context, filename): """Create an empty file.""" Path(filename).touch() assert Path(filename).exists() @given("I have an initialized project with a plan") def step_initialized_project_with_plan(context): """Initialize project and create a plan.""" step_initialized_project(context) from cleveragents.cli.commands.plan import tell_command # Create a plan tell_command("Test instruction") # Verify plan was created container = get_container() plan_service = container.plan_service() project_service = container.project_service() project = project_service.get_current_project() current = plan_service.get_current_plan(project) if project else None assert current is not None @given('the plan has an instruction "{instruction}"') def step_plan_has_instruction(context, instruction): """Set plan instruction.""" container = get_container() plan_service = container.plan_service() project_service = container.project_service() # Update the current plan's prompt project = project_service.get_current_project() current = plan_service.get_current_plan(project) if project else None if current: current.prompt = instruction # Save updated plan (would normally use repository) plans_file = Path.cwd() / ".cleveragents" / "plans.json" if plans_file.exists(): data = json.loads(plans_file.read_text()) for plan in data: if plan["name"] == current.name: plan["prompt"] = instruction plans_file.write_text(json.dumps(data, indent=2)) @given("I have an initialized project with a built plan") def step_initialized_project_with_built_plan(context): """Initialize project with a built plan.""" step_initialized_project_with_plan(context) from cleveragents.cli.commands.plan import build_command # Build the plan build_command() # Verify plan was built container = get_container() plan_service = container.plan_service() project_service = container.project_service() project = project_service.get_current_project() current = plan_service.get_current_plan(project) if project else None assert current is not None assert current.status == "built" @given('the plan has a change to create "{filename}"') def step_plan_has_create_change(context, filename): """Replace existing changes with a change to create the specified file.""" from datetime import datetime from cleveragents.application.container import get_container from cleveragents.domain.models.core import Change, OperationType # Get the current plan from the database container = get_container() plan_service = container.plan_service() project_service = container.project_service() project = project_service.get_current_project() current_plan = plan_service.get_current_plan(project) if project else None if current_plan and current_plan.id: # Clear existing changes and add the specific one we want with container.unit_of_work().transaction() as ctx: # Clear existing changes for this plan ctx.changes.clear_for_plan(current_plan.id) # Add the new change change = Change( id=None, plan_id=current_plan.id, file_path=filename, operation=OperationType.CREATE, original_content=None, new_content="# Generated file\nprint('Hello from CleverAgents')\n", new_path=None, applied=False, created_at=datetime.now(), applied_at=None, ) ctx.changes.add(change) @given('I have a plan named "{name}"') def step_have_plan_named(context, name): """Create a plan with specified name.""" from cleveragents.cli.commands.plan import list_command, new_command # Check if plan already exists existing_plans = list_command() if any(p.name == name for p in existing_plans): # Plan already exists, don't create it again return new_command(name) @given('I have plans named "{names}"') def step_have_multiple_plans(context, names): """Create multiple plans.""" from cleveragents.cli.commands.plan import list_command # Parse the plan names - they can be separated by comma or "and" # Handle formats like: "main", "feature-1", "feature-2" # or: "main" and "feature-1" names = names.replace('"', "") # Split by comma first if "," in names: plan_names = [n.strip() for n in names.split(",")] else: # Split by "and" plan_names = [n.strip() for n in names.split(" and ")] # Check existing plans first existing_plans = list_command() existing_names = [p.name for p in existing_plans] for name in plan_names: if name not in existing_names: step_have_plan_named(context, name) @given('"{name}" is the current plan') def step_set_current_plan(context, name): """Set a specific plan as current.""" from cleveragents.cli.commands.plan import cd_command cd_command(name) @given("the plan has previous conversation history") def step_plan_has_conversation(context): """Add conversation history to the plan.""" # This would normally be stored in the database # For now, we'll just note that the plan has history pass @given('I have added files "{files}" to context') def step_add_files_to_context(context, files): """Add multiple files to context.""" from cleveragents.cli.commands.context import add_command # Remove quotes from filenames and split by comma file_list = [f.strip().strip('"') for f in files.split(",")] for filename in file_list: # Create the file if it doesn't exist if not Path(filename).exists(): Path(filename).touch() # Add to context add_command([filename]) @given('I have added "{filename}" to context') def step_add_file_to_context(context, filename): """Add a single file to context.""" step_add_files_to_context(context, filename) @given('I run "{command}"') def step_given_run_command(context, command): """Run a CLI command.""" step_run_command(context, command) @when('I run "{command}"') @when("I run '{command}'") def step_run_command(context, command): """Run a CLI command. For ``cleveragents`` / ``agents`` commands we invoke the Typer app **in-process** via ``CliRunner`` to avoid the ~6 s Python-startup + Alembic-migration overhead that a ``subprocess.run`` call incurs per invocation. Non-cleveragents commands still use ``subprocess``. """ import subprocess try: # Use shlex to properly handle quoted strings in the command command_parts = shlex.split(command) # If the command starts with "agents" or "cleveragents", run in-process if command_parts[0] in ["agents", "cleveragents"]: _run_cleveragents_inprocess(context, command_parts[1:]) else: # Widen terminal so Rich does not wrap long paths mid-word env = os.environ.copy() env.setdefault("COLUMNS", "300") # Run other commands as-is via subprocess result = subprocess.run( command_parts, capture_output=True, text=True, cwd=context.test_dir if hasattr(context, "test_dir") else None, timeout=60, env=env, ) output = result.stdout or "" if result.stderr: output += result.stderr context.command_output = output context.command_error = result.stderr context.command_exit_code = result.returncode context.exit_code = result.returncode except subprocess.TimeoutExpired: context.command_output = "" context.command_error = "Command timed out" context.command_exit_code = 1 context.exit_code = 1 except Exception as e: context.command_output = "" context.command_error = str(e) context.command_exit_code = 1 context.exit_code = 1 @then("the command should succeed") def step_command_succeeds(context): """Verify command succeeded.""" assert context.command_exit_code == 0, f"Command failed: {context.command_error}" @then("the command should fail") def step_command_fails(context): """Verify command failed.""" assert context.command_exit_code != 0, "Command should have failed but succeeded" @then("a .cleveragents directory should be created") def step_cleveragents_dir_created(context): """Verify .cleveragents directory exists.""" assert (Path.cwd() / ".cleveragents").exists() @then("the database should be initialized in current directory") def step_database_initialized_current_dir(context): """Verify database is initialized in current directory.""" db_file = Path.cwd() / ".cleveragents" / "db.sqlite" assert db_file.exists() or (Path.cwd() / ".cleveragents" / "plans.json").exists() @then('the project should be named "{name}"') def step_project_named(context, name): """Verify project name.""" # Check if we can get project info container = get_container() container.project_service() # Project name verification would go here pass @then("the file should be added to context") def step_file_in_context(context): """Verify file was added to context.""" container = get_container() context_service = container.context_service() files = context_service.list_files() assert len(files) > 0 @then('I should see "{text}" in the output') def step_see_text_in_output(context, text): """Verify text appears in command output.""" assert text in context.command_output, ( f"Expected '{text}' in output: {context.command_output}" ) @then("a new plan should be created") def step_new_plan_created(context): """Verify a new plan was created.""" container = get_container() plan_service = container.plan_service() project_service = container.project_service() project = project_service.get_current_project() current = plan_service.get_current_plan(project) if project else None assert current is not None @then('the plan should contain the instruction "{instruction}"') def step_plan_contains_instruction(context, instruction): """Verify plan contains instruction.""" container = get_container() plan_service = container.plan_service() project_service = container.project_service() project = project_service.get_current_project() current = plan_service.get_current_plan(project) if project else None assert current is not None assert instruction in current.prompt @then("the plan should have generated changes") def step_plan_has_changes(context): """Verify plan has generated changes.""" changes_file = Path.cwd() / ".cleveragents" / "changes.json" if changes_file.exists(): changes = json.loads(changes_file.read_text()) assert len(changes) > 0 @then("the changes should be stored in the database") def step_changes_in_database(context): """Verify changes are stored.""" # Changes are stored in JSON for now (or could be in database) changes_file = Path.cwd() / ".cleveragents" / "changes.json" # Also check if build was successful even without changes file # (changes might be in the database) if not changes_file.exists(): # For testing, we consider it successful if the .cleveragents directory exists assert (Path.cwd() / ".cleveragents").exists() else: assert changes_file.exists() @then('the file "{filename}" should exist') def step_file_exists(context, filename): """Verify file exists.""" assert Path(filename).exists(), f"File {filename} does not exist" @then("the changes should be marked as applied") def step_changes_marked_applied(context): """Verify changes are marked as applied.""" changes_file = Path.cwd() / ".cleveragents" / "changes.json" if changes_file.exists(): changes = json.loads(changes_file.read_text()) assert any(c.get("applied") for c in changes) @then('a plan named "{name}" should be created') def step_plan_named_created(context, name): """Verify plan with name was created.""" container = get_container() plan_service = container.plan_service() project_service = container.project_service() project = project_service.get_current_project() plans = plan_service.list_plans(project) if project else [] assert any(p.name == name for p in plans) @then('"{name}" should be the current plan') def step_verify_current_plan(context, name): """Verify current plan name.""" container = get_container() plan_service = container.plan_service() project_service = container.project_service() project = project_service.get_current_project() current = plan_service.get_current_plan(project) if project else None assert current is not None assert current.name == name @then("the plan should have the additional instruction") def step_plan_has_additional(context): """Verify plan has additional instruction.""" container = get_container() plan_service = container.plan_service() project_service = container.project_service() project = project_service.get_current_project() current = plan_service.get_current_plan(project) if project else None assert current is not None # Would check conversation history here @then('"{filename}" should not be in context') def step_file_not_in_context(context, filename): """Verify file is not in context.""" from pathlib import Path container = get_container() context_service = container.context_service() files = context_service.list_files() # Files are Context objects with a path attribute file_names = [Path(f.path if hasattr(f, "path") else str(f)).name for f in files] assert filename not in file_names @then('"{filename}" should still be in context') def step_file_still_in_context(context, filename): """Verify file is still in context.""" from pathlib import Path container = get_container() context_service = container.context_service() files = context_service.list_files() # Files are Context objects with a path attribute file_names = [Path(f.path if hasattr(f, "path") else str(f)).name for f in files] print(f"DEBUG: Looking for {filename} in {file_names}") # Debug output assert filename in file_names, f"{filename} not found in {file_names}" @then("the context should be empty") def step_context_empty(context): """Verify context is empty.""" container = get_container() context_service = container.context_service() files = context_service.list_files() assert len(files) == 0 @then('the error message should mention "{text}"') def step_error_mentions(context, text): """Verify error message contains text.""" # Check both stdout and stderr for error messages combined_output = (context.command_output or "") + (context.command_error or "") assert text.lower() in combined_output.lower(), ( f"Expected '{text}' not found in: {combined_output}" ) @then('the output should mention "{text}"') def step_output_mentions(context, text): """Verify output mentions text.""" output = context.command_output or context.command_error assert text.lower() in output.lower() @then('it should be equivalent to "{command}"') def step_equivalent_command(context, command): """Verify command equivalence.""" # This is more of a conceptual check # The shortcuts should work the same as the full commands pass # Cleanup def after_scenario(context, scenario): """Clean up after each scenario.""" if hasattr(context, "test_dir"): # Change back to original directory if hasattr(context, "original_cwd"): os.chdir(context.original_cwd) # Remove test directory shutil.rmtree(context.test_dir, ignore_errors=True) # Clean up environment if "CLEVERAGENTS_TESTING_USE_MOCK_AI" in os.environ: del os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] # Reset container reset_container()