* feat(src): migrate second-brain RAG CLI + tests * fix(embedder): strip hardcoded ai.slaid098.dev endpoint * refactor: rename slaid098/opencode to opencode-config * docs(handoff): add pr-5 handoff + ADR-001 * fix(docs): rebase handoff/ADR naming to PR number + drop dangling ADR-009 refs * fix(tests): update script paths + assertions for opencode-config migration * fix(pyproject): update cov + ruff paths config/scripts -> .opencode/scripts * docs: update project map + handoff + ADR --------- Co-authored-by: opencode-agent <agent@slaid098.dev>
341 lines
11 KiB
Python
341 lines
11 KiB
Python
"""Tests for .opencode/scripts/observability.py — log parser for denials/errors.
|
|
|
|
Pattern: direct calls for parse_line/_process_line, tmp_path + monkeypatch on
|
|
obs.LOG_PATH for main() tests. Loading via importlib.util.spec_from_file_location
|
|
(same pattern as test_pipeline_status.py / test_spec_status.py).
|
|
"""
|
|
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
SCRIPT_PATH = Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "observability.py"
|
|
spec = importlib.util.spec_from_file_location("observability", SCRIPT_PATH)
|
|
obs = importlib.util.module_from_spec(spec)
|
|
sys.modules["observability"] = obs
|
|
spec.loader.exec_module(obs)
|
|
|
|
|
|
# ── parse_line ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_line_with_timestamp_and_run():
|
|
assert obs.parse_line("timestamp=2026-07-20T10:00:00 run=abc123") == (
|
|
"2026-07-20T10:00:00",
|
|
"abc123",
|
|
)
|
|
|
|
|
|
def test_parse_line_no_timestamp():
|
|
assert obs.parse_line("run=abc123") == ("?", "abc123")
|
|
|
|
|
|
def test_parse_line_no_run():
|
|
assert obs.parse_line("timestamp=2026-07-20T10:00:00") == (
|
|
"2026-07-20T10:00:00",
|
|
"?",
|
|
)
|
|
|
|
|
|
def test_parse_line_no_matches():
|
|
assert obs.parse_line("random line") == ("?", "?")
|
|
|
|
|
|
def test_parse_line_empty():
|
|
assert obs.parse_line("") == ("?", "?")
|
|
|
|
|
|
# ── _process_line: sessions ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_process_line_session_created():
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line(
|
|
'message=created agent=build run=abc id=sess1 title="Test"',
|
|
sessions,
|
|
denials,
|
|
errors,
|
|
)
|
|
assert sessions == {"abc": {"agent": "build", "session": "sess1", "title": "Test"}}
|
|
assert denials == []
|
|
assert errors == []
|
|
|
|
|
|
def test_process_line_session_no_agent():
|
|
# `agent=` present but value empty — `agent=(\S+)` regex misses → fallback "unknown".
|
|
# NOTE: spec issue #111 used `message=created run=abc id=sess1` (no `agent=` at all),
|
|
# but `observability.py:23` requires `"agent=" in line` to enter the session branch.
|
|
# Fix: `agent=` with empty value exercises the `unknown` fallback (the spec's intent).
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line(
|
|
"message=created agent= run=abc id=sess1",
|
|
sessions,
|
|
denials,
|
|
errors,
|
|
)
|
|
assert sessions == {"abc": {"agent": "unknown", "session": "sess1", "title": ""}}
|
|
|
|
|
|
def test_process_line_session_no_title():
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line(
|
|
"message=created agent=build run=abc id=sess1",
|
|
sessions,
|
|
denials,
|
|
errors,
|
|
)
|
|
assert sessions == {"abc": {"agent": "build", "session": "sess1", "title": ""}}
|
|
|
|
|
|
# ── _process_line: denials ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_process_line_denial():
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line(
|
|
'action.action=deny run=abc pattern="rm *" permission=bash',
|
|
sessions,
|
|
denials,
|
|
errors,
|
|
)
|
|
assert len(denials) == 1
|
|
d = denials[0]
|
|
assert d["run"] == "abc"
|
|
assert d["pattern"] == "rm *"
|
|
assert d["perm"] == "bash"
|
|
assert d["ts"] == "?"
|
|
assert sessions == {}
|
|
assert errors == []
|
|
|
|
|
|
def test_process_line_denial_no_pattern():
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line(
|
|
"action.action=deny run=abc permission=bash",
|
|
sessions,
|
|
denials,
|
|
errors,
|
|
)
|
|
assert len(denials) == 1
|
|
assert denials[0]["pattern"] == "?"
|
|
|
|
|
|
# ── _process_line: errors ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_process_line_error():
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line(
|
|
"message=process level=ERROR run=abc error=ToolExecFailed session.id=sess1",
|
|
sessions,
|
|
denials,
|
|
errors,
|
|
)
|
|
assert len(errors) == 1
|
|
e = errors[0]
|
|
assert e["run"] == "abc"
|
|
assert e["error"] == "ToolExecFailed"
|
|
assert e["session"] == "sess1"
|
|
assert e["ts"] == "?"
|
|
assert sessions == {}
|
|
assert denials == []
|
|
|
|
|
|
def test_process_line_error_no_session():
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line(
|
|
"message=process level=ERROR run=abc error=ToolExecFailed",
|
|
sessions,
|
|
denials,
|
|
errors,
|
|
)
|
|
assert len(errors) == 1
|
|
assert errors[0]["session"] == "?"
|
|
|
|
|
|
def test_process_line_error_no_error_field():
|
|
"""No error= field → error default 'unknown'."""
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line(
|
|
"message=process level=ERROR run=abc session.id=sess1",
|
|
sessions,
|
|
denials,
|
|
errors,
|
|
)
|
|
assert len(errors) == 1
|
|
assert errors[0]["error"] == "unknown"
|
|
|
|
|
|
# ── _process_line: unmatched ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_process_line_unmatched():
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line("random log line", sessions, denials, errors)
|
|
assert sessions == {}
|
|
assert denials == []
|
|
assert errors == []
|
|
|
|
|
|
def test_process_line_message_process_no_error():
|
|
"""message=process without level=ERROR → not classified as error."""
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line(
|
|
"message=process level=INFO run=abc",
|
|
sessions,
|
|
denials,
|
|
errors,
|
|
)
|
|
assert sessions == {}
|
|
assert denials == []
|
|
assert errors == []
|
|
|
|
|
|
def test_process_line_created_without_agent_keyword():
|
|
"""message=created present but no 'agent=' substring → not a session line."""
|
|
sessions: dict = {}
|
|
denials: list = []
|
|
errors: list = []
|
|
obs._process_line(
|
|
"message=created run=abc id=sess1",
|
|
sessions,
|
|
denials,
|
|
errors,
|
|
)
|
|
# 'message=created' in line AND 'agent=' in line — second condition fails,
|
|
# so falls through. No 'action.action=deny' or 'message=process level=ERROR'.
|
|
assert sessions == {}
|
|
assert denials == []
|
|
assert errors == []
|
|
|
|
|
|
# ── main ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_main_no_log_file(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.setattr(obs, "LOG_PATH", tmp_path / "nonexistent.log")
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
obs.main()
|
|
assert exc_info.value.code == 1
|
|
captured = capsys.readouterr()
|
|
assert "Log file not found" in captured.out
|
|
|
|
|
|
def test_main_empty_log(tmp_path, monkeypatch, capsys):
|
|
log_file = tmp_path / "test.log"
|
|
log_file.write_text("")
|
|
monkeypatch.setattr(obs, "LOG_PATH", log_file)
|
|
obs.main()
|
|
captured = capsys.readouterr()
|
|
assert "No denials or errors found in log." in captured.out
|
|
|
|
|
|
def test_main_only_sessions(tmp_path, monkeypatch, capsys):
|
|
log_file = tmp_path / "test.log"
|
|
log_file.write_text('message=created agent=build run=abc id=sess1 title="Test"\n')
|
|
monkeypatch.setattr(obs, "LOG_PATH", log_file)
|
|
obs.main()
|
|
captured = capsys.readouterr()
|
|
assert "No denials or errors found in log." in captured.out
|
|
|
|
|
|
def test_main_with_denials(tmp_path, monkeypatch, capsys):
|
|
log_file = tmp_path / "test.log"
|
|
log_file.write_text('action.action=deny run=abc pattern="rm *" permission=bash\n')
|
|
monkeypatch.setattr(obs, "LOG_PATH", log_file)
|
|
obs.main()
|
|
captured = capsys.readouterr()
|
|
assert "## Permission Denials" in captured.out
|
|
assert "`rm *`" in captured.out
|
|
assert "bash" in captured.out
|
|
assert "unknown" in captured.out # agent lookup falls back to unknown
|
|
|
|
|
|
def test_main_with_errors(tmp_path, monkeypatch, capsys):
|
|
log_file = tmp_path / "test.log"
|
|
log_file.write_text(
|
|
"message=process level=ERROR run=abc error=ToolExecFailed session.id=sess1\n"
|
|
)
|
|
monkeypatch.setattr(obs, "LOG_PATH", log_file)
|
|
obs.main()
|
|
captured = capsys.readouterr()
|
|
assert "## Process Errors" in captured.out
|
|
assert "ToolExecFailed" in captured.out
|
|
assert "sess1" in captured.out
|
|
|
|
|
|
def test_main_mixed(tmp_path, monkeypatch, capsys):
|
|
log_file = tmp_path / "test.log"
|
|
log_file.write_text(
|
|
'message=created agent=build run=abc id=sess1 title="Test"\n'
|
|
'action.action=deny run=abc pattern="rm *" permission=bash\n'
|
|
"message=process level=ERROR run=abc error=ToolExecFailed session.id=sess1\n"
|
|
)
|
|
monkeypatch.setattr(obs, "LOG_PATH", log_file)
|
|
obs.main()
|
|
captured = capsys.readouterr()
|
|
assert "# Observability Report" in captured.out
|
|
assert "## Permission Denials" in captured.out
|
|
assert "### build (1 denials)" in captured.out
|
|
assert "## Process Errors" in captured.out
|
|
# Denial row references session from sessions dict.
|
|
assert "sess1" in captured.out
|
|
|
|
|
|
def test_main_denials_grouped_by_agent(tmp_path, monkeypatch, capsys):
|
|
log_file = tmp_path / "test.log"
|
|
log_file.write_text(
|
|
'message=created agent=zebra run=r1 id=s1 title="A"\n'
|
|
'message=created agent=alpha run=r2 id=s2 title="B"\n'
|
|
'action.action=deny run=r1 pattern="p1" permission=bash\n'
|
|
'action.action=deny run=r2 pattern="p2" permission=edit\n'
|
|
)
|
|
monkeypatch.setattr(obs, "LOG_PATH", log_file)
|
|
obs.main()
|
|
captured = capsys.readouterr()
|
|
out = captured.out
|
|
# sorted alphabetically by agent
|
|
alpha_idx = out.find("### alpha")
|
|
zebra_idx = out.find("### zebra")
|
|
assert alpha_idx != -1
|
|
assert zebra_idx != -1
|
|
assert alpha_idx < zebra_idx
|
|
|
|
|
|
def test_main_errors_last_20(tmp_path, monkeypatch, capsys):
|
|
log_file = tmp_path / "test.log"
|
|
lines = []
|
|
for i in range(25):
|
|
lines.append(f"message=process level=ERROR run=run{i} error=Err{i} session.id=s{i}\n")
|
|
log_file.write_text("".join(lines))
|
|
monkeypatch.setattr(obs, "LOG_PATH", log_file)
|
|
obs.main()
|
|
captured = capsys.readouterr()
|
|
out = captured.out
|
|
# Last 20 of 25 errors: indices 5..24 → Err5..Err24
|
|
assert "Err24" in out
|
|
assert "Err5" in out
|
|
assert "Err4" not in out # not in last 20
|
|
assert "Err0" not in out
|