diff --git a/features/cli_output_formats.feature b/features/cli_output_formats.feature index c8d2f1622..405df1618 100644 --- a/features/cli_output_formats.feature +++ b/features/cli_output_formats.feature @@ -102,8 +102,18 @@ Feature: CLI output formats parity Then the format result should contain plain key-value pairs When I call format_output with a dict and format table Then the format result should contain table output + + # fix(cli): format_output() rich format must produce styled output, not JSON + Scenario: Format output rich format produces styled terminal output not JSON When I call format_output with a dict and format rich - Then the format result should be valid JSON dict + Then the format result should contain rich styled output + And the format result should not be valid JSON + + # fix(cli): format_output() color format must produce ANSI-colored output not plain text + Scenario: Format output color format produces ANSI-colored output not plain text + When I call format_output with a dict and format color + Then the format result should contain color styled output + And the format result should not be plain text only Scenario: Format output handles list data When I call format_output with a list and format plain diff --git a/features/steps/cli_output_formats_steps.py b/features/steps/cli_output_formats_steps.py index c6bcc3d2b..1c4c9488a 100644 --- a/features/steps/cli_output_formats_steps.py +++ b/features/steps/cli_output_formats_steps.py @@ -441,6 +441,12 @@ def step_call_format_rich(context: Context) -> None: context.format_result = _capture_format_output(data, "rich") +@when("I call format_output with a dict and format color") +def step_call_format_color(context: Context) -> None: + data = {"name": "test", "count": 42} + context.format_result = _capture_format_output(data, "color") + + @when("I call format_output with a list and format plain") def step_call_format_list_plain(context: Context) -> None: data = [ @@ -511,3 +517,73 @@ def step_serialized_enum_values(context: Context) -> None: 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}" + ) diff --git a/robot/cli_formats.robot b/robot/cli_formats.robot index e63832894..0d604854e 100644 --- a/robot/cli_formats.robot +++ b/robot/cli_formats.robot @@ -93,3 +93,25 @@ All Six Formats Work With Version Command ${result}= Run Process ${PYTHON} ${HELPER} global-format-all-six cwd=${WORKSPACE} Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} cli-global-format-all-six-ok + +# --------------------------------------------------------------------------- +# format_output() renderer routing tests (Issue #2921) +# --------------------------------------------------------------------------- + +Format Output Rich Produces Styled Output Not JSON + [Documentation] Verify that format_output(data, "rich") produces styled terminal output + ... and NOT raw JSON (fix for issue #2921: rich format silently fell back to JSON) + ${result}= Run Process ${PYTHON} ${HELPER} format-output-rich cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-formats-format-output-rich-ok + +Format Output Color Produces ANSI Colored Output Not Plain Text + [Documentation] Verify that format_output(data, "color") produces ANSI-colored output + ... and NOT plain text (fix for issue #2921: color format used plain renderer) + ${result}= Run Process ${PYTHON} ${HELPER} format-output-color cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-formats-format-output-color-ok diff --git a/robot/helper_cli_formats.py b/robot/helper_cli_formats.py index 04859bcfb..828b12a5f 100644 --- a/robot/helper_cli_formats.py +++ b/robot/helper_cli_formats.py @@ -214,6 +214,71 @@ def global_format_all_six() -> None: 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, @@ -226,6 +291,8 @@ _COMMANDS = { "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__": diff --git a/src/cleveragents/cli/formatting.py b/src/cleveragents/cli/formatting.py index 7cd90b5a0..47800ea8f 100644 --- a/src/cleveragents/cli/formatting.py +++ b/src/cleveragents/cli/formatting.py @@ -149,6 +149,62 @@ def _format_table(data: dict[str, Any] | list[dict[str, Any]]) -> str: return buf.getvalue().rstrip("\n") +def _format_rich(data: dict[str, Any] | list[dict[str, Any]]) -> str: + """Render data using the Rich materializer (ANSI-styled terminal output). + + Delegates to :func:`format_output_session` with the ``rich`` strategy so + that the output is consistent with the newer session-based rendering path. + The data passed here has already been redacted by the caller. + """ + from cleveragents.cli.output.session import OutputSession + + strategy = RichMaterializer() + with OutputSession( + format="rich", command="format_output", strategy=strategy + ) as session: + if isinstance(data, list): + for idx, item in enumerate(data): + if isinstance(item, dict): + panel = session.panel(f"Item {idx + 1}") + for key, val in item.items(): + panel.set_entry(key, str(_serialize_value(val))) + panel.close() + elif isinstance(data, dict): + panel = session.panel("Output") + for key, val in data.items(): + panel.set_entry(key, str(_serialize_value(val))) + panel.close() + return strategy.get_output() + + +def _format_color(data: dict[str, Any] | list[dict[str, Any]]) -> str: + """Render data using the Color materializer (ANSI-colored terminal output). + + Delegates to :func:`format_output_session` with the ``color`` strategy so + that the output is consistent with the newer session-based rendering path. + The data passed here has already been redacted by the caller. + """ + from cleveragents.cli.output.session import OutputSession + + strategy = ColorMaterializer() + with OutputSession( + format="color", command="format_output", strategy=strategy + ) as session: + if isinstance(data, list): + for idx, item in enumerate(data): + if isinstance(item, dict): + panel = session.panel(f"Item {idx + 1}") + for key, val in item.items(): + panel.set_entry(key, str(_serialize_value(val))) + panel.close() + elif isinstance(data, dict): + panel = session.panel("Output") + for key, val in data.items(): + panel.set_entry(key, str(_serialize_value(val))) + panel.close() + return strategy.get_output() + + def _redact_data( data: dict[str, Any] | list[dict[str, Any]], ) -> dict[str, Any] | list[dict[str, Any]]: @@ -316,9 +372,11 @@ def format_output( if fmt == OutputFormat.TABLE.value: return _format_table(safe_data) + if fmt == OutputFormat.RICH.value: + return _format_rich(safe_data) if fmt == OutputFormat.COLOR.value: - return format_output_session(safe_data, fmt) - # ``rich`` and any unknown value fall back to JSON + return _format_color(safe_data) + # Unknown format: fall back to JSON return _format_json(safe_data)