forked from HAL9000/cleveragents-core
48cff5cfe0
Renames `plan lifecycle-list` to `plan list` and `plan lifecycle-apply` to `plan apply` to align with the specification's canonical command names. Removes legacy V2 plan commands that occupied those names. - Renamed CLI command registrations from lifecycle-list/lifecycle-apply to list/apply - Removed legacy V2 apply and list commands (~200 lines) - Updated apply shortcut in main.py to delegate to v3 lifecycle - Added defensive null check for plan existence in apply command - Updated 63+ test, doc, and benchmark files for consistency Closes #881 Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me> Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
511 lines
16 KiB
Python
511 lines
16 KiB
Python
"""Step definitions for plan_cli_coverage_boost.feature.
|
|
|
|
Covers uncovered branches in cleveragents/cli/commands/plan.py:
|
|
- _plan_spec_dict: error_message truthy branch, legacy fallback
|
|
- _print_lifecycle_plan: all optional timestamps, estimation_actor,
|
|
invariant_actor, non-Plan fallback
|
|
- list_plans: non-rich format path
|
|
- execute_plan: non-rich format path
|
|
- apply_plan: non-rich format path
|
|
- list_plans: regex, state, processing_state filtering
|
|
- cancel_plan: rich and non-rich paths, with and without reason
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from io import StringIO
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.cli.commands import plan as plan_module
|
|
from cleveragents.cli.commands.plan import (
|
|
_plan_spec_dict,
|
|
_print_lifecycle_plan,
|
|
)
|
|
from cleveragents.cli.commands.plan import (
|
|
app as plan_app,
|
|
)
|
|
from cleveragents.domain.models.core.plan import (
|
|
NamespacedName,
|
|
Plan,
|
|
PlanIdentity,
|
|
PlanPhase,
|
|
PlanTimestamps,
|
|
ProcessingState,
|
|
ProjectLink,
|
|
)
|
|
|
|
# Valid ULIDs for test plans
|
|
_ULIDS = [
|
|
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
"01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
|
"01ARZ3NDEKTSV4RRFFQ69G5FAX",
|
|
"01ARZ3NDEKTSV4RRFFQ69G5FAY",
|
|
"01ARZ3NDEKTSV4RRFFQ69G5FAZ",
|
|
"01ARZ3NDEKTSV4RRFFQ69G5FB0",
|
|
]
|
|
|
|
|
|
def _make_plan(
|
|
*,
|
|
plan_id: str = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
name: str = "local/test-plan",
|
|
description: str = "Test plan for coverage",
|
|
phase: PlanPhase = PlanPhase.STRATEGIZE,
|
|
processing_state: ProcessingState = ProcessingState.QUEUED,
|
|
error_message: str | None = None,
|
|
estimation_actor: str | None = None,
|
|
invariant_actor: str | None = None,
|
|
timestamps: PlanTimestamps | None = None,
|
|
project_links: list[ProjectLink] | None = None,
|
|
action_name: str = "local/test-action",
|
|
) -> Plan:
|
|
"""Build a real Plan object for testing."""
|
|
if timestamps is None:
|
|
timestamps = PlanTimestamps(
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
return Plan(
|
|
identity=PlanIdentity(plan_id=plan_id),
|
|
namespaced_name=NamespacedName.parse(name),
|
|
action_name=action_name,
|
|
description=description,
|
|
phase=phase,
|
|
processing_state=processing_state,
|
|
project_links=project_links or [],
|
|
error_message=error_message,
|
|
estimation_actor=estimation_actor,
|
|
invariant_actor=invariant_actor,
|
|
timestamps=timestamps,
|
|
reusable=True,
|
|
read_only=False,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# _plan_spec_dict helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@given('a v3 Plan with error_message set to "{error_msg}"')
|
|
def step_plan_with_error_message(context, error_msg: str) -> None:
|
|
context.test_plan = _make_plan(error_message=error_msg)
|
|
|
|
|
|
@given("a v3 Plan with error_message set to None")
|
|
def step_plan_with_no_error_message(context) -> None:
|
|
context.test_plan = _make_plan(error_message=None)
|
|
|
|
|
|
@given('a non-Plan object with string value "{value}"')
|
|
def step_non_plan_object(context, value: str) -> None:
|
|
context.test_plan = value
|
|
|
|
|
|
@when("I call _plan_spec_dict on the plan")
|
|
def step_call_plan_spec_dict_on_plan(context) -> None:
|
|
context.spec_dict = _plan_spec_dict(context.test_plan)
|
|
|
|
|
|
@when("I call _plan_spec_dict on the object")
|
|
def step_call_plan_spec_dict_on_object(context) -> None:
|
|
context.spec_dict = _plan_spec_dict(context.test_plan)
|
|
|
|
|
|
@then('the spec dict should contain key "{key}" with value "{value}"')
|
|
def step_spec_dict_contains_key_value(context, key: str, value: str) -> None:
|
|
assert key in context.spec_dict, (
|
|
f"Key '{key}' not in spec dict: {context.spec_dict}"
|
|
)
|
|
assert str(context.spec_dict[key]) == value, (
|
|
f"Expected '{value}', got '{context.spec_dict[key]}'"
|
|
)
|
|
|
|
|
|
@then("the spec dict should equal {expected}")
|
|
def step_spec_dict_equals(context, expected: str) -> None:
|
|
import json
|
|
|
|
expected_dict = json.loads(expected.replace("'", '"'))
|
|
assert context.spec_dict == expected_dict, (
|
|
f"Expected {expected_dict}, got {context.spec_dict}"
|
|
)
|
|
|
|
|
|
@then('the spec dict should not contain key "{key}"')
|
|
def step_spec_dict_not_contains_key(context, key: str) -> None:
|
|
assert key not in context.spec_dict, (
|
|
f"Key '{key}' should not be in spec dict: {context.spec_dict}"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# _print_lifecycle_plan helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@given("a v3 Plan with all timestamps populated")
|
|
def step_plan_with_all_timestamps(context) -> None:
|
|
now = datetime.now()
|
|
ts = PlanTimestamps(
|
|
created_at=now - timedelta(hours=2),
|
|
updated_at=now,
|
|
strategize_started_at=now - timedelta(hours=1, minutes=50),
|
|
strategize_completed_at=now - timedelta(hours=1, minutes=40),
|
|
execute_started_at=now - timedelta(hours=1, minutes=30),
|
|
execute_completed_at=now - timedelta(hours=1),
|
|
applied_at=now - timedelta(minutes=30),
|
|
)
|
|
context.test_plan = _make_plan(
|
|
timestamps=ts,
|
|
phase=PlanPhase.APPLY,
|
|
processing_state=ProcessingState.APPLIED,
|
|
)
|
|
|
|
|
|
@given('a v3 Plan with estimation_actor set to "{actor}"')
|
|
def step_plan_with_estimation_actor(context, actor: str) -> None:
|
|
context.test_plan = _make_plan(estimation_actor=actor)
|
|
|
|
|
|
@given('a v3 Plan with invariant_actor set to "{actor}"')
|
|
def step_plan_with_invariant_actor(context, actor: str) -> None:
|
|
context.test_plan = _make_plan(invariant_actor=actor)
|
|
|
|
|
|
@when("I call _print_lifecycle_plan on the plan")
|
|
def step_call_print_lifecycle_plan_on_plan(context) -> None:
|
|
buf = StringIO()
|
|
from rich.console import Console
|
|
|
|
test_console = Console(file=buf, width=200, no_color=True)
|
|
with patch.object(plan_module, "console", test_console):
|
|
_print_lifecycle_plan(context.test_plan, title="Test Plan")
|
|
context.printed_output = buf.getvalue()
|
|
|
|
|
|
@when("I call _print_lifecycle_plan on the object")
|
|
def step_call_print_lifecycle_plan_on_object(context) -> None:
|
|
buf = StringIO()
|
|
from rich.console import Console
|
|
|
|
test_console = Console(file=buf, width=200, no_color=True)
|
|
with patch.object(plan_module, "console", test_console):
|
|
_print_lifecycle_plan(context.test_plan, title="Legacy Plan")
|
|
context.printed_output = buf.getvalue()
|
|
|
|
|
|
@then('the printed output should contain "{text}"')
|
|
def step_printed_output_contains(context, text: str) -> None:
|
|
assert text in context.printed_output, (
|
|
f"Expected '{text}' in output:\n{context.printed_output}"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# CLI runner steps for list_plans, execute, apply, list,
|
|
# cancel in non-rich and rich formats
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _output(context) -> str:
|
|
return getattr(context.result, "output", "") if hasattr(context, "result") else ""
|
|
|
|
|
|
@given("a plan lifecycle CLI runner for coverage")
|
|
def step_cli_runner_for_coverage(context) -> None:
|
|
context.runner = CliRunner()
|
|
|
|
|
|
@given("a mocked lifecycle service for plan coverage commands")
|
|
def step_mocked_lifecycle_service_for_coverage(context) -> None:
|
|
context.mock_lifecycle_service = MagicMock()
|
|
patcher = patch(
|
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
|
return_value=context.mock_lifecycle_service,
|
|
)
|
|
patcher.start()
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
context._cleanup_handlers.append(patcher.stop)
|
|
|
|
# Widen the Rich console so table columns do not wrap during tests
|
|
console_patcher = patch.object(plan_module.console, "width", 200)
|
|
console_patcher.start()
|
|
context._cleanup_handlers.append(console_patcher.stop)
|
|
|
|
|
|
@given("the mocked plan service returns legacy plans")
|
|
def step_mocked_plan_service_returns_legacy_plans(context) -> None:
|
|
"""Set up the container mock so list_plans returns legacy Plan objects."""
|
|
mock_plan = MagicMock()
|
|
mock_plan.name = "test-plan"
|
|
mock_plan.status = "active"
|
|
mock_plan.created_at = datetime.now()
|
|
mock_plan.current = False
|
|
mock_plan.id = "plan-001"
|
|
|
|
mock_project = MagicMock()
|
|
mock_project.name = "test-project"
|
|
|
|
mock_plan_service = MagicMock()
|
|
mock_plan_service.list_plans.return_value = [mock_plan]
|
|
|
|
mock_project_service = MagicMock()
|
|
mock_project_service.get_current_project.return_value = mock_project
|
|
|
|
mock_container = MagicMock()
|
|
mock_container.plan_service.return_value = mock_plan_service
|
|
mock_container.project_service.return_value = mock_project_service
|
|
|
|
patcher = patch(
|
|
"cleveragents.application.container.get_container",
|
|
return_value=mock_container,
|
|
)
|
|
patcher.start()
|
|
context._cleanup_handlers.append(patcher.stop)
|
|
|
|
|
|
@given("the service has a complete strategize plan for execute")
|
|
def step_service_has_strategize_plan(context) -> None:
|
|
plan = _make_plan(
|
|
plan_id=_ULIDS[0],
|
|
name="local/exec-plan",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.COMPLETE,
|
|
)
|
|
context.mock_lifecycle_service.get_plan.return_value = plan
|
|
context.mock_lifecycle_service.execute_plan.return_value = plan
|
|
context._execute_plan_id = _ULIDS[0]
|
|
|
|
# Mock the plan executor so the execute phase runs with a mock
|
|
executor_patcher = patch(
|
|
"cleveragents.cli.commands.plan._get_plan_executor",
|
|
return_value=MagicMock(),
|
|
)
|
|
executor_patcher.start()
|
|
context._cleanup_handlers.append(executor_patcher.stop)
|
|
|
|
|
|
@given("the service has a complete execute plan for apply")
|
|
def step_service_has_execute_plan(context) -> None:
|
|
pre_plan = _make_plan(
|
|
plan_id=_ULIDS[1],
|
|
name="local/apply-plan",
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.COMPLETE,
|
|
)
|
|
apply_queued_plan = _make_plan(
|
|
plan_id=_ULIDS[1],
|
|
name="local/apply-plan",
|
|
phase=PlanPhase.APPLY,
|
|
processing_state=ProcessingState.QUEUED,
|
|
)
|
|
applied_plan = _make_plan(
|
|
plan_id=_ULIDS[1],
|
|
name="local/apply-plan",
|
|
phase=PlanPhase.APPLY,
|
|
processing_state=ProcessingState.APPLIED,
|
|
)
|
|
# apply_plan calls get_plan twice: first to check read_only
|
|
# and phase, then again after apply_plan to drive _complete_apply_if_queued.
|
|
context.mock_lifecycle_service.get_plan.side_effect = [
|
|
pre_plan,
|
|
apply_queued_plan,
|
|
]
|
|
context.mock_lifecycle_service.apply_plan.return_value = apply_queued_plan
|
|
context.mock_lifecycle_service._complete_apply_if_queued.return_value = applied_plan
|
|
context._apply_plan_id = _ULIDS[1]
|
|
|
|
|
|
@given("the service has multiple plans for lifecycle list")
|
|
def step_service_has_multiple_plans(context) -> None:
|
|
plans = [
|
|
_make_plan(
|
|
plan_id=_ULIDS[0],
|
|
name="local/alpha-plan",
|
|
action_name="local/test-action",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.QUEUED,
|
|
),
|
|
_make_plan(
|
|
plan_id=_ULIDS[1],
|
|
name="local/beta-plan",
|
|
action_name="local/other-action",
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.PROCESSING,
|
|
),
|
|
]
|
|
context.mock_lifecycle_service.list_plans.return_value = plans
|
|
|
|
|
|
@given("the service has plans in different processing states")
|
|
def step_service_has_plans_different_states(context) -> None:
|
|
plans = [
|
|
_make_plan(
|
|
plan_id=_ULIDS[0],
|
|
name="local/plan-processing",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.PROCESSING,
|
|
),
|
|
_make_plan(
|
|
plan_id=_ULIDS[1],
|
|
name="local/plan-complete",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.COMPLETE,
|
|
),
|
|
]
|
|
context.mock_lifecycle_service.list_plans.return_value = plans
|
|
|
|
|
|
@given("the service can cancel a plan")
|
|
def step_service_can_cancel(context) -> None:
|
|
plan = _make_plan(
|
|
plan_id=_ULIDS[2],
|
|
name="local/cancel-me",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.CANCELLED,
|
|
)
|
|
context.mock_lifecycle_service.cancel_plan.return_value = plan
|
|
context._cancel_plan_id = _ULIDS[2]
|
|
|
|
|
|
# ---- When steps for CLI invocations ----
|
|
|
|
|
|
@when('I invoke list_plans with "--format" "{fmt}"')
|
|
def step_invoke_list_plans_format(context, fmt: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["list", "--format", fmt],
|
|
)
|
|
|
|
|
|
@when('I invoke execute with "--format" "json" and plan id')
|
|
def step_invoke_execute_json(context) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["execute", context._execute_plan_id, "--format", "json"],
|
|
)
|
|
|
|
|
|
@when('I invoke apply with "--format" "json" and plan id')
|
|
def step_invoke_apply_json(context) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["apply", "--yes", context._apply_plan_id, "--format", "json"],
|
|
)
|
|
|
|
|
|
@when('I invoke list with regex "{pattern}"')
|
|
def step_invoke_list_regex(context, pattern: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["list", pattern],
|
|
)
|
|
|
|
|
|
@when('I invoke list with "--state" "{state}"')
|
|
def step_invoke_list_state(context, state: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["list", "--state", state],
|
|
)
|
|
|
|
|
|
@when('I invoke list with "--processing-state" "{state}"')
|
|
def step_invoke_list_processing_state(context, state: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["list", "--processing-state", state],
|
|
)
|
|
|
|
|
|
@when('I invoke list with "--action" "{action}"')
|
|
def step_invoke_list_action(context, action: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["list", "--action", action],
|
|
)
|
|
|
|
|
|
@when('I invoke list with "--format" "{fmt}"')
|
|
def step_invoke_list_format(context, fmt: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["list", "--format", fmt],
|
|
)
|
|
|
|
|
|
@when('I invoke cancel with "--format" "json" and no reason')
|
|
def step_invoke_cancel_json_no_reason(context) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["cancel", context._cancel_plan_id, "--format", "json"],
|
|
)
|
|
|
|
|
|
@when('I invoke cancel with "--format" "json" and reason "{reason}"')
|
|
def step_invoke_cancel_json_with_reason(context, reason: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
[
|
|
"cancel",
|
|
context._cancel_plan_id,
|
|
"--format",
|
|
"json",
|
|
"--reason",
|
|
reason,
|
|
],
|
|
)
|
|
|
|
|
|
@when('I invoke cancel in rich format with reason "{reason}"')
|
|
def step_invoke_cancel_rich_with_reason(context, reason: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["cancel", context._cancel_plan_id, "--reason", reason],
|
|
)
|
|
|
|
|
|
@when("I invoke cancel in rich format without reason")
|
|
def step_invoke_cancel_rich_no_reason(context) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["cancel", context._cancel_plan_id],
|
|
)
|
|
|
|
|
|
# ---- Then steps for CLI assertions ----
|
|
|
|
|
|
@then("the plan coverage command should succeed")
|
|
def step_plan_coverage_command_succeeds(context) -> None:
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected success but got exit code {context.result.exit_code}.\n"
|
|
f"Output: {_output(context)}"
|
|
)
|
|
|
|
|
|
@then("the plan coverage command should abort")
|
|
def step_plan_coverage_command_aborts(context) -> None:
|
|
assert context.result.exit_code != 0, (
|
|
f"Expected non-zero exit code but got 0.\nOutput: {_output(context)}"
|
|
)
|
|
|
|
|
|
@then('the plan coverage output should contain "{text}"')
|
|
def step_plan_coverage_output_contains(context, text: str) -> None:
|
|
output = _output(context)
|
|
assert text in output, f"Expected '{text}' in output:\n{output}"
|
|
|
|
|
|
@then('the plan coverage output should not contain "{text}"')
|
|
def step_plan_coverage_output_not_contains(context, text: str) -> None:
|
|
output = _output(context)
|
|
assert text not in output, f"Did not expect '{text}' in output:\n{output}"
|