forked from HAL9000/cleveragents-core
5f07316641
Fixed 5 bugs preventing the M1 E2E acceptance test from passing: 1. _get_lifecycle_service() in action.py and plan.py bypassed the DI container, creating PlanLifecycleService without UnitOfWork. All plan/action data was in-memory only and lost between subprocess calls. Now uses container.plan_lifecycle_service() for DB persistence. 2. `plan execute` CLI only called service.execute_plan() (a pure state transition) without running PlanExecutor phase processing. Rewrote to detect the plan's current phase/state and dispatch synchronously: Strategize/queued → run_strategize(), Strategize/complete → transition + run_execute(), Execute/queued → run_execute(). 3. `plan apply` CLI had no plan_id argument. Added optional positional plan_id with _lifecycle_apply_with_id() that drives the plan through Apply/queued → Apply/processing → Apply/applied. 4. Preflight guardrail in start_strategize() built action_registry from the in-memory _actions dict only. Added get_action(plan.action_name) call to load the action from DB into cache before the guardrail check. 5. Robot Framework Create File syntax used continuation lines producing 9 arguments instead of 1. Fixed to use Catenate SEPARATOR=\n then pass single variable to Create File. Also fixed --branch main to --branch master (git init default). update mocks for execute_plan CLI changes across unit and integration tests The new execute_plan() command calls _get_plan_executor() and service.get_plan(plan_id) for phase/state detection. Existing tests only mocked _get_lifecycle_service, so MagicMock defaults caused phase/state comparisons to fail. Changes across 14 files: - Patch _get_plan_executor in all test setups that invoke the CLI execute command (Behave step files + Robot helper scripts) - Set service.get_plan.return_value to real Plan objects with correct phase/state so the execute_plan dispatch logic works - Fix error-path tests to use STRATEGIZE/COMPLETE plans so the error side_effects are actually reached - Fix "Multiple plans eligible" → "Multiple plans ready" message text to match existing test expectations increase Robot Framework subprocess timeouts for CI resource contention Three integration tests were timing out in CI due to resource contention when pabot runs multiple test suites in parallel. All three pass locally and the timeouts were simply too tight for constrained CI environments. - tdd_session_create_di.robot: 30s → 90s (DI container init + DB setup) - database_integration.robot: 60s → 120s (Run Python Script keyword) - m3_e2e_verification.robot: 60s → 120s (correction-live-revert spawns 3 sequential CLI subprocesses with full container initialization) ISSUES CLOSED: #789
492 lines
16 KiB
Python
492 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
|
|
- lifecycle_apply_plan: non-rich format path
|
|
- lifecycle_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, lifecycle-apply, lifecycle-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:
|
|
plan = _make_plan(
|
|
plan_id=_ULIDS[1],
|
|
name="local/apply-plan",
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.COMPLETE,
|
|
)
|
|
context.mock_lifecycle_service.apply_plan.return_value = 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 lifecycle-apply with "--format" "json" and plan id')
|
|
def step_invoke_lifecycle_apply_json(context) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["lifecycle-apply", context._apply_plan_id, "--format", "json"],
|
|
)
|
|
|
|
|
|
@when('I invoke lifecycle-list with regex "{pattern}"')
|
|
def step_invoke_lifecycle_list_regex(context, pattern: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["lifecycle-list", pattern],
|
|
)
|
|
|
|
|
|
@when('I invoke lifecycle-list with "--state" "{state}"')
|
|
def step_invoke_lifecycle_list_state(context, state: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["lifecycle-list", "--state", state],
|
|
)
|
|
|
|
|
|
@when('I invoke lifecycle-list with "--processing-state" "{state}"')
|
|
def step_invoke_lifecycle_list_processing_state(context, state: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["lifecycle-list", "--processing-state", state],
|
|
)
|
|
|
|
|
|
@when('I invoke lifecycle-list with "--action" "{action}"')
|
|
def step_invoke_lifecycle_list_action(context, action: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["lifecycle-list", "--action", action],
|
|
)
|
|
|
|
|
|
@when('I invoke lifecycle-list with "--format" "{fmt}"')
|
|
def step_invoke_lifecycle_list_format(context, fmt: str) -> None:
|
|
context.result = context.runner.invoke(
|
|
plan_app,
|
|
["lifecycle-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}"
|