Files
cleveragents-core/features/steps/cli_consistency_steps.py
brent.edwards 95d3e09925
CI / build (push) Successful in 19s
CI / lint (push) Successful in 3m21s
CI / quality (push) Successful in 3m43s
CI / typecheck (push) Successful in 4m13s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 4m17s
CI / unit_tests (push) Successful in 5m45s
CI / docker (push) Successful in 1m3s
CI / integration_tests (push) Successful in 6m49s
CI / e2e_tests (push) Successful in 9m8s
CI / coverage (push) Failing after 13m47s
CI / benchmark-publish (push) Successful in 19m43s
CI / status-check (push) Failing after 1s
feat(cli): final CLI polish and UX consistency pass (#1018)
## Summary

Final CLI polish and UX consistency pass: shared constants, centralized error formatting, shell completion, and standardized help text.

### New Modules

- **`cli/constants.py`** (70 lines): Exit codes (`EXIT_SUCCESS`=0 through `EXIT_CONFLICT`=4), format defaults (`FORMAT_TEXT`, `FORMAT_JSON`, `FORMAT_TABLE`)
- **`cli/errors.py`** (105 lines): `cli_error()` with hint support, `cli_warning()`, `cli_not_found()` with resource-type-aware hint

### CLI Changes

- Shell `completion` command generating scripts for bash/zsh/fish/powershell
- Standardized help text across command modules
- Error functions exported from `cli/__init__.py`

### Tests

- **17 Behave scenarios**: Exit codes, error formatting, cli_not_found, format constants, help text, completion
- **15 Robot integration tests**: All subcommands respond to --help, invalid commands return non-zero, completion generation works

### Quality Gates

| Session | Result |
|---|---|
| `nox -s lint` | PASS |
| `nox -s typecheck` | PASS (0 errors) |
| `nox -s unit_tests` | PASS (10,912 scenarios) |
| `nox -s coverage_report` | 97% (>= 97%) |

Closes #861

Reviewed-on: #1018
Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
2026-03-21 01:14:18 +00:00

292 lines
9.4 KiB
Python

"""Step definitions for CLI consistency and UX polish feature."""
from __future__ import annotations
import json
from io import StringIO
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _call_cli_error(
context: Context,
message: str,
hint: str | None = None,
exit_code: int | None = None,
) -> None:
"""Invoke cli_error with a captured stderr console."""
from rich.console import Console
from cleveragents.cli.errors import cli_error
buf = StringIO()
mock_console = Console(file=buf, stderr=True, no_color=True)
kwargs: dict[str, object] = {}
if hint is not None:
kwargs["hint"] = hint
if exit_code is not None:
kwargs["exit_code"] = exit_code
with patch("cleveragents.cli.errors._get_err_console", return_value=mock_console):
try:
cli_error(message, **kwargs) # type: ignore[arg-type]
except SystemExit as exc:
context.exit_code = exc.code
context.stderr_capture = buf
def _call_cli_warning(
context: Context,
message: str,
hint: str | None = None,
) -> None:
"""Invoke cli_warning with a captured stderr console."""
from rich.console import Console
from cleveragents.cli.errors import cli_warning
buf = StringIO()
mock_console = Console(file=buf, stderr=True, no_color=True)
with patch("cleveragents.cli.errors._get_err_console", return_value=mock_console):
if hint is not None:
cli_warning(message, hint=hint)
else:
cli_warning(message)
context.stderr_capture = buf
def _call_cli_not_found(
context: Context,
resource_type: str,
name: str,
) -> None:
"""Invoke cli_not_found with a captured stderr console."""
from rich.console import Console
from cleveragents.cli.errors import cli_not_found
buf = StringIO()
mock_console = Console(file=buf, stderr=True, no_color=True)
with patch("cleveragents.cli.errors._get_err_console", return_value=mock_console):
try:
cli_not_found(resource_type, name)
except SystemExit as exc:
context.exit_code = exc.code
context.stderr_capture = buf
# ------------------------------------------------------------------
# Constants module
# ------------------------------------------------------------------
@given("the CLI constants module is imported")
def step_import_constants(context: Context) -> None:
from cleveragents.cli.constants import (
DEFAULT_FORMAT,
EXIT_CONFLICT,
EXIT_ERROR,
EXIT_NOT_FOUND,
EXIT_SUCCESS,
EXIT_USAGE,
FORMAT_HELP,
FORMAT_JSON,
FORMAT_TABLE,
FORMAT_TEXT,
VALID_FORMATS,
)
context.const = {
"EXIT_SUCCESS": EXIT_SUCCESS,
"EXIT_ERROR": EXIT_ERROR,
"EXIT_USAGE": EXIT_USAGE,
"EXIT_NOT_FOUND": EXIT_NOT_FOUND,
"EXIT_CONFLICT": EXIT_CONFLICT,
"FORMAT_HELP": FORMAT_HELP,
"DEFAULT_FORMAT": DEFAULT_FORMAT,
"VALID_FORMATS": VALID_FORMATS,
"FORMAT_TEXT": FORMAT_TEXT,
"FORMAT_JSON": FORMAT_JSON,
"FORMAT_TABLE": FORMAT_TABLE,
}
@then("EXIT_SUCCESS should equal {value:d}")
def step_exit_success_equals(context: Context, value: int) -> None:
assert context.const["EXIT_SUCCESS"] == value
@then("EXIT_ERROR should equal {value:d}")
def step_exit_error_equals(context: Context, value: int) -> None:
assert context.const["EXIT_ERROR"] == value
@then("EXIT_USAGE should equal {value:d}")
def step_exit_usage_equals(context: Context, value: int) -> None:
assert context.const["EXIT_USAGE"] == value
@then("EXIT_NOT_FOUND should equal {value:d}")
def step_exit_not_found_equals(context: Context, value: int) -> None:
assert context.const["EXIT_NOT_FOUND"] == value
@then("EXIT_CONFLICT should equal {value:d}")
def step_exit_conflict_equals(context: Context, value: int) -> None:
assert context.const["EXIT_CONFLICT"] == value
@then('FORMAT_HELP should mention "{text}"')
def step_format_help_mentions(context: Context, text: str) -> None:
assert text in context.const["FORMAT_HELP"], (
f"Expected '{text}' in FORMAT_HELP: {context.const['FORMAT_HELP']}"
)
@then('DEFAULT_FORMAT should equal "{value}"')
def step_default_format_equals(context: Context, value: str) -> None:
assert context.const["DEFAULT_FORMAT"] == value
@then('VALID_FORMATS should contain "{value}"')
def step_valid_formats_contains(context: Context, value: str) -> None:
assert value in context.const["VALID_FORMATS"]
@then('FORMAT_TEXT should equal "{value}"')
def step_format_text_equals(context: Context, value: str) -> None:
assert context.const["FORMAT_TEXT"] == value
@then('FORMAT_JSON should equal "{value}"')
def step_format_json_equals(context: Context, value: str) -> None:
assert context.const["FORMAT_JSON"] == value
@then('FORMAT_TABLE should equal "{value}"')
def step_format_table_equals(context: Context, value: str) -> None:
assert context.const["FORMAT_TABLE"] == value
# ------------------------------------------------------------------
# Error formatting (parameterised steps matching feature scenarios)
# ------------------------------------------------------------------
@given("the CLI errors module is imported")
def step_import_errors(context: Context) -> None:
context.stderr_capture = StringIO()
context.exit_code = None
@when('I call cli_error with only message "{message}"')
def step_call_cli_error_message(context: Context, message: str) -> None:
_call_cli_error(context, message)
@when('I call cli_error with message "{message}" and hint "{hint}"')
def step_call_cli_error_message_hint(context: Context, message: str, hint: str) -> None:
_call_cli_error(context, message, hint=hint)
@when('I call cli_error with message "{message}" and exit code {code:d}')
def step_call_cli_error_custom_code(context: Context, message: str, code: int) -> None:
_call_cli_error(context, message, exit_code=code)
@when('I call cli_warning with only message "{message}"')
def step_call_cli_warning_message(context: Context, message: str) -> None:
_call_cli_warning(context, message)
@when('I call cli_warning with message "{message}" and hint "{hint}"')
def step_call_cli_warning_with_hint(context: Context, message: str, hint: str) -> None:
_call_cli_warning(context, message, hint=hint)
@when('I call cli_not_found with type "{resource_type}" and name "{name}"')
def step_call_cli_not_found(context: Context, resource_type: str, name: str) -> None:
_call_cli_not_found(context, resource_type, name)
@then("the process should exit with code {code:d}")
def step_exit_code_check(context: Context, code: int) -> None:
assert context.exit_code == code, (
f"Expected exit code {code}, got {context.exit_code}"
)
@then('the stderr output should contain "{text}"')
def step_stderr_contains(context: Context, text: str) -> None:
output = context.stderr_capture.getvalue()
assert text in output, f"Expected '{text}' in stderr: {output!r}"
# ------------------------------------------------------------------
# CLI runner tests
# ------------------------------------------------------------------
# NOTE: The following steps are defined in cli_steps.py and
# garbage_collection_cli_steps.py respectively:
# - @given("a CLI test runner")
# - @when('I run the CLI with "{args}"')
# - @then("the CLI exit code should be {code:d}")
# - @then('the CLI output should contain "{text}"')
# - @then("the CLI output should be valid JSON")
#
# Behave loads all step files, so those shared definitions are
# available here without re-declaration. We only need to register
# the ``a CLI test runner`` and ``I run the CLI with`` steps because
# cli_steps.py uses a different phrasing ("I run the *CleverAgents*
# CLI with").
@given("a CLI consistency test runner")
def step_cli_consistency_runner(context: Context) -> None:
from typer.testing import CliRunner
context.cli_runner = CliRunner()
@when('I invoke the CLI with "{args}"')
def step_invoke_cli(context: Context, args: str) -> None:
from typer.testing import CliRunner
from cleveragents.cli.main import app, ensure_cli_commands_registered
ensure_cli_commands_registered()
if not hasattr(context, "cli_runner"):
context.cli_runner = CliRunner()
runner = context.cli_runner
arg_list = args.split()
context.cli_result = runner.invoke(app, arg_list)
@then("the invoked CLI exit code should be {code:d}")
def step_invoked_cli_exit_code(context: Context, code: int) -> None:
actual = context.cli_result.exit_code
assert actual == code, (
f"Expected exit code {code}, got {actual}.\nstdout: {context.cli_result.output}"
)
@then('the invoked CLI output should contain "{text}"')
def step_invoked_cli_output_contains(context: Context, text: str) -> None:
output = context.cli_result.output
assert text in output, f"Expected '{text}' in output: {output!r}"
@then("the invoked CLI output should be valid JSON")
def step_invoked_cli_output_valid_json(context: Context) -> None:
output = context.cli_result.output.strip()
try:
json.loads(output)
except json.JSONDecodeError as exc:
raise AssertionError(
f"Output is not valid JSON: {exc}\nOutput: {output!r}"
) from exc