Files
cleveragents-core/features/steps/service_steps.py
T

1434 lines
53 KiB
Python

"""Step definitions for service-level tests."""
import os
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.container import get_container, override_providers
from cleveragents.application.services.context_service import ContextService
from cleveragents.application.services.plan_service import PlanService
from cleveragents.application.services.project_service import ProjectService
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError, ValidationError
def add_cleanup(context: Context, handler):
"""Add a cleanup handler to the context."""
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(handler)
@given("I have a project service")
def step_have_project_service(context: Context) -> None:
"""Create a project service instance."""
import uuid
settings = Settings()
# Create a unique temp dir for this test
context.test_dir = tempfile.mkdtemp(prefix="test_project_")
# Create a unique database file
db_file = Path(context.test_dir) / f"test_{uuid.uuid4().hex}.db"
db_url = f"sqlite:///{db_file}"
# Create unit of work instead of passing db_url directly
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
unit_of_work = UnitOfWork(db_url)
context.project_service = ProjectService(settings, unit_of_work)
context.project_service.search_root = Path(context.test_dir)
def cleanup():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
@when('I initialize a project "{name}" at "{path}"')
def step_initialize_project(context: Context, name: str, path: str) -> None:
"""Initialize a project."""
if path == "/tmp/test":
path = context.test_dir
try:
context.project = context.project_service.initialize_project(
name=name, path=Path(path), force=False
)
context.error = None
except Exception as e:
context.error = e
@when('I initialize a project "{name}" at "{path}" with force')
def step_initialize_project_with_force(context: Context, name: str, path: str) -> None:
"""Initialize a project with force flag."""
if path == "/tmp/test":
path = context.test_dir
context.project = context.project_service.initialize_project(
name=name, path=Path(path), force=True
)
context.error = None
@when('I try to initialize a project "{name}" at "{path}" without force')
def step_try_initialize_without_force(context: Context, name: str, path: str) -> None:
"""Try to initialize a project without force."""
if path == "/tmp/test":
path = context.test_dir
try:
context.project = context.project_service.initialize_project(
name=name, path=Path(path), force=False
)
context.error = None
except Exception as e:
context.error = e
@given('I have initialized a service project "{name}" at "{path}"')
def step_have_initialized_service_project(
context: Context, name: str, path: str
) -> None:
"""Initialize a project as a given for service tests."""
if path == "/tmp/test":
path = context.test_dir
context.project = context.project_service.initialize_project(
name=name, path=Path(path), force=False
)
@then("the project should be created successfully")
def step_project_created_successfully(context: Context) -> None:
"""Verify project was created."""
assert context.project is not None, "Project was not created"
assert context.error is None, f"Got error: {context.error}"
@then('the project name should be "{name}"')
def step_project_name_should_be(context: Context, name: str) -> None:
"""Verify project name."""
assert context.project.name == name, (
f"Expected name {name}, got {context.project.name}"
)
@then('the project path should be "{path}"')
def step_project_path_should_be(context: Context, path: str) -> None:
"""Verify project path."""
if path == "/tmp/test":
path = context.test_dir
expected_path = Path(path)
assert context.project.path == expected_path, (
f"Expected path {expected_path}, got {context.project.path}"
)
@then('the .cleveragents directory should exist at "{path}"')
def step_cleveragents_dir_should_exist(context: Context, path: str) -> None:
"""Verify .cleveragents directory exists."""
if path.startswith("/tmp/test"):
path = path.replace("/tmp/test", context.test_dir)
assert Path(path).exists(), f"Directory {path} does not exist"
@then("I should get a ValidationError")
def step_should_get_validation_error(context: Context) -> None:
"""Verify we got a ValidationError."""
assert isinstance(context.error, ValidationError), (
f"Expected ValidationError, got {type(context.error)}"
)
@then('the error message should contain "{text}"')
def step_error_should_contain(context: Context, text: str) -> None:
"""Verify error message contains text."""
assert text in str(context.error), (
f"Error message doesn't contain '{text}': {context.error}"
)
# Context Service steps
@given("I have a context service")
def step_have_context_service(context: Context) -> None:
"""Create a context service instance."""
import os
import uuid
settings = Settings()
# Create a unique temp dir for this test
context.test_dir = tempfile.mkdtemp(prefix="test_context_")
# Create .cleveragents directory for context storage
cleveragents_dir = Path(context.test_dir) / ".cleveragents"
cleveragents_dir.mkdir(exist_ok=True)
# Create a unique database file
db_file = Path(context.test_dir) / f"test_{uuid.uuid4().hex}.db"
db_url = f"sqlite:///{db_file}"
# Change to test directory so service finds the .cleveragents dir
context.original_cwd = os.getcwd()
os.chdir(context.test_dir)
# Create unit of work instead of passing db_url directly
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
unit_of_work = UnitOfWork(db_url)
# Create services we need
context.context_service = ContextService(settings, unit_of_work)
# Also create a project service to initialize a test project
from cleveragents.application.services.project_service import ProjectService
project_service = ProjectService(settings, unit_of_work)
# Initialize a test project
context.test_project = project_service.initialize_project(
name="test-project", path=Path(context.test_dir), force=True
)
# Also create a plan service and create a plan
from cleveragents.application.services.plan_service import PlanService
plan_service = PlanService(settings, unit_of_work)
context.test_plan = plan_service.create_plan(
project=context.test_project, prompt="Test plan for context"
)
# Return to original directory
os.chdir(context.original_cwd)
def cleanup():
if hasattr(context, "original_cwd"):
os.chdir(context.original_cwd)
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
@given("I have a context service without a plan")
def step_have_context_service_without_plan(context: Context) -> None:
"""Create a context service without creating a plan."""
import os
import uuid
settings = Settings()
context.test_dir = tempfile.mkdtemp(prefix="test_context_")
cleveragents_dir = Path(context.test_dir) / ".cleveragents"
cleveragents_dir.mkdir(exist_ok=True)
db_file = Path(context.test_dir) / f"test_{uuid.uuid4().hex}.db"
db_url = f"sqlite:///{db_file}"
context.original_cwd = os.getcwd()
os.chdir(context.test_dir)
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
unit_of_work = UnitOfWork(db_url)
context.context_service = ContextService(settings, unit_of_work)
from cleveragents.application.services.project_service import ProjectService
project_service = ProjectService(settings, unit_of_work)
context.test_project = project_service.initialize_project(
name="test-project", path=Path(context.test_dir), force=True
)
if context.test_project.id is not None:
with context.context_service.unit_of_work.transaction() as txn:
plans = txn.plans.get_all_for_project(context.test_project.id)
for plan in plans:
plan.current = False
txn.plans.update(plan)
context.test_project.current_plan_id = None
txn.projects.update(context.test_project)
context.test_project.current_plan_id = None
os.chdir(context.original_cwd)
def cleanup():
if hasattr(context, "original_cwd"):
os.chdir(context.original_cwd)
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
@when('I try to add "{filename}" to context')
def step_try_add_file_to_context(context: Context, filename: str) -> None:
"""Attempt to add a file to context and capture errors."""
test_file = Path(context.test_dir) / filename
if not test_file.exists():
test_file.write_text("test content")
try:
context.context_service.add_to_context(
project=context.test_project, path=test_file
)
context.error = None
except Exception as exc:
context.error = exc
@then("a plan error should be raised for missing plan")
def step_plan_error_missing_plan(context: Context) -> None:
"""Ensure a PlanError was raised when no plan exists."""
assert isinstance(context.error, PlanError), (
f"Expected PlanError, got {type(context.error)}"
)
assert "No current plan" in str(context.error), str(context.error)
@when('I add file "{filename}" to context again')
def step_add_file_to_context_again(context: Context, filename: str) -> None:
"""Add a file to context that already exists."""
test_file = Path(context.test_dir) / filename
added_files, already_in_context = context.context_service.add_to_context(
project=context.test_project, path=test_file
)
context.added_files = added_files
context.already_in_context = already_in_context
context.add_result = (added_files, already_in_context)
context.last_added_filename = filename
@then("the file should be reported as already in context")
def step_file_reported_already(context: Context) -> None:
"""Verify duplicate additions land in the already-in-context list."""
assert hasattr(context, "already_in_context"), "No duplicate tracking recorded"
filenames = {Path(p).name for p in context.already_in_context}
expected = getattr(context, "last_added_filename", None)
assert expected in filenames, (
f"Expected {expected} in already_in_context, found {filenames}"
)
@when("I add the directory to context without recursion")
def step_add_directory_without_recursion(context: Context) -> None:
"""Add a directory to context without descending into subdirectories."""
target_dir = getattr(context, "dir_under_test", None)
if target_dir is None:
target_dir = context.test_subdir
added_files, already_in_context = context.context_service.add_to_context(
project=context.test_project, path=target_dir, recursive=False
)
context.added_files = added_files
context.already_in_context = already_in_context
context.add_result = (added_files, already_in_context)
@then("{count:d} top-level file should be added from the directory")
def step_top_level_file_count(context: Context, count: int) -> None:
"""Ensure the expected number of files were added without recursion."""
assert len(context.added_files) == count, (
f"Expected {count} files, got {len(context.added_files)}"
)
@then('the nested file "{filename}" should not be added to context')
def step_nested_file_not_added(context: Context, filename: str) -> None:
"""Verify nested files are excluded when not recursing."""
added_names = {Path(p).name for p in context.added_files}
assert filename not in added_names, (
f"Nested file {filename} should not be added: {added_names}"
)
@then('the ignored file "{filename}" should not be added to context')
def step_ignored_file_not_added(context: Context, filename: str) -> None:
"""Verify ignored patterns are skipped when scanning directories."""
added_names = {Path(p).name for p in context.added_files}
assert filename not in added_names, (
f"Ignored file {filename} should not be added: {added_names}"
)
@when('I add file "{filename}" to context')
def step_add_file_to_context(context: Context, filename: str) -> None:
"""Add a file to context."""
test_file = Path(context.test_dir) / filename
if not test_file.exists():
test_file.write_text("test content")
added_files, already_in_context = context.context_service.add_to_context(
project=context.test_project, path=test_file
)
context.added_files = added_files
context.already_in_context = already_in_context
context.add_result = (added_files, already_in_context)
@given("the context service max file size is set to {size:d} bytes")
def step_set_max_file_size(context: Context, size: int) -> None:
"""Override the per-file size guardrail."""
context.context_service.max_file_size = size
@given("the context service max context size is set to {size:d} bytes")
def step_set_max_context_size(context: Context, size: int) -> None:
"""Override the aggregate context size guardrail."""
context.context_service.max_context_size = size
@given('I have a test file "{filename}" of size {size:d} bytes')
def step_have_test_file_of_size(context: Context, filename: str, size: int) -> None:
"""Create a test file with a specific size."""
if not hasattr(context, "test_dir"):
context.test_dir = tempfile.mkdtemp(prefix="test_context_")
def cleanup():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
test_file = Path(context.test_dir) / filename
test_file.write_text("a" * size)
@when('I add existing file "{filename}" to context')
def step_add_existing_file(context: Context, filename: str) -> None:
"""Add a previously created file without rewriting it."""
test_file = Path(context.test_dir) / filename
added_files, already_in_context = context.context_service.add_to_context(
project=context.test_project, path=test_file
)
context.added_files = added_files
context.already_in_context = already_in_context
context.add_result = (added_files, already_in_context)
context.last_added_filename = filename
@given("file reads will fail during addition")
def step_patch_read_text(context: Context) -> None:
"""Patch Path.read_text to simulate unreadable files."""
original_read_text = Path.read_text
def failing_read_text(self: Path, *args, **kwargs):
if hasattr(context, "test_dir") and str(self).startswith(context.test_dir):
raise OSError("Simulated read failure")
return original_read_text(self, *args, **kwargs)
patcher = patch("pathlib.Path.read_text", new=failing_read_text)
patcher.start()
context.read_text_patcher = patcher
def cleanup():
patcher.stop()
add_cleanup(context, cleanup)
@then("no files should be added by the last operation")
def step_no_files_added(context: Context) -> None:
"""Ensure the most recent add_to_context call added nothing."""
assert len(getattr(context, "added_files", [])) == 0, (
f"Expected no files added, got {context.added_files}"
)
@then('only "{filename}" should remain in the context')
def step_only_specific_file_in_context(context: Context, filename: str) -> None:
"""Ensure only the provided file remains tracked."""
files = context.context_service.list_context(project=context.test_project)
names = {Path(f.path).name for f in files}
assert names == {filename}, f"Expected only {filename}, found {names}"
@then("the file should be added to service context")
def step_file_should_be_added(context: Context) -> None:
"""Verify file was added."""
assert len(context.added_files) > 0, "No files were added"
@given('I have a special filesystem entry "{name}"')
def step_have_special_filesystem_entry(context: Context, name: str) -> None:
"""Create a special filesystem entry such as a FIFO."""
if not hasattr(context, "test_dir"):
context.test_dir = tempfile.mkdtemp(prefix="test_context_")
def cleanup_dir():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup_dir)
special_path = Path(context.test_dir) / name
if special_path.exists():
special_path.unlink()
os.mkfifo(special_path)
context.special_path = special_path
def cleanup_entry() -> None:
if special_path.exists():
special_path.unlink()
add_cleanup(context, cleanup_entry)
@when('I add special entry "{name}" to context')
def step_add_special_entry(context: Context, name: str) -> None:
"""Attempt to add a special filesystem entry to context."""
special_path = getattr(context, "special_path", Path(context.test_dir) / name)
added_files, already_in_context = context.context_service.add_to_context(
project=context.test_project, path=special_path
)
context.added_files = added_files
context.already_in_context = already_in_context
context.add_result = (added_files, already_in_context)
@when("I add the directory to context recursively again")
def step_add_directory_recursively_again(context: Context) -> None:
"""Re-run recursive directory addition to surface duplicates."""
step_add_directory_recursively(context)
@when("I add the directory to context without recursion again")
def step_add_directory_without_recursion_again(context: Context) -> None:
"""Re-run non-recursive directory addition to surface duplicates."""
step_add_directory_without_recursion(context)
@then("the last addition should report {count:d} duplicates")
def step_last_addition_should_report_duplicates(context: Context, count: int) -> None:
"""Verify the most recent add_to_context reported duplicates."""
duplicates = getattr(context, "already_in_context", [])
assert len(duplicates) == count, (
f"Expected {count} duplicates, found {len(duplicates)}: {duplicates}"
)
@when('I add "{filename}" with an unsaved plan')
def step_add_with_unsaved_plan(context: Context, filename: str) -> None:
"""Try to add a file when the plan lacks an identifier."""
assert hasattr(context, "test_plan"), "Test plan not initialized"
test_file = Path(context.test_dir) / filename
if not test_file.exists():
test_file.write_text("test content")
with context.context_service.unit_of_work.transaction() as txn:
plan = context.test_plan.model_copy(deep=True)
plan.id = None
result = context.context_service._add_file_to_context(txn, plan, test_file)
context.last_add_status = result
context.added_files = []
context.already_in_context = []
context.add_result = ([], [])
@then('the context listings should not contain "{filename}"')
def step_list_should_not_contain(context: Context, filename: str) -> None:
"""Ensure a given filename is absent from collected listings."""
file_names: set[str] = set()
if getattr(context, "context_files", None):
files = context.context_files
if files:
if hasattr(files[0], "path"):
file_names = {Path(str(f.path)).name for f in files}
else:
file_names = {Path(str(f["path"])).name for f in files}
elif getattr(context, "listed_files", None):
file_names = {Path(path).name for path in context.listed_files}
assert filename not in file_names, (
f'Filename "{filename}" unexpectedly present in {sorted(file_names)}'
)
@then("the removed count should be 0")
def step_removed_count_zero(context: Context) -> None:
"""Verify that no context entries were removed."""
assert getattr(context, "removed_count", 0) == 0, (
f"Expected 0 removals, got {getattr(context, 'removed_count', None)}"
)
@when("I clear the context and record the removed count")
def step_clear_context_and_record(context: Context) -> None:
"""Clear context and store the removed item count."""
context.cleared_count = context.context_service.clear_context(
project=context.test_project
)
@then("the recorded removal count should be 0")
def step_recorded_removal_zero(context: Context) -> None:
"""Verify that a recorded clear operation removed nothing."""
assert getattr(context, "cleared_count", 0) == 0, (
f"Expected 0 removals, got {getattr(context, 'cleared_count', None)}"
)
@given("the test project loses its identifier")
def step_project_loses_identifier(context: Context) -> None:
"""Remove the project identifier to exercise guard clauses."""
assert hasattr(context, "test_project"), "Test project not initialized"
context.test_project.id = None
@when("I show context content")
def step_show_context_content(context: Context) -> None:
"""Capture the current context content mapping."""
context.shown_content = context.context_service.show_context_content(
project=context.test_project
)
@then('the shown context content should include "{filename}"')
def step_shown_content_includes_filename(context: Context, filename: str) -> None:
"""Ensure the shown context includes the requested filename."""
shown = getattr(context, "shown_content", {})
file_names = {Path(path).name for path in shown}
assert filename in file_names, (
f'Expected "{filename}" in {sorted(file_names)} from shown content'
)
@then('fetching context content for "{filename}" should return stored content')
def step_fetch_context_content_matches(context: Context, filename: str) -> None:
"""Verify content lookup returns the stored file text."""
test_file = Path(context.test_dir) / filename
expected = test_file.read_text()
content = context.context_service.get_context_content(
project=context.test_project, path=test_file
)
assert content == expected, (
f"Expected stored content for {filename}, got {content!r}"
)
@then('fetching context content for "{filename}" should return nothing')
def step_fetch_context_content_none(context: Context, filename: str) -> None:
"""Ensure content lookup returns None when not present."""
test_file = Path(context.test_dir) / filename
content = context.context_service.get_context_content(
project=context.test_project, path=test_file
)
assert content is None, f"Expected no content for {filename}, got {content!r}"
@when("I list context files for the current project")
def step_list_files_for_current_project(context: Context) -> None:
"""List context files using an explicit project argument."""
context.listed_files = context.context_service.list_files(
project=context.test_project
)
@given("the container project service returns no project")
def step_container_project_service_returns_none(context: Context) -> None:
"""Override the container project service to return None."""
class NullProjectService:
def get_current_project(self):
return None
override = NullProjectService()
override_providers(project_service=override)
container = get_container()
def cleanup() -> None:
if hasattr(container, "project_service"):
container.project_service.reset_override()
add_cleanup(context, cleanup)
context.project_service_override = override
# Plan Service steps
@given("I have a plan service")
def step_have_plan_service(context: Context) -> None:
"""Create a plan service instance."""
import os
import uuid
settings = Settings()
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
# Always create a fresh temp dir for isolation
context.test_dir = tempfile.mkdtemp(prefix="test_plan_")
# Create .cleveragents directory for plan storage
cleveragents_dir = Path(context.test_dir) / ".cleveragents"
cleveragents_dir.mkdir(exist_ok=True)
# Create a unique database file
db_file = Path(context.test_dir) / f"test_{uuid.uuid4().hex}.db"
db_url = f"sqlite:///{db_file}"
# Store original directory
context.original_cwd = os.getcwd()
# Change to test directory and stay there for the service to work properly
os.chdir(context.test_dir)
# Create unit of work instead of passing db_url directly
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
unit_of_work = UnitOfWork(db_url)
# Get the AI provider from container (which has been set up by environment.py)
from cleveragents.application.container import get_container
container = get_container()
ai_provider = container.ai_provider()
context.plan_service = PlanService(settings, unit_of_work, ai_provider)
context.plan_service.actor_service.ensure_default_mock_actor(force=True)
# Also create a project service to initialize a test project
from cleveragents.application.services.project_service import ProjectService
project_service = ProjectService(settings, unit_of_work)
# Initialize a test project
context.test_project = project_service.initialize_project(
name="test-project", path=Path(context.test_dir), force=True
)
# Don't change back - stay in test directory for operations
def cleanup():
if hasattr(context, "original_cwd"):
os.chdir(context.original_cwd)
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
@given('I have created a plan with "{prompt}"')
def step_given_created_plan_with_prompt(context: Context, prompt: str) -> None:
"""Create a plan with given prompt as a Given step."""
# Check if we're in a service test context by checking for plan_service
if hasattr(context, "plan_service"):
# Use service for service tests
# We're already in the test directory from step_have_plan_service
context.plan = context.plan_service.create_plan(
project=context.test_project, prompt=prompt
)
elif hasattr(context, "test_dir"):
# Use in-process CLI for core_cli_commands tests (avoids subprocess overhead)
import os
from cleveragents.cli.commands.plan import tell as plan_tell
prev_cwd = os.getcwd()
os.chdir(context.test_dir)
try:
plan_tell(prompt=prompt)
context.has_plan = True
except Exception as exc:
raise AssertionError(f"Failed to create plan: {exc}") from exc
finally:
os.chdir(prev_cwd)
else:
# No context set up - need to create plan service
step_have_plan_service(context)
context.plan = context.plan_service.create_plan(
project=context.test_project, prompt=prompt
)
@when('I create a plan with prompt "{prompt}"')
def step_create_plan_with_prompt(context: Context, prompt: str) -> None:
"""Create a plan with given prompt."""
context.plan = context.plan_service.create_plan(
project=context.test_project, prompt=prompt
)
@then("the plan should be created")
def step_plan_should_be_created(context: Context) -> None:
"""Verify plan was created."""
assert context.plan is not None, "Plan was not created"
@then('the plan prompt should be "{prompt}"')
def step_plan_prompt_should_be(context: Context, prompt: str) -> None:
"""Verify plan prompt."""
assert context.plan.prompt == prompt, (
f"Expected prompt '{prompt}', got '{context.plan.prompt}'"
)
@when("I build the plan")
def step_build_plan(context: Context) -> None:
"""Build the current plan."""
# We're already in the test directory from plan service creation
context.changes = context.plan_service.build_plan(project=context.test_project)
@when("I try to build the plan")
def step_try_build_plan(context: Context) -> None:
"""Attempt to build the plan while capturing exceptions."""
try:
context.plan_service.build_plan(project=context.test_project)
context.exception = None
except Exception as exc: # pragma: no cover - exercised via Behave tests
context.exception = exc
@given("I have created and built a plan")
def step_given_created_and_built_plan(context: Context) -> None:
"""Create and build a plan for testing."""
# Check if we're in a service test context by checking for plan_service
if hasattr(context, "plan_service"):
# Use service for service tests
# Create a plan
context.plan = context.plan_service.create_plan(
project=context.test_project, prompt="Test plan for apply"
)
# Build it to generate changes
context.changes = context.plan_service.build_plan(project=context.test_project)
elif hasattr(context, "test_dir"):
# Use in-process CLI for core_cli_commands tests (avoids subprocess overhead)
import os
from cleveragents.cli.commands.plan import build as plan_build
from cleveragents.cli.commands.plan import tell as plan_tell
prev_cwd = os.getcwd()
os.chdir(context.test_dir)
try:
plan_tell(prompt="Add example code")
plan_build()
except Exception as exc:
err_msg = str(exc)
if "No AI provider configured" in err_msg:
context.scenario.skip(
"Skipping test - no AI provider configured (expected in test env)"
)
return
raise AssertionError(f"Failed to create/build plan: {exc}") from exc
finally:
os.chdir(prev_cwd)
else:
# No context set up - need to create plan service
step_have_plan_service(context)
context.plan = context.plan_service.create_plan(
project=context.test_project, prompt="Test plan for apply"
)
context.changes = context.plan_service.build_plan(project=context.test_project)
@then("changes should be generated")
def step_changes_should_be_generated(context: Context) -> None:
"""Verify changes were generated."""
assert len(context.changes) > 0, "No changes were generated"
@when("I apply the changes")
def step_apply_changes(context: Context) -> None:
"""Apply the changes."""
context.applied_count = context.plan_service.apply_changes(
project=context.test_project
)
@then("the changes should be applied")
def step_changes_should_be_applied(context: Context) -> None:
"""Verify changes were applied."""
assert context.applied_count > 0, "No changes were applied"
@then("files should be created or modified")
def step_files_should_be_created(context: Context) -> None:
"""Verify files were created or modified."""
from pathlib import Path
# Check that at least one file from the changes exists
for change in context.changes:
file_path = (
Path(change.file_path)
if isinstance(change.file_path, str)
else change.file_path
)
if file_path.exists():
return # At least one file was created
# For test purposes, if no actual files were created, check that we have changes
assert len(context.changes) > 0, "No changes were generated"
@given('I have a test file "{filename}"')
def step_have_test_file(context: Context, filename: str) -> None:
"""Create a test file."""
if not hasattr(context, "test_dir"):
context.test_dir = tempfile.mkdtemp(prefix="test_context_")
def cleanup():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
test_file = Path(context.test_dir) / filename
test_file.write_text("test content")
@then("the context should contain {count:d} file")
@then("the context should contain {count:d} files")
def step_context_should_contain_count(context: Context, count: int) -> None:
"""Verify context contains specific number of files."""
files = context.context_service.list_context(project=context.test_project)
assert len(files) == count, f"Expected {count} files, got {len(files)}"
@given("I have a directory with multiple files")
def step_have_directory_with_files(context: Context) -> None:
"""Create a directory with multiple test files."""
if not hasattr(context, "test_dir"):
context.test_dir = tempfile.mkdtemp(prefix="test_context_")
def cleanup():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
# Create a subdirectory with files
subdir = Path(context.test_dir) / "subdir"
subdir.mkdir()
(subdir / "file1.py").write_text("content1")
(subdir / "file2.py").write_text("content2")
context.test_subdir = subdir
@given("I have a directory with nested ignored files")
def step_have_directory_with_nested_ignored(context: Context) -> None:
"""Create a directory containing nested files and ignored patterns."""
if not hasattr(context, "test_dir"):
context.test_dir = tempfile.mkdtemp(prefix="test_context_")
def cleanup():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
base_dir = Path(context.test_dir) / "nested_ignored"
base_dir.mkdir(exist_ok=True)
(base_dir / "root.py").write_text("root")
(base_dir / "ignored.pyc").write_text("cache")
nested_dir = base_dir / "child"
nested_dir.mkdir(exist_ok=True)
(nested_dir / "nested.py").write_text("nested")
git_dir = base_dir / ".git"
git_dir.mkdir(exist_ok=True)
(git_dir / "config").write_text("config")
context.dir_under_test = base_dir
@given("I have a directory with ignored context files")
def step_have_directory_with_ignored_context_files(context: Context) -> None:
"""Create a directory with files that should be ignored."""
if not hasattr(context, "test_dir"):
context.test_dir = tempfile.mkdtemp(prefix="test_context_")
def cleanup():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
target_dir = Path(context.test_dir) / "ignored_context_files"
target_dir.mkdir(exist_ok=True)
(target_dir / "keep.txt").write_text("keep")
(target_dir / "hidden.txt").write_text("hidden")
(target_dir / "ignored.pyc").write_text("ignored cache")
context.context_service.extra_ignore_patterns = ["hidden.txt"]
context.dir_under_test = target_dir
@when("I add the directory to context recursively")
def step_add_directory_recursively(context: Context) -> None:
"""Add a directory to context recursively."""
target_dir = getattr(context, "dir_under_test", None)
if target_dir is None:
target_dir = context.test_subdir
added_files, already_in_context = context.context_service.add_to_context(
project=context.test_project, path=target_dir, recursive=True
)
context.added_files = added_files
context.already_in_context = already_in_context
context.add_result = (added_files, already_in_context)
@then("all files should be added to context")
def step_all_files_should_be_added(context: Context) -> None:
"""Verify all files from directory were added."""
assert len(context.added_files) == 2, (
f"Expected 2 files, got {len(context.added_files)}"
)
@given('I have added "{filename}" to the context')
def step_have_added_file_to_context(context: Context, filename: str) -> None:
"""Add a file to context as a given."""
# Create the file if it doesn't exist
if not hasattr(context, "test_dir"):
context.test_dir = tempfile.mkdtemp(prefix="test_context_")
def cleanup():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
test_file = Path(context.test_dir) / filename
test_file.write_text("test content")
context.context_service.add_to_context(project=context.test_project, path=test_file)
@when('I remove "{filename}" from context')
def step_remove_file_from_context(context: Context, filename: str) -> None:
"""Remove a file from context."""
test_file = Path(context.test_dir) / filename
context.removed_count = context.context_service.remove_from_context(
project=context.test_project, path=test_file
)
@when('I remove directory "{name}" from context')
def step_remove_directory_from_context(context: Context, name: str) -> None:
"""Remove a directory from context."""
directory_path = Path(context.test_dir) / name
context.removed_count = context.context_service.remove_from_context(
project=context.test_project, path=directory_path
)
@then("the service context should be empty")
def step_context_should_be_empty(context: Context) -> None:
"""Verify context is empty."""
files = context.context_service.list_context(project=context.test_project)
assert len(files) == 0, f"Expected empty context, got {len(files)} files"
@given("I have added multiple files to context")
def step_have_added_multiple_files(context: Context) -> None:
"""Add multiple files to context."""
if not hasattr(context, "test_dir"):
context.test_dir = tempfile.mkdtemp(prefix="test_context_")
def cleanup():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
# Add multiple files
for i in range(3):
test_file = Path(context.test_dir) / f"file{i}.py"
test_file.write_text(f"content {i}")
context.context_service.add_to_context(
project=context.test_project, path=test_file
)
@when("I clear the context")
def step_clear_context(context: Context) -> None:
"""Clear the context."""
context.context_service.clear_context(project=context.test_project)
@when("I list the context")
def step_list_context(context: Context) -> None:
"""List context files."""
context.context_files = context.context_service.list_context(
project=context.test_project
)
@given("the container lookup for projects fails")
def step_container_lookup_for_projects_fails(context: Context) -> None:
"""Override container project service to raise during lookup."""
from cleveragents.application.container import get_container, override_providers
class FailingProjectService:
def get_current_project(self):
raise RuntimeError("Simulated project lookup failure")
failing_service = FailingProjectService()
override_providers(project_service=failing_service)
container = get_container()
def cleanup():
container.project_service.reset_override()
add_cleanup(context, cleanup)
context.project_service_override = failing_service
context.project_service_override_cleanup = cleanup
@given("the container project service returns the current test project")
def step_container_project_service_returns_test_project(context: Context) -> None:
"""Override container project service to return the test project."""
assert hasattr(context, "test_project"), "Test project not initialized"
class CurrentProjectService:
def __init__(self, behave_context: Context) -> None:
self._context = behave_context
def get_current_project(self):
return getattr(self._context, "test_project", None)
override = CurrentProjectService(context)
override_providers(project_service=override)
container = get_container()
def cleanup():
if hasattr(container, "project_service"):
container.project_service.reset_override()
add_cleanup(context, cleanup)
context.project_service_override = override
context.project_service_override_cleanup = cleanup
@when("I list context files without providing a project")
def step_list_context_files_without_project(context: Context) -> None:
"""List context files relying on container-backed project lookup."""
context.listed_files = context.context_service.list_files(project=None)
@then("I should receive 1 context file path")
def step_should_receive_single_context_file_path(context: Context) -> None:
"""Verify a single context file path is returned."""
assert hasattr(context, "listed_files"), "No context file listing captured"
assert len(context.listed_files) == 1, (
f"Expected 1 context file, got {len(context.listed_files)}"
)
@then('the list of file paths should include "listed.py"')
def step_listed_paths_include_listed_py(context: Context) -> None:
"""Ensure the listed files include the expected filename."""
assert hasattr(context, "listed_files"), "No context file listing captured"
file_names = {Path(path).name for path in context.listed_files}
assert "listed.py" in file_names, f'"listed.py" not found in {file_names}'
@then("I should receive 0 context file paths")
def step_should_receive_zero_context_file_paths(context: Context) -> None:
"""Verify no context file paths are returned."""
assert hasattr(context, "listed_files"), "No context file listing captured"
assert len(context.listed_files) == 0, (
f"Expected 0 context files, got {len(context.listed_files)}"
)
@then("I should see {count:d} files in the list")
def step_should_see_files_in_list(context: Context, count: int) -> None:
"""Verify number of files in list."""
assert len(context.context_files) == count, (
f"Expected {count} files, got {len(context.context_files)}"
)
@then('the list should contain "{filename}"')
def step_list_should_contain_file(context: Context, filename: str) -> None:
"""Verify a specific file is in the list."""
# Handle both dict and Context object types
if context.context_files and hasattr(context.context_files[0], "path"):
# Context objects
file_paths = [Path(str(f.path)).name for f in context.context_files]
else:
# Dictionaries (legacy)
file_paths = [Path(f["path"]).name for f in context.context_files]
assert filename in file_paths, f"File {filename} not in list: {file_paths}"
@then('the list should not contain "hidden.txt"')
def step_list_should_not_contain_hidden(context: Context) -> None:
"""Ensure hidden files are excluded from the collected list."""
file_names = set()
if getattr(context, "context_files", None):
files = context.context_files
if hasattr(files[0], "path"):
file_names = {Path(str(f.path)).name for f in files}
else:
file_names = {Path(f["path"]).name for f in files}
elif getattr(context, "listed_files", None):
file_names = {Path(path).name for path in context.listed_files}
assert "hidden.txt" not in file_names, (
f'"hidden.txt" unexpectedly found in {file_names}'
)
@then('the list should not contain "ignored.pyc"')
def step_list_should_not_contain_ignored_pyc(context: Context) -> None:
"""Ensure ignored cache files are excluded from the collected list."""
file_names = set()
if getattr(context, "context_files", None):
files = context.context_files
if hasattr(files[0], "path"):
file_names = {Path(str(f.path)).name for f in files}
else:
file_names = {Path(f["path"]).name for f in files}
elif getattr(context, "listed_files", None):
file_names = {Path(path).name for path in context.listed_files}
assert "ignored.pyc" not in file_names, (
f'"ignored.pyc" unexpectedly found in {file_names}'
)
@when('I create a plan named "{name}"')
def step_create_plan_named(context: Context, name: str) -> None:
"""Create a plan with a specific name."""
context.plan = context.plan_service.create_plan(
project=context.test_project, name=name
)
@when('I create an empty plan named "{name}"')
def step_create_empty_plan_named(context: Context, name: str) -> None:
"""Create an empty plan with a specific name."""
context.plan = context.plan_service.new_plan(
project=context.test_project, name=name
)
@then('the plan name should be "{name}"')
def step_plan_name_should_be(context: Context, name: str) -> None:
"""Verify plan has specific name."""
assert context.plan.name == name, (
f"Expected name '{name}', got '{context.plan.name}'"
)
@then("the plan prompt should be empty")
def step_plan_prompt_should_be_empty(context: Context) -> None:
"""Verify plan prompt is empty or a placeholder."""
# Accept either empty or the placeholder prompt used for new empty plans
assert (
not context.plan.prompt
or context.plan.prompt == ""
or context.plan.prompt == "New plan"
), f"Expected empty or placeholder prompt, got '{context.plan.prompt}'"
@then("the plan should be the current plan")
def step_plan_should_be_current(context: Context) -> None:
"""Verify the plan is the current plan."""
assert context.plan.current is True, "Plan is not marked as current"
current = context.plan_service.get_current_plan(project=context.test_project)
assert current is not None, "No current plan found"
assert current.id == context.plan.id, (
f"Plan ID mismatch: expected {context.plan.id}, got {current.id}"
)
assert current.name == context.plan.name, (
f"Plan name mismatch: expected {context.plan.name}, got {current.name}"
)
@then("the changes should include file operations")
def step_changes_should_include_file_ops(context: Context) -> None:
"""Verify changes include file operations."""
assert any(
change.operation in ["create", "modify", "delete"] for change in context.changes
)
@then('the plan should have name "{name}"')
def step_plan_should_have_name(context: Context, name: str) -> None:
"""Verify plan has specific name."""
assert context.plan.name == name, (
f"Expected name '{name}', got '{context.plan.name}'"
)
@given("I have created multiple plans")
def step_given_created_multiple_plans(context: Context) -> None:
"""Create multiple plans."""
if not hasattr(context, "plan_service"):
step_have_plan_service(context)
context.plans = []
for i in range(3):
plan = context.plan_service.create_plan(
project=context.test_project, name=f"plan-{i}", prompt=f"Test plan {i}"
)
context.plans.append(plan)
@when("I list all plans")
def step_list_all_plans(context: Context) -> None:
"""List all plans."""
context.all_plans = context.plan_service.list_plans(project=context.test_project)
@then("I should see all created plans")
def step_should_see_all_plans(context: Context) -> None:
"""Verify all plans are listed."""
assert len(context.all_plans) >= len(context.plans), (
f"Expected at least {len(context.plans)} plans, got {len(context.all_plans)}"
)
@then("each plan should have a name and status")
def step_each_plan_should_have_name_status(context: Context) -> None:
"""Verify each plan has name and status."""
for plan in context.all_plans:
assert plan.name is not None, "Plan missing name"
assert plan.status is not None, "Plan missing status"
@given('I have created plan "{name}"')
def step_given_created_plan_named(context: Context, name: str) -> None:
"""Create a plan with specific name."""
if not hasattr(context, "plan_service"):
step_have_plan_service(context)
context.plan_service.create_plan(
project=context.test_project, name=name, prompt=f"Test {name}"
)
@when('I switch to plan "{name}"')
def step_switch_to_plan(context: Context, name: str) -> None:
"""Switch to a specific plan."""
context.plan_service.switch_to_plan(project=context.test_project, name=name)
@then('"{name}" should be the current service plan')
def step_should_be_current_plan(context: Context, name: str) -> None:
"""Verify specific plan is current."""
current = context.plan_service.get_current_plan(project=context.test_project)
assert current is not None, "No current plan"
assert current.name == name, f"Expected current plan '{name}', got '{current.name}'"
@then('"{name}" should not be current')
def step_should_not_be_current(context: Context, name: str) -> None:
"""Verify specific plan is not current."""
current = context.plan_service.get_current_plan(project=context.test_project)
if current is not None:
assert current.name != name, f"Plan '{name}' should not be current"
@when('I add "{text}" to the plan')
def step_add_text_to_plan(context: Context, text: str) -> None:
"""Add text to the current plan."""
# We're already in the test directory from plan service creation
context.plan_service.continue_plan(project=context.test_project, prompt=text)
@then("the plan should contain both instructions")
def step_plan_should_contain_both(context: Context) -> None:
"""Verify plan contains both initial and additional instructions."""
current = context.plan_service.get_current_plan(project=context.test_project)
assert "Initial instructions" in current.prompt
assert "Additional instructions" in current.prompt
@given("I am in a project directory")
def step_in_project_directory(context: Context) -> None:
"""Set up being in a project directory."""
if not hasattr(context, "project_service"):
step_have_project_service(context)
# Initialize a project in the test directory
context.project = context.project_service.initialize_project(
name="test-project", path=Path(context.test_dir), force=False
)
@when("I get the current project")
def step_get_current_project(context: Context) -> None:
"""Get the current project."""
import os
# Check if we need to change directory or if we're already in the project directory
if hasattr(context, "test_dir") and context.test_dir:
# Change to the project directory temporarily
old_cwd = os.getcwd()
try:
os.chdir(context.test_dir)
context.current_project = context.project_service.get_current_project()
finally:
os.chdir(old_cwd)
else:
# We're already in the correct directory (e.g., from "And I change to that project directory")
context.current_project = context.project_service.get_current_project()
@then("I should receive the project information")
def step_should_receive_project_info(context: Context) -> None:
"""Verify we got project information."""
assert context.current_project is not None, "No project information received"
@then("the project should have a name")
def step_project_should_have_name(context: Context) -> None:
"""Verify project has a name."""
assert context.current_project.name is not None, "Project has no name"
@then("the project should have a path")
def step_project_should_have_path(context: Context) -> None:
"""Verify project has a path."""
assert context.current_project.path is not None, "Project has no path"
@given("I have a current project")
def step_have_current_project(context: Context) -> None:
"""Ensure we have a current project."""
if not hasattr(context, "project_service"):
settings = Settings()
context.project_service = ProjectService(settings, "sqlite:///test.db")
if not hasattr(context, "test_dir"):
context.test_dir = tempfile.mkdtemp(prefix="test_project_")
def cleanup():
if hasattr(context, "test_dir") and Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
add_cleanup(context, cleanup)
# Initialize a project
context.project = context.project_service.initialize_project(
name="test-project", path=Path(context.test_dir), force=False
)
@when("I get project statistics")
def step_get_project_statistics(context: Context) -> None:
"""Get project statistics."""
context.statistics = context.project_service.get_project_stats(context.project)
@then("I should receive statistics")
def step_should_receive_statistics(context: Context) -> None:
"""Verify we got statistics."""
assert context.statistics is not None, "No statistics received"
@then('the statistics should include "{key}"')
def step_statistics_should_include(context: Context, key: str) -> None:
"""Verify statistics include a specific key."""
assert key in context.statistics, (
f"Statistics missing key '{key}': {context.statistics}"
)