Files
cleveragents-core/robot/helper_cli_formats.py
T
freemo 6e47abbd63 fix(cli): fix format_output() to use rich and color renderers instead of JSON fallback
The format_output() function in src/cleveragents/cli/formatting.py had two
routing bugs that caused incorrect output for the 'rich' and 'color' formats:

1. The 'rich' format had no explicit dispatch branch and silently fell through
   to the final JSON fallback, returning raw JSON instead of styled terminal
   output. Since 'rich' is the default CLI format (per ADR-021), this meant
   all commands using format_output() (version, info, diagnostics) produced
   JSON by default.

2. The 'color' format was incorrectly routed to _format_plain() instead of a
   color-aware renderer, producing plain text with no ANSI color codes.

Fix:
- Added _format_rich() helper that delegates to RichMaterializer via
  OutputSession, producing ANSI-styled terminal output consistent with
  format_output_session().
- Added _format_color() helper that delegates to ColorMaterializer via
  OutputSession, producing ANSI-colored terminal output.
- Added explicit OutputFormat.RICH dispatch in format_output() routing.
- Fixed OutputFormat.COLOR dispatch to use _format_color() instead of
  _format_plain().

Tests:
- Updated existing BDD scenario that was validating the buggy behavior
  (expected JSON for rich format) to now assert correct styled output.
- Added new BDD scenarios: 'rich format produces styled terminal output not
  JSON' and 'color format produces ANSI-colored output not plain text'.
- Added Robot Framework integration tests in cli_formats.robot and
  helper_cli_formats.py verifying end-to-end styled output for both formats.

All nox sessions pass: lint, typecheck, unit_tests, security_scan.

ISSUES CLOSED: #2921
2026-05-30 11:09:06 -04:00

303 lines
11 KiB
Python

"""Helper script for cli_formats.robot smoke tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import json
import sys
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import yaml
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from typer.testing import CliRunner # noqa: E402
from cleveragents.cli.commands.action import app as action_app # noqa: E402
from cleveragents.cli.commands.plan import app as plan_app # noqa: E402
from cleveragents.domain.models.core.action import Action, ActionState # noqa: E402
from cleveragents.domain.models.core.plan import ( # noqa: E402
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
runner = CliRunner()
_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
def _mock_action(name: str = "local/fmt-smoke") -> Action:
return Action(
namespaced_name=NamespacedName.parse(name),
description="Smoke 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.now(),
updated_at=datetime.now(),
)
def _mock_plan(name: str = "local/fmt-smoke-plan") -> Plan:
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_ULID),
namespaced_name=NamespacedName.parse(name),
description="Smoke plan",
definition_of_done="Tests pass",
action_name="local/fmt-smoke",
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,
)
def action_list_json() -> None:
svc = MagicMock()
svc.list_actions.return_value = [_mock_action("local/a1"), _mock_action("local/a2")]
with patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=svc,
):
result = runner.invoke(action_app, ["list", "--format", "json"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, list) and len(parsed) == 2
print("cli-formats-action-list-json-ok")
def action_show_yaml() -> None:
svc = MagicMock()
svc.get_action_by_name.return_value = _mock_action()
with patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=svc,
):
result = runner.invoke(
action_app, ["show", "local/fmt-smoke", "--format", "yaml"]
)
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
parsed = yaml.safe_load(result.output.strip())
assert "namespaced_name" in parsed
print("cli-formats-action-show-yaml-ok")
def plan_list_json() -> None:
svc = MagicMock()
svc.list_plans.return_value = [_mock_plan()]
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=svc,
):
result = runner.invoke(plan_app, ["list", "--format", "json"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, list)
assert "plan_id" in parsed[0]
print("cli-formats-plan-list-json-ok")
def plan_status_plain() -> None:
svc = MagicMock()
svc.get_plan.return_value = _mock_plan()
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=svc,
):
result = runner.invoke(plan_app, ["status", _ULID, "--format", "plain"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
assert "plan_id:" in result.output
assert "processing_state:" in result.output
print("cli-formats-plan-status-plain-ok")
def global_format_json_version() -> None:
"""Test global --format json propagates to version command."""
from cleveragents.cli.main import app as main_app
result = runner.invoke(main_app, ["--format", "json", "version"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, dict)
assert "version" in parsed
print("cli-global-format-json-version-ok")
def global_format_yaml_version() -> None:
"""Test global --format yaml propagates to version command."""
from cleveragents.cli.main import app as main_app
result = runner.invoke(main_app, ["--format", "yaml", "version"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
parsed = yaml.safe_load(result.output.strip())
assert isinstance(parsed, dict)
assert "version" in parsed
print("cli-global-format-yaml-version-ok")
def global_format_plain_version() -> None:
"""Test global --format plain propagates to version command."""
from cleveragents.cli.main import app as main_app
result = runner.invoke(main_app, ["--format", "plain", "version"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
assert "version:" in result.output
print("cli-global-format-plain-version-ok")
def global_format_json_info() -> None:
"""Test global --format json propagates to info command."""
from cleveragents.cli.main import app as main_app
result = runner.invoke(main_app, ["--format", "json", "info"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, dict)
print("cli-global-format-json-info-ok")
def global_format_json_diagnostics() -> None:
"""Test global --format json propagates to diagnostics command."""
from cleveragents.cli.main import app as main_app
result = runner.invoke(main_app, ["--format", "json", "diagnostics"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, dict)
print("cli-global-format-json-diagnostics-ok")
def global_format_shorthand() -> None:
"""Test global -f json shorthand propagates to version command."""
from cleveragents.cli.main import app as main_app
result = runner.invoke(main_app, ["-f", "json", "version"])
assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, dict)
assert "version" in parsed
print("cli-global-format-shorthand-ok")
def global_format_all_six() -> None:
"""Test all six output formats work via the global --format flag."""
from cleveragents.cli.main import app as main_app
formats = ["json", "yaml", "plain", "rich", "table", "color"]
for fmt in formats:
result = runner.invoke(main_app, ["--format", fmt, "version"])
assert result.exit_code == 0, (
f"Format '{fmt}' failed: exit={result.exit_code}: {result.output}"
)
print("cli-global-format-all-six-ok")
def format_output_rich() -> None:
"""Verify format_output(data, 'rich') produces styled output, not JSON.
Regression test for issue #2921: rich format silently fell back to JSON.
"""
import json as _json
from cleveragents.cli.formatting import format_output
data = {"name": "smoke-test", "status": "active", "count": 42}
result = format_output(data, "rich")
# Must NOT be valid JSON (that was the bug)
try:
_json.loads(result)
raise AssertionError(
f"format_output(data, 'rich') returned valid JSON — "
f"expected styled terminal output, got: {result!r}"
)
except (_json.JSONDecodeError, ValueError):
pass # Expected: rich output is not JSON
# Must contain the data keys
assert "name" in result, f"Rich output missing 'name': {result!r}"
assert "status" in result, f"Rich output missing 'status': {result!r}"
# Must contain ANSI codes or panel header (styled output)
has_ansi = "\033[" in result or "\x1b[" in result
has_panel = "Output" in result
assert has_ansi or has_panel, (
f"Rich output has no ANSI codes or panel header: {result!r}"
)
print("cli-formats-format-output-rich-ok")
def format_output_color() -> None:
"""Verify format_output(data, 'color') produces ANSI-colored output, not plain text.
Regression test for issue #2921: color format used plain renderer instead of color.
"""
from cleveragents.cli.formatting import format_output
data = {"name": "smoke-test", "status": "active", "count": 42}
result = format_output(data, "color")
# Must contain the data keys
assert "name" in result, f"Color output missing 'name': {result!r}"
assert "status" in result, f"Color output missing 'status': {result!r}"
# Must NOT be plain text only (that was the bug)
plain_text = "name: smoke-test\nstatus: active\ncount: 42"
assert result != plain_text, (
f"format_output(data, 'color') returned plain text — "
f"expected ANSI-colored output, got: {result!r}"
)
# Must contain ANSI codes or panel header (styled output)
has_ansi = "\033[" in result or "\x1b[" in result
has_panel = "Output" in result
assert has_ansi or has_panel, (
f"Color output has no ANSI codes or panel header: {result!r}"
)
print("cli-formats-format-output-color-ok")
_COMMANDS = {
"action-list-json": action_list_json,
"action-show-yaml": action_show_yaml,
"plan-list-json": plan_list_json,
"plan-status-plain": plan_status_plain,
"global-format-json-version": global_format_json_version,
"global-format-yaml-version": global_format_yaml_version,
"global-format-plain-version": global_format_plain_version,
"global-format-json-info": global_format_json_info,
"global-format-json-diagnostics": global_format_json_diagnostics,
"global-format-shorthand": global_format_shorthand,
"global-format-all-six": global_format_all_six,
"format-output-rich": format_output_rich,
"format-output-color": format_output_color,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(2)
_COMMANDS[sys.argv[1]]()