forked from HAL9000/cleveragents-core
614 lines
21 KiB
Python
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()
|