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>
360 lines
12 KiB
Python
360 lines
12 KiB
Python
"""Step definitions for plan_cli_cancel_revert_coverage.feature.
|
|
|
|
Covers uncovered lines in cleveragents/cli/commands/plan.py:
|
|
- Line 1817: timestamps.created_at.strftime in list_plans rich table
|
|
- Lines 1821-1822: console.print(table) and CleverAgentsError catch
|
|
- Lines 1845-1855: cancel_plan command body
|
|
- Lines 1878-1888: revert_plan command body
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.cli.commands.plan import app as plan_app
|
|
from cleveragents.domain.models.core.plan import (
|
|
AutomationProfileProvenance,
|
|
AutomationProfileRef,
|
|
InvariantSource,
|
|
NamespacedName,
|
|
Plan,
|
|
PlanIdentity,
|
|
PlanInvariant,
|
|
PlanPhase,
|
|
PlanTimestamps,
|
|
ProcessingState,
|
|
ProjectLink,
|
|
)
|
|
|
|
# Valid ULIDs for test plans
|
|
_ULID_CANCEL = "01ARZ3NDEKTSV4RRFFQ69G5FB1"
|
|
_ULID_REVERT = "01ARZ3NDEKTSV4RRFFQ69G5FB2"
|
|
_ULID_LIST_1 = "01ARZ3NDEKTSV4RRFFQ69G5FB3"
|
|
_ULID_LIST_2 = "01ARZ3NDEKTSV4RRFFQ69G5FB4"
|
|
|
|
|
|
def _make_plan(
|
|
*,
|
|
plan_id: str = _ULID_CANCEL,
|
|
name: str = "local/test-plan",
|
|
description: str = "Test plan for cancel-revert coverage",
|
|
phase: PlanPhase = PlanPhase.STRATEGIZE,
|
|
processing_state: ProcessingState = ProcessingState.QUEUED,
|
|
project_links: list[ProjectLink] | None = None,
|
|
automation_profile: AutomationProfileRef | None = None,
|
|
invariants: list[PlanInvariant] | None = None,
|
|
timestamps: PlanTimestamps | None = None,
|
|
reversion_count: int = 0,
|
|
action_name: str = "local/test-action",
|
|
) -> Plan:
|
|
"""Build a real Plan object for testing."""
|
|
if timestamps is None:
|
|
timestamps = PlanTimestamps(
|
|
created_at=datetime(2025, 6, 15, 10, 30, 0),
|
|
updated_at=datetime(2025, 6, 15, 11, 0, 0),
|
|
)
|
|
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 [],
|
|
automation_profile=automation_profile,
|
|
invariants=invariants or [],
|
|
timestamps=timestamps,
|
|
reversion_count=reversion_count,
|
|
reusable=True,
|
|
read_only=False,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a CLI runner for cancel-revert coverage")
|
|
def step_cli_runner(context):
|
|
context.runner = CliRunner()
|
|
|
|
|
|
@given("a mocked lifecycle service for cancel-revert coverage")
|
|
def step_mocked_lifecycle_service(context):
|
|
context.mock_service = MagicMock()
|
|
patcher = patch(
|
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
|
return_value=context.mock_service,
|
|
)
|
|
patcher.start()
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
context._cleanup_handlers.append(patcher.stop)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps — list rich format
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the service returns plans with project links and timestamps for rich list")
|
|
def step_service_plans_with_links_and_timestamps(context):
|
|
plans = [
|
|
_make_plan(
|
|
plan_id=_ULID_LIST_1,
|
|
name="local/plan-with-links",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.QUEUED,
|
|
project_links=[
|
|
ProjectLink(project_name="proj-alpha"),
|
|
ProjectLink(project_name="proj-beta"),
|
|
],
|
|
timestamps=PlanTimestamps(
|
|
created_at=datetime(2025, 3, 10, 14, 25, 0),
|
|
updated_at=datetime(2025, 3, 10, 15, 0, 0),
|
|
),
|
|
),
|
|
]
|
|
context.mock_service.list_plans.return_value = plans
|
|
|
|
|
|
@given("the service returns plans with an automation profile for rich list")
|
|
def step_service_plans_with_profile(context):
|
|
plans = [
|
|
_make_plan(
|
|
plan_id=_ULID_LIST_1,
|
|
name="local/profiled-plan",
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.PROCESSING,
|
|
project_links=[ProjectLink(project_name="proj-one")],
|
|
automation_profile=AutomationProfileRef(
|
|
profile_name="trusted",
|
|
provenance=AutomationProfileProvenance.ACTION,
|
|
),
|
|
timestamps=PlanTimestamps(
|
|
created_at=datetime(2025, 4, 1, 9, 0, 0),
|
|
updated_at=datetime(2025, 4, 1, 10, 0, 0),
|
|
),
|
|
),
|
|
]
|
|
context.mock_service.list_plans.return_value = plans
|
|
|
|
|
|
@given("the service returns a plan with three project links for rich list")
|
|
def step_service_plan_three_links(context):
|
|
plans = [
|
|
_make_plan(
|
|
plan_id=_ULID_LIST_1,
|
|
name="local/multi-project",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.QUEUED,
|
|
project_links=[
|
|
ProjectLink(project_name="proj-a"),
|
|
ProjectLink(project_name="proj-b"),
|
|
ProjectLink(project_name="proj-c"),
|
|
],
|
|
timestamps=PlanTimestamps(
|
|
created_at=datetime(2025, 5, 20, 8, 15, 0),
|
|
updated_at=datetime(2025, 5, 20, 9, 0, 0),
|
|
),
|
|
),
|
|
]
|
|
context.mock_service.list_plans.return_value = plans
|
|
|
|
|
|
@given("the service returns plans with invariants for rich list")
|
|
def step_service_plans_with_invariants(context):
|
|
plans = [
|
|
_make_plan(
|
|
plan_id=_ULID_LIST_1,
|
|
name="local/invariant-plan",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.QUEUED,
|
|
project_links=[ProjectLink(project_name="proj-one")],
|
|
invariants=[
|
|
PlanInvariant(
|
|
text="No breaking changes", source=InvariantSource.ACTION
|
|
),
|
|
PlanInvariant(
|
|
text="Keep backward compat", source=InvariantSource.PROJECT
|
|
),
|
|
],
|
|
timestamps=PlanTimestamps(
|
|
created_at=datetime(2025, 6, 1, 12, 0, 0),
|
|
updated_at=datetime(2025, 6, 1, 13, 0, 0),
|
|
),
|
|
),
|
|
]
|
|
context.mock_service.list_plans.return_value = plans
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps — cancel_plan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the service can cancel a plan for cancel-revert coverage")
|
|
def step_service_can_cancel(context):
|
|
plan = _make_plan(
|
|
plan_id=_ULID_CANCEL,
|
|
name="local/cancel-target",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.CANCELLED,
|
|
project_links=[ProjectLink(project_name="proj-cancel")],
|
|
)
|
|
context.mock_service.cancel_plan.return_value = plan
|
|
context._cancel_plan_id = _ULID_CANCEL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps — revert_plan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the service can revert a plan for cancel-revert coverage")
|
|
def step_service_can_revert(context):
|
|
plan = _make_plan(
|
|
plan_id=_ULID_REVERT,
|
|
name="local/revert-target",
|
|
phase=PlanPhase.STRATEGIZE,
|
|
processing_state=ProcessingState.QUEUED,
|
|
project_links=[ProjectLink(project_name="proj-revert")],
|
|
reversion_count=1,
|
|
)
|
|
context.mock_service.revert_plan.return_value = plan
|
|
context._revert_plan_id = _ULID_REVERT
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I invoke list in rich format for cancel-revert coverage")
|
|
def step_invoke_list_rich(context):
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["list", "--format", "rich"],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — cancel_plan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I invoke cancel in rich format without reason for cancel-revert coverage")
|
|
def step_invoke_cancel_rich_no_reason(context):
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["cancel", context._cancel_plan_id],
|
|
)
|
|
|
|
|
|
@when(
|
|
'I invoke cancel in rich format with reason "{reason}" for cancel-revert coverage'
|
|
)
|
|
def step_invoke_cancel_rich_with_reason(context, reason):
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["cancel", context._cancel_plan_id, "--reason", reason],
|
|
)
|
|
|
|
|
|
@when('I invoke cancel with format "{fmt}" and no reason for cancel-revert coverage')
|
|
def step_invoke_cancel_fmt_no_reason(context, fmt):
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["cancel", context._cancel_plan_id, "--format", fmt],
|
|
)
|
|
|
|
|
|
@when(
|
|
'I invoke cancel with format "{fmt}" and reason "{reason}" for cancel-revert coverage'
|
|
)
|
|
def step_invoke_cancel_fmt_with_reason(context, fmt, reason):
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["cancel", context._cancel_plan_id, "--format", fmt, "--reason", reason],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps — revert_plan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I invoke revert in rich format with default phase for cancel-revert coverage")
|
|
def step_invoke_revert_rich_default(context):
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["revert", context._revert_plan_id],
|
|
)
|
|
|
|
|
|
@when(
|
|
'I invoke revert in rich format with reason "{reason}" for cancel-revert coverage'
|
|
)
|
|
def step_invoke_revert_rich_with_reason(context, reason):
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["revert", context._revert_plan_id, "--reason", reason],
|
|
)
|
|
|
|
|
|
@when('I invoke revert with format "{fmt}" for cancel-revert coverage')
|
|
def step_invoke_revert_fmt(context, fmt):
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["revert", context._revert_plan_id, "--format", fmt],
|
|
)
|
|
|
|
|
|
@when('I invoke revert with invalid phase "{phase}" for cancel-revert coverage')
|
|
def step_invoke_revert_invalid_phase(context, phase):
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["revert", context._revert_plan_id, "--to-phase", phase],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _output(context) -> str:
|
|
return getattr(context.result, "output", "") if hasattr(context, "result") else ""
|
|
|
|
|
|
@then("the cancel-revert command should succeed")
|
|
def step_command_succeeds(context):
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0 but got {context.result.exit_code}.\n"
|
|
f"Output: {_output(context)}"
|
|
)
|
|
|
|
|
|
@then("the cancel-revert command should abort")
|
|
def step_command_aborts(context):
|
|
assert context.result.exit_code != 0, (
|
|
f"Expected non-zero exit code but got 0.\nOutput: {_output(context)}"
|
|
)
|
|
|
|
|
|
@then('the cancel-revert output should contain "{text}"')
|
|
def step_output_contains(context, text):
|
|
output = _output(context)
|
|
assert text in output, f"Expected '{text}' in output:\n{output}"
|
|
|
|
|
|
@then('the cancel-revert output should not contain "{text}"')
|
|
def step_output_not_contains(context, text):
|
|
output = _output(context)
|
|
assert text not in output, f"Did not expect '{text}' in output:\n{output}"
|