Files
temp/features/steps/m1_sourcecode_smoke_steps.py
T
hurui200320 414abb1396 fix(cli): add missing --yes flag to plan apply command (#1127)
## 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: cleveragents/cleveragents-core#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>
2026-03-26 07:50:09 +00:00

598 lines
22 KiB
Python

"""Step definitions for M1 source-code plan lifecycle smoke tests.
All step names are prefixed with ``m1 smoke`` to avoid ``AmbiguousStep``
conflicts with existing steps.
"""
from __future__ import annotations
import contextlib
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import yaml
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.action import app as action_app
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.cli.commands.project import app as project_app
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "m1"
_PLAN_ULID = "01M1SM0KE00000000000000001"
def _make_m1_plan(
*,
name: str = "local/m1-smoke-plan",
action_name: str = "local/m1-source-review",
phase: PlanPhase = PlanPhase.STRATEGIZE,
state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
arguments: dict[str, object] | None = None,
is_terminal: bool = False,
) -> Plan:
"""Create a Plan instance for M1 smoke tests."""
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse(name),
description="M1 smoke test plan",
definition_of_done="Source code reviewed",
action_name=action_name,
phase=phase,
processing_state=state,
project_links=project_links or [],
arguments=dict(arguments) if arguments else {},
arguments_order=[],
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 _make_m1_action(
name: str = "local/m1-source-review",
) -> Action:
"""Create an Action for M1 smoke tests."""
return Action(
namespaced_name=NamespacedName.parse(name),
description="Minimal source-code review action",
long_description=None,
definition_of_done="Source code reviewed",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
reusable=True,
read_only=True,
state=ActionState.AVAILABLE,
created_by=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
def _register_patcher_cleanup(context: Context, patcher: Any) -> None:
"""Start a patcher and ensure it is always stopped."""
patcher.start()
context.add_cleanup(_safe_stop_patcher, patcher)
def _safe_stop_patcher(patcher: Any) -> None:
"""Best-effort patcher stop for repeated/failed scenarios."""
with contextlib.suppress(RuntimeError):
patcher.stop()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a m1 smoke test runner")
def step_m1_smoke_runner(context: Context) -> None:
"""Set up the CLI runner for M1 smoke tests."""
context.runner = CliRunner()
@given("a m1 smoke mocked lifecycle service")
def step_m1_smoke_mock_service(context: Context) -> None:
"""Set up the mocked lifecycle service for M1 smoke tests."""
from cleveragents.application.services.plan_lifecycle_service import (
ActionNotAvailableError,
)
context.mock_service = MagicMock()
# Default: get_action_by_name raises for any action not explicitly set up
context._m1_known_actions = {} # type-checked in step helpers
def _get_action_by_name_side_effect(name: str) -> Action:
if name in context._m1_known_actions:
return context._m1_known_actions[name]
raise ActionNotAvailableError(action_name=name, state=ActionState.ARCHIVED)
context.mock_service.get_action_by_name.side_effect = (
_get_action_by_name_side_effect
)
context.plan_patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.mock_service,
)
context.action_patcher = patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=context.mock_service,
)
context.project_patcher = patch(
"cleveragents.cli.commands.project._get_namespaced_project_repo",
)
context.resource_patcher = patch(
"cleveragents.cli.commands.project._get_resource_registry_service",
)
context.link_patcher = patch(
"cleveragents.cli.commands.project._get_resource_link_repo",
)
_register_patcher_cleanup(context, context.plan_patcher)
_register_patcher_cleanup(context, context.action_patcher)
context.mock_project_repo = context.project_patcher.start()
context.add_cleanup(_safe_stop_patcher, context.project_patcher)
context.mock_resource_svc = context.resource_patcher.start()
context.add_cleanup(_safe_stop_patcher, context.resource_patcher)
context.mock_link_repo = context.link_patcher.start()
context.add_cleanup(_safe_stop_patcher, context.link_patcher)
context.last_result = None
context.captured_plan_id = None
# ---------------------------------------------------------------------------
# Fixture loading steps
# ---------------------------------------------------------------------------
@when("I m1 smoke load the git repo fixture")
def step_m1_load_git_repo(context: Context) -> None:
"""Load the git repo fixture JSON."""
fixture_path = _FIXTURES_DIR / "git_repo.json"
with open(fixture_path) as f:
context.git_repo_fixtures = json.load(f)
@then("the m1 smoke git repo fixture should have a minimal repo entry")
def step_m1_git_repo_has_minimal(context: Context) -> None:
"""Verify fixture has a minimal_git_repo entry."""
fixtures = context.git_repo_fixtures["fixtures"]
names = [f["name"] for f in fixtures]
assert "minimal_git_repo" in names, f"Expected 'minimal_git_repo' in {names}"
@then("the m1 smoke minimal repo should contain expected files")
def step_m1_git_repo_has_files(context: Context) -> None:
"""Verify the minimal repo defines expected files."""
fixtures = context.git_repo_fixtures["fixtures"]
minimal = next(f for f in fixtures if f["name"] == "minimal_git_repo")
files = minimal["files"]
assert "README.md" in files, "Expected README.md in fixture files"
assert "src/main.py" in files, "Expected src/main.py in fixture files"
@when("I m1 smoke load the git checkout resource fixture")
def step_m1_load_git_checkout(context: Context) -> None:
"""Load the git-checkout resource fixture JSON."""
fixture_path = _FIXTURES_DIR / "git_checkout_resource.json"
with open(fixture_path) as f:
context.checkout_fixtures = json.load(f)
@then("the m1 smoke git checkout fixture should have a minimal entry")
def step_m1_checkout_has_minimal(context: Context) -> None:
"""Verify fixture has a minimal_git_checkout entry."""
fixtures = context.checkout_fixtures["fixtures"]
names = [f["name"] for f in fixtures]
assert "minimal_git_checkout" in names, (
f"Expected 'minimal_git_checkout' in {names}"
)
@then('the m1 smoke minimal checkout should have type "{rtype}"')
def step_m1_checkout_type(context: Context, rtype: str) -> None:
"""Verify the minimal checkout has the expected resource type."""
fixtures = context.checkout_fixtures["fixtures"]
minimal = next(f for f in fixtures if f["name"] == "minimal_git_checkout")
assert minimal["resource_type"] == rtype, (
f"Expected type '{rtype}', got '{minimal['resource_type']}'"
)
@when("I m1 smoke load the action YAML fixture")
def step_m1_load_action_yaml(context: Context) -> None:
"""Load the action YAML fixture."""
fixture_path = _FIXTURES_DIR / "action_sourcecode.yaml"
with open(fixture_path) as f:
context.action_fixture = yaml.safe_load(f)
@then('the m1 smoke action fixture should define strategy actor "{actor}"')
def step_m1_action_strategy_actor(context: Context, actor: str) -> None:
"""Verify the action fixture defines the expected strategy actor."""
assert context.action_fixture["strategy_actor"] == actor
@then('the m1 smoke action fixture should define execution actor "{actor}"')
def step_m1_action_execution_actor(context: Context, actor: str) -> None:
"""Verify the action fixture defines the expected execution actor."""
assert context.action_fixture["execution_actor"] == actor
# ---------------------------------------------------------------------------
# Action create steps
# ---------------------------------------------------------------------------
@given("a m1 smoke temporary action config file")
def step_m1_temp_action_config(context: Context) -> None:
"""Create a temp YAML config file for action create."""
fixture_path = _FIXTURES_DIR / "action_sourcecode.yaml"
context.m1_action_config = str(fixture_path)
context.mock_service.create_action.return_value = _make_m1_action()
@when("I m1 smoke invoke action create with the config")
def step_m1_invoke_action_create(context: Context) -> None:
"""Invoke action create CLI with config file."""
result = context.runner.invoke(
action_app,
["create", "--config", context.m1_action_config],
)
context.last_result = result
@then("the m1 smoke action create should succeed")
def step_m1_action_create_ok(context: Context) -> None:
"""Verify action create succeeded."""
assert context.last_result is not None
assert context.last_result.exit_code == 0, (
f"Expected exit 0, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
@then('the m1 smoke action output should contain "{text}"')
def step_m1_action_output_contains(context: Context, text: str) -> None:
"""Verify action output contains expected text."""
output = context.last_result.output
assert text in output, f"Expected '{text}' in: {output}"
# ---------------------------------------------------------------------------
# Project + resource link steps
# ---------------------------------------------------------------------------
@when('I m1 smoke create a temp project "{name}"')
def step_m1_create_project(context: Context, name: str) -> None:
"""Create a temp project via the mock."""
mock_repo = context.mock_project_repo.return_value
mock_project = MagicMock()
mock_project.namespaced_name = name
mock_project.namespace = name.split("/")[0]
mock_project.name = name.split("/")[1]
mock_project.description = "M1 smoke test project"
mock_project.linked_resources = []
mock_project.created_at = datetime.now()
mock_project.updated_at = datetime.now()
mock_repo.create.return_value = None
mock_repo.get.return_value = mock_project
result = context.runner.invoke(
project_app,
["create", name, "--format", "plain"],
)
context.last_result = result
@then("the m1 smoke project creation should succeed")
def step_m1_project_create_ok(context: Context) -> None:
"""Verify project creation succeeded."""
assert context.last_result is not None
assert context.last_result.exit_code == 0, (
f"Expected exit 0, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
@then('the m1 smoke project output should contain "{text}"')
def step_m1_project_output_contains(context: Context, text: str) -> None:
"""Verify project output contains expected text."""
output = context.last_result.output
assert text in output, f"Expected '{text}' in: {output}"
@given('a m1 smoke project "{name}" exists')
def step_m1_project_exists(context: Context, name: str) -> None:
"""Set up existing project mock."""
mock_repo = context.mock_project_repo.return_value
mock_project = MagicMock()
mock_project.namespaced_name = name
mock_project.namespace = name.split("/")[0]
mock_project.name = name.split("/")[1]
mock_project.linked_resources = []
mock_repo.get.return_value = mock_project
@given('a m1 smoke resource "{name}" exists')
def step_m1_resource_exists(context: Context, name: str) -> None:
"""Set up existing resource mock."""
mock_registry = context.mock_resource_svc.return_value
mock_resource = MagicMock()
mock_resource.resource_id = "01M1RESOURCE000000000000001"
mock_resource.name = name
mock_registry.show_resource.return_value = mock_resource
@when('I m1 smoke link resource "{resource}" to project "{project}"')
def step_m1_link_resource(context: Context, resource: str, project: str) -> None:
"""Link a resource to a project via CLI."""
mock_link_repo = context.mock_link_repo.return_value
mock_link_repo.create_link.return_value = None
result = context.runner.invoke(
project_app,
["link-resource", project, resource, "--format", "plain"],
)
context.last_result = result
@then("the m1 smoke link should succeed")
def step_m1_link_ok(context: Context) -> None:
"""Verify resource link succeeded."""
assert context.last_result is not None
assert context.last_result.exit_code == 0, (
f"Expected exit 0, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
# ---------------------------------------------------------------------------
# Plan use steps
# ---------------------------------------------------------------------------
@given('a m1 smoke action "{name}" exists')
def step_m1_action_exists(context: Context, name: str) -> None:
"""Set up an existing action for plan use."""
action = _make_m1_action(name)
context._m1_known_actions[name] = action
context.mock_service.use_action.return_value = _make_m1_plan(action_name=name)
@when('I m1 smoke invoke plan use with action "{name}"')
def step_m1_plan_use(context: Context, name: str) -> None:
"""Invoke plan use with the given action."""
result = context.runner.invoke(plan_app, ["use", name])
context.last_result = result
if result.exit_code == 0:
context.captured_plan_id = _PLAN_ULID
@when('I m1 smoke invoke plan use linking project "{project}" to action "{name}"')
def step_m1_plan_use_with_project(context: Context, project: str, name: str) -> None:
"""Invoke plan use with action and project."""
result = context.runner.invoke(plan_app, ["use", name, project])
context.last_result = result
@when('I m1 smoke invoke plan use passing arg "{arg_str}" to action "{name}"')
def step_m1_plan_use_with_arg(context: Context, arg_str: str, name: str) -> None:
"""Invoke plan use with action and --arg."""
result = context.runner.invoke(plan_app, ["use", name, "--arg", arg_str])
context.last_result = result
if result.exit_code == 0:
context.captured_plan_id = _PLAN_ULID
context.last_result = result
@when('I m1 smoke invoke plan use with invalid strategy actor "{actor}"')
def step_m1_plan_use_invalid_actor(context: Context, actor: str) -> None:
"""Invoke plan use with an invalid actor format."""
result = context.runner.invoke(
plan_app, ["use", "local/m1-source-review", "--strategy-actor", actor]
)
context.last_result = result
@then("the m1 smoke plan use should succeed")
def step_m1_plan_use_ok(context: Context) -> None:
"""Verify plan use succeeded."""
assert context.last_result is not None
assert context.last_result.exit_code == 0, (
f"Expected exit 0, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
@then("the m1 smoke plan use should fail")
def step_m1_plan_use_fail(context: Context) -> None:
"""Verify plan use failed."""
assert context.last_result is not None
assert context.last_result.exit_code != 0
@then('the m1 smoke plan should be in phase "{phase}"')
def step_m1_plan_phase(context: Context, phase: str) -> None:
"""Verify plan is in the expected phase."""
output = context.last_result.output
assert phase in output.lower(), f"Expected phase '{phase}' in output: {output}"
@then("the m1 smoke captured plan id should not be empty")
def step_m1_plan_id_captured(context: Context) -> None:
"""Verify we captured a plan ID."""
assert context.captured_plan_id is not None
assert len(context.captured_plan_id) > 0
# ---------------------------------------------------------------------------
# Plan execute steps
# ---------------------------------------------------------------------------
@given("a m1 smoke plan exists in strategize phase")
def step_m1_plan_in_strategize(context: Context) -> None:
"""Set up a plan in strategize/complete phase for execute."""
plan = _make_m1_plan(
phase=PlanPhase.STRATEGIZE,
state=ProcessingState.COMPLETE,
)
context.mock_service.list_plans.return_value = [plan]
context.mock_service.get_plan.return_value = plan
context.mock_service.execute_plan.return_value = _make_m1_plan(
phase=PlanPhase.EXECUTE,
state=ProcessingState.QUEUED,
)
# Patch the plan executor so the execute phase runs with a mock
context._m1_executor_patcher = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
_register_patcher_cleanup(context, context._m1_executor_patcher)
@when("I m1 smoke invoke plan execute")
def step_m1_plan_execute(context: Context) -> None:
"""Invoke plan execute."""
result = context.runner.invoke(plan_app, ["execute"])
context.last_result = result
# Clean up executor patcher if it was set
with contextlib.suppress(AttributeError):
context._m1_executor_patcher.stop()
@then("the m1 smoke plan execute should succeed")
def step_m1_plan_execute_ok(context: Context) -> None:
"""Verify plan execute succeeded."""
assert context.last_result is not None
assert context.last_result.exit_code == 0, (
f"Expected exit 0, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
@then("the m1 smoke plan execute should fail")
def step_m1_plan_execute_fail(context: Context) -> None:
"""Verify plan execute failed."""
assert context.last_result is not None
assert context.last_result.exit_code != 0
# ---------------------------------------------------------------------------
# Plan diff steps
# ---------------------------------------------------------------------------
@given("a m1 smoke plan exists in execute phase with changeset")
def step_m1_plan_with_changeset(context: Context) -> None:
"""Set up a plan in execute phase with a changeset mock."""
plan = _make_m1_plan(
phase=PlanPhase.EXECUTE,
state=ProcessingState.COMPLETE,
)
context.mock_service.get_plan.return_value = plan
context.mock_service.list_plans.return_value = [plan]
# Mock the apply service since plan diff uses _get_apply_service()
mock_apply_svc = MagicMock()
mock_apply_svc.diff.return_value = "No changes detected."
context.apply_patcher = patch(
"cleveragents.cli.commands.plan._get_apply_service",
return_value=mock_apply_svc,
)
_register_patcher_cleanup(context, context.apply_patcher)
@when("I m1 smoke invoke plan diff")
def step_m1_plan_diff(context: Context) -> None:
"""Invoke plan diff."""
result = context.runner.invoke(plan_app, ["diff", _PLAN_ULID])
context.last_result = result
@then("the m1 smoke plan diff should succeed")
def step_m1_plan_diff_ok(context: Context) -> None:
"""Verify plan diff succeeded."""
assert context.last_result is not None
# diff may succeed or return 0 even without changes
assert context.last_result.exit_code == 0, (
f"Expected exit 0, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
# ---------------------------------------------------------------------------
# Plan apply steps
# ---------------------------------------------------------------------------
@given("a m1 smoke plan exists in apply phase")
def step_m1_plan_in_apply(context: Context) -> None:
"""Set up a plan in apply phase."""
plan = _make_m1_plan(
phase=PlanPhase.APPLY,
state=ProcessingState.QUEUED,
)
context.mock_service.list_plans.return_value = [plan]
context.mock_service.get_plan.return_value = plan
context.mock_service.apply_plan.return_value = _make_m1_plan(
phase=PlanPhase.APPLY,
state=ProcessingState.APPLIED,
)
# For execute attempts on an apply-phase plan, raise error
from cleveragents.application.services.plan_lifecycle_service import (
InvalidPhaseTransitionError,
)
context.mock_service.execute_plan.side_effect = InvalidPhaseTransitionError(
from_phase=PlanPhase.APPLY,
to_phase=PlanPhase.EXECUTE,
message="Cannot execute a plan in apply phase",
)
@when("I m1 smoke invoke plan lifecycle-apply")
def step_m1_plan_apply(context: Context) -> None:
"""Invoke plan lifecycle-apply."""
result = context.runner.invoke(plan_app, ["lifecycle-apply", "--yes", _PLAN_ULID])
context.last_result = result
@then("the m1 smoke plan apply should succeed")
def step_m1_plan_apply_ok(context: Context) -> None:
"""Verify plan apply succeeded."""
assert context.last_result is not None
assert context.last_result.exit_code == 0, (
f"Expected exit 0, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
@then("the m1 smoke plan should be in terminal state")
def step_m1_plan_terminal(context: Context) -> None:
"""Verify the plan is in terminal state."""
output = context.last_result.output.lower()
assert "appl" in output or "terminal" in output, (
f"Expected terminal state indicator in: {context.last_result.output}"
)