fix(cli): address reviewer feedback on format_output rich/color fix

- Move function-level imports to module top level in formatting.py:
  * Remove redundant OutputSession import inside _format_rich()
  * Remove redundant OutputSession import inside _format_color()
  * Move `import sys` from inside format_output() to module level

- Fix robot/helper_cli_formats.py:
  * Remove redundant `import json as _json` inside format_output_rich();
    use the top-level `json` module directly
  * Replace non-deterministic datetime.now() calls in _mock_action() and
    _mock_plan() with fixed datetime(2025, 1, 15, 10, 0, 0)

- Split cli_output_formats_steps.py to comply with 500-line limit:
  * Extract all @then step definitions into new file
    features/steps/cli_output_format_validation_steps.py
  * Behave auto-discovers steps from any .py file in steps/

ISSUES CLOSED: #2921
This commit is contained in:
2026-05-30 09:00:06 -04:00
committed by drew
parent 6e47abbd63
commit a726b96d26
4 changed files with 133 additions and 134 deletions
@@ -0,0 +1,127 @@
"""Step definitions for CLI output format validation (rich / color formats)."""
from __future__ import annotations
import json
import yaml
from behave import then
from behave.runner import Context
@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)
# Verify spec-required envelope fields are present
for field in ("command", "status", "exit_code", "data", "timing", "messages"):
assert field in parsed, f"Envelope field '{field}' missing from JSON result"
# Verify data field contains the original dict
assert isinstance(parsed["data"], 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)
# Verify spec-required envelope fields are present
for field in ("command", "status", "exit_code", "data", "timing", "messages"):
assert field in parsed, f"Envelope field '{field}' missing from YAML result"
# Verify data field contains the original dict
assert isinstance(parsed["data"], 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)
@then("the format result should contain rich styled output")
def step_format_result_rich_styled(context: Context) -> None:
"""Assert that rich format produces styled output (ANSI codes or key-value lines).
The rich format must NOT produce raw JSON — it must produce styled terminal
output via the RichMaterializer (ANSI escape codes or structured key-value
lines with panel headers).
"""
result = context.format_result
# Rich output must contain the data keys
assert "name" in result, f"Rich output missing 'name': {result!r}"
assert "count" in result, f"Rich output missing 'count': {result!r}"
# Rich output must contain styled panel header or ANSI codes
has_ansi = "\033[" in result or "\x1b[" in result
has_panel_header = "Output" in result
assert has_ansi or has_panel_header, (
f"Rich output has no ANSI codes or panel header — got: {result!r}"
)
@then("the format result should not be valid JSON")
def step_format_result_not_json(context: Context) -> None:
"""Assert that the format result is NOT valid JSON (i.e. not the JSON fallback)."""
try:
json.loads(context.format_result)
raise AssertionError(
f"format_output() with 'rich' returned valid JSON — "
f"expected styled terminal output, got: {context.format_result!r}"
)
except (json.JSONDecodeError, ValueError):
pass # Expected: rich output is not JSON
@then("the format result should contain color styled output")
def step_format_result_color_styled(context: Context) -> None:
"""Assert that color format produces ANSI-colored output (not plain text).
The color format must NOT produce plain text — it must produce ANSI-colored
terminal output via the ColorMaterializer.
"""
result = context.format_result
# Color output must contain the data keys
assert "name" in result, f"Color output missing 'name': {result!r}"
assert "count" in result, f"Color output missing 'count': {result!r}"
# Color output must contain ANSI escape codes or styled panel header
has_ansi = "\033[" in result or "\x1b[" in result
has_panel_header = "Output" in result
assert has_ansi or has_panel_header, (
f"Color output has no ANSI codes or panel header — got: {result!r}"
)
@then("the format result should not be plain text only")
def step_format_result_not_plain_text(context: Context) -> None:
"""Assert that the color format result is NOT plain text (i.e. not the plain fallback).
Plain text would look like 'name: test\\ncount: 42' with no ANSI codes.
Color output must have ANSI escape codes or styled panel headers.
"""
result = context.format_result
has_ansi = "\033[" in result or "\x1b[" in result
has_panel_header = "Output" in result
# If it's purely plain text (no ANSI, no panel header), that's the bug
is_plain_only = not has_ansi and not has_panel_header
assert not is_plain_only, (
f"format_output() with 'color' returned plain text — "
f"expected ANSI-colored output, got: {result!r}"
)
-121
View File
@@ -466,124 +466,3 @@ def step_call_serialize_with_enum(context: Context) -> None:
"ts": datetime(2025, 6, 1, 12, 0), "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)
# Verify spec-required envelope fields are present
for field in ("command", "status", "exit_code", "data", "timing", "messages"):
assert field in parsed, f"Envelope field '{field}' missing from JSON result"
# Verify data field contains the original dict
assert isinstance(parsed["data"], 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)
# Verify spec-required envelope fields are present
for field in ("command", "status", "exit_code", "data", "timing", "messages"):
assert field in parsed, f"Envelope field '{field}' missing from YAML result"
# Verify data field contains the original dict
assert isinstance(parsed["data"], 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)
@then("the format result should contain rich styled output")
def step_format_result_rich_styled(context: Context) -> None:
"""Assert that rich format produces styled output (ANSI codes or key-value lines).
The rich format must NOT produce raw JSON — it must produce styled terminal
output via the RichMaterializer (ANSI escape codes or structured key-value
lines with panel headers).
"""
result = context.format_result
# Rich output must contain the data keys
assert "name" in result, f"Rich output missing 'name': {result!r}"
assert "count" in result, f"Rich output missing 'count': {result!r}"
# Rich output must contain styled panel header or ANSI codes
has_ansi = "\033[" in result or "\x1b[" in result
has_panel_header = "Output" in result
assert has_ansi or has_panel_header, (
f"Rich output has no ANSI codes or panel header — got: {result!r}"
)
@then("the format result should not be valid JSON")
def step_format_result_not_json(context: Context) -> None:
"""Assert that the format result is NOT valid JSON (i.e. not the JSON fallback)."""
try:
json.loads(context.format_result)
raise AssertionError(
f"format_output() with 'rich' returned valid JSON — "
f"expected styled terminal output, got: {context.format_result!r}"
)
except (json.JSONDecodeError, ValueError):
pass # Expected: rich output is not JSON
@then("the format result should contain color styled output")
def step_format_result_color_styled(context: Context) -> None:
"""Assert that color format produces ANSI-colored output (not plain text).
The color format must NOT produce plain text — it must produce ANSI-colored
terminal output via the ColorMaterializer.
"""
result = context.format_result
# Color output must contain the data keys
assert "name" in result, f"Color output missing 'name': {result!r}"
assert "count" in result, f"Color output missing 'count': {result!r}"
# Color output must contain ANSI escape codes or styled panel header
has_ansi = "\033[" in result or "\x1b[" in result
has_panel_header = "Output" in result
assert has_ansi or has_panel_header, (
f"Color output has no ANSI codes or panel header — got: {result!r}"
)
@then("the format result should not be plain text only")
def step_format_result_not_plain_text(context: Context) -> None:
"""Assert that the color format result is NOT plain text (i.e. not the plain fallback).
Plain text would look like 'name: test\\ncount: 42' with no ANSI codes.
Color output must have ANSI escape codes or styled panel headers.
"""
result = context.format_result
has_ansi = "\033[" in result or "\x1b[" in result
has_panel_header = "Output" in result
# If it's purely plain text (no ANSI, no panel header), that's the bug
is_plain_only = not has_ansi and not has_panel_header
assert not is_plain_only, (
f"format_output() with 'color' returned plain text — "
f"expected ANSI-colored output, got: {result!r}"
)
+5 -7
View File
@@ -49,13 +49,13 @@ def _mock_action(name: str = "local/fmt-smoke") -> Action:
read_only=False, read_only=False,
state=ActionState.AVAILABLE, state=ActionState.AVAILABLE,
created_by=None, created_by=None,
created_at=datetime.now(), created_at=datetime(2025, 1, 15, 10, 0, 0),
updated_at=datetime.now(), updated_at=datetime(2025, 1, 15, 10, 0, 0),
) )
def _mock_plan(name: str = "local/fmt-smoke-plan") -> Plan: def _mock_plan(name: str = "local/fmt-smoke-plan") -> Plan:
now = datetime.now() now = datetime(2025, 1, 15, 10, 0, 0)
return Plan( return Plan(
identity=PlanIdentity(plan_id=_ULID), identity=PlanIdentity(plan_id=_ULID),
namespaced_name=NamespacedName.parse(name), namespaced_name=NamespacedName.parse(name),
@@ -219,8 +219,6 @@ def format_output_rich() -> None:
Regression test for issue #2921: rich format silently fell back to JSON. Regression test for issue #2921: rich format silently fell back to JSON.
""" """
import json as _json
from cleveragents.cli.formatting import format_output from cleveragents.cli.formatting import format_output
data = {"name": "smoke-test", "status": "active", "count": 42} data = {"name": "smoke-test", "status": "active", "count": 42}
@@ -228,12 +226,12 @@ def format_output_rich() -> None:
# Must NOT be valid JSON (that was the bug) # Must NOT be valid JSON (that was the bug)
try: try:
_json.loads(result) json.loads(result)
raise AssertionError( raise AssertionError(
f"format_output(data, 'rich') returned valid JSON — " f"format_output(data, 'rich') returned valid JSON — "
f"expected styled terminal output, got: {result!r}" f"expected styled terminal output, got: {result!r}"
) )
except (_json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
pass # Expected: rich output is not JSON pass # Expected: rich output is not JSON
# Must contain the data keys # Must contain the data keys
+1 -6
View File
@@ -14,6 +14,7 @@ Based on v3_spec.md implementation plan Stage A4b / G5b.render.
from __future__ import annotations from __future__ import annotations
import json import json
import sys
import time import time
from datetime import datetime from datetime import datetime
from enum import Enum, StrEnum from enum import Enum, StrEnum
@@ -156,8 +157,6 @@ def _format_rich(data: dict[str, Any] | list[dict[str, Any]]) -> str:
that the output is consistent with the newer session-based rendering path. that the output is consistent with the newer session-based rendering path.
The data passed here has already been redacted by the caller. The data passed here has already been redacted by the caller.
""" """
from cleveragents.cli.output.session import OutputSession
strategy = RichMaterializer() strategy = RichMaterializer()
with OutputSession( with OutputSession(
format="rich", command="format_output", strategy=strategy format="rich", command="format_output", strategy=strategy
@@ -184,8 +183,6 @@ def _format_color(data: dict[str, Any] | list[dict[str, Any]]) -> str:
that the output is consistent with the newer session-based rendering path. that the output is consistent with the newer session-based rendering path.
The data passed here has already been redacted by the caller. The data passed here has already been redacted by the caller.
""" """
from cleveragents.cli.output.session import OutputSession
strategy = ColorMaterializer() strategy = ColorMaterializer()
with OutputSession( with OutputSession(
format="color", command="format_output", strategy=strategy format="color", command="format_output", strategy=strategy
@@ -339,8 +336,6 @@ def format_output(
The rendered string. For machine-readable formats the output The rendered string. For machine-readable formats the output
is written directly to stdout and an empty string is returned. is written directly to stdout and an empty string is returned.
""" """
import sys
t_start = time.monotonic() t_start = time.monotonic()
safe_data = _redact_data(data) safe_data = _redact_data(data)
fmt = format_type.lower() fmt = format_type.lower()