The dashboard could only show what was powered on this instant, so it could
not answer "did the last build pass?" — and no local execution history exists
to answer it from (`report job` reads a PowerShell-era JSONL path that nothing
writes on this host, and the recent artifact dirs are empty).
Recent runs now come from Gitea's Actions API (`operations/executions.py`):
`/api/v1/admin/actions/runs` for every repo on the instance, falling back to
`/api/v1/user/actions/runs` when the token is not admin-scoped. One list call
per refresh, cached 10s, so the card's 5s htmx poll costs Gitea at most one
call per window. Live VMs are grouped under the run that spawned them — the
job id is `{run_id}-{run_attempt}[-suffix]` and the clone name carries it — so
a matrix run collects several VMs on one row. Every failure mode (no token,
401, unreachable Gitea, malformed payload) degrades to a notice plus the plain
VM rows, each keeping its Remove button, so the card is never less useful than
the table it replaces. Orphans stay where they were: the sweep in Maintenance,
the count in the status-header tile.
Auth reuses the access token from the operator's own OIDC login, which
`auth.py` previously discarded. SECURITY: that token is kept server-side only
(`app.state.gitea_tokens`, dropped on logout) and never written into the
session — `SessionMiddleware` signs the session cookie but does not encrypt
it, and this service is deployed over plain HTTP on the LAN, so a bearer token
in there would be readable in the browser and on the wire. An expired token is
refreshed once; `[web] gitea_token_cred` names an optional keyring PAT for the
cases a login token cannot cover (service restart, non-admin operator).
`GET /vms` and `partials/vms.html` are gone; `POST /vms/{id}/remove` and
`POST /vms/cleanup` are untouched.
Design: docs/superpowers/specs/2026-07-25-executions-card-design.md
568 lines
18 KiB
Python
568 lines
18 KiB
Python
"""Tests for the pure ``operations.executions`` layer (Executions card).
|
|
|
|
The module under test talks to the Gitea Actions API over
|
|
``urllib.request.urlopen``, which is monkeypatched throughout: no test ever
|
|
touches the network. Zero ``click``/``print`` in the module itself — every
|
|
call returns an :class:`ExecutionsView`, and *no* failure mode raises.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.error
|
|
from datetime import UTC, datetime
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
import ci_orchestrator.operations.executions as ex
|
|
from ci_orchestrator.config import (
|
|
BackendConfig,
|
|
CargoCacheConfig,
|
|
Config,
|
|
Paths,
|
|
WebConfig,
|
|
WebOidcConfig,
|
|
)
|
|
from ci_orchestrator.operations.executions import Execution, ExecutionsView, list_executions
|
|
from ci_orchestrator.operations.vms import LiveVm
|
|
|
|
_NOW = datetime(2026, 7, 25, 12, 0, 0, tzinfo=UTC)
|
|
|
|
|
|
def _make_config(
|
|
tmp_path: Path,
|
|
*,
|
|
gitea_url: str | None = "https://gitea.example",
|
|
limit: int = 15,
|
|
) -> Config:
|
|
return Config(
|
|
paths=Paths(
|
|
root=tmp_path,
|
|
templates=tmp_path / "templates",
|
|
build_vms=tmp_path / "build-vms",
|
|
artifacts=tmp_path / "artifacts",
|
|
keys=tmp_path / "keys",
|
|
),
|
|
backend=BackendConfig(),
|
|
cargo_cache=CargoCacheConfig(
|
|
enabled=True, root=tmp_path / "cache", retention_days=30
|
|
),
|
|
web=WebConfig(gitea_url=gitea_url, executions_limit=limit),
|
|
)
|
|
|
|
|
|
def _run(
|
|
*,
|
|
run_id: int = 412,
|
|
number: int = 12,
|
|
attempt: int = 1,
|
|
status: str = "success",
|
|
conclusion: str = "success",
|
|
started: str | None = "2026-07-25T11:50:00Z",
|
|
completed: str | None = "2026-07-25T11:57:20Z",
|
|
branch: str = "main",
|
|
repo: str = "simone/local-ci-cd-system",
|
|
path: str = ".gitea/workflows/build-ns7zip.yml",
|
|
) -> dict[str, Any]:
|
|
"""One ``ActionWorkflowRun`` as Gitea 1.26 serialises it."""
|
|
return {
|
|
"id": run_id,
|
|
"run_number": number,
|
|
"run_attempt": attempt,
|
|
"status": status,
|
|
"conclusion": conclusion,
|
|
"started_at": started,
|
|
"completed_at": completed,
|
|
"head_branch": branch,
|
|
"head_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
|
|
"event": "push",
|
|
"path": path,
|
|
"display_title": "feat: something",
|
|
"html_url": f"https://gitea.example/{repo}/actions/runs/{number}",
|
|
"repository": {"full_name": repo},
|
|
"trigger_actor": {"login": "Simone"},
|
|
}
|
|
|
|
|
|
class _FakeResponse:
|
|
"""Minimal ``urlopen`` context manager returning a JSON body."""
|
|
|
|
def __init__(self, payload: object) -> None:
|
|
self._body = BytesIO(json.dumps(payload).encode("utf-8"))
|
|
|
|
def __enter__(self) -> BytesIO:
|
|
return self._body
|
|
|
|
def __exit__(self, *_exc: object) -> None:
|
|
return None
|
|
|
|
|
|
def _http_error(code: int) -> urllib.error.HTTPError:
|
|
return urllib.error.HTTPError(
|
|
url="https://gitea.example", code=code, msg="nope", hdrs=None, fp=None
|
|
)
|
|
|
|
|
|
def _install_urlopen(
|
|
monkeypatch: pytest.MonkeyPatch, handler: Any
|
|
) -> list[tuple[str, str | None]]:
|
|
"""Patch ``urlopen``; return the (url, authorization) log it appends to."""
|
|
calls: list[tuple[str, str | None]] = []
|
|
|
|
def _fake_urlopen(request: Any, timeout: float | None = None) -> Any:
|
|
calls.append((request.full_url, request.get_header("Authorization")))
|
|
result = handler(request.full_url)
|
|
if isinstance(result, Exception):
|
|
raise result
|
|
return _FakeResponse(result)
|
|
|
|
monkeypatch.setattr(ex.urllib.request, "urlopen", _fake_urlopen)
|
|
return calls
|
|
|
|
|
|
def _admin_ok(runs: list[dict[str, Any]]) -> Any:
|
|
def handler(url: str) -> Any:
|
|
if "/admin/actions/runs" in url:
|
|
return {"total_count": len(runs), "workflow_runs": runs}
|
|
raise AssertionError(f"unexpected url: {url}")
|
|
|
|
return handler
|
|
|
|
|
|
def _vm(name: str, *, ip: str = "192.168.79.201", uptime: int = 120) -> LiveVm:
|
|
return LiveVm(
|
|
name=name,
|
|
vmx=f"/var/lib/ci/build-vms/{name}/{name}.vmx",
|
|
ip=ip,
|
|
guest_os="ubuntu-64",
|
|
uptime_sec=uptime,
|
|
is_orphan=False,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_cache() -> Any:
|
|
"""Every test starts with an empty TTL cache (it is module-level state)."""
|
|
ex.clear_cache()
|
|
yield
|
|
ex.clear_cache()
|
|
|
|
|
|
# ── happy path ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_admin_payload_parsed_into_executions(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
calls = _install_urlopen(monkeypatch, _admin_ok([_run()]))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.error is None
|
|
assert view.source == "admin"
|
|
assert view.auth_failed is False
|
|
assert view.runs == [
|
|
Execution(
|
|
run_id=412,
|
|
number=12,
|
|
attempt=1,
|
|
workflow="build-ns7zip.yml",
|
|
repo="simone/local-ci-cd-system",
|
|
branch="main",
|
|
commit="a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
|
|
event="push",
|
|
actor="Simone",
|
|
status="success",
|
|
started=datetime(2026, 7, 25, 11, 50, tzinfo=UTC),
|
|
completed=datetime(2026, 7, 25, 11, 57, 20, tzinfo=UTC),
|
|
duration_sec=440,
|
|
html_url="https://gitea.example/simone/local-ci-cd-system/actions/runs/12",
|
|
vms=[],
|
|
)
|
|
]
|
|
# Bearer auth against the admin endpoint, with the configured limit.
|
|
assert calls == [
|
|
(
|
|
"https://gitea.example/api/v1/admin/actions/runs?limit=15",
|
|
"Bearer tok",
|
|
)
|
|
]
|
|
|
|
|
|
def test_limit_argument_overrides_config(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
calls = _install_urlopen(monkeypatch, _admin_ok([]))
|
|
|
|
list_executions(_make_config(tmp_path), [], token="tok", limit=3, now=_NOW)
|
|
|
|
assert calls[0][0].endswith("limit=3")
|
|
|
|
|
|
def test_running_run_duration_counts_from_now(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
running = _run(status="in_progress", conclusion="", completed=None)
|
|
_install_urlopen(monkeypatch, _admin_ok([running]))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.runs[0].status == "running"
|
|
assert view.runs[0].completed is None
|
|
# 11:50:00 -> 12:00:00
|
|
assert view.runs[0].duration_sec == 600
|
|
|
|
|
|
def test_run_without_start_has_no_duration(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
queued = _run(status="waiting", conclusion="", started=None, completed=None)
|
|
_install_urlopen(monkeypatch, _admin_ok([queued]))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.runs[0].status == "queued"
|
|
assert view.runs[0].started is None
|
|
assert view.runs[0].duration_sec is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "conclusion", "expected"),
|
|
[
|
|
("in_progress", "", "running"),
|
|
("running", "", "running"),
|
|
("waiting", "", "queued"),
|
|
("queued", "", "queued"),
|
|
("blocked", "", "queued"),
|
|
("completed", "success", "success"),
|
|
("completed", "failure", "failure"),
|
|
("completed", "cancelled", "cancelled"),
|
|
("completed", "skipped", "skipped"),
|
|
("success", "success", "success"),
|
|
("failure", "failure", "failure"),
|
|
("completed", "neutral", "unknown"),
|
|
("", "", "unknown"),
|
|
],
|
|
)
|
|
def test_status_mapping(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
status: str,
|
|
conclusion: str,
|
|
expected: str,
|
|
) -> None:
|
|
_install_urlopen(
|
|
monkeypatch, _admin_ok([_run(status=status, conclusion=conclusion)])
|
|
)
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.runs[0].status == expected
|
|
|
|
|
|
def test_missing_fields_degrade_per_field(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A sparse run object must still produce a row, never an exception."""
|
|
_install_urlopen(monkeypatch, _admin_ok([{"id": 9}]))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
row = view.runs[0]
|
|
assert (row.run_id, row.number, row.attempt) == (9, 0, 1)
|
|
assert (row.workflow, row.repo, row.branch, row.commit) == ("", "", "", "")
|
|
assert (row.event, row.actor, row.html_url) == ("", "", "")
|
|
assert row.status == "unknown"
|
|
assert row.duration_sec is None
|
|
assert view.error is None
|
|
|
|
|
|
def test_actor_falls_back_to_actor_field(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
payload = _run()
|
|
del payload["trigger_actor"]
|
|
payload["actor"] = {"login": "someone-else"}
|
|
_install_urlopen(monkeypatch, _admin_ok([payload]))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.runs[0].actor == "someone-else"
|
|
|
|
|
|
def test_non_list_runs_payload_is_an_error(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_install_urlopen(monkeypatch, lambda _url: {"workflow_runs": "nope"})
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.runs == []
|
|
assert view.error is not None
|
|
assert view.source == "none"
|
|
|
|
|
|
# ── endpoint fallback ───────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize("code", [403, 404])
|
|
def test_admin_denied_falls_back_to_user_endpoint(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, code: int
|
|
) -> None:
|
|
def handler(url: str) -> Any:
|
|
if "/admin/" in url:
|
|
return _http_error(code)
|
|
return {"workflow_runs": [_run()]}
|
|
|
|
calls = _install_urlopen(monkeypatch, handler)
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.source == "user"
|
|
assert view.error is None
|
|
assert len(view.runs) == 1
|
|
assert [url for url, _auth in calls] == [
|
|
"https://gitea.example/api/v1/admin/actions/runs?limit=15",
|
|
"https://gitea.example/api/v1/user/actions/runs?limit=15",
|
|
]
|
|
|
|
|
|
def test_both_endpoints_denied_degrades(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_install_urlopen(monkeypatch, lambda _url: _http_error(403))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.runs == []
|
|
assert view.source == "none"
|
|
assert view.error is not None
|
|
assert "403" in view.error
|
|
|
|
|
|
# ── degraded paths ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_missing_token_lists_vms_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls = _install_urlopen(monkeypatch, _admin_ok([_run()]))
|
|
vm = _vm("Clone_412-1_20260725_115000_linux")
|
|
|
|
view = list_executions(_make_config(tmp_path), [vm], token=None, now=_NOW)
|
|
|
|
assert view == ExecutionsView(
|
|
runs=[],
|
|
unmatched_vms=[vm],
|
|
source="none",
|
|
error="Gitea token not configured.",
|
|
auth_failed=False,
|
|
)
|
|
assert calls == [] # no HTTP attempted without a token
|
|
|
|
|
|
def test_missing_gitea_url_lists_vms_only(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
calls = _install_urlopen(monkeypatch, _admin_ok([_run()]))
|
|
config = _make_config(tmp_path, gitea_url=None)
|
|
|
|
view = list_executions(config, [_vm("Clone_1-1_20260725_115000_x")], token="tok", now=_NOW)
|
|
|
|
assert view.runs == []
|
|
assert view.error is not None
|
|
assert len(view.unmatched_vms) == 1
|
|
assert calls == []
|
|
|
|
|
|
def test_gitea_url_derived_from_oidc_issuer(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""No explicit ``gitea_url``: the OIDC issuer is the fallback base URL."""
|
|
calls = _install_urlopen(monkeypatch, _admin_ok([]))
|
|
config = _make_config(tmp_path, gitea_url=None)
|
|
config = Config(
|
|
paths=config.paths,
|
|
backend=config.backend,
|
|
cargo_cache=config.cargo_cache,
|
|
web=WebConfig(
|
|
oidc=WebOidcConfig(
|
|
issuer="https://gitea.example",
|
|
client_id="cid",
|
|
redirect_url="http://host/auth/callback",
|
|
)
|
|
),
|
|
)
|
|
|
|
view = list_executions(config, [], token="tok", now=_NOW)
|
|
|
|
assert view.error is None
|
|
assert calls[0][0].startswith("https://gitea.example/api/v1/admin/")
|
|
|
|
|
|
def test_401_sets_auth_failed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_install_urlopen(monkeypatch, lambda _url: _http_error(401))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="stale", now=_NOW)
|
|
|
|
assert view.auth_failed is True
|
|
assert view.error is not None
|
|
assert view.runs == []
|
|
|
|
|
|
def test_network_error_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_install_urlopen(monkeypatch, lambda _url: urllib.error.URLError("no route to host"))
|
|
vm = _vm("Clone_412-1_20260725_115000_linux")
|
|
|
|
view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW)
|
|
|
|
assert view.runs == []
|
|
assert view.unmatched_vms == [vm]
|
|
assert view.error is not None
|
|
assert view.auth_failed is False
|
|
|
|
|
|
def test_timeout_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_install_urlopen(monkeypatch, lambda _url: TimeoutError("timed out"))
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.runs == []
|
|
assert view.error is not None
|
|
|
|
|
|
def test_malformed_json_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class _BadBody:
|
|
def __enter__(self) -> Any:
|
|
return self
|
|
|
|
def __exit__(self, *_exc: object) -> None:
|
|
return None
|
|
|
|
def read(self) -> bytes:
|
|
return b"{not json"
|
|
|
|
monkeypatch.setattr(
|
|
ex.urllib.request, "urlopen", lambda _req, timeout=None: _BadBody()
|
|
)
|
|
|
|
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
|
|
|
|
assert view.runs == []
|
|
assert view.error is not None
|
|
|
|
|
|
# ── VM correlation ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_vm_attached_to_its_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=412, attempt=1)]))
|
|
vm = _vm("Clone_412-1_20260725_115000_linux")
|
|
|
|
view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW)
|
|
|
|
assert view.runs[0].vms == [vm]
|
|
assert view.unmatched_vms == []
|
|
|
|
|
|
def test_matrix_run_collects_several_vms(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)]))
|
|
win = _vm("Clone_412-1-win_20260725_115000_a")
|
|
linux = _vm("Clone_412-1-linux_20260725_115005_b")
|
|
|
|
view = list_executions(_make_config(tmp_path), [win, linux], token="tok", now=_NOW)
|
|
|
|
assert view.runs[0].vms == [win, linux]
|
|
assert view.unmatched_vms == []
|
|
|
|
|
|
def test_attempt_is_part_of_the_match(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A clone from attempt 1 must not attach to attempt 2 of the same run."""
|
|
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=412, attempt=2)]))
|
|
vm = _vm("Clone_412-1_20260725_115000_linux")
|
|
|
|
view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW)
|
|
|
|
assert view.runs[0].vms == []
|
|
assert view.unmatched_vms == [vm]
|
|
|
|
|
|
def test_unparseable_and_stale_vms_are_unmatched(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)]))
|
|
stale = _vm("Clone_370-1_20260722_101500_linux")
|
|
weird = _vm("some-manually-made-vm")
|
|
|
|
view = list_executions(_make_config(tmp_path), [stale, weird], token="tok", now=_NOW)
|
|
|
|
assert view.runs[0].vms == []
|
|
assert view.unmatched_vms == [stale, weird]
|
|
|
|
|
|
# ── TTL cache ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_second_call_within_ttl_reuses_the_fetch(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
calls = _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)]))
|
|
clock = [1000.0]
|
|
monkeypatch.setattr(ex, "_monotonic", lambda: clock[0])
|
|
config = _make_config(tmp_path)
|
|
vm = _vm("Clone_412-1_20260725_115000_linux")
|
|
|
|
first = list_executions(config, [], token="tok", now=_NOW)
|
|
clock[0] += ex._TTL_SEC / 2
|
|
second = list_executions(config, [vm], token="tok", now=_NOW)
|
|
|
|
assert len(calls) == 1
|
|
assert first.runs[0].vms == []
|
|
# Cached *runs*, freshly correlated: the VM shows up without a new fetch.
|
|
assert second.runs[0].vms == [vm]
|
|
|
|
|
|
def test_expired_ttl_refetches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls = _install_urlopen(monkeypatch, _admin_ok([_run()]))
|
|
clock = [1000.0]
|
|
monkeypatch.setattr(ex, "_monotonic", lambda: clock[0])
|
|
config = _make_config(tmp_path)
|
|
|
|
list_executions(config, [], token="tok", now=_NOW)
|
|
clock[0] += ex._TTL_SEC + 0.1
|
|
list_executions(config, [], token="tok", now=_NOW)
|
|
|
|
assert len(calls) == 2
|
|
|
|
|
|
def test_cache_is_keyed_by_token_and_limit(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
calls = _install_urlopen(monkeypatch, _admin_ok([_run()]))
|
|
monkeypatch.setattr(ex, "_monotonic", lambda: 1000.0)
|
|
config = _make_config(tmp_path)
|
|
|
|
list_executions(config, [], token="tok", now=_NOW)
|
|
list_executions(config, [], token="other", now=_NOW)
|
|
list_executions(config, [], token="other", limit=5, now=_NOW)
|
|
|
|
assert len(calls) == 3
|
|
|
|
|
|
def test_errors_are_cached_too(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A Gitea outage must not turn every 5s poll into an HTTP attempt."""
|
|
calls = _install_urlopen(monkeypatch, lambda _url: urllib.error.URLError("down"))
|
|
monkeypatch.setattr(ex, "_monotonic", lambda: 1000.0)
|
|
config = _make_config(tmp_path)
|
|
|
|
first = list_executions(config, [], token="tok", now=_NOW)
|
|
second = list_executions(config, [], token="tok", now=_NOW)
|
|
|
|
assert first.error == second.error
|
|
assert len(calls) == 1
|