Files
temp/features/steps/cli_output_formats_steps.py
T
freemo 48cff5cfe0 refactor(cli): rename plan lifecycle-list and lifecycle-apply to match specification
Renames `plan lifecycle-list` to `plan list` and `plan lifecycle-apply` to `plan apply` to align with the specification's canonical command names. Removes legacy V2 plan commands that occupied those names.

- Renamed CLI command registrations from lifecycle-list/lifecycle-apply to list/apply
- Removed legacy V2 apply and list commands (~200 lines)
- Updated apply shortcut in main.py to delegate to v3 lifecycle
- Added defensive null check for plan existence in apply command
- Updated 63+ test, doc, and benchmark files for consistency

Closes #881

Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
2026-04-02 19:09:04 +00:00

471 lines
15 KiB
Python

"""Step definitions for CLI output format parity tests."""
from __future__ import annotations
import json
from datetime import datetime
from unittest.mock import MagicMock, patch
import yaml
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
import cleveragents.cli.commands.action as _action_mod
import cleveragents.cli.commands.plan as _plan_mod
from cleveragents.cli.commands.action import app as action_app
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.cli.formatting import _serialize_value, format_output
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
def _make_action(name: str = "local/fmt-action") -> Action:
return Action(
namespaced_name=NamespacedName.parse(name),
description="Format test action",
long_description=None,
definition_of_done="Tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
reusable=True,
read_only=False,
state=ActionState.AVAILABLE,
created_by=None,
created_at=datetime(2025, 1, 15, 10, 0, 0),
updated_at=datetime(2025, 1, 15, 10, 0, 0),
)
def _make_plan(
name: str = "local/fmt-plan",
plan_id: str = _ULID,
) -> Plan:
now = datetime(2025, 1, 15, 10, 0, 0)
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
description="Format test plan",
definition_of_done="Tests pass",
action_name="local/fmt-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
project_links=[ProjectLink(project_name="proj-a")],
timestamps=PlanTimestamps(created_at=now, updated_at=now),
created_by=None,
reusable=True,
read_only=False,
)
@given("a CLI output format test runner")
def step_format_test_runner(context: Context) -> None:
context.runner = CliRunner()
context.saved_keys = {}
@given("a mocked lifecycle service for format tests")
def step_mocked_lifecycle_for_formats(context: Context) -> None:
context.mock_action_service = MagicMock()
context.mock_plan_service = MagicMock()
context.action_patcher = patch.object(
_action_mod,
"_get_lifecycle_service",
return_value=context.mock_action_service,
)
context.plan_patcher = patch.object(
_plan_mod,
"_get_lifecycle_service",
return_value=context.mock_plan_service,
)
context.action_patcher.start()
context.plan_patcher.start()
# Patch module-level Rich Console objects so they never inject ANSI
# escape codes into structured output (JSON/YAML/plain). Without this,
# console.print(json_string) adds syntax-highlighting escapes when Rich
# detects a real terminal, which makes json.loads() / yaml.safe_load()
# fail on the user's machine even though the tests pass inside a
# headless container where Console auto-disables colour.
from rich.console import Console as _Console
_plain_console = _Console(no_color=True, highlight=False)
context.action_console_patcher = patch.object(
_action_mod, "console", _plain_console
)
context.plan_console_patcher = patch.object(_plan_mod, "console", _plain_console)
context.action_console_patcher.start()
context.plan_console_patcher.start()
# Patch _notify_facade so that the A2A facade bootstrap does not
# trigger real DI container construction, alembic migrations, or
# structlog output that would pollute captured CLI stdout and break
# json.loads() / yaml.safe_load() assertions.
context.plan_facade_patcher = patch.object(
_plan_mod, "_notify_facade", lambda *_a, **_kw: None
)
context.plan_facade_patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(context.action_patcher.stop)
context._cleanup_handlers.append(context.plan_patcher.stop)
context._cleanup_handlers.append(context.action_console_patcher.stop)
context._cleanup_handlers.append(context.plan_console_patcher.stop)
context._cleanup_handlers.append(context.plan_facade_patcher.stop)
# ------- Given steps -------
@given("there are actions for format testing")
def step_actions_for_format(context: Context) -> None:
context.mock_action_service.list_actions.return_value = [
_make_action("local/action-a"),
_make_action("local/action-b"),
]
@given("there is a single action for format testing")
def step_single_action_for_format(context: Context) -> None:
context.format_action = _make_action()
context.mock_action_service.get_action_by_name.return_value = context.format_action
@given("there are plans for format testing")
def step_plans_for_format(context: Context) -> None:
context.mock_plan_service.list_plans.return_value = [
_make_plan("local/plan-a", "01ARZ3NDEKTSV4RRFFQ69G5FAV"),
_make_plan("local/plan-b", "01ARZ3NDEKTSV4RRFFQ69G5FAW"),
]
@given("there is a single plan for format testing")
def step_single_plan_for_format(context: Context) -> None:
context.format_plan = _make_plan()
context.mock_plan_service.get_plan.return_value = context.format_plan
# ------- When steps -------
@when("I run action list with --format json")
def step_action_list_json(context: Context) -> None:
context.result = context.runner.invoke(action_app, ["list", "--format", "json"])
@when("I run action show with --format yaml")
def step_action_show_yaml(context: Context) -> None:
context.result = context.runner.invoke(
action_app,
["show", str(context.format_action.namespaced_name), "--format", "yaml"],
)
@when("I run action show with --format json")
def step_action_show_json(context: Context) -> None:
context.result = context.runner.invoke(
action_app,
["show", str(context.format_action.namespaced_name), "--format", "json"],
)
@when("I run plan list with --format json")
def step_plan_list_json(context: Context) -> None:
context.result = context.runner.invoke(plan_app, ["list", "--format", "json"])
@when("I run plan status with plan id and --format json")
def step_plan_status_json(context: Context) -> None:
context.result = context.runner.invoke(
plan_app, ["status", _ULID, "--format", "json"]
)
@when("I run plan status with plan id and --format plain")
def step_plan_status_plain(context: Context) -> None:
context.result = context.runner.invoke(
plan_app, ["status", _ULID, "--format", "plain"]
)
@when("I save the output keys as json_keys")
def step_save_json_keys(context: Context) -> None:
data = json.loads(context.result.output.strip())
context.saved_keys["json_keys"] = set(data.keys())
@when("I save the output keys as yaml_keys")
def step_save_yaml_keys(context: Context) -> None:
data = yaml.safe_load(context.result.output.strip())
context.saved_keys["yaml_keys"] = set(data.keys())
# ------- Then steps -------
@then("the output should be valid JSON")
def step_output_valid_json(context: Context) -> None:
assert context.result.exit_code == 0, (
f"CLI exited with {context.result.exit_code}: {context.result.output}"
)
parsed = json.loads(context.result.output.strip())
assert parsed is not None
@then("the output should be valid YAML")
def step_output_valid_yaml(context: Context) -> None:
assert context.result.exit_code == 0, (
f"CLI exited with {context.result.exit_code}: {context.result.output}"
)
parsed = yaml.safe_load(context.result.output.strip())
assert parsed is not None
@then('the JSON should contain key "{key}"')
def step_json_contains_key(context: Context, key: str) -> None:
output = context.result.output.strip()
parsed = json.loads(output)
if isinstance(parsed, list):
assert key in parsed[0], f"Key '{key}' not in {list(parsed[0].keys())}"
else:
assert key in parsed, f"Key '{key}' not in {list(parsed.keys())}"
@then('the YAML should contain key "{key}"')
def step_yaml_contains_key(context: Context, key: str) -> None:
output = context.result.output.strip()
parsed = yaml.safe_load(output)
if isinstance(parsed, list):
assert key in parsed[0], f"Key '{key}' not in {list(parsed[0].keys())}"
else:
assert key in parsed, f"Key '{key}' not in {list(parsed.keys())}"
@then('the format output should contain "{text}"')
def step_format_output_contains_text(context: Context, text: str) -> None:
assert context.result.exit_code == 0, (
f"CLI exited with {context.result.exit_code}: {context.result.output}"
)
assert text in context.result.output, (
f"'{text}' not found in output: {context.result.output[:300]}"
)
@then("json_keys and yaml_keys should match")
def step_keys_match(context: Context) -> None:
jk = context.saved_keys["json_keys"]
yk = context.saved_keys["yaml_keys"]
assert jk == yk, f"Key mismatch: json={jk}, yaml={yk}"
# ------- Additional Given steps -------
@given("there is an archivable action for format testing")
def step_archivable_action(context: Context) -> None:
action = _make_action()
context.format_action = action
context.mock_action_service.get_action_by_name.return_value = action
context.mock_action_service.archive_action.return_value = action
@given("there are plans for format status listing")
def step_plans_for_status_listing(context: Context) -> None:
context.mock_plan_service.list_plans.return_value = [
_make_plan("local/plan-a", "01ARZ3NDEKTSV4RRFFQ69G5FAV"),
_make_plan("local/plan-b", "01ARZ3NDEKTSV4RRFFQ69G5FAW"),
]
@given("there is an action for plan use format test")
def step_action_for_plan_use(context: Context) -> None:
action = _make_action()
plan = _make_plan()
context.mock_plan_service.get_action_by_name.return_value = action
context.mock_plan_service.use_action.return_value = plan
@given("there is a plan for cancel format test")
def step_plan_for_cancel(context: Context) -> None:
context.mock_plan_service.cancel_plan.return_value = _make_plan()
# ------- Additional When steps -------
@when("I run action list with --format table")
def step_action_list_table(context: Context) -> None:
context.result = context.runner.invoke(action_app, ["list", "--format", "table"])
@when("I run action list with --format plain")
def step_action_list_plain(context: Context) -> None:
context.result = context.runner.invoke(action_app, ["list", "--format", "plain"])
@when("I run action archive with --format json")
def step_action_archive_json(context: Context) -> None:
context.result = context.runner.invoke(
action_app,
[
"archive",
str(context.format_action.namespaced_name),
"--format",
"json",
],
)
@when("I run plan status without id and --format json")
def step_plan_status_no_id_json(context: Context) -> None:
context.result = context.runner.invoke(plan_app, ["status", "--format", "json"])
@when("I run plan use with --format json")
def step_plan_use_json(context: Context) -> None:
context.result = context.runner.invoke(
plan_app,
[
"use",
"local/fmt-action",
"proj-a",
"--format",
"json",
],
)
@when("I run plan cancel with --format json")
def step_plan_cancel_json(context: Context) -> None:
context.result = context.runner.invoke(
plan_app,
["cancel", _ULID, "--format", "json"],
)
# ------- Direct formatting module tests -------
def _capture_format_output(data, fmt):
"""Call format_output capturing stdout (machine-readable formats write there)."""
from contextlib import redirect_stdout
from io import StringIO
buf = StringIO()
with redirect_stdout(buf):
result = format_output(data, fmt)
return result or buf.getvalue().rstrip("\n")
@when("I call format_output with a dict and format json")
def step_call_format_json(context: Context) -> None:
data = {
"name": "test",
"count": 42,
"created_at": datetime(2025, 1, 15).isoformat(),
}
context.format_result = _capture_format_output(data, "json")
@when("I call format_output with a dict and format yaml")
def step_call_format_yaml(context: Context) -> None:
data = {"name": "test", "count": 42}
context.format_result = _capture_format_output(data, "yaml")
@when("I call format_output with a dict and format plain")
def step_call_format_plain(context: Context) -> None:
data = {
"name": "test",
"nested": {"a": 1, "b": 2},
"items": ["x", "y"],
}
context.format_result = _capture_format_output(data, "plain")
@when("I call format_output with a dict and format table")
def step_call_format_table(context: Context) -> None:
data = {"name": "test", "count": 42}
context.format_result = _capture_format_output(data, "table")
@when("I call format_output with a dict and format rich")
def step_call_format_rich(context: Context) -> None:
data = {"name": "test", "count": 42}
context.format_result = _capture_format_output(data, "rich")
@when("I call format_output with a list and format plain")
def step_call_format_list_plain(context: Context) -> None:
data = [
{"name": "a", "value": 1},
{"name": "b", "value": 2},
]
context.format_result = _capture_format_output(data, "plain")
@when("I call serialize_value with enum and nested data")
def step_call_serialize_with_enum(context: Context) -> None:
context.serialized = _serialize_value(
{
"state": ActionState.AVAILABLE,
"nested": {"key": "val"},
"items": [ActionState.ARCHIVED, datetime(2025, 1, 1)],
"ts": datetime(2025, 6, 1, 12, 0),
}
)
# ------- Additional Then steps -------
@then("the format result should be valid JSON dict")
def step_format_result_json_dict(context: Context) -> None:
parsed = json.loads(context.format_result)
assert isinstance(parsed, dict)
@then("the format result should be valid YAML dict")
def step_format_result_yaml_dict(context: Context) -> None:
parsed = yaml.safe_load(context.format_result)
assert isinstance(parsed, dict)
@then("the format result should contain plain key-value pairs")
def step_format_result_plain(context: Context) -> None:
assert "name: test" in context.format_result
assert "nested:" in context.format_result
assert "items:" in context.format_result
@then("the format result should contain table output")
def step_format_result_table(context: Context) -> None:
assert "name" in context.format_result
assert "count" in context.format_result
@then("the format result should contain separator lines")
def step_format_result_separator(context: Context) -> None:
assert "---" in context.format_result
@then("the serialized result should have string enum values")
def step_serialized_enum_values(context: Context) -> None:
assert context.serialized["state"] == "available"
assert isinstance(context.serialized["nested"], dict)
assert context.serialized["items"][0] == "archived"
assert isinstance(context.serialized["ts"], str)