mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""Tests for edge cases in tools/file_operations.py.
|
|
|
|
Covers:
|
|
- ``_is_likely_binary()`` content-analysis branch (dead-code removal regression guard)
|
|
- ``_check_lint()`` robustness against file paths containing curly braces
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from tools.file_operations import ShellFileOperations, _parse_search_context_line
|
|
|
|
|
|
# =========================================================================
|
|
# _is_likely_binary edge cases
|
|
# =========================================================================
|
|
|
|
|
|
class TestIsLikelyBinary:
|
|
"""Verify content-analysis logic after dead-code removal."""
|
|
|
|
@pytest.fixture()
|
|
def ops(self):
|
|
return ShellFileOperations.__new__(ShellFileOperations)
|
|
|
|
def test_binary_extension_returns_true(self, ops):
|
|
"""Known binary extensions should short-circuit without content analysis."""
|
|
assert ops._is_likely_binary("image.png") is True
|
|
assert ops._is_likely_binary("archive.tar.gz", content_sample="hello") is True
|
|
|
|
def test_text_content_returns_false(self, ops):
|
|
"""Normal printable text should not be classified as binary."""
|
|
sample = "Hello, world!\nThis is a normal text file.\n"
|
|
assert ops._is_likely_binary("unknown.xyz", content_sample=sample) is False
|
|
|
|
|
|
def test_just_above_threshold(self, ops):
|
|
"""301/1000 = 30.1% non-printable → should be binary."""
|
|
sample = "\x00" * 301 + "a" * 699
|
|
assert ops._is_likely_binary("data.xyz", content_sample=sample) is True
|
|
|
|
def test_tabs_and_newlines_excluded(self, ops):
|
|
"""Tabs, carriage returns, and newlines should not count as non-printable."""
|
|
sample = "\t" * 400 + "\n" * 300 + "\r" * 200 + "a" * 100
|
|
assert ops._is_likely_binary("file.txt", content_sample=sample) is False
|
|
|
|
def test_content_sample_longer_than_1000(self, ops):
|
|
"""Only the first 1000 characters should be analysed."""
|
|
# First 1000 chars: 200 NUL + 800 printable = 20% → not binary
|
|
# Remaining 1000 chars: all NUL → ignored by [:1000] slice
|
|
sample = "\x00" * 200 + "a" * 800 + "\x00" * 1000
|
|
assert ops._is_likely_binary("file.xyz", content_sample=sample) is False
|
|
|
|
|
|
# =========================================================================
|
|
# _check_lint edge cases
|
|
# =========================================================================
|
|
|
|
|
|
class TestCheckLintBracePaths:
|
|
"""Verify _check_lint handles file paths with curly braces safely.
|
|
|
|
Uses ``.js`` to exercise the shell-linter path since ``.py`` now goes
|
|
through the in-process ast.parse linter (see TestCheckLintInproc).
|
|
"""
|
|
|
|
@pytest.fixture()
|
|
def ops(self):
|
|
obj = ShellFileOperations.__new__(ShellFileOperations)
|
|
obj._command_cache = {}
|
|
return obj
|
|
|
|
def test_normal_path(self, ops):
|
|
"""Normal path without braces should work as before."""
|
|
with patch.object(ops, "_has_command", return_value=True), \
|
|
patch.object(ops, "_exec") as mock_exec:
|
|
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
|
|
result = ops._check_lint("/tmp/test_file.js")
|
|
|
|
assert result.success is True
|
|
# Verify the command was built correctly
|
|
cmd_arg = mock_exec.call_args[0][0]
|
|
assert "'/tmp/test_file.js'" in cmd_arg
|
|
|
|
def test_path_with_curly_braces(self, ops):
|
|
"""Path containing ``{`` and ``}`` must not raise KeyError/ValueError."""
|
|
with patch.object(ops, "_has_command", return_value=True), \
|
|
patch.object(ops, "_exec") as mock_exec:
|
|
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
|
|
# This would raise KeyError with .format() but works with .replace()
|
|
result = ops._check_lint("/tmp/{test}_file.js")
|
|
|
|
assert result.success is True
|
|
cmd_arg = mock_exec.call_args[0][0]
|
|
assert "{test}" in cmd_arg
|
|
|
|
def test_path_with_nested_braces(self, ops):
|
|
"""Path with complex brace patterns like ``{{var}}`` should be safe."""
|
|
with patch.object(ops, "_has_command", return_value=True), \
|
|
patch.object(ops, "_exec") as mock_exec:
|
|
mock_exec.return_value = MagicMock(exit_code=0, stdout="")
|
|
result = ops._check_lint("/tmp/{{var}}.js")
|
|
|
|
assert result.success is True
|
|
|
|
def test_unsupported_extension_skipped(self, ops):
|
|
"""Extensions without a linter should return a skipped result."""
|
|
result = ops._check_lint("/tmp/file.unknown_ext")
|
|
assert result.skipped is True
|
|
|
|
def test_missing_linter_skipped(self, ops):
|
|
"""When the linter binary is not installed, skip gracefully."""
|
|
with patch.object(ops, "_has_command", return_value=False):
|
|
result = ops._check_lint("/tmp/test.js")
|
|
assert result.skipped is True
|
|
|
|
def test_lint_failure_returns_output(self, ops):
|
|
"""When the linter exits non-zero, result should capture output."""
|
|
with patch.object(ops, "_has_command", return_value=True), \
|
|
patch.object(ops, "_exec") as mock_exec:
|
|
mock_exec.return_value = MagicMock(
|
|
exit_code=1,
|
|
stdout="SyntaxError: invalid syntax",
|
|
)
|
|
result = ops._check_lint("/tmp/bad.js")
|
|
|
|
assert result.success is False
|
|
assert "SyntaxError" in result.output
|
|
|
|
|
|
class TestCheckLintInproc:
|
|
"""Verify in-process linters (.py via ast.parse, .json, .yaml, .toml).
|
|
|
|
These bypass the shell linter table entirely and parse content
|
|
directly in Python — no subprocess, no toolchain dependency.
|
|
"""
|
|
|
|
@pytest.fixture()
|
|
def ops(self):
|
|
obj = ShellFileOperations.__new__(ShellFileOperations)
|
|
obj._command_cache = {}
|
|
return obj
|
|
|
|
def test_python_inproc_clean(self, ops):
|
|
"""Valid Python content passes in-process ast.parse."""
|
|
result = ops._check_lint("/tmp/ok.py", content="x = 1\n")
|
|
assert result.success is True
|
|
assert not result.skipped
|
|
assert result.output == ""
|
|
|
|
|
|
def test_json_inproc_clean(self, ops):
|
|
result = ops._check_lint("/tmp/a.json", content='{"a": 1}')
|
|
assert result.success is True
|
|
|
|
|
|
def test_toml_inproc_error(self, ops):
|
|
result = ops._check_lint("/tmp/b.toml", content='[section\nk = "v"')
|
|
assert result.success is False
|
|
assert "TOMLDecodeError" in result.output
|
|
|
|
|
|
class TestCheckLintDelta:
|
|
"""Verify _check_lint_delta() filters pre-existing errors from post-edit output."""
|
|
|
|
@pytest.fixture()
|
|
def ops(self):
|
|
obj = ShellFileOperations.__new__(ShellFileOperations)
|
|
obj._command_cache = {}
|
|
return obj
|
|
|
|
def test_clean_post_no_pre_lint(self, ops):
|
|
"""Hot path: post-write is clean, pre-lint should be skipped entirely."""
|
|
with patch.object(ops, "_check_lint", wraps=ops._check_lint) as wrapped:
|
|
r = ops._check_lint_delta("/tmp/a.py", pre_content="x = 0\n", post_content="x = 1\n")
|
|
# Post-lint called exactly once (clean), pre-lint never called.
|
|
assert wrapped.call_count == 1
|
|
assert r.success is True
|
|
|
|
|
|
def test_pre_existing_remains_flagged_but_not_new(self, ops):
|
|
"""Single-error parsers (ast) may miss that post is OK — be cautious."""
|
|
# Pre has line-1 error, post keeps it (and doesn't add anything new)
|
|
pre = 'def a(:\n pass\n'
|
|
post = 'def a(:\n pass\n\nprint(42)\n' # still line 1 broken
|
|
r = ops._check_lint_delta("/tmp/d.py", pre_content=pre, post_content=post)
|
|
# File is still broken — don't lie and claim success — but flag it as pre-existing
|
|
assert r.success is False
|
|
assert "pre-existing" in (r.message or "").lower()
|
|
|
|
|
|
# =========================================================================
|
|
# Pagination bounds
|
|
# =========================================================================
|
|
|
|
|
|
class TestPaginationBounds:
|
|
"""Invalid pagination inputs should not leak into shell commands."""
|
|
|
|
def test_read_file_clamps_offset_and_limit_before_building_sed_range(self):
|
|
env = MagicMock()
|
|
env.cwd = "/tmp"
|
|
ops = ShellFileOperations(env)
|
|
commands = []
|
|
|
|
def fake_exec(command, *args, **kwargs):
|
|
commands.append(command)
|
|
if command.startswith("wc -c"):
|
|
return MagicMock(exit_code=0, stdout="12")
|
|
if command.startswith("head -c"):
|
|
return MagicMock(exit_code=0, stdout="line1\nline2\n")
|
|
if command.startswith("sed -n"):
|
|
return MagicMock(exit_code=0, stdout="line1\n")
|
|
if command.startswith("wc -l"):
|
|
return MagicMock(exit_code=0, stdout="2")
|
|
return MagicMock(exit_code=0, stdout="")
|
|
|
|
with patch.object(ops, "_exec", side_effect=fake_exec):
|
|
result = ops.read_file("notes.txt", offset=0, limit=0)
|
|
|
|
assert result.error is None
|
|
assert "1|line1" in result.content
|
|
sed_commands = [cmd for cmd in commands if cmd.startswith("sed -n")]
|
|
assert sed_commands == ["sed -n '1,1p' 'notes.txt'"]
|
|
|
|
def test_search_clamps_offset_and_limit_before_building_head_pipeline(self):
|
|
env = MagicMock()
|
|
env.cwd = "/tmp"
|
|
ops = ShellFileOperations(env)
|
|
commands = []
|
|
|
|
def fake_exec(command, *args, **kwargs):
|
|
commands.append(command)
|
|
if command.startswith("test -e"):
|
|
return MagicMock(exit_code=0, stdout="exists")
|
|
if command.startswith("rg --files"):
|
|
return MagicMock(exit_code=0, stdout="a.py\n")
|
|
return MagicMock(exit_code=0, stdout="")
|
|
|
|
with patch.object(ops, "_has_command", side_effect=lambda cmd: cmd == "rg"), \
|
|
patch.object(ops, "_exec", side_effect=fake_exec):
|
|
result = ops.search("*.py", target="files", path=".", offset=-4, limit=-2)
|
|
|
|
assert result.files == ["a.py"]
|
|
rg_commands = [cmd for cmd in commands if cmd.startswith("rg --files")]
|
|
assert rg_commands
|
|
assert "| head -n 1" in rg_commands[0]
|
|
|
|
|
|
# =========================================================================
|
|
# Search context parsing
|
|
# =========================================================================
|
|
|
|
|
|
class TestSearchContextParsing:
|
|
def test_parse_search_context_line_prefers_rightmost_numeric_separator(self):
|
|
parsed = _parse_search_context_line("dir/file-12-name.py-8-context here")
|
|
|
|
assert parsed == ("dir/file-12-name.py", 8, "context here")
|
|
|
|
|
|
def test_search_with_grep_context_handles_filename_with_dash_digits(self):
|
|
env = MagicMock()
|
|
env.cwd = "/tmp"
|
|
ops = ShellFileOperations(env)
|
|
|
|
with patch.object(ops, "_exec") as mock_exec:
|
|
mock_exec.return_value = MagicMock(
|
|
exit_code=0,
|
|
stdout="dir/file-12-name.py-8-context here\n",
|
|
)
|
|
result = ops._search_with_grep(
|
|
"needle",
|
|
path=".",
|
|
file_glob=None,
|
|
limit=10,
|
|
offset=0,
|
|
output_mode="content",
|
|
context=1,
|
|
)
|
|
|
|
assert result.error is None
|
|
assert result.total_count == 1
|
|
assert result.matches[0].path == "dir/file-12-name.py"
|
|
assert result.matches[0].line_number == 8
|
|
assert result.matches[0].content == "context here"
|