ab911dbdc4
The format_output() function returned a string that callers passed to Rich console.print(), which wraps long lines at terminal width. This injected literal newline characters into JSON string values (e.g. in definition_of_done fields), producing invalid JSON that downstream parsers could not decode (JSONDecodeError: Invalid control character). For machine-readable formats (json, yaml, plain), format_output() now writes the rendered output directly to sys.stdout and returns an empty string. This preserves the exact serialization from json.dumps/ yaml.dump without Rich text processing artifacts. Refs: #746
571 lines
19 KiB
Python
571 lines
19 KiB
Python
"""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:
|
|
parsed = json.loads(context.artifacts_output)
|
|
assert "validation_summary" in parsed
|
|
assert parsed["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:
|
|
parsed = json.loads(context.artifacts_output)
|
|
assert "apply_summary" in parsed
|
|
assert parsed["apply_summary"]["files_changed"] == "7"
|
|
assert parsed["apply_summary"]["validations_run"] == "4"
|