forked from HAL9000/cleveragents-core
84b0c10dbf
## Summary - tighten `plan correct` active-plan fallback so it only runs for isolated `CLEVERAGENTS_HOME` mismatch cases and never when explicit DB env overrides are configured - narrow fallback exception handling in `_resolve_active_plan_id()` to expected DB/path/service failures; unexpected errors now surface instead of being silently swallowed - add BDD regression coverage for both safeguards in `features/consolidated_plan_misc.feature` + `features/steps/plan_cli_legacy_r2_steps.py` ## Validation - `nox -e lint`: PASS - `nox -e typecheck`: PASS - `nox -e unit_tests`: PASS - `nox -e integration_tests`: FAIL in current branch baseline (29 failing Robot integration tests in this environment) - `nox -e e2e_tests`: FAIL in current branch baseline (45 failing E2E tests in this environment) - `nox -e coverage_report`: PASS (97%) Closes #1025 Reviewed-on: cleveragents/cleveragents-core#1184 Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com> Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com> Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
550 lines
18 KiB
Python
550 lines
18 KiB
Python
"""Step definitions for plan_cli_legacy_r2.feature.
|
|
|
|
Targets remaining partial branches in
|
|
``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 3 of 3.
|
|
|
|
Covers:
|
|
- Legacy wrappers: no-project branch, continue_command prompt/no-prompt
|
|
- ``_resolve_active_plan_id``: no active plans, service error
|
|
- ``build_command``: None changes -> empty list
|
|
- ``list_command``: None plans -> empty list
|
|
|
|
All step text uses the ``r2plan-`` prefix to avoid collisions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
import warnings
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import typer
|
|
from behave import then, when
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.cli.commands.plan import (
|
|
_resolve_active_plan_id,
|
|
)
|
|
from cleveragents.core.exceptions import CleverAgentsError
|
|
from cleveragents.domain.models.core.plan import (
|
|
AutomationProfileRef,
|
|
NamespacedName,
|
|
Plan,
|
|
PlanIdentity,
|
|
PlanInvariant,
|
|
PlanPhase,
|
|
PlanTimestamps,
|
|
ProcessingState,
|
|
ProjectLink,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_ULID_BASE = "01ARZ3NDEKTSV4RRFFQ69G5F"
|
|
_runner = CliRunner()
|
|
|
|
|
|
def _ulid(suffix: str = "A1") -> str:
|
|
"""Return a valid 26-char ULID for tests.
|
|
|
|
ULIDs use Crockford's Base32 (0-9, A-H, J-K, M-N, P-T, V-Z; no I/L/O/U).
|
|
"""
|
|
# Map potentially invalid chars to valid Crockford Base32
|
|
cleaned = (
|
|
suffix.replace("I", "J").replace("L", "K").replace("O", "P").replace("U", "V")
|
|
)
|
|
base = _ULID_BASE + cleaned
|
|
return base[:26]
|
|
|
|
|
|
def _make_plan(
|
|
*,
|
|
plan_id: str | None = None,
|
|
name: str = "local/r2-plan",
|
|
description: str = "Test plan for r2 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,
|
|
validation_summary: dict[str, Any] | None = None,
|
|
error_message: str | None = None,
|
|
last_completed_step: int = -1,
|
|
last_checkpoint_id: str | None = None,
|
|
definition_of_done: str | None = None,
|
|
arguments: dict[str, Any] | None = None,
|
|
arguments_order: list[str] | None = None,
|
|
estimation_actor: str | None = None,
|
|
invariant_actor: str | None = None,
|
|
timestamps: PlanTimestamps | None = None,
|
|
action_name: str = "local/test-action",
|
|
) -> Plan:
|
|
if timestamps is None:
|
|
timestamps = PlanTimestamps(
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
return Plan(
|
|
identity=PlanIdentity(plan_id=plan_id or _ulid("A1")),
|
|
namespaced_name=NamespacedName.parse(name),
|
|
action_name=action_name,
|
|
description=description,
|
|
definition_of_done=definition_of_done,
|
|
phase=phase,
|
|
processing_state=processing_state,
|
|
strategy_actor=None,
|
|
execution_actor=None,
|
|
project_links=project_links or [],
|
|
automation_profile=automation_profile,
|
|
invariants=invariants or [],
|
|
validation_summary=validation_summary,
|
|
error_message=error_message,
|
|
last_completed_step=last_completed_step,
|
|
last_checkpoint_id=last_checkpoint_id,
|
|
arguments=arguments or {},
|
|
arguments_order=arguments_order or [],
|
|
estimation_actor=estimation_actor,
|
|
invariant_actor=invariant_actor,
|
|
timestamps=timestamps,
|
|
created_by=None,
|
|
reusable=True,
|
|
read_only=False,
|
|
)
|
|
|
|
|
|
def _mock_container(project_exists: bool = True) -> MagicMock:
|
|
"""Build a mock container for legacy wrapper tests."""
|
|
container = MagicMock()
|
|
project_service = MagicMock()
|
|
plan_service = MagicMock()
|
|
if project_exists:
|
|
project_service.get_current_project.return_value = SimpleNamespace(name="proj")
|
|
else:
|
|
project_service.get_current_project.return_value = None
|
|
container.project_service.return_value = project_service
|
|
container.plan_service.return_value = plan_service
|
|
return container
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps - legacy programmatic wrappers (no project)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("r2plan-I call tell_command with no project")
|
|
def step_tell_no_project(context: Any) -> None:
|
|
container = _mock_container(project_exists=False)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
try:
|
|
from cleveragents.cli.commands.plan import tell_command
|
|
|
|
tell_command("do stuff")
|
|
context.r2_error = None
|
|
except CleverAgentsError as exc:
|
|
context.r2_error = exc
|
|
|
|
|
|
@when("r2plan-I call build_command with no project")
|
|
def step_build_no_project(context: Any) -> None:
|
|
container = _mock_container(project_exists=False)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
try:
|
|
from cleveragents.cli.commands.plan import build_command
|
|
|
|
build_command()
|
|
context.r2_error = None
|
|
except CleverAgentsError as exc:
|
|
context.r2_error = exc
|
|
|
|
|
|
@when("r2plan-I call apply_command with no project")
|
|
def step_apply_no_project(context: Any) -> None:
|
|
container = _mock_container(project_exists=False)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
try:
|
|
from cleveragents.cli.commands.plan import apply_command
|
|
|
|
apply_command()
|
|
context.r2_error = None
|
|
except CleverAgentsError as exc:
|
|
context.r2_error = exc
|
|
|
|
|
|
@when("r2plan-I call new_command with no project")
|
|
def step_new_no_project(context: Any) -> None:
|
|
container = _mock_container(project_exists=False)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
try:
|
|
from cleveragents.cli.commands.plan import new_command
|
|
|
|
new_command("test")
|
|
context.r2_error = None
|
|
except CleverAgentsError as exc:
|
|
context.r2_error = exc
|
|
|
|
|
|
@when("r2plan-I call current_command with no project")
|
|
def step_current_no_project(context: Any) -> None:
|
|
container = _mock_container(project_exists=False)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
try:
|
|
from cleveragents.cli.commands.plan import current_command
|
|
|
|
current_command()
|
|
context.r2_error = None
|
|
except CleverAgentsError as exc:
|
|
context.r2_error = exc
|
|
|
|
|
|
@when("r2plan-I call list_command with no project")
|
|
def step_list_no_project(context: Any) -> None:
|
|
container = _mock_container(project_exists=False)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
try:
|
|
from cleveragents.cli.commands.plan import list_command
|
|
|
|
list_command()
|
|
context.r2_error = None
|
|
except CleverAgentsError as exc:
|
|
context.r2_error = exc
|
|
|
|
|
|
@when("r2plan-I call cd_command with no project")
|
|
def step_cd_no_project(context: Any) -> None:
|
|
container = _mock_container(project_exists=False)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
try:
|
|
from cleveragents.cli.commands.plan import cd_command
|
|
|
|
cd_command("some-plan")
|
|
context.r2_error = None
|
|
except CleverAgentsError as exc:
|
|
context.r2_error = exc
|
|
|
|
|
|
@when("r2plan-I call continue_command with no project")
|
|
def step_continue_no_project(context: Any) -> None:
|
|
container = _mock_container(project_exists=False)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
try:
|
|
from cleveragents.cli.commands.plan import continue_command
|
|
|
|
continue_command()
|
|
context.r2_error = None
|
|
except CleverAgentsError as exc:
|
|
context.r2_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps - continue_command with prompt / no-prompt
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('r2plan-I call continue_command with prompt "{prompt}"')
|
|
def step_continue_with_prompt(context: Any, prompt: str) -> None:
|
|
container = _mock_container(project_exists=True)
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
from cleveragents.cli.commands.plan import continue_command
|
|
|
|
continue_command(prompt=prompt)
|
|
context.r2_plan_service = container.plan_service()
|
|
|
|
|
|
@when("r2plan-I call continue_command with no prompt and no current plan")
|
|
def step_continue_no_prompt_no_plan(context: Any) -> None:
|
|
container = _mock_container(project_exists=True)
|
|
plan_service = container.plan_service()
|
|
plan_service.get_current_plan.return_value = None
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
try:
|
|
from cleveragents.cli.commands.plan import continue_command
|
|
|
|
continue_command(prompt=None)
|
|
context.r2_error = None
|
|
except CleverAgentsError as exc:
|
|
context.r2_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps - build_command returns None / list_command returns None
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("r2plan-I call build_command with build returning None")
|
|
def step_build_returns_none(context: Any) -> None:
|
|
container = _mock_container(project_exists=True)
|
|
plan_service = container.plan_service()
|
|
plan_service.build_plan.return_value = None
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
from cleveragents.cli.commands.plan import build_command
|
|
|
|
context.r2_build_result = build_command()
|
|
|
|
|
|
@when("r2plan-I call list_command with list returning None")
|
|
def step_list_returns_none(context: Any) -> None:
|
|
container = _mock_container(project_exists=True)
|
|
plan_service = container.plan_service()
|
|
plan_service.list_plans.return_value = None
|
|
with (
|
|
patch(
|
|
"cleveragents.application.container.get_container", return_value=container
|
|
),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
from cleveragents.cli.commands.plan import list_command
|
|
|
|
context.r2_list_result = list_command()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps - _resolve_active_plan_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("r2plan-I call _resolve_active_plan_id with no active plans")
|
|
def step_resolve_no_active(context: Any) -> None:
|
|
mock_svc = MagicMock()
|
|
# All plans are terminal
|
|
p = _make_plan(phase=PlanPhase.APPLY, processing_state=ProcessingState.APPLIED)
|
|
mock_svc.list_plans.return_value = [p]
|
|
with patch(
|
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
|
return_value=mock_svc,
|
|
):
|
|
try:
|
|
_resolve_active_plan_id()
|
|
context.r2_error = None
|
|
except typer.Abort:
|
|
context.r2_error = typer.Abort()
|
|
|
|
|
|
@when("r2plan-I call _resolve_active_plan_id with service error")
|
|
def step_resolve_service_error(context: Any) -> None:
|
|
with patch(
|
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
|
side_effect=CleverAgentsError("service unavailable"),
|
|
):
|
|
try:
|
|
_resolve_active_plan_id()
|
|
context.r2_error = None
|
|
except typer.Abort:
|
|
context.r2_error = typer.Abort()
|
|
|
|
|
|
@when(
|
|
"r2plan-I call _resolve_active_plan_id with explicit DB env override and no active plans"
|
|
)
|
|
def step_resolve_skips_home_fallback_when_db_override(context: Any) -> None:
|
|
mock_svc = MagicMock()
|
|
terminal_plan = _make_plan(
|
|
phase=PlanPhase.APPLY,
|
|
processing_state=ProcessingState.APPLIED,
|
|
)
|
|
mock_svc.list_plans.return_value = [terminal_plan]
|
|
|
|
with tempfile.TemporaryDirectory(prefix="r2plan-db-override-") as tmp:
|
|
home_dir = os.path.join(tmp, "home")
|
|
os.makedirs(home_dir, exist_ok=True)
|
|
|
|
mock_uow = MagicMock()
|
|
with (
|
|
patch(
|
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
|
return_value=mock_svc,
|
|
),
|
|
patch(
|
|
"cleveragents.infrastructure.database.unit_of_work.UnitOfWork",
|
|
return_value=mock_uow,
|
|
),
|
|
patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLEVERAGENTS_HOME": home_dir,
|
|
"CLEVERAGENTS_DATABASE_URL": "sqlite:////tmp/explicit-test.db",
|
|
},
|
|
clear=False,
|
|
),
|
|
):
|
|
try:
|
|
_resolve_active_plan_id()
|
|
context.r2_error = None
|
|
except typer.Abort:
|
|
context.r2_error = typer.Abort()
|
|
|
|
context.r2_home_fallback_attempted = mock_uow.called
|
|
|
|
|
|
@when("r2plan-I call _resolve_active_plan_id with unexpected fallback error")
|
|
def step_resolve_unexpected_fallback_error(context: Any) -> None:
|
|
mock_svc = MagicMock()
|
|
terminal_plan = _make_plan(
|
|
phase=PlanPhase.APPLY,
|
|
processing_state=ProcessingState.APPLIED,
|
|
)
|
|
mock_svc.list_plans.return_value = [terminal_plan]
|
|
|
|
with tempfile.TemporaryDirectory(prefix="r2plan-fallback-error-") as tmp:
|
|
home_dir = os.path.join(tmp, "home")
|
|
workspace_dir = os.path.join(tmp, "workspace")
|
|
os.makedirs(home_dir, exist_ok=True)
|
|
os.makedirs(workspace_dir, exist_ok=True)
|
|
|
|
original_cwd = os.getcwd()
|
|
os.chdir(workspace_dir)
|
|
try:
|
|
with (
|
|
patch(
|
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
|
return_value=mock_svc,
|
|
),
|
|
patch(
|
|
"cleveragents.infrastructure.database.unit_of_work.UnitOfWork",
|
|
side_effect=RuntimeError("unexpected fallback crash"),
|
|
),
|
|
patch.dict(
|
|
os.environ,
|
|
{
|
|
"CLEVERAGENTS_HOME": home_dir,
|
|
"CLEVERAGENTS_DATABASE_URL": "",
|
|
"CLEVERAGENTS_TEST_DATABASE_URL": "",
|
|
},
|
|
clear=False,
|
|
),
|
|
):
|
|
try:
|
|
_resolve_active_plan_id()
|
|
context.r2_error = None
|
|
except typer.Abort:
|
|
context.r2_error = typer.Abort()
|
|
except RuntimeError as exc:
|
|
context.r2_error = exc
|
|
finally:
|
|
os.chdir(original_cwd)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps - legacy wrapper assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('r2plan-a CleverAgentsError should be raised with message "{fragment}"')
|
|
def step_agents_error(context: Any, fragment: str) -> None:
|
|
assert context.r2_error is not None, "Expected CleverAgentsError but none raised"
|
|
assert isinstance(context.r2_error, CleverAgentsError), (
|
|
f"Expected CleverAgentsError, got {type(context.r2_error).__name__}"
|
|
)
|
|
assert fragment in context.r2_error.message, (
|
|
f"Expected '{fragment}' in '{context.r2_error.message}'"
|
|
)
|
|
|
|
|
|
@then("r2plan-the continue_plan service method should be called")
|
|
def step_continue_called(context: Any) -> None:
|
|
context.r2_plan_service.continue_plan.assert_called_once()
|
|
|
|
|
|
@then("r2plan-the build result should be an empty list")
|
|
def step_build_empty(context: Any) -> None:
|
|
assert context.r2_build_result == [], f"Expected [], got {context.r2_build_result}"
|
|
|
|
|
|
@then("r2plan-the list result should be an empty list")
|
|
def step_list_empty(context: Any) -> None:
|
|
assert context.r2_list_result == [], f"Expected [], got {context.r2_list_result}"
|
|
|
|
|
|
@then("r2plan-a typer Abort should be raised")
|
|
def step_typer_abort(context: Any) -> None:
|
|
assert context.r2_error is not None, "Expected typer.Abort but none raised"
|
|
assert isinstance(context.r2_error, typer.Abort), (
|
|
f"Expected typer.Abort, got {type(context.r2_error).__name__}"
|
|
)
|
|
|
|
|
|
@then("r2plan-the home DB fallback should not be attempted")
|
|
def step_home_fallback_not_attempted(context: Any) -> None:
|
|
attempted = getattr(context, "r2_home_fallback_attempted", None)
|
|
assert attempted is False, "Expected fallback UnitOfWork not to be called"
|
|
|
|
|
|
@then("r2plan-a RuntimeError should be raised")
|
|
def step_runtime_error(context: Any) -> None:
|
|
assert context.r2_error is not None, "Expected RuntimeError but none raised"
|
|
assert isinstance(context.r2_error, RuntimeError), (
|
|
f"Expected RuntimeError, got {type(context.r2_error).__name__}"
|
|
)
|