289 lines
10 KiB
Python
289 lines
10 KiB
Python
|
|
"""Tests for hermes_cli/logs.py — log viewing and filtering."""
|
||
|
|
|
||
|
|
import os
|
||
|
|
import textwrap
|
||
|
|
from datetime import datetime, timedelta
|
||
|
|
from io import StringIO
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from hermes_cli.logs import (
|
||
|
|
LOG_FILES,
|
||
|
|
_extract_level,
|
||
|
|
_matches_filters,
|
||
|
|
_parse_line_timestamp,
|
||
|
|
_parse_since,
|
||
|
|
_read_last_n_lines,
|
||
|
|
list_logs,
|
||
|
|
tail_log,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Fixtures
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def log_dir(tmp_path, monkeypatch):
|
||
|
|
"""Create a fake HERMES_HOME with a logs/ directory."""
|
||
|
|
home = Path(os.environ["HERMES_HOME"])
|
||
|
|
logs = home / "logs"
|
||
|
|
logs.mkdir(parents=True, exist_ok=True)
|
||
|
|
return logs
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def sample_agent_log(log_dir):
|
||
|
|
"""Write a realistic agent.log with mixed levels and sessions."""
|
||
|
|
lines = textwrap.dedent("""\
|
||
|
|
2026-04-05 10:00:00,000 INFO run_agent: conversation turn: session=sess_aaa model=claude provider=openrouter platform=cli history=0 msg='hello'
|
||
|
|
2026-04-05 10:00:01,000 INFO run_agent: tool terminal completed (0.50s, 200 chars)
|
||
|
|
2026-04-05 10:00:02,000 INFO run_agent: API call #1: model=claude provider=openrouter in=1000 out=200 total=1200 latency=1.5s
|
||
|
|
2026-04-05 10:00:03,000 WARNING run_agent: Tool web_search returned error (2.00s): timeout
|
||
|
|
2026-04-05 10:00:04,000 INFO run_agent: conversation turn: session=sess_bbb model=gpt-5 provider=openai platform=telegram history=5 msg='fix bug'
|
||
|
|
2026-04-05 10:00:05,000 ERROR run_agent: API call failed after 3 retries. rate limited
|
||
|
|
2026-04-05 10:00:06,000 INFO run_agent: tool read_file completed (0.01s, 500 chars)
|
||
|
|
2026-04-05 10:00:07,000 DEBUG run_agent: verbose internal detail
|
||
|
|
2026-04-05 10:00:08,000 INFO credential_pool: credential pool: marking key-1 exhausted (status=429), rotating
|
||
|
|
2026-04-05 10:00:09,000 INFO credential_pool: credential pool: rotated to key-2
|
||
|
|
""")
|
||
|
|
path = log_dir / "agent.log"
|
||
|
|
path.write_text(lines)
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def sample_errors_log(log_dir):
|
||
|
|
"""Write a small errors.log."""
|
||
|
|
lines = textwrap.dedent("""\
|
||
|
|
2026-04-05 10:00:03,000 WARNING run_agent: Tool web_search returned error (2.00s): timeout
|
||
|
|
2026-04-05 10:00:05,000 ERROR run_agent: API call failed after 3 retries. rate limited
|
||
|
|
""")
|
||
|
|
path = log_dir / "errors.log"
|
||
|
|
path.write_text(lines)
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _parse_since
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestParseSince:
|
||
|
|
def test_hours(self):
|
||
|
|
cutoff = _parse_since("2h")
|
||
|
|
assert cutoff is not None
|
||
|
|
assert (datetime.now() - cutoff).total_seconds() == pytest.approx(7200, abs=5)
|
||
|
|
|
||
|
|
def test_minutes(self):
|
||
|
|
cutoff = _parse_since("30m")
|
||
|
|
assert cutoff is not None
|
||
|
|
assert (datetime.now() - cutoff).total_seconds() == pytest.approx(1800, abs=5)
|
||
|
|
|
||
|
|
def test_days(self):
|
||
|
|
cutoff = _parse_since("1d")
|
||
|
|
assert cutoff is not None
|
||
|
|
assert (datetime.now() - cutoff).total_seconds() == pytest.approx(86400, abs=5)
|
||
|
|
|
||
|
|
def test_seconds(self):
|
||
|
|
cutoff = _parse_since("60s")
|
||
|
|
assert cutoff is not None
|
||
|
|
assert (datetime.now() - cutoff).total_seconds() == pytest.approx(60, abs=5)
|
||
|
|
|
||
|
|
def test_invalid_returns_none(self):
|
||
|
|
assert _parse_since("abc") is None
|
||
|
|
assert _parse_since("") is None
|
||
|
|
assert _parse_since("10x") is None
|
||
|
|
|
||
|
|
def test_whitespace_handling(self):
|
||
|
|
cutoff = _parse_since(" 1h ")
|
||
|
|
assert cutoff is not None
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _parse_line_timestamp
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestParseLineTimestamp:
|
||
|
|
def test_standard_format(self):
|
||
|
|
ts = _parse_line_timestamp("2026-04-05 10:00:00,123 INFO something")
|
||
|
|
assert ts is not None
|
||
|
|
assert ts.year == 2026
|
||
|
|
assert ts.hour == 10
|
||
|
|
|
||
|
|
def test_no_timestamp(self):
|
||
|
|
assert _parse_line_timestamp("just some text") is None
|
||
|
|
|
||
|
|
def test_continuation_line(self):
|
||
|
|
assert _parse_line_timestamp(" at module.function (line 42)") is None
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _extract_level
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestExtractLevel:
|
||
|
|
def test_info(self):
|
||
|
|
assert _extract_level("2026-04-05 10:00:00 INFO run_agent: something") == "INFO"
|
||
|
|
|
||
|
|
def test_warning(self):
|
||
|
|
assert _extract_level("2026-04-05 10:00:00 WARNING run_agent: bad") == "WARNING"
|
||
|
|
|
||
|
|
def test_error(self):
|
||
|
|
assert _extract_level("2026-04-05 10:00:00 ERROR run_agent: crash") == "ERROR"
|
||
|
|
|
||
|
|
def test_debug(self):
|
||
|
|
assert _extract_level("2026-04-05 10:00:00 DEBUG run_agent: detail") == "DEBUG"
|
||
|
|
|
||
|
|
def test_no_level(self):
|
||
|
|
assert _extract_level("just a plain line") is None
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _matches_filters
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestMatchesFilters:
|
||
|
|
def test_no_filters_always_matches(self):
|
||
|
|
assert _matches_filters("any line") is True
|
||
|
|
|
||
|
|
def test_level_filter_passes(self):
|
||
|
|
assert _matches_filters(
|
||
|
|
"2026-04-05 10:00:00 WARNING something",
|
||
|
|
min_level="WARNING",
|
||
|
|
) is True
|
||
|
|
|
||
|
|
def test_level_filter_rejects(self):
|
||
|
|
assert _matches_filters(
|
||
|
|
"2026-04-05 10:00:00 INFO something",
|
||
|
|
min_level="WARNING",
|
||
|
|
) is False
|
||
|
|
|
||
|
|
def test_session_filter_passes(self):
|
||
|
|
assert _matches_filters(
|
||
|
|
"session=sess_aaa model=claude",
|
||
|
|
session_filter="sess_aaa",
|
||
|
|
) is True
|
||
|
|
|
||
|
|
def test_session_filter_rejects(self):
|
||
|
|
assert _matches_filters(
|
||
|
|
"session=sess_aaa model=claude",
|
||
|
|
session_filter="sess_bbb",
|
||
|
|
) is False
|
||
|
|
|
||
|
|
def test_since_filter_passes(self):
|
||
|
|
# Line from the future should always pass
|
||
|
|
assert _matches_filters(
|
||
|
|
"2099-01-01 00:00:00 INFO future",
|
||
|
|
since=datetime.now(),
|
||
|
|
) is True
|
||
|
|
|
||
|
|
def test_since_filter_rejects(self):
|
||
|
|
assert _matches_filters(
|
||
|
|
"2020-01-01 00:00:00 INFO past",
|
||
|
|
since=datetime.now(),
|
||
|
|
) is False
|
||
|
|
|
||
|
|
def test_combined_filters(self):
|
||
|
|
line = "2099-01-01 00:00:00 WARNING run_agent: session=abc error"
|
||
|
|
assert _matches_filters(
|
||
|
|
line, min_level="WARNING", session_filter="abc",
|
||
|
|
since=datetime.now(),
|
||
|
|
) is True
|
||
|
|
# Fails session filter
|
||
|
|
assert _matches_filters(
|
||
|
|
line, min_level="WARNING", session_filter="xyz",
|
||
|
|
) is False
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _read_last_n_lines
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestReadLastNLines:
|
||
|
|
def test_reads_correct_count(self, sample_agent_log):
|
||
|
|
lines = _read_last_n_lines(sample_agent_log, 3)
|
||
|
|
assert len(lines) == 3
|
||
|
|
|
||
|
|
def test_reads_all_when_fewer(self, sample_agent_log):
|
||
|
|
lines = _read_last_n_lines(sample_agent_log, 100)
|
||
|
|
assert len(lines) == 10 # sample has 10 lines
|
||
|
|
|
||
|
|
def test_empty_file(self, log_dir):
|
||
|
|
empty = log_dir / "empty.log"
|
||
|
|
empty.write_text("")
|
||
|
|
lines = _read_last_n_lines(empty, 10)
|
||
|
|
assert lines == []
|
||
|
|
|
||
|
|
def test_last_line_content(self, sample_agent_log):
|
||
|
|
lines = _read_last_n_lines(sample_agent_log, 1)
|
||
|
|
assert "rotated to key-2" in lines[0]
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# tail_log
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestTailLog:
|
||
|
|
def test_basic_tail(self, sample_agent_log, capsys):
|
||
|
|
tail_log("agent", num_lines=3)
|
||
|
|
captured = capsys.readouterr()
|
||
|
|
assert "agent.log" in captured.out
|
||
|
|
# Should have the header + 3 lines
|
||
|
|
lines = captured.out.strip().split("\n")
|
||
|
|
assert len(lines) == 4 # 1 header + 3 content
|
||
|
|
|
||
|
|
def test_level_filter(self, sample_agent_log, capsys):
|
||
|
|
tail_log("agent", num_lines=50, level="ERROR")
|
||
|
|
captured = capsys.readouterr()
|
||
|
|
assert "level>=ERROR" in captured.out
|
||
|
|
# Only the ERROR line should appear
|
||
|
|
content_lines = [l for l in captured.out.strip().split("\n") if not l.startswith("---")]
|
||
|
|
assert len(content_lines) == 1
|
||
|
|
assert "API call failed" in content_lines[0]
|
||
|
|
|
||
|
|
def test_session_filter(self, sample_agent_log, capsys):
|
||
|
|
tail_log("agent", num_lines=50, session="sess_bbb")
|
||
|
|
captured = capsys.readouterr()
|
||
|
|
content_lines = [l for l in captured.out.strip().split("\n") if not l.startswith("---")]
|
||
|
|
assert len(content_lines) == 1
|
||
|
|
assert "sess_bbb" in content_lines[0]
|
||
|
|
|
||
|
|
def test_errors_log(self, sample_errors_log, capsys):
|
||
|
|
tail_log("errors", num_lines=10)
|
||
|
|
captured = capsys.readouterr()
|
||
|
|
assert "errors.log" in captured.out
|
||
|
|
assert "WARNING" in captured.out or "ERROR" in captured.out
|
||
|
|
|
||
|
|
def test_unknown_log_exits(self):
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
tail_log("nonexistent")
|
||
|
|
|
||
|
|
def test_missing_file_exits(self, log_dir):
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
tail_log("agent") # agent.log doesn't exist in clean log_dir
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# list_logs
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestListLogs:
|
||
|
|
def test_lists_files(self, sample_agent_log, sample_errors_log, capsys):
|
||
|
|
list_logs()
|
||
|
|
captured = capsys.readouterr()
|
||
|
|
assert "agent.log" in captured.out
|
||
|
|
assert "errors.log" in captured.out
|
||
|
|
|
||
|
|
def test_empty_dir(self, log_dir, capsys):
|
||
|
|
list_logs()
|
||
|
|
captured = capsys.readouterr()
|
||
|
|
assert "no log files yet" in captured.out
|
||
|
|
|
||
|
|
def test_shows_sizes(self, sample_agent_log, capsys):
|
||
|
|
list_logs()
|
||
|
|
captured = capsys.readouterr()
|
||
|
|
# File is small, should show as bytes or KB
|
||
|
|
assert "B" in captured.out or "KB" in captured.out
|