diff --git a/tests/test_hermes_hung_builds.py b/tests/test_hermes_hung_builds.py new file mode 100644 index 0000000..7c128ec --- /dev/null +++ b/tests/test_hermes_hung_builds.py @@ -0,0 +1,96 @@ +"""Tests for detecting Jenkins builds that never reach a terminal result.""" + +from __future__ import annotations + +from ariadne.services import hermes_hung_builds as module + + +NOW = 1_800_000_000.0 +_MINUTE_MS = 60_000 + + +def _build(minutes_ago: float, building: bool = True, **overrides): # type: ignore[no-untyped-def] + payload = { + "number": 272, + "building": building, + "timestamp": int((NOW - minutes_ago * 60) * 1000), + "url": "https://ci.example/job/metis/272/", + } + payload.update(overrides) + return payload + + +def test_elapsed_minutes_measures_from_the_start_timestamp() -> None: + assert module.elapsed_minutes(_build(75), now=NOW) == 75.0 + + +def test_elapsed_minutes_treats_an_unreadable_start_as_zero() -> None: + """An unreadable timestamp must never look like a hung build.""" + + assert module.elapsed_minutes({}, now=NOW) == 0.0 + assert module.elapsed_minutes({"timestamp": None}, now=NOW) == 0.0 + assert module.elapsed_minutes({"timestamp": "not-a-number"}, now=NOW) == 0.0 + assert module.elapsed_minutes({"timestamp": 0}, now=NOW) == 0.0 + assert module.elapsed_minutes({"timestamp": -5}, now=NOW) == 0.0 + + +def test_elapsed_minutes_never_goes_negative() -> None: + """A clock skewed into the future must not produce a negative age.""" + + assert module.elapsed_minutes(_build(-30), now=NOW) == 0.0 + + +def test_elapsed_minutes_defaults_to_the_wall_clock() -> None: + assert module.elapsed_minutes(_build(0.0, timestamp=1)) > 0.0 + + +def test_a_running_build_past_the_cap_is_hung() -> None: + assert module.is_hung(_build(75), 45.0, now=NOW) is True + + +def test_a_running_build_within_the_cap_is_not_hung() -> None: + assert module.is_hung(_build(10), 45.0, now=NOW) is False + assert module.is_hung(_build(45), 45.0, now=NOW) is False + + +def test_a_finished_build_is_never_hung() -> None: + """Terminal builds are the normal path's business, not this one.""" + + assert module.is_hung(_build(600, building=False), 45.0, now=NOW) is False + assert module.is_hung({"timestamp": 1}, 45.0, now=NOW) is False + + +def test_a_non_positive_cap_disables_the_check() -> None: + assert module.is_hung(_build(6000), 0.0, now=NOW) is False + assert module.is_hung(_build(6000), -1.0, now=NOW) is False + + +def test_hung_bundle_carries_only_observed_facts() -> None: + bundle = module.hung_bundle("metis", _build(75), 45.0) + jenkins = bundle["jenkins"] + + assert jenkins["job"] == "metis" + assert jenkins["build_number"] == 272 + assert jenkins["building"] is True + assert jenkins["cap_minutes"] == 45.0 + assert jenkins["console_failures"] == [] + assert bundle["log_evidence"] == {"records": []} + assert "has been running for" in jenkins["console_tail"] + assert "holding a Jenkins agent slot" in jenkins["console_tail"] + + +def test_hung_bundle_survives_a_build_with_nothing_in_it() -> None: + """Bundle construction must not raise on a sparse Jenkins payload.""" + + bundle = module.hung_bundle("metis", {}, 45.0) + + assert bundle["jenkins"]["build_number"] is None + assert bundle["jenkins"]["url"] == "" + assert bundle["jenkins"]["elapsed_minutes"] == 0.0 + + +def test_reason_and_classification_are_stable_labels() -> None: + """These land in issue titles and event details, so they are frozen.""" + + assert module.HUNG_REASON == "hung_build" + assert module.HUNG_CLASSIFICATION == "build_exceeded_time_cap"