Files
temp/features/steps/cli_plan_context_commands_steps.py
T

614 lines
21 KiB
Python

"""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()