Files
cleveragents-core/robot/helper_cli_lifecycle_e2e.py
freemo 5f07316641 fix: wire DI persistence and plan execute/apply for M1 lifecycle
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
2026-03-15 20:50:02 +00:00

350 lines
11 KiB
Python

"""Helper script for cli_lifecycle_e2e.robot end-to-end smoke tests.
Each subcommand is self-contained and prints a sentinel on success.
"""
from __future__ import annotations
import os
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from typer.testing import CliRunner # noqa: E402
from cleveragents.cli.commands.action import app as action_app # noqa: E402
from cleveragents.cli.commands.plan import app as plan_app # noqa: E402
from cleveragents.domain.models.core.action import Action, ActionState # noqa: E402
from cleveragents.domain.models.core.plan import ( # noqa: E402
AutomationProfileProvenance,
AutomationProfileRef,
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
runner = CliRunner()
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
_VALID_YAML = """\
name: local/e2e-action
description: E2E lifecycle action
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: All e2e tests pass
"""
def _mock_action(name: str = "local/e2e-action") -> Action:
return Action(
namespaced_name=NamespacedName.parse(name),
description="E2E lifecycle action",
long_description=None,
definition_of_done="All e2e tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
state=ActionState.AVAILABLE,
reusable=True,
read_only=False,
created_at=datetime.now(),
updated_at=datetime.now(),
created_by=None,
)
def _mock_plan(
name: str = "local/e2e-plan",
phase: PlanPhase = PlanPhase.STRATEGIZE,
state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
plan_id: str = _PLAN_ULID,
) -> Plan:
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
description="E2E lifecycle plan",
definition_of_done="Tests pass",
action_name="local/e2e-action",
phase=phase,
processing_state=state,
project_links=project_links or [ProjectLink(project_name="proj-a")],
arguments={"target_coverage": 80},
arguments_order=["target_coverage"],
automation_profile=AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.PLAN,
),
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
reusable=True,
read_only=False,
created_by=None,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
def _write_yaml(content: str) -> str:
fd, path = tempfile.mkstemp(suffix=".yaml")
with os.fdopen(fd, "w") as fh:
fh.write(content)
return path
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def action_create() -> None:
"""Verify action create from config file."""
mock_service = MagicMock()
mock_service.create_action.return_value = _mock_action()
yaml_path = _write_yaml(_VALID_YAML)
try:
with patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(action_app, ["create", "--config", yaml_path])
if result.exit_code == 0:
print("cli-lifecycle-action-create-ok")
else:
print(
f"FAIL: action create returned {result.exit_code}", file=sys.stderr
)
print(result.output, file=sys.stderr)
sys.exit(1)
finally:
os.unlink(yaml_path)
def plan_use() -> None:
"""Verify plan use creates a plan in Strategize phase."""
mock_service = MagicMock()
mock_service.get_action_by_name.return_value = _mock_action()
mock_service.use_action.return_value = _mock_plan()
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["use", "local/e2e-action", "proj-a"])
if result.exit_code == 0:
print("cli-lifecycle-plan-use-ok")
else:
print(f"FAIL: plan use returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def plan_execute() -> None:
"""Verify plan execute transitions to Execute phase."""
mock_service = MagicMock()
mock_service.get_plan.return_value = _mock_plan(
phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE
)
mock_service.execute_plan.return_value = _mock_plan(
phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED
)
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
result = runner.invoke(plan_app, ["execute", _PLAN_ULID])
if result.exit_code == 0:
print("cli-lifecycle-plan-execute-ok")
else:
print(f"FAIL: plan execute returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def plan_apply() -> None:
"""Verify plan lifecycle-apply transitions to Apply phase."""
mock_service = MagicMock()
mock_service.apply_plan.return_value = _mock_plan(
phase=PlanPhase.APPLY, state=ProcessingState.QUEUED
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
if result.exit_code == 0:
print("cli-lifecycle-plan-apply-ok")
else:
print(f"FAIL: plan apply returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def plan_status() -> None:
"""Verify plan status shows plan details."""
mock_service = MagicMock()
mock_service.get_plan.return_value = _mock_plan(
project_links=[ProjectLink(project_name="proj-a", alias="api")]
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["status", _PLAN_ULID])
if result.exit_code == 0:
print("cli-lifecycle-plan-status-ok")
else:
print(f"FAIL: plan status returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def plan_cancel() -> None:
"""Verify plan cancel cancels a non-terminal plan."""
mock_service = MagicMock()
mock_service.cancel_plan.return_value = _mock_plan(
phase=PlanPhase.STRATEGIZE, state=ProcessingState.CANCELLED
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(
plan_app, ["cancel", _PLAN_ULID, "--reason", "E2E test cancel"]
)
if result.exit_code == 0:
print("cli-lifecycle-plan-cancel-ok")
else:
print(f"FAIL: plan cancel returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def plan_list() -> None:
"""Verify lifecycle-list shows plan table."""
mock_service = MagicMock()
mock_service.list_plans.return_value = [
_mock_plan(name="local/plan-a", plan_id="01KHDE6WWS2171PWW3GJEBXZ8A"),
_mock_plan(name="local/plan-b", plan_id="01KHDE6WWS2171PWW3GJEBXZ8B"),
]
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["lifecycle-list"])
if result.exit_code == 0:
print("cli-lifecycle-plan-list-ok")
else:
print(f"FAIL: plan list returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def full_lifecycle() -> None:
"""End-to-end: action create -> plan use -> execute -> apply."""
mock_service = MagicMock()
action = _mock_action()
mock_service.create_action.return_value = action
mock_service.get_action_by_name.return_value = action
plan_strat = _mock_plan(phase=PlanPhase.STRATEGIZE, state=ProcessingState.QUEUED)
plan_exec = _mock_plan(phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED)
plan_apply = _mock_plan(phase=PlanPhase.APPLY, state=ProcessingState.QUEUED)
mock_service.use_action.return_value = plan_strat
mock_service.execute_plan.return_value = plan_exec
mock_service.get_plan.return_value = _mock_plan(
phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE
)
mock_service.apply_plan.return_value = plan_apply
yaml_path = _write_yaml(_VALID_YAML)
try:
with (
patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
# Step 1: Create action
r1 = runner.invoke(action_app, ["create", "--config", yaml_path])
assert r1.exit_code == 0, f"action create failed: {r1.output}"
# Step 2: Plan use
r2 = runner.invoke(plan_app, ["use", "local/e2e-action", "proj-a"])
assert r2.exit_code == 0, f"plan use failed: {r2.output}"
# Step 3: Plan execute
r3 = runner.invoke(plan_app, ["execute", _PLAN_ULID])
assert r3.exit_code == 0, f"plan execute failed: {r3.output}"
# Step 4: Plan apply
r4 = runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
assert r4.exit_code == 0, f"plan apply failed: {r4.output}"
print("cli-lifecycle-full-e2e-ok")
except AssertionError as exc:
print(f"FAIL: {exc}", file=sys.stderr)
sys.exit(1)
finally:
os.unlink(yaml_path)
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
_COMMANDS = {
"action-create": action_create,
"plan-use": plan_use,
"plan-execute": plan_execute,
"plan-apply": plan_apply,
"plan-status": plan_status,
"plan-cancel": plan_cancel,
"plan-list": plan_list,
"full-lifecycle": full_lifecycle,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(2)
_COMMANDS[sys.argv[1]]()
if __name__ == "__main__":
main()