Files
cleveragents-core/features/steps/plan_diff_artifacts_steps.py
T
Luis Mendes ab911dbdc4 fix(cli): write machine-readable formats directly to stdout bypassing Rich line-wrapping
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
2026-03-17 09:53:57 +00:00

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"