Files
cleveragents-core/features/steps/cli_extensions_steps.py
T
freemo f69224e7e9
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Failing after 15s
CI / quality (pull_request) Successful in 17s
CI / build (pull_request) Successful in 17s
CI / security (pull_request) Successful in 29s
CI / typecheck (pull_request) Successful in 31s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 3m44s
CI / unit_tests (pull_request) Successful in 7m4s
CI / docker (pull_request) Has been skipped
feat(cli): add action and plan CLI extensions
2026-02-23 10:46:53 +00:00

568 lines
20 KiB
Python

"""Step definitions for CLI extensions feature (plan + action)."""
from __future__ import annotations
from datetime import datetime
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.action import app as action_app
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.cli.commands.plan import validate_namespaced_actor
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
def _make_plan(
*,
name: str = "local/test-plan",
action_name: str = "local/test-action",
phase: PlanPhase = PlanPhase.STRATEGIZE,
state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
arguments: dict[str, object] | None = None,
automation_profile: AutomationProfileRef | None = None,
invariants: list[PlanInvariant] | None = None,
strategy_actor: str | None = "openai/gpt-4",
execution_actor: str | None = "openai/gpt-4",
estimation_actor: str | None = None,
invariant_actor: str | None = None,
) -> Plan:
"""Create a Plan instance for CLI extension tests."""
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse(name),
description="Test plan description",
definition_of_done="All tests pass",
action_name=action_name,
phase=phase,
processing_state=state,
project_links=project_links or [],
arguments=dict(arguments) if arguments else {},
arguments_order=[],
automation_profile=automation_profile,
invariants=invariants or [],
strategy_actor=strategy_actor,
execution_actor=execution_actor,
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
reusable=True,
read_only=False,
created_by=None,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
def _make_action(
name: str = "local/test-action",
*,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
invariants: list[str] | None = None,
inputs_schema: dict[str, object] | None = None,
automation_profile: str | None = None,
) -> Action:
"""Create an Action for CLI extension tests."""
return Action(
namespaced_name=NamespacedName.parse(name),
description="Test action description",
long_description=None,
definition_of_done="All tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
reusable=True,
read_only=False,
state=ActionState.AVAILABLE,
automation_profile=automation_profile,
invariants=invariants or [],
inputs_schema=inputs_schema,
created_by=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a cli extensions test runner")
def step_cli_ext_runner(context: Context) -> None:
"""Set up the CLI runner."""
context.runner = CliRunner()
@given("a cli extensions mocked lifecycle service")
def step_cli_ext_mock_service(context: Context) -> None:
"""Set up the mocked lifecycle service."""
context.mock_service = MagicMock()
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.plan_patcher.start()
context.action_patcher.start()
# Store the last plan created for inspection
context.last_plan = None
context.last_result = None
# ---------------------------------------------------------------------------
# plan use: action setup
# ---------------------------------------------------------------------------
@given("a cli extensions action exists")
def step_cli_ext_action_exists(context: Context) -> None:
"""Set up an action for plan use tests."""
context.mock_service.get_action_by_name.return_value = _make_action()
context.mock_service.use_action.return_value = _make_plan()
# ---------------------------------------------------------------------------
# plan use: automation profile
# ---------------------------------------------------------------------------
@when('I run plan use with automation profile flag "{profile}"')
def step_run_plan_use_with_profile(context: Context, profile: str) -> None:
"""Run plan use with --automation-profile."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--automation-profile", profile],
)
context.last_result = result
context.last_profile = profile
@then("the cli extensions plan use should succeed")
def step_plan_use_should_succeed(context: Context) -> None:
"""Verify plan use succeeded."""
assert context.last_result is not None
assert context.last_result.exit_code == 0, (
f"Expected exit code 0, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
@then("the cli extensions plan use should fail")
def step_plan_use_should_fail(context: Context) -> None:
"""Verify plan use failed."""
assert context.last_result is not None
assert context.last_result.exit_code != 0
@then('the cli extensions plan should have automation profile "{profile}"')
def step_plan_has_profile(context: Context, profile: str) -> None:
"""Verify plan output mentions automation profile."""
output = context.last_result.output
assert profile in output, f"Expected '{profile}' in output: {output}"
# ---------------------------------------------------------------------------
# plan use: invariant flags
# ---------------------------------------------------------------------------
@when('I run plan use with invariant flags "{inv1}" and "{inv2}"')
def step_run_plan_use_with_invariants(context: Context, inv1: str, inv2: str) -> None:
"""Run plan use with two --invariant flags."""
result = context.runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--invariant",
inv1,
"--invariant",
inv2,
],
)
context.last_result = result
@then('the cli extensions service received invariants "{inv1}" and "{inv2}"')
def step_service_received_invariants(context: Context, inv1: str, inv2: str) -> None:
"""Verify invariants were passed to the service."""
call_args = context.mock_service.use_action.call_args
assert call_args is not None, "use_action was not called"
invariants = call_args.kwargs.get("invariants") or call_args[1].get("invariants")
assert invariants is not None, "No invariants passed to use_action"
texts = [inv.text for inv in invariants]
assert inv1 in texts, f"Expected '{inv1}' in {texts}"
assert inv2 in texts, f"Expected '{inv2}' in {texts}"
@when('I run plan use with single invariant "{inv_text}"')
def step_run_plan_use_with_single_invariant(context: Context, inv_text: str) -> None:
"""Run plan use with a single --invariant flag."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--invariant", inv_text],
)
context.last_result = result
@then('the cli extensions service received single invariant "{inv_text}"')
def step_service_received_single_invariant(context: Context, inv_text: str) -> None:
"""Verify single invariant was passed."""
call_args = context.mock_service.use_action.call_args
assert call_args is not None
invariants = call_args.kwargs.get("invariants") or call_args[1].get("invariants")
assert invariants is not None
texts = [inv.text for inv in invariants]
assert inv_text in texts, f"Expected '{inv_text}' in {texts}"
# ---------------------------------------------------------------------------
# plan use: actor override validation (valid)
# ---------------------------------------------------------------------------
@when('I run plan use with strategy actor flag "{actor}"')
def step_run_plan_use_strategy_actor(context: Context, actor: str) -> None:
"""Run plan use with --strategy-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--strategy-actor", actor],
)
context.last_result = result
@when('I run plan use with execution actor flag "{actor}"')
def step_run_plan_use_execution_actor(context: Context, actor: str) -> None:
"""Run plan use with --execution-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--execution-actor", actor],
)
context.last_result = result
@when('I run plan use with estimation actor flag "{actor}"')
def step_run_plan_use_estimation_actor(context: Context, actor: str) -> None:
"""Run plan use with --estimation-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--estimation-actor", actor],
)
context.last_result = result
@when('I run plan use with invariant actor flag "{actor}"')
def step_run_plan_use_invariant_actor(context: Context, actor: str) -> None:
"""Run plan use with --invariant-actor."""
result = context.runner.invoke(
plan_app,
["use", "local/test-action", "--invariant-actor", actor],
)
context.last_result = result
@then('the cli extensions plan has strategy actor "{actor}"')
def step_plan_has_strategy_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the strategy actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
@then('the cli extensions plan has execution actor "{actor}"')
def step_plan_has_execution_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the execution actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
@then('the cli extensions plan has estimation actor "{actor}"')
def step_plan_has_estimation_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the estimation actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
@then('the cli extensions plan has invariant actor "{actor}"')
def step_plan_has_invariant_actor(context: Context, actor: str) -> None:
"""Verify plan output shows the invariant actor."""
output = context.last_result.output
assert actor in output, f"Expected '{actor}' in output: {output}"
# ---------------------------------------------------------------------------
# plan use: actor override validation (invalid format)
# ---------------------------------------------------------------------------
@then("the cli extensions plan use should fail with actor validation error")
def step_plan_use_fail_actor_validation(context: Context) -> None:
"""Verify plan use failed due to actor validation."""
assert context.last_result is not None
assert context.last_result.exit_code != 0, (
f"Expected non-zero exit, got {context.last_result.exit_code}. "
f"Output: {context.last_result.output}"
)
# ---------------------------------------------------------------------------
# plan status: automation profile
# ---------------------------------------------------------------------------
@given('cli extensions plans exist with automation profile "{profile}"')
def step_plans_with_profile(context: Context, profile: str) -> None:
"""Set up plans that have an automation profile."""
plan = _make_plan(
automation_profile=AutomationProfileRef(
profile_name=profile,
provenance=AutomationProfileProvenance.PLAN,
)
)
context.mock_service.list_plans.return_value = [plan]
context.mock_service.get_plan.return_value = plan
@when("I run cli extensions plan status")
def step_run_plan_status(context: Context) -> None:
"""Run plan status (no plan_id = list all)."""
result = context.runner.invoke(plan_app, ["status"])
context.last_result = result
@then('the cli extensions status output should contain "{text}"')
def step_status_contains(context: Context, text: str) -> None:
"""Verify status output contains text."""
assert context.last_result.exit_code == 0
assert text in context.last_result.output, (
f"Expected '{text}' in: {context.last_result.output}"
)
@when('I run cli extensions plan status with format "{fmt}"')
def step_run_plan_status_fmt(context: Context, fmt: str) -> None:
"""Run plan status with --format."""
result = context.runner.invoke(plan_app, ["status", "--format", fmt])
context.last_result = result
@then(
"the cli extensions status json output should contain automation profile "
'"{profile}"'
)
def step_status_json_profile(context: Context, profile: str) -> None:
"""Verify JSON output contains automation profile."""
assert context.last_result.exit_code == 0
output = context.last_result.output
assert profile in output, f"Expected '{profile}' in: {output}"
# ---------------------------------------------------------------------------
# plan lifecycle-list: invariants
# ---------------------------------------------------------------------------
@given("cli extensions plans exist with invariants")
def step_plans_with_invariants(context: Context) -> None:
"""Set up plans that have invariants."""
plan = _make_plan(
invariants=[
PlanInvariant(text="No warnings", source=InvariantSource.PLAN),
PlanInvariant(text="Keep compat", source=InvariantSource.ACTION),
]
)
context.mock_service.list_plans.return_value = [plan]
@when("I run cli extensions plan lifecycle-list")
def step_run_lifecycle_list(context: Context) -> None:
"""Run plan lifecycle-list."""
result = context.runner.invoke(plan_app, ["lifecycle-list"])
context.last_result = result
@then("the cli extensions list output should contain invariant count")
def step_list_has_invariant_count(context: Context) -> None:
"""Verify list output contains invariant count."""
assert context.last_result.exit_code == 0
# The table should show "2" for invariant count
assert "2" in context.last_result.output, (
f"Expected '2' invariant count in: {context.last_result.output}"
)
@when('I run cli extensions plan lifecycle-list with format "{fmt}"')
def step_run_lifecycle_list_fmt(context: Context, fmt: str) -> None:
"""Run plan lifecycle-list with --format."""
result = context.runner.invoke(plan_app, ["lifecycle-list", "--format", fmt])
context.last_result = result
@then("the cli extensions list json output should contain invariants")
def step_list_json_invariants(context: Context) -> None:
"""Verify JSON output contains invariants field."""
assert context.last_result.exit_code == 0
output = context.last_result.output
assert "invariants" in output, f"Expected 'invariants' in: {output}"
assert "No warnings" in output, f"Expected 'No warnings' in: {output}"
# ---------------------------------------------------------------------------
# action show: optional fields
# ---------------------------------------------------------------------------
@given('a cli extensions action with estimation actor "{actor}"')
def step_action_with_estimation_actor(context: Context, actor: str) -> None:
"""Set up an action with estimation actor."""
action = _make_action(estimation_actor=actor)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@given('a cli extensions action with invariant actor "{actor}"')
def step_action_with_invariant_actor(context: Context, actor: str) -> None:
"""Set up an action with invariant actor."""
action = _make_action(invariant_actor=actor)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@given("a cli extensions action with invariants")
def step_action_with_invariants(context: Context) -> None:
"""Set up an action with invariants."""
action = _make_action(invariants=["No regressions", "Keep backward compat"])
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@given("a cli extensions action with inputs schema")
def step_action_with_inputs_schema(context: Context) -> None:
"""Set up an action with inputs_schema."""
action = _make_action(
inputs_schema={
"type": "object",
"properties": {"coverage": {"type": "integer"}},
}
)
context.mock_service.get_action_by_name.return_value = action
context.test_action = action
@when("I run cli extensions action show")
def step_run_action_show(context: Context) -> None:
"""Run action show."""
result = context.runner.invoke(action_app, ["show", "local/test-action"])
context.last_result = result
@when('I run cli extensions action show with format "{fmt}"')
def step_run_action_show_fmt(context: Context, fmt: str) -> None:
"""Run action show with --format."""
result = context.runner.invoke(
action_app, ["show", "local/test-action", "--format", fmt]
)
context.last_result = result
@then('the cli extensions action output should contain "{text}"')
def step_action_output_contains(context: Context, text: str) -> None:
"""Verify action output contains text."""
assert context.last_result.exit_code == 0, (
f"Exit code: {context.last_result.exit_code}, "
f"output: {context.last_result.output}"
)
assert text in context.last_result.output, (
f"Expected '{text}' in: {context.last_result.output}"
)
@then('the cli extensions action json should contain "{key}"')
def step_action_json_contains(context: Context, key: str) -> None:
"""Verify JSON output contains key."""
assert context.last_result.exit_code == 0
output = context.last_result.output
assert key in output, f"Expected '{key}' in: {output}"
# ---------------------------------------------------------------------------
# plan use: combined profile + invariant
# ---------------------------------------------------------------------------
@when('I run plan use with profile "{profile}" and invariant "{inv_text}"')
def step_run_plan_use_profile_invariant(
context: Context, profile: str, inv_text: str
) -> None:
"""Run plan use with both --automation-profile and --invariant."""
result = context.runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--automation-profile",
profile,
"--invariant",
inv_text,
],
)
context.last_result = result
# ---------------------------------------------------------------------------
# validate_namespaced_actor unit tests
# ---------------------------------------------------------------------------
@then('validate_namespaced_actor accepts "{actor}"')
def step_validate_actor_accepts(context: Context, actor: str) -> None:
"""Verify validate_namespaced_actor accepts a valid name."""
result = validate_namespaced_actor(actor, "--test")
assert result == actor
@then('validate_namespaced_actor rejects "{actor}"')
def step_validate_actor_rejects(context: Context, actor: str) -> None:
"""Verify validate_namespaced_actor rejects an invalid name."""
try:
validate_namespaced_actor(actor, "--test")
raise AssertionError(f"Expected ValidationError for '{actor}'")
except ValidationError:
pass # Expected
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
def after_scenario(context: Context, scenario: object) -> None:
"""Clean up patchers."""
for name in ("plan_patcher", "action_patcher"):
patcher = getattr(context, name, None)
if patcher:
patcher.stop()