Files
stack/tests/perf/test_hooks.py
kert cb530c25d9 chore: snapshot WIP + fix cross-test conf pollution
Bundled commit across unrelated threads: perf collector/meter/logs/export
updates, SSO bootstrap and traefik/nginx/gitea infra, dev backend scripts
(gitea/github/woodpecker), diag test updates, and misc config.

Also fix test pollution: five tests were doing raw sys.modules.pop("conf")
to simulate ImportError on `import conf`, but raw dict mutation isn't
tracked by monkeypatch and leaked the eviction into subsequent tests.
Downstream tests that did `from conf import secret` at module level held
references to the pre-eviction conf, while re-imports inside tests got a
fresh conf, so patches to conf.cfg._data didn't land on the secret()
closure's cfg — causing tests/conf/test_conf.py::TestSecret and
tests/conf/test_connect.py::TestDuckdb::test_custom_db_name to fail.

Fix: use monkeypatch.delitem(sys.modules, "conf", raising=False) so the
eviction is reverted on teardown. Applied to test_diag_ci.py, test_init.py,
test_tracer.py, and test_resource.py (two sites).
2026-04-10 13:09:14 -04:00

293 lines
9.9 KiB
Python

"""Tests for perf.hooks — Gitea issue filing on failure/skip."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
class TestOnStepFailure:
"""on_step_failure builds correct issue title and body."""
@patch("perf.hooks._file_issue")
def test_files_issue_on_exception(self, mock_file):
mock_file.return_value = {"number": 999}
from perf.hooks import on_step_failure
exc = ValueError("missing column: person_id")
on_step_failure("core.encounter", exc, pipeline="readmissions")
mock_file.assert_called_once()
title, body = mock_file.call_args[0][:2]
assert "core.encounter" in title
assert "readmissions" in title
assert "failed" in title
assert "ValueError" in body
assert "missing column: person_id" in body
assert "Traceback" in body
@patch("perf.hooks._file_issue")
def test_labels_include_bug_and_pipeline(self, mock_file):
mock_file.return_value = {"number": 1}
from perf.hooks import on_step_failure
on_step_failure("x.y", RuntimeError("boom"), pipeline="core")
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
assert "bug" in labels
assert "pipeline" in labels
class TestOnStepSkip:
"""on_step_skip builds correct issue for skipped steps."""
@patch("perf.hooks._file_issue")
def test_files_issue_on_skip(self, mock_file):
mock_file.return_value = {"number": 100}
from perf.hooks import on_step_skip
on_step_skip("readmissions._int_enc", pipeline="readmissions")
mock_file.assert_called_once()
title = mock_file.call_args[0][0]
assert "skipped" in title
assert "readmissions._int_enc" in title
class TestPytestHook:
"""pytest_runtest_logreport files issues in CI."""
@patch("perf.hooks._file_test_issue")
def test_ignores_setup_phase(self, mock_file):
from perf.hooks import pytest_runtest_logreport
report = MagicMock()
report.when = "setup"
pytest_runtest_logreport(report)
mock_file.assert_not_called()
@patch("perf.hooks._file_test_issue")
def test_ignores_without_env_var(self, mock_file, monkeypatch):
monkeypatch.delenv("STACK_FILE_TEST_ISSUES", raising=False)
from perf.hooks import pytest_runtest_logreport
report = MagicMock()
report.when = "call"
report.failed = True
pytest_runtest_logreport(report)
mock_file.assert_not_called()
@patch("perf.hooks._file_test_issue")
def test_files_on_failure_in_ci(self, mock_file, monkeypatch):
monkeypatch.setenv("STACK_FILE_TEST_ISSUES", "true")
from perf.hooks import pytest_runtest_logreport
report = MagicMock()
report.when = "call"
report.failed = True
report.skipped = False
pytest_runtest_logreport(report)
mock_file.assert_called_once_with(report, "failed")
@patch("perf.hooks._file_test_issue")
def test_files_on_skip_in_ci(self, mock_file, monkeypatch):
monkeypatch.setenv("STACK_FILE_TEST_ISSUES", "true")
from perf.hooks import pytest_runtest_logreport
report = MagicMock()
report.when = "call"
report.failed = False
report.skipped = True
pytest_runtest_logreport(report)
mock_file.assert_called_once_with(report, "skipped")
class TestFileTestIssue:
"""_file_test_issue builds correct markdown body."""
@patch("perf.hooks._file_issue")
def test_body_includes_nodeid(self, mock_file):
mock_file.return_value = {"number": 50}
from perf.hooks import _file_test_issue
report = MagicMock()
report.nodeid = "tests/aco/test_core.py::test_encounter"
report.longrepr = "AssertionError: ..."
report.longreprtext = "AssertionError: expected 10 got 0"
_file_test_issue(report, "failed")
title, body = mock_file.call_args[0][:2]
assert "test_core.py::test_encounter" in title
assert "failed" in title
assert "AssertionError" in body
@patch("perf.hooks._file_issue")
def test_failed_gets_bug_label(self, mock_file):
mock_file.return_value = {"number": 51}
from perf.hooks import _file_test_issue
report = MagicMock()
report.nodeid = "tests/foo.py::test_bar"
report.longrepr = None
_file_test_issue(report, "failed")
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
assert "bug" in labels
assert "test" in labels
@patch("perf.hooks._file_issue")
def test_skipped_no_bug_label(self, mock_file):
mock_file.return_value = {"number": 52}
from perf.hooks import _file_test_issue
report = MagicMock()
report.nodeid = "tests/foo.py::test_baz"
report.longrepr = None
_file_test_issue(report, "skipped")
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
assert "bug" not in labels
assert "test" in labels
class TestGetToken:
"""_get_token resolves from env or config fallback."""
def test_returns_env_token(self, monkeypatch):
monkeypatch.setenv("GITEA_TOKEN", "test-token-123")
from perf.hooks import _get_token
assert _get_token() == "test-token-123"
def test_falls_back_to_conf_secret(self, monkeypatch):
monkeypatch.delenv("GITEA_TOKEN", raising=False)
from perf.hooks import _get_token
# conf.secret will raise ImportError in test env — returns ""
assert _get_token() == ""
def test_conf_import_error_returns_empty(self, monkeypatch):
monkeypatch.delenv("GITEA_TOKEN", raising=False)
from perf.hooks import _get_token
result = _get_token()
assert result == ""
class TestFileIssue:
"""_file_issue handles missing token and API errors gracefully."""
def test_returns_none_without_token(self, monkeypatch):
monkeypatch.delenv("GITEA_TOKEN", raising=False)
from perf.hooks import _file_issue
assert _file_issue("title", "body") is None
@patch("perf.hooks._get_token", return_value="tok")
def test_returns_none_on_api_error(self, mock_token):
from perf.hooks import _file_issue
# GiteaClient import will fail in test env → exception path
result = _file_issue("title", "body", labels=["bug"])
assert result is None
@patch("perf.hooks._get_token", return_value="tok")
def test_happy_path_with_labels(self, mock_token):
"""Mock the GiteaClient so we exercise the success branch (lines 63-68)."""
import sys
import types
from unittest.mock import MagicMock
from perf.hooks import _file_issue
fake_client = MagicMock()
fake_client.resolve_labels.return_value = [42]
fake_client.create_issue.return_value = {"number": 7, "title": "x"}
fake_module = types.ModuleType("api.clients.gitea")
fake_module.GiteaClient = MagicMock(return_value=fake_client)
# Insert into sys.modules so the inner `from api.clients.gitea import …`
# picks up our stub instead of the real package.
original = sys.modules.get("api.clients.gitea")
sys.modules["api.clients.gitea"] = fake_module
try:
result = _file_issue("title", "body", labels=["bug"])
assert result == {"number": 7, "title": "x"}
fake_client.resolve_labels.assert_called_once()
fake_client.create_issue.assert_called_once()
fake_client.close.assert_called_once()
finally:
if original is not None:
sys.modules["api.clients.gitea"] = original
else:
sys.modules.pop("api.clients.gitea", None)
@patch("perf.hooks._get_token", return_value="tok")
def test_happy_path_without_label_ids(self, mock_token):
"""resolve_labels returns empty → labels key not added."""
import sys
import types
from unittest.mock import MagicMock
from perf.hooks import _file_issue
fake_client = MagicMock()
fake_client.resolve_labels.return_value = []
fake_client.create_issue.return_value = {"number": 8}
fake_module = types.ModuleType("api.clients.gitea")
fake_module.GiteaClient = MagicMock(return_value=fake_client)
original = sys.modules.get("api.clients.gitea")
sys.modules["api.clients.gitea"] = fake_module
try:
result = _file_issue("title", "body", labels=["bug"])
assert result == {"number": 8}
finally:
if original is not None:
sys.modules["api.clients.gitea"] = original
else:
sys.modules.pop("api.clients.gitea", None)
class TestGitShaShort:
"""_git_sha_short handles subprocess failures."""
def test_returns_sha_in_git_repo(self):
from perf.hooks import _git_sha_short
result = _git_sha_short()
# We're in a git repo, so should get a short sha
assert len(result) >= 7 or result == "unknown"
@patch("subprocess.run", side_effect=OSError("no git"))
def test_returns_unknown_on_exception(self, mock_run):
from perf.hooks import _git_sha_short
assert _git_sha_short() == "unknown"
class TestRunnerIntegration:
"""Pipeline runner calls on_step_failure when a step raises."""
@patch("perf.hooks.on_step_failure")
def test_runner_fires_hook_on_failure(self, mock_hook):
import polars as pl
from aco.pipe.runner import run_pipeline
def bad_step(input_layer__data):
raise RuntimeError("data corrupted")
data = pl.DataFrame({"id": [1]})
try:
run_pipeline([("ns.bad", bad_step)], lambda ref: data)
except RuntimeError:
pass
mock_hook.assert_called_once()
args = mock_hook.call_args
assert args[0][0] == "ns.bad"
assert isinstance(args[0][1], RuntimeError)