fix(cli): raise on subcommand registration failure instead of partial silent return #3264
@@ -0,0 +1,119 @@
|
||||
"""Step definitions for tdd_cli_incomplete_subcommand_registration.feature.
|
||||
|
||||
This test captures bug #2604: _register_subcommands() in
|
||||
src/cleveragents/cli/main.py catches all exceptions during subcommand
|
||||
import and registration, then silently returns instead of raising.
|
||||
This violates CONTRIBUTING.md error-handling standards.
|
||||
|
||||
The fix replaces the bare ``return`` with ``raise SystemExit(1)`` so the
|
||||
CLI exits with a non-zero exit code instead of entering a partially
|
||||
initialized state.
|
||||
|
||||
Once the fix for #2604 is merged, the ``@tdd_expected_fail`` scenario
|
||||
(which asserts the old silent-return behaviour) must be removed so the
|
||||
remaining scenarios run as regression guards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.runner import Context # type: ignore[import-untyped]
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner(mix_stderr=False)
|
||||
|
||||
|
||||
@given("the CLI subcommand import raises an ImportError during registration")
|
||||
def step_given_import_error(context: Context) -> None:
|
||||
"""Arrange: patch the subcommand import to raise ImportError."""
|
||||
context.import_error = ImportError("simulated subcommand import failure #2604")
|
||||
|
||||
|
||||
@when("the CLI is invoked with any command")
|
||||
def step_when_cli_invoked(context: Context) -> None:
|
||||
"""Act: invoke the CLI while the subcommand import is broken."""
|
||||
import cleveragents.cli.main as cli_main
|
||||
|
||||
# Reset the registration flag so _register_subcommands() runs again.
|
||||
original_flag = cli_main._subcommands_registered
|
||||
cli_main._subcommands_registered = False
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"cleveragents.cli.main._register_subcommands",
|
||||
side_effect=context.import_error,
|
||||
):
|
||||
# We patch _register_subcommands itself to raise so we can test
|
||||
# the caller's behaviour without needing to break real imports.
|
||||
# The real fix is inside _register_subcommands, but the caller
|
||||
# (ensure_cli_commands_registered / the app callback) must also
|
||||
# propagate the SystemExit.
|
||||
from cleveragents.cli.main import app
|
||||
|
||||
result = runner.invoke(app, ["--help"], catch_exceptions=True)
|
||||
context.cli_result = result
|
||||
finally:
|
||||
cli_main._subcommands_registered = original_flag
|
||||
|
||||
|
||||
@then("the CLI exits with a non-zero exit code")
|
||||
def step_then_non_zero_exit(context: Context) -> None:
|
||||
"""Assert: the CLI must not exit with 0 when registration fails."""
|
||||
assert context.cli_result.exit_code != 0, (
|
||||
f"Bug #2604: CLI exited with code 0 after subcommand registration "
|
||||
f"failure. The CLI must exit with a non-zero code to signal the "
|
||||
f"error. Got exit_code={context.cli_result.exit_code}. "
|
||||
f"Output: {context.cli_result.output!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the error output contains "Failed to register subcommands"')
|
||||
def step_then_error_message(context: Context) -> None:
|
||||
"""Assert: the error message must be present in the output."""
|
||||
# CliRunner with mix_stderr=False separates stdout and stderr.
|
||||
# The error is printed to stderr via get_err_console().
|
||||
combined = (context.cli_result.output or "") + (
|
||||
getattr(context.cli_result, "stderr", "") or ""
|
||||
)
|
||||
# When _register_subcommands itself is patched to raise, the message
|
||||
# won't appear (it's inside the function). We test the exit code path
|
||||
# separately. For the message test, we need to invoke the real function
|
||||
# with a broken import.
|
||||
import cleveragents.cli.main as cli_main
|
||||
|
||||
original_flag = cli_main._subcommands_registered
|
||||
cli_main._subcommands_registered = False
|
||||
|
||||
try:
|
||||
# Patch the internal import inside _register_subcommands by making
|
||||
# the first import inside the try block raise.
|
||||
with patch(
|
||||
"cleveragents.cli.commands.project",
|
||||
side_effect=ImportError("simulated import failure #2604"),
|
||||
):
|
||||
from cleveragents.cli.main import app
|
||||
|
||||
result = runner.invoke(app, ["--help"], catch_exceptions=True)
|
||||
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
||||
finally:
|
||||
cli_main._subcommands_registered = original_flag
|
||||
|
||||
assert "Failed to register subcommands" in combined, (
|
||||
f"Bug #2604: Expected 'Failed to register subcommands' in CLI output "
|
||||
f"when subcommand registration fails, but got: {combined!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the CLI exits with exit code 0")
|
||||
def step_then_zero_exit(context: Context) -> None:
|
||||
"""Assert the OLD (buggy) behaviour: CLI exits with 0 on registration error.
|
||||
|
||||
This scenario is tagged @tdd_expected_fail because it asserts the bug.
|
||||
Once the fix is merged, this scenario must be removed.
|
||||
"""
|
||||
assert context.cli_result.exit_code == 0, (
|
||||
f"Expected exit code 0 (old buggy behaviour), "
|
||||
f"got {context.cli_result.exit_code}"
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
@tdd_issue @tdd_issue_2604
|
||||
Feature: TDD Issue #2604 — Incomplete subcommand registration on error
|
||||
As a CLI user
|
||||
I want the CLI to fail loudly when subcommand registration encounters an error
|
||||
So that the CLI is never left in a partially initialized state
|
||||
|
||||
This test captures bug #2604. The _register_subcommands() function in
|
||||
src/cleveragents/cli/main.py catches all exceptions during subcommand
|
||||
import and registration, prints a traceback, then silently returns.
|
||||
This violates CONTRIBUTING.md error-handling standards: "Errors must
|
||||
never be suppressed. Exceptions should propagate to the top-level
|
||||
execution handler."
|
||||
|
||||
The fix replaces the bare `return` with `raise SystemExit(1)` so the
|
||||
CLI exits with a non-zero exit code instead of entering a partially
|
||||
initialized state.
|
||||
|
||||
Scenario: Bug #2604 — subcommand registration failure exits with non-zero code
|
||||
Given the CLI subcommand import raises an ImportError during registration
|
||||
When the CLI is invoked with any command
|
||||
Then the CLI exits with a non-zero exit code
|
||||
|
||||
Scenario: Bug #2604 — subcommand registration failure prints error message
|
||||
Given the CLI subcommand import raises an ImportError during registration
|
||||
When the CLI is invoked with any command
|
||||
Then the error output contains "Failed to register subcommands"
|
||||
|
||||
@tdd_expected_fail
|
||||
Scenario: Bug #2604 — old behaviour silently returns on registration error
|
||||
Given the CLI subcommand import raises an ImportError during registration
|
||||
When the CLI is invoked with any command
|
||||
Then the CLI exits with exit code 0
|
||||
@@ -101,13 +101,13 @@ def _register_subcommands() -> None:
|
||||
from cleveragents.cli.commands.db import app as db_app
|
||||
from cleveragents.cli.commands.repl import _repl_app
|
||||
from cleveragents.cli.commands.server import app as server_app
|
||||
except Exception as exc: # pragma: no cover
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
|
||||
err = get_err_console()
|
||||
err.print(f"[red]Failed to register subcommands:[/red] {exc}")
|
||||
err.print(traceback.format_exc())
|
||||
return
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
app.add_typer(project.app, name="project", help="Project management")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user