414abb1396
CI / benchmark-publish (push) Waiting to run
CI / lint (push) Successful in 19s
CI / build (push) Successful in 20s
CI / quality (push) Successful in 3m49s
CI / typecheck (push) Successful in 3m58s
CI / benchmark-regression (push) Waiting to run
CI / security (push) Successful in 4m7s
CI / integration_tests (push) Successful in 6m48s
CI / unit_tests (push) Successful in 7m30s
CI / docker (push) Successful in 1m12s
CI / e2e_tests (push) Successful in 10m1s
CI / coverage (push) Successful in 11m48s
CI / status-check (push) Successful in 1s
## Summary Adds the `--yes`/`-y` flag to the `lifecycle-apply` CLI command as required by the specification (`agents plan apply [--yes|-y] <PLAN_ID>`). Without `--yes`, a confirmation prompt now displays before proceeding with the destructive Apply phase. With `--yes`, the apply proceeds immediately without prompting. Closes #932 ## Changes ### Source Code - **`src/cleveragents/cli/commands/plan.py`**: Added `yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt")] = False` parameter to `lifecycle_apply_plan`. Added `typer.confirm()` prompt before the apply operation, consistent with the pattern used by `rollback_plan`, `correct_plan`, and other destructive commands. - Confirmation prompt text matches spec exactly: `"Apply changes for plan {plan_id}?"` producing `Apply changes for plan <ID>? [y/N]:`. - Fixed redundant plan ID display when `pre_plan` is `None` — now shows `"Apply changes for plan X?"` instead of `"Apply plan X (X)?"`. - Added `except ValueError` handler consistent with sibling commands `lifecycle_execute_plan` and `_lifecycle_apply_with_id`. - Added `except Exception` catch-all handler with `isinstance(e, (typer.Abort, typer.Exit))` re-raise guard, consistent with `lifecycle_execute_plan`. - Moved `PlanPhase` and `ProcessingState` imports to module level per CONTRIBUTING.md §Import Guidelines. ### TDD Tag Removal (Bug Fix Workflow) - **`features/tdd_plan_apply_yes_flag.feature`**: Removed `@tdd_expected_fail` tag (leaving `@tdd_bug` and `@tdd_bug_932` as permanent regression guards). - **`robot/tdd_plan_apply_yes_flag.robot`**: Removed `tdd_expected_fail` tag (leaving `tdd_bug` and `tdd_bug_932`). ### Test Updates Updated all existing `lifecycle-apply` invocations across 17 test/benchmark files to pass `--yes`, since the new confirmation prompt would otherwise abort in non-interactive test environments: - 9 Behave step definition files - 3 Robot Framework helper scripts - 2 Robot Framework e2e acceptance tests - 3 ASV benchmark files (4 invocations: `cli_robot_flow_bench.py` ×2, `m1_sourcecode_smoke_bench.py` ×1, `plan_cli_smoke_bench.py` ×1) ### Confirmation Prompt Tests (New + Strengthened) - **`features/tdd_plan_apply_yes_flag.feature`**: 5 scenarios total: - `lifecycle-apply recognises the --yes long flag` — verifies flag acceptance, prompt suppression, exit code 0, and `apply_plan` was called - `lifecycle-apply recognises the -y short flag` — same as above for short flag - `lifecycle-apply without --yes prompts for confirmation and user declines` — verifies `"Apply cancelled."` message, `exit_code == 0`, and `apply_plan` was NOT called - `lifecycle-apply without --yes prompts for confirmation and user accepts` — verifies prompt appears, `exit_code == 0`, and `apply_plan` was called - `lifecycle-apply catches unexpected exceptions cleanly` — verifies `"Unexpected error"` output, no traceback leak, non-zero exit code (exercises the `except Exception` catch-all) - **`features/steps/tdd_plan_apply_yes_flag_steps.py`**: Refactored step definitions: - `_make_mock_plan` uses `PlanPhase` and `ProcessingState` enum types instead of raw strings - `_make_mock_plan` uses `datetime.now(tz=UTC)` instead of timezone-naive `datetime.now()` - Unified prompt suppression step handles both `--yes` and `-y` via parameterised step pattern - Added `When` step for unexpected error scenario with `RuntimeError` side_effect - Added `Then` step for non-zero exit code assertion - **Feature/Robot documentation**: Updated stale descriptions that said "implementation does not accept --yes" to reflect the flag is now implemented. ### Documentation - **`docs/reference/plan_cli.md`**: Updated `lifecycle-apply` section with: - `### Synopsis` heading with code block - `### Options` table listing `--yes/-y` and `--format/-f` flags - `### Arguments` table listing `PLAN_ID` - Matches the style used by other command sections in the same file ## Review Fixes (Cycle 3 — Luis's review) | ID | Severity | Issue | Resolution | |----|----------|-------|------------| | M1 | Medium | `typer.Abort()` on user decline produces exit code 1 and redundant "Aborted." | Changed to `raise typer.Exit(0)` — consistent with `correct_decision` and legacy `apply` | | M2 | Medium | Missing exit code assertion on decline scenario | Added `And the lifecycle-apply exit code should be 0` to the decline scenario | | M3 | Medium | Spec compliance: "summary of pending changes" not implemented | Deferred — spec example shows summary *after* confirmation, not before; implementation matches spec. Ticket-vs-spec ambiguity noted. | | L1 | Low | Missing `except Exception` catch-all handler | Added catch-all matching `lifecycle_execute_plan` pattern; re-raises `typer.Abort`/`typer.Exit` | | L2 | Low | Documentation description not updated | Expanded description in `plan_cli.md` to explain confirmation prompt and `--yes` | | L3 | Low | Dead code `is not None` guards | Removed both guards — `get_plan()` raises `NotFoundError`, never returns `None` | | I1 | Info | Duplicate `PlanPhase` import | Hoisted import to top of `try` block, eliminating duplicate at old line 2087 | | I2 | Info | `typer.confirm` without explicit `default=False` | Added `default=False` for consistency with sibling commands | | L4 | Low | No test for `--yes` after positional arg | Not addressed — Typer/Click handles both orderings; low risk | | L5 | Low | No test for auto-select + interactive prompt | Not addressed — separate concern outside ticket scope | | I3 | Info | Robot helper only tests flag recognition | By design — noted as informational | ## Review Fixes (Cycle 4 — Self-QA) | ID | Severity | Issue | Resolution | |----|----------|-------|------------| | Major-1 | Major | No test for `except Exception` catch-all handler | Added new scenario `"lifecycle-apply catches unexpected exceptions cleanly"` with `RuntimeError` side_effect; asserts `"Unexpected error"` output, no traceback, non-zero exit | | Minor-2 | Minor | Missing `ValueError` handler inconsistent with siblings | Added `except ValueError as e:` with `"[red]Execution Error:[/red]"` before catch-all, matching `lifecycle_execute_plan` and `_lifecycle_apply_with_id` | | Minor-3 | Minor | Flag scenarios don't verify `apply_plan` called | Added `And the lifecycle-apply should have called apply` to both `--yes` and `-y` scenarios | | Minor-4 | Minor | Stale docstring in Robot helper references `tdd_expected_fail` inversion | Updated to reflect bug is fixed and tests serve as regression guards | | Minor-5 | Minor | `plan_cli.md` lacks Options table for `lifecycle-apply` | Added Synopsis, Options, and Arguments sections matching sibling command style | | Nit-6 | Nit | Duplicated step defs for `--yes` vs `-y` prompt suppression | Unified into single parameterised step `"the lifecycle-apply {flag} output should not contain the confirmation prompt"` | | Nit-7 | Nit | `datetime.now()` timezone-naive | Changed to `datetime.now(tz=UTC)` | | Nit-8 | Nit | `_make_mock_plan` params use `str` instead of enum types | Changed to `PlanPhase` and `ProcessingState` enum types | ## Review Fixes (Cycle 5 — Jeff's approval note) | ID | Severity | Issue | Resolution | |----|----------|-------|------------| | Import-1 | Minor | `PlanPhase`/`ProcessingState` imports inside function body instead of module level | Moved to module-level import per CONTRIBUTING.md §Import Guidelines | ## Known Limitations / Deferred Items - **M3: Ticket AC mentions "summary of pending changes"** but the spec example only shows `"Apply changes for plan <ID>? [y/N]: y"` without a change summary. The implementation shows plan ID only (matching the spec), not a change summary. This is a ticket-vs-spec ambiguity; recommend discussing with ticket author. - **Legacy `apply` command** accepts `--yes` but does not pass it to `_lifecycle_apply_with_id()`. This is a pre-existing issue outside the scope of this ticket. - **`pre_plan is None` branch** has no explicit test. Pre-existing architectural issue; no action taken. ## Quality Gates | Gate | Result | |------|--------| | `nox -s lint` | ✅ passed | | `nox -s typecheck` | ✅ passed (0 errors) | | `nox -s unit_tests` | ✅ passed (471 features, 12,424 scenarios, 0 failures) | | `nox -s integration_tests` | ✅ passed (1,727 tests, 0 failures) | | `nox -s e2e_tests` | ✅ passed (41 tests, 0 failures) | | `nox -s coverage_report` | ✅ passed (≥97% coverage) | Reviewed-on: #1127 Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com> Co-authored-by: Rui Hu <rui.hu@cleverthis.com> Co-committed-by: Rui Hu <rui.hu@cleverthis.com>
513 lines
18 KiB
Python
513 lines
18 KiB
Python
"""Step definitions for plan_cli_coverage.feature.
|
|
|
|
Covers uncovered lines/branches in cleveragents/cli/commands/plan.py:
|
|
- Lines 818, 827-828: legacy apply Progress block and PlanError handler
|
|
- Branch 999: legacy list when plan.current is False
|
|
- Lines 1017-1035: legacy cd command full body
|
|
- Lines 1314-1315: invariant_actor parameter in use_action
|
|
- Lines 1391-1392: building PlanInvariant list from --invariant flags
|
|
- Lines 1573-1578: lifecycle-apply auto-discovery with 0 plans
|
|
- Lines 1594-1596: lifecycle-apply read-only guard
|
|
- Lines 1700-1701: status CleverAgentsError handler
|
|
- Branch 1738: errors command when has_error_recovery is False
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.cli.commands.plan import app as plan_app
|
|
from cleveragents.core.exceptions import CleverAgentsError, PlanError
|
|
from cleveragents.domain.models.core.plan import (
|
|
NamespacedName,
|
|
Plan,
|
|
PlanIdentity,
|
|
PlanPhase,
|
|
PlanTimestamps,
|
|
ProcessingState,
|
|
ProjectLink,
|
|
)
|
|
|
|
_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAA"
|
|
_ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FAB"
|
|
|
|
_PATCH_CONTAINER = "cleveragents.application.container.get_container"
|
|
_PATCH_GET_LIFECYCLE = "cleveragents.cli.commands.plan._get_lifecycle_service"
|
|
_PATCH_GET_PROJECT = "cleveragents.cli.commands.plan._get_current_project"
|
|
|
|
|
|
def _make_lifecycle_plan(
|
|
*,
|
|
plan_id: str = _ULID_A,
|
|
name: str = "local/test-plan",
|
|
phase: PlanPhase = PlanPhase.STRATEGIZE,
|
|
processing_state: ProcessingState = ProcessingState.QUEUED,
|
|
project_links: list[ProjectLink] | None = None,
|
|
error_message: str | None = None,
|
|
error_details: dict[str, str] | None = None,
|
|
read_only: bool = False,
|
|
) -> Plan:
|
|
"""Build a real Plan domain object for testing."""
|
|
return Plan(
|
|
identity=PlanIdentity(plan_id=plan_id),
|
|
namespaced_name=NamespacedName.parse(name),
|
|
action_name="local/test-action",
|
|
description="Coverage test plan",
|
|
definition_of_done=None,
|
|
strategy_actor=None,
|
|
execution_actor=None,
|
|
phase=phase,
|
|
processing_state=processing_state,
|
|
project_links=project_links or [],
|
|
timestamps=PlanTimestamps(
|
|
created_at=datetime(2025, 6, 15, 10, 0, 0),
|
|
updated_at=datetime(2025, 6, 15, 11, 0, 0),
|
|
),
|
|
error_message=error_message,
|
|
error_details=error_details,
|
|
reusable=True,
|
|
read_only=read_only,
|
|
created_by=None,
|
|
)
|
|
|
|
|
|
def _make_legacy_project() -> SimpleNamespace:
|
|
"""Build a minimal project stub for legacy commands."""
|
|
return SimpleNamespace(name="test-project", path="/tmp/test")
|
|
|
|
|
|
def _make_legacy_plan(
|
|
*,
|
|
name: str = "my-plan",
|
|
status: str = "active",
|
|
current: bool = True,
|
|
) -> SimpleNamespace:
|
|
"""Build a minimal legacy plan stub."""
|
|
return SimpleNamespace(
|
|
name=name,
|
|
status=status,
|
|
current=current,
|
|
created_at=datetime(2025, 1, 1),
|
|
prompt="do stuff",
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Given — CLI runner
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan-cov CLI runner")
|
|
def step_plan_cov_cli_runner(context: Context) -> None:
|
|
"""Initialise a CliRunner and cleanup list."""
|
|
context.plan_cov_runner = CliRunner()
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Given — Legacy apply mocks
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@given("a mocked legacy container for plan-cov apply that succeeds")
|
|
def step_mock_apply_success(context: Context) -> None:
|
|
"""Set up mocks so legacy apply command succeeds through Progress block."""
|
|
context.plan_cov_apply_changes_return = 1
|
|
context.plan_cov_apply_changes_side_effect = None
|
|
|
|
|
|
@given("a mocked legacy container for plan-cov apply that raises PlanError")
|
|
def step_mock_apply_plan_error(context: Context) -> None:
|
|
"""Set up mocks so legacy apply raises PlanError during apply_changes."""
|
|
context.plan_cov_apply_changes_return = None
|
|
context.plan_cov_apply_changes_side_effect = PlanError("Sandbox conflict")
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Given — Legacy list mocks
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@given("a mocked legacy container for plan-cov list with non-current plans")
|
|
def step_mock_list_non_current(context: Context) -> None:
|
|
"""Mark that legacy list should produce plans with current=False."""
|
|
context.plan_cov_list_plans = [
|
|
_make_legacy_plan(name="alpha", current=False),
|
|
_make_legacy_plan(name="beta", current=False),
|
|
]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Given — Legacy cd mocks
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@given("a mocked legacy container for plan-cov cd that succeeds")
|
|
def step_mock_cd_success(context: Context) -> None:
|
|
"""Mark that legacy cd should succeed."""
|
|
context.plan_cov_cd_return = _make_legacy_plan(name="my-plan")
|
|
context.plan_cov_cd_side_effect = None
|
|
|
|
|
|
@given("a mocked legacy container for plan-cov cd that raises CleverAgentsError")
|
|
def step_mock_cd_error(context: Context) -> None:
|
|
"""Mark that legacy cd should raise CleverAgentsError."""
|
|
context.plan_cov_cd_return = None
|
|
context.plan_cov_cd_side_effect = CleverAgentsError("not found")
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Given — Lifecycle service for use_action
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@given("a mocked lifecycle service for plan-cov use action")
|
|
def step_mock_lifecycle_for_use(context: Context) -> None:
|
|
"""Set up lifecycle service mock for the use command."""
|
|
mock_service = MagicMock()
|
|
mock_action = MagicMock()
|
|
mock_action.namespaced_name = "local/test-action"
|
|
mock_service.get_action_by_name.return_value = mock_action
|
|
mock_plan = _make_lifecycle_plan()
|
|
mock_service.use_action.return_value = mock_plan
|
|
|
|
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
|
|
p1.start()
|
|
context._cleanup_handlers.append(p1.stop)
|
|
context.plan_cov_service = mock_service
|
|
context.plan_cov_plan = mock_plan
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Given — Lifecycle service for lifecycle-apply
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@given("a mocked lifecycle service for plan-cov that returns no execute-complete plans")
|
|
def step_mock_lifecycle_apply_no_plans(context: Context) -> None:
|
|
"""Set up lifecycle service that returns no complete plans in Execute phase."""
|
|
mock_service = MagicMock()
|
|
mock_service.list_plans.return_value = []
|
|
|
|
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
|
|
p1.start()
|
|
context._cleanup_handlers.append(p1.stop)
|
|
|
|
|
|
@given(
|
|
"a mocked lifecycle service for plan-cov that returns multiple execute-complete plans"
|
|
)
|
|
def step_mock_lifecycle_apply_multiple_plans(context: Context) -> None:
|
|
"""Set up lifecycle service that returns multiple complete Execute plans."""
|
|
mock_service = MagicMock()
|
|
plan_a = _make_lifecycle_plan(
|
|
plan_id=_ULID_A,
|
|
name="local/plan-a",
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.COMPLETE,
|
|
)
|
|
plan_b = _make_lifecycle_plan(
|
|
plan_id=_ULID_B,
|
|
name="local/plan-b",
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.COMPLETE,
|
|
)
|
|
mock_service.list_plans.return_value = [plan_a, plan_b]
|
|
|
|
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
|
|
p1.start()
|
|
context._cleanup_handlers.append(p1.stop)
|
|
|
|
|
|
@given("a mocked lifecycle service for plan-cov that returns a read-only plan")
|
|
def step_mock_lifecycle_apply_readonly(context: Context) -> None:
|
|
"""Set up lifecycle service that returns a read-only plan."""
|
|
mock_service = MagicMock()
|
|
ro_plan = _make_lifecycle_plan(
|
|
plan_id=_ULID_A,
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.COMPLETE,
|
|
read_only=True,
|
|
)
|
|
mock_service.get_plan.return_value = ro_plan
|
|
|
|
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
|
|
p1.start()
|
|
context._cleanup_handlers.append(p1.stop)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Given — Lifecycle service for status command error
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@given("a mocked lifecycle service for plan-cov status that raises CleverAgentsError")
|
|
def step_mock_lifecycle_status_error(context: Context) -> None:
|
|
"""Set up lifecycle service that raises CleverAgentsError on get_plan."""
|
|
mock_service = MagicMock()
|
|
mock_service.get_plan.side_effect = CleverAgentsError("service unavailable")
|
|
|
|
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
|
|
p1.start()
|
|
context._cleanup_handlers.append(p1.stop)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Given — Lifecycle service for errors command
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@given("a mocked lifecycle service for plan-cov errors with no error_category")
|
|
def step_mock_lifecycle_errors_no_category(context: Context) -> None:
|
|
"""Set up lifecycle service returning a plan with error_details lacking error_category."""
|
|
mock_service = MagicMock()
|
|
plan = _make_lifecycle_plan(
|
|
plan_id=_ULID_A,
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.ERRORED,
|
|
error_message="Something went wrong",
|
|
error_details={"error_phase": "execute"},
|
|
)
|
|
mock_service.get_plan.return_value = plan
|
|
|
|
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
|
|
p1.start()
|
|
context._cleanup_handlers.append(p1.stop)
|
|
|
|
|
|
@given("a mocked lifecycle service for plan-cov errors with empty details")
|
|
def step_mock_lifecycle_errors_empty_details(context: Context) -> None:
|
|
"""Set up lifecycle service returning a plan with no error_details or error_message."""
|
|
mock_service = MagicMock()
|
|
plan = _make_lifecycle_plan(
|
|
plan_id=_ULID_A,
|
|
phase=PlanPhase.EXECUTE,
|
|
processing_state=ProcessingState.QUEUED,
|
|
error_message=None,
|
|
error_details=None,
|
|
)
|
|
mock_service.get_plan.return_value = plan
|
|
|
|
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
|
|
p1.start()
|
|
context._cleanup_handlers.append(p1.stop)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# When — Legacy apply
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@when("I invoke plan-cov legacy apply with --yes")
|
|
def step_invoke_legacy_apply(context: Context) -> None:
|
|
"""Invoke the legacy apply command with auto-confirm."""
|
|
mock_container = MagicMock()
|
|
mock_plan_service = MagicMock()
|
|
mock_plan_service.get_pending_changes.return_value = [
|
|
SimpleNamespace(operation="modify", file_path="a.py"),
|
|
]
|
|
if context.plan_cov_apply_changes_side_effect:
|
|
mock_plan_service.apply_changes.side_effect = (
|
|
context.plan_cov_apply_changes_side_effect
|
|
)
|
|
else:
|
|
mock_plan_service.apply_changes.return_value = (
|
|
context.plan_cov_apply_changes_return
|
|
)
|
|
mock_container.plan_service.return_value = mock_plan_service
|
|
|
|
project = _make_legacy_project()
|
|
|
|
with (
|
|
patch(_PATCH_CONTAINER, return_value=mock_container),
|
|
patch(_PATCH_GET_PROJECT, return_value=project),
|
|
):
|
|
context.plan_cov_result = context.plan_cov_runner.invoke(
|
|
plan_app, ["apply", "--yes"]
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# When — Legacy list
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@when("I invoke plan-cov legacy list")
|
|
def step_invoke_legacy_list(context: Context) -> None:
|
|
"""Invoke the legacy list command in rich format."""
|
|
mock_container = MagicMock()
|
|
mock_plan_service = MagicMock()
|
|
mock_plan_service.list_plans.return_value = context.plan_cov_list_plans
|
|
mock_container.plan_service.return_value = mock_plan_service
|
|
|
|
project = _make_legacy_project()
|
|
|
|
with (
|
|
patch(_PATCH_CONTAINER, return_value=mock_container),
|
|
patch(_PATCH_GET_PROJECT, return_value=project),
|
|
):
|
|
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["list"])
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# When — Legacy cd
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@when('I invoke plan-cov legacy cd "{name}"')
|
|
def step_invoke_legacy_cd(context: Context, name: str) -> None:
|
|
"""Invoke the legacy cd command with a plan name."""
|
|
mock_container = MagicMock()
|
|
mock_plan_service = MagicMock()
|
|
if context.plan_cov_cd_side_effect:
|
|
mock_plan_service.switch_to_plan.side_effect = context.plan_cov_cd_side_effect
|
|
else:
|
|
mock_plan_service.switch_to_plan.return_value = context.plan_cov_cd_return
|
|
mock_container.plan_service.return_value = mock_plan_service
|
|
|
|
project = _make_legacy_project()
|
|
|
|
with (
|
|
patch(_PATCH_CONTAINER, return_value=mock_container),
|
|
patch(_PATCH_GET_PROJECT, return_value=project),
|
|
):
|
|
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["cd", name])
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# When — use action with actor overrides
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@when('I invoke plan-cov use with estimation-actor "{est}" and invariant-actor "{inv}"')
|
|
def step_invoke_use_actor_overrides(context: Context, est: str, inv: str) -> None:
|
|
"""Invoke use command with estimation-actor and invariant-actor."""
|
|
context.plan_cov_result = context.plan_cov_runner.invoke(
|
|
plan_app,
|
|
[
|
|
"use",
|
|
"local/test-action",
|
|
"--project",
|
|
"proj-1",
|
|
"--estimation-actor",
|
|
est,
|
|
"--invariant-actor",
|
|
inv,
|
|
],
|
|
)
|
|
|
|
|
|
@when('I invoke plan-cov use with invariant "{text}"')
|
|
def step_invoke_use_with_invariant(context: Context, text: str) -> None:
|
|
"""Invoke use command with --invariant flag."""
|
|
context.plan_cov_result = context.plan_cov_runner.invoke(
|
|
plan_app,
|
|
[
|
|
"use",
|
|
"local/test-action",
|
|
"--project",
|
|
"proj-1",
|
|
"--invariant",
|
|
text,
|
|
],
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# When — lifecycle-apply
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@when("I invoke plan-cov lifecycle-apply without plan_id")
|
|
def step_invoke_lifecycle_apply_no_id(context: Context) -> None:
|
|
"""Invoke lifecycle-apply without specifying a plan ID."""
|
|
context.plan_cov_result = context.plan_cov_runner.invoke(
|
|
plan_app, ["lifecycle-apply", "--yes"]
|
|
)
|
|
|
|
|
|
@when('I invoke plan-cov lifecycle-apply with plan_id "{pid}"')
|
|
def step_invoke_lifecycle_apply_with_id(context: Context, pid: str) -> None:
|
|
"""Invoke lifecycle-apply with an explicit plan ID."""
|
|
context.plan_cov_result = context.plan_cov_runner.invoke(
|
|
plan_app, ["lifecycle-apply", "--yes", pid]
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# When — status
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@when('I invoke plan-cov status "{pid}"')
|
|
def step_invoke_status(context: Context, pid: str) -> None:
|
|
"""Invoke the status command with a plan ID."""
|
|
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["status", pid])
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# When — errors
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@when('I invoke plan-cov errors "{pid}" with format "{fmt}"')
|
|
def step_invoke_errors_json(context: Context, pid: str, fmt: str) -> None:
|
|
"""Invoke the errors command with a given format."""
|
|
context.plan_cov_result = context.plan_cov_runner.invoke(
|
|
plan_app, ["errors", pid, "--format", fmt]
|
|
)
|
|
|
|
|
|
@when('I invoke plan-cov errors "{pid}"')
|
|
def step_invoke_errors_rich(context: Context, pid: str) -> None:
|
|
"""Invoke the errors command in default rich format."""
|
|
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["errors", pid])
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Then — result assertions
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@then("the plan-cov command should exit normally")
|
|
def step_assert_exit_ok(context: Context) -> None:
|
|
"""Assert the CLI exited with code 0."""
|
|
assert context.plan_cov_result.exit_code == 0, (
|
|
f"Expected exit_code=0, got {context.plan_cov_result.exit_code}. "
|
|
f"Output: {context.plan_cov_result.output}"
|
|
)
|
|
|
|
|
|
@then("the plan-cov command should abort")
|
|
def step_assert_abort(context: Context) -> None:
|
|
"""Assert the CLI exited with a non-zero code (abort)."""
|
|
assert context.plan_cov_result.exit_code != 0, (
|
|
f"Expected non-zero exit_code, got {context.plan_cov_result.exit_code}. "
|
|
f"Output: {context.plan_cov_result.output}"
|
|
)
|
|
|
|
|
|
@then('the plan-cov output should contain "{text}"')
|
|
def step_assert_output_contains(context: Context, text: str) -> None:
|
|
"""Assert the CLI output contains the expected text."""
|
|
assert text in context.plan_cov_result.output, (
|
|
f"Expected output to contain '{text}'. "
|
|
f"Actual output: {context.plan_cov_result.output}"
|
|
)
|
|
|
|
|
|
@then("the plan-cov created plan should have estimation_actor set")
|
|
def step_assert_estimation_actor(context: Context) -> None:
|
|
"""Assert the plan was updated with estimation_actor."""
|
|
plan = context.plan_cov_plan
|
|
assert plan.estimation_actor == "openai/gpt-4", (
|
|
f"Expected estimation_actor='openai/gpt-4', got {plan.estimation_actor}"
|
|
)
|