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
## 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>
292 lines
9.4 KiB
Python
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
|