`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.
Parallel directories for one source package, folded into the mirror:
tests/acp -> tests/acp_adapter (its __init__/conftest move with it)
tests/cli -> tests/hermes_cli (prompt_toolkit fixture merged into
hermes_cli/conftest.py)
tests/run_agent -> tests/agent (backoff fixture becomes
agent/conftest.py)
tests/relay -> tests/gateway/relay
tests/state -> tests/hermes_state
246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.
Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.
Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).
Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
114 lines
4.2 KiB
Python
114 lines
4.2 KiB
Python
"""Tests for the KeyboardInterrupt guard around slash command dispatch.
|
||
|
||
A Ctrl+C during a slow slash command (e.g. /skills browse on a large
|
||
skill tree, or /sessions list against a multi-GB SQLite DB) used to
|
||
unwind to the outer prompt_toolkit loop and kill the entire session.
|
||
The fix wraps `self.process_command(user_input)` in a try/except
|
||
KeyboardInterrupt so the command aborts but the session survives.
|
||
|
||
These tests verify the contract without spinning up the full
|
||
prompt_toolkit input loop. We exercise the same try/except by calling
|
||
through a thin wrapper that mirrors the real dispatch shape.
|
||
"""
|
||
|
||
from unittest.mock import patch
|
||
|
||
from cli import HermesCLI
|
||
|
||
|
||
def _make_cli():
|
||
cli = HermesCLI.__new__(HermesCLI)
|
||
cli._should_exit = False
|
||
cli.conversation_history = []
|
||
cli.agent = None
|
||
cli._session_db = None
|
||
return cli
|
||
|
||
|
||
def _dispatch(cli, user_input: str, process_command_side_effect=None):
|
||
"""Mirror the production dispatch shape from cli.py around line 14236.
|
||
|
||
Real call site:
|
||
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
|
||
_cprint(f"\\n⚙️ {user_input}")
|
||
try:
|
||
if not self.process_command(user_input):
|
||
self._should_exit = True
|
||
if app.is_running:
|
||
app.exit()
|
||
except KeyboardInterrupt:
|
||
_cprint("\\n[dim]Command interrupted.[/dim]")
|
||
continue
|
||
"""
|
||
if process_command_side_effect is not None:
|
||
with patch.object(cli, "process_command", side_effect=process_command_side_effect) as mock_pc:
|
||
try:
|
||
if not cli.process_command(user_input):
|
||
cli._should_exit = True
|
||
except KeyboardInterrupt:
|
||
# Mirror production: swallow, do NOT raise.
|
||
pass
|
||
return mock_pc
|
||
|
||
|
||
class TestSlashCommandKeyboardInterrupt:
|
||
def test_keyboardinterrupt_in_slash_command_does_not_set_exit(self):
|
||
"""Ctrl+C in the middle of /skills browse must NOT set _should_exit.
|
||
|
||
Before the fix: KeyboardInterrupt unwinds past the dispatch,
|
||
the outer event loop catches it, session dies.
|
||
After the fix: KeyboardInterrupt is caught locally, _should_exit
|
||
stays False, the prompt loop continues.
|
||
"""
|
||
cli = _make_cli()
|
||
|
||
def raises_keyboard_interrupt(_cmd):
|
||
raise KeyboardInterrupt("user pressed Ctrl+C during slow command")
|
||
|
||
_dispatch(cli, "/skills browse", process_command_side_effect=raises_keyboard_interrupt)
|
||
|
||
assert cli._should_exit is False, (
|
||
"KeyboardInterrupt during slash command must not flag exit"
|
||
)
|
||
|
||
def test_normal_slash_command_returns_truthy_keeps_session_alive(self):
|
||
"""A successful slash command (returns truthy) must NOT set _should_exit."""
|
||
cli = _make_cli()
|
||
|
||
_dispatch(cli, "/help", process_command_side_effect=[True])
|
||
|
||
assert cli._should_exit is False
|
||
|
||
def test_slash_command_returning_false_sets_exit(self):
|
||
"""The legitimate exit signal — process_command() returning False —
|
||
still sets _should_exit. This is the path /exit / /quit use."""
|
||
cli = _make_cli()
|
||
|
||
_dispatch(cli, "/exit", process_command_side_effect=[False])
|
||
|
||
assert cli._should_exit is True
|
||
|
||
def test_other_exceptions_propagate(self):
|
||
"""Only KeyboardInterrupt is caught locally. Other exceptions must
|
||
propagate so they show up in logs and the global handler can deal
|
||
with them — silently swallowing all exceptions would mask bugs."""
|
||
cli = _make_cli()
|
||
|
||
class CustomError(Exception):
|
||
pass
|
||
|
||
def raises_custom(_cmd):
|
||
raise CustomError("real bug")
|
||
|
||
try:
|
||
with patch.object(cli, "process_command", side_effect=raises_custom):
|
||
try:
|
||
if not cli.process_command("/something"):
|
||
cli._should_exit = True
|
||
except KeyboardInterrupt:
|
||
pass # would NOT catch CustomError
|
||
except CustomError:
|
||
return # expected — non-KBI exceptions propagate
|
||
|
||
raise AssertionError("CustomError should have propagated")
|