"""Step definitions for plan diff and artifacts feature tests.""" from __future__ import annotations import json from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context # type: ignore[import-untyped] from cleveragents.application.services.plan_apply_service import ( PlanApplyService, ) from cleveragents.application.services.plan_lifecycle_service import ( PlanLifecycleService, ) from cleveragents.config.settings import Settings from cleveragents.core.exceptions import PlanError, ValidationError from cleveragents.domain.models.core.change import ( ChangeEntry, ChangeOperation, InMemoryChangeSetStore, SpecChangeSet, ) from cleveragents.domain.models.core.plan import ( PlanPhase, ProcessingState, ) def _create_lifecycle_service() -> PlanLifecycleService: """Create a lifecycle service for testing.""" settings = Settings() return PlanLifecycleService(settings=settings) def _create_test_action_and_plan( service: PlanLifecycleService, ) -> str: """Create a test action and plan, returning the plan_id.""" service.create_action( name="local/test-diff", description="Test diff action", definition_of_done="Tests pass", strategy_actor="local/planner", execution_actor="local/executor", ) plan = service.use_action(action_name="local/test-diff") return plan.identity.plan_id def _advance_plan_to_execute_complete( service: PlanLifecycleService, plan_id: str, ) -> None: """Advance a plan through strategize and execute to complete.""" service.start_strategize(plan_id) service.complete_strategize(plan_id) plan = service.get_plan(plan_id) if plan.phase == PlanPhase.STRATEGIZE: service.execute_plan(plan_id) plan = service.get_plan(plan_id) if plan.phase == PlanPhase.EXECUTE: service.start_execute(plan_id) service.complete_execute(plan_id) def _advance_plan_to_apply_processing( service: PlanLifecycleService, plan_id: str, ) -> None: """Advance a plan to apply/processing state.""" _advance_plan_to_execute_complete(service, plan_id) plan = service.get_plan(plan_id) if plan.phase == PlanPhase.EXECUTE: service.apply_plan(plan_id) plan = service.get_plan(plan_id) if plan.phase == PlanPhase.APPLY and plan.state == ProcessingState.QUEUED: service.start_apply(plan_id) def _create_test_changeset(plan_id: str) -> SpecChangeSet: """Create a test changeset with sample entries.""" return SpecChangeSet( changeset_id="test-changeset-001", plan_id=plan_id, entries=[ ChangeEntry( plan_id=plan_id, resource_id="res-001", tool_name="file-write", operation=ChangeOperation.CREATE, path="src/new_file.py", before_hash=None, after_hash="abc123def456", ), ChangeEntry( plan_id=plan_id, resource_id="res-002", tool_name="file-edit", operation=ChangeOperation.MODIFY, path="src/existing.py", before_hash="old111222333", after_hash="new444555666", ), ChangeEntry( plan_id=plan_id, resource_id="res-003", tool_name="file-delete", operation=ChangeOperation.DELETE, path="src/obsolete.py", before_hash="del789aaa", after_hash=None, ), ], ) # -- Background --- @given("I have a plan apply service with an in-memory changeset store") def step_have_apply_service(context: Context) -> None: context.lifecycle_service = _create_lifecycle_service() context.changeset_store = InMemoryChangeSetStore() context.apply_service = PlanApplyService( lifecycle_service=context.lifecycle_service, changeset_store=context.changeset_store, ) context.plan_id = None context.changeset = None context.diff_output = None context.artifacts_output = None context.error = None context.guard_result = None # -- Given steps --- @given("a plan with a changeset containing file changes") def step_plan_with_changeset(context: Context) -> None: plan_id = _create_test_action_and_plan(context.lifecycle_service) _advance_plan_to_execute_complete(context.lifecycle_service, plan_id) plan = context.lifecycle_service.get_plan(plan_id) changeset = _create_test_changeset(plan_id) # Store the changeset context.changeset_store._store[changeset.changeset_id] = changeset # Set changeset_id on plan plan.changeset_id = changeset.changeset_id plan.sandbox_refs = ["sandbox-ref-001", "sandbox-ref-002"] context.lifecycle_service.commit_plan(plan) context.plan_id = plan_id context.changeset = changeset @given("a plan with no changeset") def step_plan_with_no_changeset(context: Context) -> None: plan_id = _create_test_action_and_plan(context.lifecycle_service) context.plan_id = plan_id @given("a plan with an empty changeset") def step_plan_with_empty_changeset(context: Context) -> None: plan_id = _create_test_action_and_plan(context.lifecycle_service) _advance_plan_to_execute_complete(context.lifecycle_service, plan_id) plan = context.lifecycle_service.get_plan(plan_id) empty_cs = SpecChangeSet( changeset_id="empty-changeset-001", plan_id=plan_id, entries=[], ) context.changeset_store._store[empty_cs.changeset_id] = empty_cs plan.changeset_id = empty_cs.changeset_id context.lifecycle_service.commit_plan(plan) context.plan_id = plan_id @given("a plan with a changeset and validation summary") def step_plan_with_validation(context: Context) -> None: plan_id = _create_test_action_and_plan(context.lifecycle_service) _advance_plan_to_execute_complete(context.lifecycle_service, plan_id) plan = context.lifecycle_service.get_plan(plan_id) changeset = _create_test_changeset(plan_id) context.changeset_store._store[changeset.changeset_id] = changeset plan.changeset_id = changeset.changeset_id plan.validation_summary = { "total": 3, "passed": 2, "failed": 1, "skipped": 0, } context.lifecycle_service.commit_plan(plan) context.plan_id = plan_id @given("a plan in apply processing state") def step_plan_in_apply_processing(context: Context) -> None: plan_id = _create_test_action_and_plan(context.lifecycle_service) _advance_plan_to_apply_processing(context.lifecycle_service, plan_id) context.plan_id = plan_id # -- When steps --- @when("I request the diff in rich format") def step_request_diff_rich(context: Context) -> None: context.diff_output = context.apply_service.diff(context.plan_id, fmt="rich") @when("I request the diff in plain format") def step_request_diff_plain(context: Context) -> None: context.diff_output = _capture_service_output( context.apply_service.diff, context.plan_id, fmt="plain" ) @when("I request the diff in JSON format") def step_request_diff_json(context: Context) -> None: context.diff_output = _capture_service_output( context.apply_service.diff, context.plan_id, fmt="json" ) @when("I request the diff in YAML format") def step_request_diff_yaml(context: Context) -> None: context.diff_output = _capture_service_output( context.apply_service.diff, context.plan_id, fmt="yaml" ) @when("I try to get the diff") def step_try_get_diff(context: Context) -> None: try: context.diff_output = context.apply_service.diff(context.plan_id) context.error = None except PlanError as e: context.error = e @when("I request the artifacts") def step_request_artifacts(context: Context) -> None: context.artifacts_output = _capture_service_output( context.apply_service.artifacts, context.plan_id ) def _capture_service_output(func, *args, **kwargs): """Call a service method capturing stdout (format_output writes there).""" from contextlib import redirect_stdout from io import StringIO buf = StringIO() with redirect_stdout(buf): result = func(*args, **kwargs) return result or buf.getvalue().rstrip("\n") @when("I request the artifacts in JSON format") def step_request_artifacts_json(context: Context) -> None: context.artifacts_output = _capture_service_output( context.apply_service.artifacts, context.plan_id, fmt="json" ) @when("I request the artifacts in plain format") def step_request_artifacts_plain(context: Context) -> None: context.artifacts_output = _capture_service_output( context.apply_service.artifacts, context.plan_id, fmt="plain" ) @when("I request the artifacts in YAML format") def step_request_artifacts_yaml(context: Context) -> None: context.artifacts_output = _capture_service_output( context.apply_service.artifacts, context.plan_id, fmt="yaml" ) @when("I check the empty changeset guard") def step_check_guard(context: Context) -> None: try: context.guard_result = context.apply_service.guard_empty_changeset( context.plan_id ) context.error = None except PlanError as e: context.error = e @when("I check the empty changeset guard with allow_empty") def step_check_guard_allow(context: Context) -> None: context.guard_result = context.apply_service.guard_empty_changeset( context.plan_id, allow_empty=True ) @when( "I persist the apply summary with {files:d} files and {validations:d} validations" ) def step_persist_summary(context: Context, files: int, validations: int) -> None: context.plan = context.apply_service.persist_apply_summary( context.plan_id, files_changed=files, validations_run=validations, ) @when("a merge failure occurs with conflict details") def step_merge_failure(context: Context) -> None: context.plan = context.apply_service.handle_merge_failure( context.plan_id, conflict_details="Conflict in src/main.py lines 10-20: both sides modified", ) @when("I try to create PlanApplyService with None lifecycle") def step_create_with_none(context: Context) -> None: try: PlanApplyService(lifecycle_service=None) # type: ignore[arg-type] context.error = None except ValidationError as e: context.error = e # -- Then steps --- @then("the diff output should contain the changeset ID") def step_diff_contains_changeset_id(context: Context) -> None: assert context.diff_output is not None assert "test-changeset-001" in context.diff_output @then("the diff output should contain file paths") def step_diff_contains_paths(context: Context) -> None: assert "src/new_file.py" in context.diff_output assert "src/existing.py" in context.diff_output @then("the diff output should contain operation labels") def step_diff_contains_ops(context: Context) -> None: assert context.diff_output is not None # Rich format uses operation labels has_label = ( "new file" in context.diff_output or "modified" in context.diff_output or "deleted" in context.diff_output ) assert has_label, f"No operation label found in: {context.diff_output[:200]}" @then("the diff output should contain unified diff markers") def step_diff_contains_markers(context: Context) -> None: assert "---" in context.diff_output assert "+++" in context.diff_output assert "@@" in context.diff_output @then("the diff output should contain the plan ID") def step_diff_contains_plan_id(context: Context) -> None: assert context.plan_id is not None assert context.plan_id in context.diff_output @then("the diff output should be valid JSON") def step_diff_valid_json(context: Context) -> None: parsed = json.loads(context.diff_output) assert isinstance(parsed, dict) context.parsed_json = parsed @then("the JSON diff should contain entries list") def step_json_has_entries(context: Context) -> None: parsed = json.loads(context.diff_output) assert "entries" in parsed assert isinstance(parsed["entries"], list) assert len(parsed["entries"]) > 0 @then("the JSON diff should contain summary counts") def step_json_has_summary(context: Context) -> None: parsed = json.loads(context.diff_output) assert "summary" in parsed summary = parsed["summary"] assert "total" in summary assert "creates" in summary @then("the diff output should contain YAML keys") def step_diff_yaml_keys(context: Context) -> None: assert "changeset_id" in context.diff_output assert "plan_id" in context.diff_output @then("a plan error about missing changeset should be raised") def step_error_missing_changeset(context: Context) -> None: assert context.error is not None assert isinstance(context.error, PlanError) assert "ChangeSet" in context.error.message @then("the diff output should indicate no changes") def step_diff_no_changes(context: Context) -> None: assert context.diff_output is not None assert ( "No changes" in context.diff_output or "no changes" in context.diff_output.lower() ) @then("the artifacts should contain the plan ID") def step_artifacts_has_plan_id(context: Context) -> None: assert context.plan_id in context.artifacts_output @then("the artifacts should contain the changeset ID") def step_artifacts_has_changeset_id(context: Context) -> None: assert "test-changeset-001" in context.artifacts_output @then("the artifacts should contain sandbox refs") def step_artifacts_has_sandbox_refs(context: Context) -> None: assert "sandbox-ref-001" in context.artifacts_output @then("the artifacts should contain files changed list") def step_artifacts_has_files(context: Context) -> None: assert ( "src/new_file.py" in context.artifacts_output or "new_file" in context.artifacts_output ) @then("the artifacts JSON should contain validation summary") def step_artifacts_json_validation(context: Context) -> None: data = json.loads(context.artifacts_output) assert "validation_summary" in data assert data["validation_summary"]["total"] == 3 @then("the artifacts should show null changeset") def step_artifacts_null_changeset(context: Context) -> None: assert context.artifacts_output is not None # Should show null/None for changeset summary assert "null" in context.artifacts_output or "None" in context.artifacts_output @then("a plan error about empty changeset should be raised") def step_error_empty_changeset(context: Context) -> None: assert context.error is not None assert isinstance(context.error, PlanError) assert "empty" in context.error.message.lower() @then("the guard should return True") def step_guard_true(context: Context) -> None: assert context.guard_result is True @then("the plan metadata should contain apply_files_changed of {count:d}") def step_metadata_files(context: Context, count: int) -> None: plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.error_details is not None assert plan.error_details["apply_files_changed"] == str(count) @then("the plan metadata should contain apply_validations_run of {count:d}") def step_metadata_validations(context: Context, count: int) -> None: plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.error_details is not None assert plan.error_details["apply_validations_run"] == str(count) @then("the plan metadata should contain apply_completed_at timestamp") def step_metadata_timestamp(context: Context) -> None: plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.error_details is not None assert "apply_completed_at" in plan.error_details @then("the plan should be in an errored processing state") def step_plan_errored(context: Context) -> None: plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.processing_state == ProcessingState.ERRORED @then("the plan error message should contain merge conflict info") def step_error_merge_info(context: Context) -> None: plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.error_message is not None assert "Merge failed" in plan.error_message @then("the plan metadata should contain merge_conflict details") def step_metadata_merge_conflict(context: Context) -> None: plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.error_details is not None assert "merge_conflict" in plan.error_details @then("the plan metadata should contain sandbox_rollback pending") def step_metadata_rollback(context: Context) -> None: plan = context.lifecycle_service.get_plan(context.plan_id) assert plan.error_details is not None assert plan.error_details.get("sandbox_rollback") == "pending" @then("a validation error should be raised for lifecycle service") def step_validation_error(context: Context) -> None: assert context.error is not None assert isinstance(context.error, ValidationError) @then("the artifacts output should contain key-value pairs") def step_artifacts_kv(context: Context) -> None: assert context.artifacts_output is not None assert "plan_id" in context.artifacts_output @then("the artifacts output should contain YAML structure") def step_artifacts_yaml(context: Context) -> None: assert context.artifacts_output is not None assert "plan_id" in context.artifacts_output assert "phase" in context.artifacts_output # -- Additional Given steps for coverage --- @given("a plan with a changeset and apply summary metadata") def step_plan_with_apply_metadata(context: Context) -> None: """Create a plan with changeset and apply summary stored in error_details.""" plan_id = _create_test_action_and_plan(context.lifecycle_service) _advance_plan_to_execute_complete(context.lifecycle_service, plan_id) plan = context.lifecycle_service.get_plan(plan_id) changeset = _create_test_changeset(plan_id) context.changeset_store._store[changeset.changeset_id] = changeset plan.changeset_id = changeset.changeset_id plan.sandbox_refs = ["sandbox-ref-001"] # Simulate apply summary metadata (as persist_apply_summary would set) if plan.error_details is None: plan.error_details = {} plan.error_details["apply_files_changed"] = "7" plan.error_details["apply_validations_run"] = "4" context.lifecycle_service.commit_plan(plan) context.plan_id = plan_id @given("a plan with a changeset ID but store has no entry") def step_plan_with_changeset_id_but_no_store_entry(context: Context) -> None: """Create a plan whose changeset_id points to a non-existent store entry.""" plan_id = _create_test_action_and_plan(context.lifecycle_service) _advance_plan_to_execute_complete(context.lifecycle_service, plan_id) plan = context.lifecycle_service.get_plan(plan_id) # Set changeset_id but do NOT add anything to the store plan.changeset_id = "missing-changeset-999" context.lifecycle_service.commit_plan(plan) context.plan_id = plan_id # -- Additional Then steps for coverage --- @then("the artifacts JSON should contain apply summary") def step_artifacts_json_apply_summary(context: Context) -> None: data = json.loads(context.artifacts_output) assert "apply_summary" in data assert data["apply_summary"]["files_changed"] == "7" assert data["apply_summary"]["validations_run"] == "4"