Files
stack/tests/api/test_diag.py
kert bd8cda1931
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
remove: scrub all woodpecker references from codebase
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.
2026-04-18 18:51:21 -04:00

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"]