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).
293 lines
9.9 KiB
Python
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)
|