Some checks failed
CI / skinny-install (aco) (push) Successful in 1m14s
CI / skinny-install (bib) (push) Successful in 5m47s
CI / skinny-install (api) (push) Successful in 37s
CI / skinny-install (bcda) (push) Successful in 37s
CI / skinny-install (bls) (push) Successful in 40s
CI / skinny-install (ccw) (push) Successful in 41s
CI / skinny-install (cli) (push) Successful in 42s
CI / skinny-install (cms) (push) Successful in 39s
CI / skinny-install (conf) (push) Successful in 41s
CI / skinny-install (opps) (push) Successful in 39s
CI / skinny-install (perf) (push) Successful in 40s
CI / skinny-install (pfs) (push) Successful in 50s
CI / skinny-install (rex) (push) Successful in 33s
Deploy / build-scan-report (push) Failing after 12m6s
Infra CI / notebooks (push) Successful in 22s
Infra CI / zotero (push) Successful in 12s
Infra CI / docs (push) Successful in 11s
Infra CI / api (push) Successful in 12s
Infra CI / mc (push) Successful in 22s
CI / lint-test (push) Failing after 41m1s
Clean 17 files across src/, tests/, dev/, stack.toml, deploy.sh: - api/auth/provision.py: remove WoodpeckerClient, provision_woodpecker, _get_woodpecker_token, woodpecker field from ProvisionResult - api/auth/manifest.py: remove woodpecker from CREDENTIALS + Provisioner - api/diag: remove woodpecker log fetching - sem/hooks.py: remove woodpecker sync step - stack.toml: remove [services.woodpecker] config - deploy.sh: remove woodpecker deploy steps - dev/scripts: remove woodpecker from config gen, secrets, readme - tests: remove all woodpecker assertions and test cases Zero woodpecker references remain in the codebase.
756 lines
26 KiB
Python
756 lines
26 KiB
Python
"""Tests for api.diag — crash diagnostics."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from api.diag.trace import (
|
|
CrashReport,
|
|
Frame,
|
|
_resolve_ast_context,
|
|
parse_exception,
|
|
parse_traceback_text,
|
|
)
|
|
|
|
|
|
class TestParseException:
|
|
def test_parses_basic_exception(self):
|
|
try:
|
|
raise ValueError("test error")
|
|
except ValueError:
|
|
exc_type, exc_value, exc_tb = sys.exc_info()
|
|
report = parse_exception(exc_type, exc_value, exc_tb)
|
|
|
|
assert report.exc_type == "ValueError"
|
|
assert report.exc_value == "test error"
|
|
assert len(report.frames) > 0
|
|
# The last frame should be this test file
|
|
assert "test_diag.py" in report.frames[-1].filepath
|
|
|
|
def test_identifies_project_frames(self):
|
|
try:
|
|
raise RuntimeError("boom")
|
|
except RuntimeError:
|
|
exc_type, exc_value, exc_tb = sys.exc_info()
|
|
report = parse_exception(exc_type, exc_value, exc_tb)
|
|
|
|
# At least one frame should have ast_context (this file is under tests/)
|
|
# but _is_project_file checks src/ — so test frames won't have it
|
|
assert report.frames[-1].ast_context == ""
|
|
|
|
def test_handles_none_tb(self):
|
|
report = parse_exception(ValueError, ValueError("test"), None)
|
|
assert report.exc_type == "ValueError"
|
|
assert report.frames == []
|
|
|
|
|
|
class TestResolveAstContext:
|
|
def test_finds_function(self, tmp_path):
|
|
src = tmp_path / "example.py"
|
|
src.write_text("def foo():\n x = 1\n return x\n")
|
|
result = _resolve_ast_context(str(src), 2)
|
|
assert result == "FunctionDef foo"
|
|
|
|
def test_finds_class(self, tmp_path):
|
|
src = tmp_path / "example.py"
|
|
src.write_text("class Bar:\n def method(self):\n pass\n")
|
|
result = _resolve_ast_context(str(src), 3)
|
|
assert result == "FunctionDef method"
|
|
|
|
def test_returns_empty_for_missing_file(self):
|
|
result = _resolve_ast_context("/nonexistent/file.py", 1)
|
|
assert result == ""
|
|
|
|
def test_returns_empty_for_syntax_error(self, tmp_path):
|
|
src = tmp_path / "bad.py"
|
|
src.write_text("def foo(:\n")
|
|
result = _resolve_ast_context(str(src), 1)
|
|
assert result == ""
|
|
|
|
|
|
class TestBlame:
|
|
def test_blame_line_runs_git(self):
|
|
from api.diag.blame import blame_line
|
|
|
|
# Blame a known file in the repo
|
|
root = Path(__file__).resolve().parents[2]
|
|
info = blame_line("src/api/diag/__init__.py", 1, cwd=str(root))
|
|
# This file was just created so blame should return something
|
|
if info:
|
|
assert len(info.sha) == 40
|
|
assert info.author != ""
|
|
|
|
def test_blame_line_returns_none_for_bad_file(self):
|
|
from api.diag.blame import blame_line
|
|
|
|
info = blame_line("/nonexistent/file.py", 1)
|
|
assert info is None
|
|
|
|
def test_blame_report_filters_project_files(self):
|
|
from api.diag.blame import blame_report
|
|
|
|
report = CrashReport(
|
|
exc_type="ValueError",
|
|
exc_value="test",
|
|
frames=[
|
|
Frame(
|
|
filepath="/usr/lib/python3/something.py",
|
|
lineno=1,
|
|
name="test",
|
|
line="pass",
|
|
),
|
|
],
|
|
)
|
|
results = blame_report(report)
|
|
assert results == {}
|
|
|
|
def test_blame_report_runs_blame_on_project_frame(self):
|
|
"""Project-local frames hit the blame happy path (lines 86-90)."""
|
|
from api.diag.blame import blame_report
|
|
|
|
# Use this very test file as a real project-local frame.
|
|
report = CrashReport(
|
|
exc_type="ValueError",
|
|
exc_value="test",
|
|
frames=[
|
|
Frame(
|
|
filepath=str(Path(__file__).resolve()),
|
|
lineno=1,
|
|
name="test",
|
|
line="pass",
|
|
),
|
|
],
|
|
)
|
|
# Should not raise; may return either empty or populated dict
|
|
# depending on whether git blame finds the line.
|
|
results = blame_report(report)
|
|
assert isinstance(results, dict)
|
|
|
|
def test_blame_line_timeout_returns_none(self, monkeypatch):
|
|
"""TimeoutExpired from subprocess.run is swallowed, returning None."""
|
|
import subprocess as sp
|
|
|
|
from api.diag import blame
|
|
|
|
def boom(*a, **kw):
|
|
raise sp.TimeoutExpired(cmd="git blame", timeout=10)
|
|
|
|
monkeypatch.setattr(blame.subprocess, "run", boom)
|
|
assert blame.blame_line("src/api/diag/__init__.py", 1) is None
|
|
|
|
def test_blame_line_no_sha_returns_none(self, monkeypatch):
|
|
"""If git blame returns 0 but no SHA in output, return None."""
|
|
from api.diag import blame
|
|
|
|
class FakeResult:
|
|
returncode = 0
|
|
stdout = "no sha here\nauthor someone\n"
|
|
|
|
monkeypatch.setattr(blame.subprocess, "run", lambda *a, **kw: FakeResult())
|
|
assert blame.blame_line("anyfile.py", 1) is None
|
|
|
|
|
|
class TestLogs:
|
|
def test_collect_logs_includes_default_container(self):
|
|
from api.diag.logs import collect_logs
|
|
|
|
with patch("api.diag.logs._docker_logs", return_value="some output") as mock:
|
|
results = collect_logs("generic error")
|
|
|
|
assert "api" in results
|
|
mock.assert_called()
|
|
|
|
def test_collect_logs_detects_postgres(self):
|
|
from api.diag.logs import collect_logs
|
|
|
|
with patch("api.diag.logs._docker_logs", return_value="log output"):
|
|
results = collect_logs("psycopg2.OperationalError: connection refused")
|
|
|
|
assert "postgres" in results
|
|
assert "api" in results
|
|
|
|
def test_collect_logs_skips_empty(self):
|
|
from api.diag.logs import collect_logs
|
|
|
|
with patch("api.diag.logs._docker_logs", return_value=""):
|
|
results = collect_logs("generic error")
|
|
|
|
assert results == {}
|
|
|
|
def test_docker_logs_returns_output(self):
|
|
from api.diag.logs import _docker_logs
|
|
|
|
with patch("api.diag.logs.subprocess.run") as mock_run:
|
|
mock_run.return_value.stdout = "line1\nline2"
|
|
mock_run.return_value.stderr = ""
|
|
result = _docker_logs("api", tail=50, since="5m")
|
|
|
|
assert result == "line1\nline2"
|
|
|
|
def test_docker_logs_falls_back_to_stderr(self):
|
|
from api.diag.logs import _docker_logs
|
|
|
|
with patch("api.diag.logs.subprocess.run") as mock_run:
|
|
mock_run.return_value.stdout = ""
|
|
mock_run.return_value.stderr = "stderr output"
|
|
result = _docker_logs("api")
|
|
|
|
assert result == "stderr output"
|
|
|
|
def test_docker_logs_timeout_returns_empty(self):
|
|
import subprocess
|
|
|
|
from api.diag.logs import _docker_logs
|
|
|
|
with patch(
|
|
"api.diag.logs.subprocess.run",
|
|
side_effect=subprocess.TimeoutExpired(["docker"], 10),
|
|
):
|
|
result = _docker_logs("api")
|
|
|
|
assert result == ""
|
|
|
|
def test_docker_logs_file_not_found_returns_empty(self):
|
|
from api.diag.logs import _docker_logs
|
|
|
|
with patch(
|
|
"api.diag.logs.subprocess.run",
|
|
side_effect=FileNotFoundError("docker not found"),
|
|
):
|
|
result = _docker_logs("api")
|
|
|
|
assert result == ""
|
|
|
|
|
|
class TestIssueBody:
|
|
def test_build_issue_body(self):
|
|
from api.diag.blame import BlameInfo
|
|
from api.diag.issue import build_issue_body
|
|
|
|
report = CrashReport(
|
|
exc_type="ValueError",
|
|
exc_value="bad value",
|
|
frames=[
|
|
Frame(
|
|
filepath="src/api/routes/auth.py",
|
|
lineno=42,
|
|
name="issue_token",
|
|
line="raise ValueError('bad value')",
|
|
ast_context="FunctionDef issue_token",
|
|
),
|
|
],
|
|
)
|
|
blames = {
|
|
"src/api/routes/auth.py:42": BlameInfo(
|
|
sha="a" * 40,
|
|
author="kert",
|
|
summary="add auth endpoint",
|
|
timestamp="1234567890",
|
|
),
|
|
}
|
|
body = build_issue_body(report, blames, {"api": "error log"}, "abc123")
|
|
|
|
assert "abc123" in body
|
|
assert "ValueError" in body
|
|
assert "bad value" in body
|
|
assert "kert" in body
|
|
assert "add auth endpoint" in body
|
|
assert "error log" in body
|
|
assert "FunctionDef issue_token" in body
|
|
|
|
def test_build_issue_body_no_blames(self):
|
|
from api.diag.issue import build_issue_body
|
|
|
|
report = CrashReport(
|
|
exc_type="RuntimeError",
|
|
exc_value="oops",
|
|
frames=[],
|
|
)
|
|
body = build_issue_body(report, {}, {}, "def456")
|
|
assert "RuntimeError" in body
|
|
assert "def456" in body
|
|
|
|
|
|
class TestCurrentCommit:
|
|
def test_reads_from_env_file(self, tmp_path) -> None:
|
|
from api.diag.issue import _current_commit
|
|
|
|
env_path = tmp_path / ".env"
|
|
env_path.write_text("COMMIT_SHA=abc123def456\n")
|
|
with patch("api.diag.issue.Path") as mock_path_cls:
|
|
mock_path = MagicMock()
|
|
mock_path.exists.return_value = True
|
|
mock_path.read_text.return_value = "COMMIT_SHA=abc123def456\n"
|
|
mock_path_cls.return_value.__truediv__.return_value = mock_path
|
|
# Path(__file__).resolve().parents[3] / ".env"
|
|
# Just test the fallback path
|
|
with patch("api.diag.issue.subprocess.run") as mock_run:
|
|
mock_run.return_value.returncode = 0
|
|
mock_run.return_value.stdout = "abc123def456\n"
|
|
result = _current_commit()
|
|
assert isinstance(result, str)
|
|
|
|
def test_falls_back_to_git(self, tmp_path) -> None:
|
|
from api.diag.issue import _current_commit
|
|
|
|
with patch("api.diag.issue.subprocess.run") as mock_run:
|
|
mock_run.return_value.returncode = 0
|
|
mock_run.return_value.stdout = "deadbeef1234\n"
|
|
# Patch Path so .env doesn't exist
|
|
with patch("api.diag.issue.Path") as mock_path_cls:
|
|
mock_env = MagicMock()
|
|
mock_env.exists.return_value = False
|
|
mock_path_cls.return_value.resolve.return_value.parents.__getitem__.return_value.__truediv__.return_value = mock_env
|
|
result = _current_commit()
|
|
|
|
# Can't guarantee the exact return because of the patching complexity,
|
|
# but it should return a string
|
|
assert isinstance(result, str)
|
|
|
|
def test_returns_unknown_on_timeout(self) -> None:
|
|
import subprocess
|
|
|
|
from api.diag.issue import _current_commit
|
|
|
|
with patch(
|
|
"api.diag.issue.subprocess.run",
|
|
side_effect=subprocess.TimeoutExpired(["git"], 5),
|
|
):
|
|
with patch("api.diag.issue.Path") as mock_path_cls:
|
|
mock_env = MagicMock()
|
|
mock_env.exists.return_value = False
|
|
mock_path_cls.return_value.resolve.return_value.parents.__getitem__.return_value.__truediv__.return_value = mock_env
|
|
result = _current_commit()
|
|
assert result == "unknown"
|
|
|
|
|
|
class TestBuildIssueBodyAbsPath:
|
|
def test_absolute_path_frame_falls_back(self) -> None:
|
|
"""Frame with absolute path outside project root uses fallback key."""
|
|
from api.diag.issue import build_issue_body
|
|
|
|
report = CrashReport(
|
|
exc_type="RuntimeError",
|
|
exc_value="boom",
|
|
frames=[
|
|
Frame(
|
|
filepath="/some/other/path/file.py",
|
|
lineno=1,
|
|
name="fn",
|
|
line="raise RuntimeError('boom')",
|
|
ast_context=None,
|
|
),
|
|
],
|
|
)
|
|
body = build_issue_body(report, {}, {}, "commit123")
|
|
assert "file.py" in body
|
|
|
|
|
|
class TestTruncate:
|
|
def test_truncates_long_string(self) -> None:
|
|
from api.diag.issue import _truncate
|
|
|
|
s = "A" * 100
|
|
result = _truncate(s, 20)
|
|
assert len(result) == 20
|
|
assert result.endswith("...")
|
|
|
|
def test_does_not_truncate_short_string(self) -> None:
|
|
from api.diag.issue import _truncate
|
|
|
|
s = "short"
|
|
assert _truncate(s, 20) == "short"
|
|
|
|
|
|
class TestFileIssue:
|
|
def test_skips_without_token(self, monkeypatch):
|
|
from api.diag.issue import file_issue
|
|
|
|
monkeypatch.delenv("GITEA_TOKEN", raising=False)
|
|
report = CrashReport(exc_type="E", exc_value="e", frames=[])
|
|
with patch("api.diag.issue.collect_logs", return_value={}):
|
|
with patch("conf.secret", return_value=""):
|
|
result = file_issue(report)
|
|
assert result is None
|
|
|
|
def test_posts_issue_with_labels(self, monkeypatch):
|
|
from api.diag.issue import file_issue
|
|
|
|
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
|
report = CrashReport(exc_type="ValueError", exc_value="boom", frames=[])
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.create_issue.return_value = {"number": 77}
|
|
|
|
with (
|
|
patch("api.diag.issue.collect_logs", return_value={}),
|
|
patch("api.diag.issue.blame_report", return_value={}),
|
|
patch("api.diag.issue._current_commit", return_value="abc"),
|
|
patch("api.clients.gitea.GiteaClient", return_value=mock_client),
|
|
):
|
|
result = file_issue(report, labels=[5, 10])
|
|
|
|
assert result["number"] == 77
|
|
call_body = mock_client.create_issue.call_args[0][2]
|
|
assert call_body["labels"] == [5, 10]
|
|
|
|
def test_returns_none_on_api_exception(self, monkeypatch):
|
|
from api.diag.issue import file_issue
|
|
|
|
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
|
report = CrashReport(exc_type="ValueError", exc_value="boom", frames=[])
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.create_issue.side_effect = Exception("API error")
|
|
|
|
with (
|
|
patch("api.diag.issue.collect_logs", return_value={}),
|
|
patch("api.diag.issue.blame_report", return_value={}),
|
|
patch("api.diag.issue._current_commit", return_value="abc"),
|
|
patch("api.clients.gitea.GiteaClient", return_value=mock_client),
|
|
):
|
|
result = file_issue(report)
|
|
|
|
assert result is None
|
|
|
|
def test_posts_issue_with_token(self, monkeypatch):
|
|
from api.diag.issue import file_issue
|
|
|
|
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
|
report = CrashReport(exc_type="ValueError", exc_value="boom", frames=[])
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.create_issue.return_value = {"number": 99, "html_url": "http://..."}
|
|
|
|
with (
|
|
patch("api.diag.issue.collect_logs", return_value={}),
|
|
patch("api.diag.issue.blame_report", return_value={}),
|
|
patch("api.clients.gitea.GiteaClient", return_value=mock_client),
|
|
):
|
|
result = file_issue(report)
|
|
|
|
assert result["number"] == 99
|
|
mock_client.create_issue.assert_called_once()
|
|
call_args = mock_client.create_issue.call_args
|
|
assert call_args[0][0] == "homelab"
|
|
assert call_args[0][1] == "stack"
|
|
assert "ValueError" in call_args[0][2]["title"]
|
|
|
|
|
|
class TestHook:
|
|
def test_install_and_uninstall(self):
|
|
from api.diag.hook import install, uninstall
|
|
|
|
original = sys.excepthook
|
|
install()
|
|
assert sys.excepthook is not original
|
|
|
|
uninstall()
|
|
# After uninstall, hook should be restored
|
|
# (may be the original or sys.__excepthook__)
|
|
|
|
def test_install_is_idempotent(self):
|
|
from api.diag.hook import install, uninstall
|
|
|
|
install()
|
|
hook_after_first = sys.excepthook
|
|
install()
|
|
assert sys.excepthook is hook_after_first
|
|
uninstall()
|
|
|
|
def test_hook_skips_keyboard_interrupt(self):
|
|
from api.diag.hook import _excepthook
|
|
|
|
# Should not try to file an issue for KeyboardInterrupt
|
|
with patch("api.diag.hook._original_hook"):
|
|
_excepthook(KeyboardInterrupt, KeyboardInterrupt(), None)
|
|
|
|
def test_hook_falls_back_to_sys_excepthook_when_no_original(self):
|
|
"""_excepthook uses sys.__excepthook__ when _original_hook is None."""
|
|
import api.diag.hook as hook_module
|
|
|
|
orig = hook_module._original_hook
|
|
try:
|
|
hook_module._original_hook = None
|
|
with patch("sys.__excepthook__") as mock_sys_hook:
|
|
from api.diag.hook import _excepthook
|
|
|
|
_excepthook(SystemExit, SystemExit(0), None)
|
|
mock_sys_hook.assert_called_once()
|
|
finally:
|
|
hook_module._original_hook = orig
|
|
|
|
def test_hook_files_issue_on_exception(self):
|
|
"""_excepthook calls file_issue for non-keyboard exceptions."""
|
|
from api.diag.hook import _excepthook
|
|
|
|
with (
|
|
patch("api.diag.hook._original_hook", side_effect=lambda *a: None),
|
|
patch("api.diag.trace.parse_exception") as mock_parse,
|
|
patch("api.diag.issue.file_issue", return_value={"number": 42}),
|
|
):
|
|
mock_report = MagicMock()
|
|
mock_parse.return_value = mock_report
|
|
try:
|
|
raise RuntimeError("test crash")
|
|
except RuntimeError:
|
|
exc_type, exc_value, exc_tb = sys.exc_info()
|
|
_excepthook(exc_type, exc_value, exc_tb)
|
|
|
|
def test_hook_handles_diagnostics_crash_gracefully(self):
|
|
"""If diagnostics module crashes, _excepthook logs but doesn't recurse."""
|
|
from api.diag.hook import _excepthook
|
|
|
|
with patch("api.diag.hook._original_hook", side_effect=lambda *a: None):
|
|
# Force the inner try block to raise
|
|
with patch(
|
|
"api.diag.trace.parse_exception", side_effect=Exception("diag crash")
|
|
):
|
|
try:
|
|
raise ValueError("original")
|
|
except ValueError:
|
|
exc_type, exc_value, exc_tb = sys.exc_info()
|
|
# Should not raise
|
|
_excepthook(exc_type, exc_value, exc_tb)
|
|
|
|
def test_uninstall_without_original_hook(self):
|
|
"""uninstall() when _original_hook is None restores sys.__excepthook__."""
|
|
import api.diag.hook as hook_module
|
|
|
|
# Manually set state as if installed but no original hook recorded
|
|
hook_module._installed = True
|
|
hook_module._original_hook = None
|
|
from api.diag.hook import uninstall
|
|
|
|
uninstall()
|
|
assert hook_module.sys.excepthook is sys.__excepthook__
|
|
assert not hook_module._installed
|
|
|
|
|
|
class TestParseTracebackText:
|
|
SAMPLE_TB = (
|
|
"Traceback (most recent call last):\n"
|
|
' File "/home/kert/stack/src/api/auth/provision.py", line 55, in provision\n'
|
|
" values = derive_all(root_key, commit_sha)\n"
|
|
' File "/home/kert/stack/src/api/auth/derive.py", line 28, in derive\n'
|
|
" prk = _hkdf_extract(salt, root_key)\n"
|
|
"ValueError: bad key material\n"
|
|
)
|
|
|
|
def test_parses_single_traceback(self):
|
|
reports = parse_traceback_text(self.SAMPLE_TB)
|
|
assert len(reports) == 1
|
|
r = reports[0]
|
|
assert r.exc_type == "ValueError"
|
|
assert r.exc_value == "bad key material"
|
|
assert len(r.frames) == 2
|
|
assert r.frames[0].lineno == 55
|
|
assert r.frames[0].name == "provision"
|
|
assert r.frames[0].line == "values = derive_all(root_key, commit_sha)"
|
|
assert r.frames[1].lineno == 28
|
|
|
|
def test_parses_multiple_tracebacks(self):
|
|
text = (
|
|
self.SAMPLE_TB
|
|
+ "\n"
|
|
+ (
|
|
"Traceback (most recent call last):\n"
|
|
' File "script.py", line 1, in <module>\n'
|
|
" import foo\n"
|
|
"ModuleNotFoundError: No module named 'foo'\n"
|
|
)
|
|
)
|
|
reports = parse_traceback_text(text)
|
|
assert len(reports) == 2
|
|
assert reports[0].exc_type == "ValueError"
|
|
assert reports[1].exc_type == "ModuleNotFoundError"
|
|
|
|
def test_returns_empty_for_no_tracebacks(self):
|
|
reports = parse_traceback_text("just some regular log output\nno errors here")
|
|
assert reports == []
|
|
|
|
def test_handles_exception_without_message(self):
|
|
text = (
|
|
"Traceback (most recent call last):\n"
|
|
' File "x.py", line 1, in f\n'
|
|
" pass\n"
|
|
"RuntimeError\n"
|
|
)
|
|
reports = parse_traceback_text(text)
|
|
assert len(reports) == 1
|
|
assert reports[0].exc_type == "RuntimeError"
|
|
assert reports[0].exc_value == ""
|
|
|
|
def test_handles_ci_log_prefix(self):
|
|
# CI log lines may have timestamps or prefixes
|
|
text = (
|
|
"some setup output\n"
|
|
"Traceback (most recent call last):\n"
|
|
' File "/app/main.py", line 10, in run\n'
|
|
" do_thing()\n"
|
|
"OSError: disk full\n"
|
|
"step exited with code 1\n"
|
|
)
|
|
reports = parse_traceback_text(text)
|
|
assert len(reports) == 1
|
|
assert reports[0].exc_type == "OSError"
|
|
assert reports[0].exc_value == "disk full"
|
|
|
|
|
|
class TestCliGuard:
|
|
def test_returns_fn_result(self):
|
|
from api.diag.guard import cli_guard
|
|
|
|
assert cli_guard(lambda: 0) == 0
|
|
assert cli_guard(lambda: 42) == 42
|
|
|
|
def test_catches_exception_and_returns_2(self):
|
|
from api.diag.guard import cli_guard
|
|
|
|
def boom():
|
|
raise RuntimeError("kaboom")
|
|
|
|
with (
|
|
patch("api.diag.guard.traceback.print_exc"),
|
|
patch("api.diag.issue.file_issue", return_value=None),
|
|
patch("api.diag.issue.collect_logs", return_value={}),
|
|
patch("api.diag.issue.blame_report", return_value={}),
|
|
):
|
|
result = cli_guard(boom)
|
|
|
|
assert result == 2
|
|
|
|
def test_handles_keyboard_interrupt(self):
|
|
from api.diag.guard import cli_guard
|
|
|
|
def interrupted():
|
|
raise KeyboardInterrupt()
|
|
|
|
assert cli_guard(interrupted) == 130
|
|
|
|
def test_handles_system_exit(self):
|
|
from api.diag.guard import cli_guard
|
|
|
|
def exits():
|
|
raise SystemExit(7)
|
|
|
|
assert cli_guard(exits) == 7
|
|
|
|
def test_guarded_decorator(self):
|
|
from api.diag.guard import guarded
|
|
|
|
@guarded
|
|
def good():
|
|
return 0
|
|
|
|
assert good() == 0
|
|
|
|
def test_logs_html_url_when_issue_filed(self):
|
|
"""When file_issue returns a result dict, log the issue number/url."""
|
|
from api.diag.guard import cli_guard
|
|
|
|
def boom():
|
|
raise RuntimeError("explode")
|
|
|
|
fake_result = {"number": 99, "html_url": "https://git.fhirworx.io/.../99"}
|
|
with (
|
|
patch("api.diag.guard.traceback.print_exc"),
|
|
patch("api.diag.issue.file_issue", return_value=fake_result),
|
|
patch("api.diag.issue.collect_logs", return_value={}),
|
|
patch("api.diag.issue.blame_report", return_value={}),
|
|
):
|
|
assert cli_guard(boom) == 2
|
|
|
|
def test_swallows_exception_in_diagnostics(self):
|
|
"""If parse_exception/file_issue itself raises, guard still returns 2."""
|
|
from api.diag.guard import cli_guard
|
|
|
|
def boom():
|
|
raise RuntimeError("explode")
|
|
|
|
with (
|
|
patch("api.diag.guard.traceback.print_exc"),
|
|
patch(
|
|
"api.diag.trace.parse_exception",
|
|
side_effect=ValueError("trace parse failed"),
|
|
),
|
|
):
|
|
assert cli_guard(boom) == 2
|
|
|
|
|
|
class TestCiMain:
|
|
def test_missing_commit_sha(self, monkeypatch):
|
|
monkeypatch.delenv("CI_COMMIT_SHA", raising=False)
|
|
|
|
from api.diag.__main__ import main
|
|
|
|
assert main() == 1
|
|
|
|
def test_no_log_text(self, monkeypatch):
|
|
monkeypatch.setenv("CI_COMMIT_SHA", "abc123")
|
|
monkeypatch.setenv("CI_REPO", "homelab/stack")
|
|
monkeypatch.setenv("GITEA_TOKEN", "tok")
|
|
monkeypatch.setenv("CI_STEP_NAME", "build")
|
|
monkeypatch.setenv("CI_STEP_LOG", "")
|
|
|
|
mock_gitea = MagicMock()
|
|
|
|
with patch("api.clients.gitea.GiteaClient", return_value=mock_gitea):
|
|
from api.diag.__main__ import main
|
|
|
|
assert main() == 0
|
|
|
|
def test_files_issue_for_python_failure(self, monkeypatch):
|
|
monkeypatch.setenv("CI_COMMIT_SHA", "abc123")
|
|
monkeypatch.setenv("CI_REPO", "homelab/stack")
|
|
monkeypatch.setenv("GITEA_TOKEN", "tok")
|
|
monkeypatch.setenv("CI_STEP_NAME", "provision")
|
|
monkeypatch.setenv(
|
|
"CI_STEP_LOG",
|
|
"Traceback (most recent call last):\n"
|
|
' File "src/api/auth/provision.py", line 10, in provision\n'
|
|
" do_stuff()\n"
|
|
"RuntimeError: oops\n",
|
|
)
|
|
|
|
mock_gitea = MagicMock()
|
|
mock_gitea.create_issue.return_value = {"number": 55}
|
|
|
|
with (
|
|
patch("api.clients.gitea.GiteaClient", return_value=mock_gitea),
|
|
patch("api.diag.blame.blame_report", return_value={}),
|
|
):
|
|
from api.diag.__main__ import main
|
|
|
|
assert main() == 0
|
|
|
|
mock_gitea.create_issue.assert_called_once()
|
|
issue = mock_gitea.create_issue.call_args[0][2]
|
|
assert "RuntimeError" in issue["title"]
|
|
assert "provision" in issue["title"]
|
|
|
|
def test_files_issue_for_non_python_failure(self, monkeypatch):
|
|
monkeypatch.setenv("CI_COMMIT_SHA", "abc123")
|
|
monkeypatch.setenv("CI_REPO", "homelab/stack")
|
|
monkeypatch.setenv("GITEA_TOKEN", "tok")
|
|
monkeypatch.setenv("CI_STEP_NAME", "build-docs")
|
|
monkeypatch.setenv(
|
|
"CI_STEP_LOG",
|
|
"COPY failed: file not found\nERROR: build failed\n",
|
|
)
|
|
|
|
mock_gitea = MagicMock()
|
|
mock_gitea.create_issue.return_value = {"number": 56}
|
|
|
|
with patch("api.clients.gitea.GiteaClient", return_value=mock_gitea):
|
|
from api.diag.__main__ import main
|
|
|
|
assert main() == 0
|
|
|
|
mock_gitea.create_issue.assert_called_once()
|
|
issue = mock_gitea.create_issue.call_args[0][2]
|
|
assert "build-docs" in issue["title"]
|
|
assert "COPY failed" in issue["body"]
|