Files
local-ci-cd-system/tests/python/test_web_cards.py
T
Simone c598d0658e feat(web): keep the four info cards, enrich them, drop the status-header tiles
The dashboard used to open with six status-header tiles *and* four compact
cards restating them (runner state lived in three places at once). The tiles
are gone; each card is now the single owner of its subject, enriched with
what only the tiles had (CPU%/RAM/disk with gauges) plus what neither had
(hostname, kernel, full CPU model, the vmware-host.service row).

All four cards share the same <dl> grammar and font scale now -- VMware and
Runners were a bordered <table> at a different size/case, which read as a
visibly different style sitting next to Host and Cargo cache's plain
label/value lists.

operations/host.py also gains delete_template()/TemplateDeleteResult, used
by the template-delete route in the next commit.
2026-07-25 19:33:19 +02:00

332 lines
12 KiB
Python

"""Tests for the Stage F card layout: four info cards, no status header.
The dashboard used to open with six status-header tiles *and* four compact
cards that restated them (runner state lived in three places at once). Stage F
keeps the cards and deletes the tiles: each card is now the single owner of its
subject, enriched with what only the tiles used to show (CPU%/RAM/disk with
their gauges) plus what neither had (hostname, kernel, CPU model, and the
``vmware-host.service`` row).
Health is still never color-only: every gauge is paired with a text level
(``ok``/``elevated``/``critical``/``unknown``) from :func:`_resource_levels`.
"""
from __future__ import annotations
import shutil
from collections import namedtuple
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
import ci_orchestrator.web.app as app_module
from ci_orchestrator.config import BackendConfig, CargoCacheConfig, Config, Paths
from ci_orchestrator.operations.cache import CacheStatus
from ci_orchestrator.operations.host import DiskStatus, HostResources, VmwareModuleHealth
from ci_orchestrator.web.app import create_app, get_backend
_DiskUsage = namedtuple("_DiskUsage", ["total", "used", "free"])
_RESOURCES = HostResources(
cpu_pct=42.0,
ncpu=16,
mem_total_gb=62.5,
mem_used_gb=20.0,
uptime_sec=93784,
load1=0.5,
load5=0.75,
load15=1.0,
cpu_model="Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz",
kernel="7.0.0-28-generic",
hostname="ci-host",
)
_VMWARE_OK = VmwareModuleHealth(
vmmon_loaded=True, vmnet_loaded=True, host_service_active=True,
host_service_sub_state="running",
)
_CACHE = CacheStatus(
root=Path("/var/lib/ci/cache/cargo"),
total_bytes=1024,
entry_count=7,
oldest_age_days=4,
retention_days=30,
)
def _make_config(root: Path) -> Config:
return Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=root / "artifacts",
keys=root / "keys",
),
backend=BackendConfig(),
cargo_cache=CargoCacheConfig(
enabled=True, root=root / "cache" / "cargo", retention_days=30
),
)
class _FakeBackend:
def list_running(self) -> list[Any]:
return []
def get_ip(self, handle: Any, timeout: float = 0.0) -> str | None:
return None
def _client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
gb = 1024**3
monkeypatch.setattr(
shutil, "disk_usage", lambda _p: _DiskUsage(total=100 * gb, used=40 * gb, free=60 * gb)
)
monkeypatch.setattr(shutil, "which", lambda _name: None)
monkeypatch.setattr(app_module, "host_resources", lambda: _RESOURCES)
monkeypatch.setattr(app_module, "vmware_module_health", lambda: _VMWARE_OK)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend()
return TestClient(app)
# ── _resource_levels: thresholds behind the card gauges ──────────────────
_DISK_OK = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=60.0, used_pct=40.0)
_DISK_ELEVATED = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=25.0, used_pct=75.0)
_DISK_CRITICAL = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=5.0, used_pct=90.0)
def test_resource_levels_without_a_snapshot_is_unknown_not_false_green() -> None:
levels = app_module._resource_levels(resources=None, disk=None)
assert levels.cpu_class == "tile-unknown"
assert levels.cpu_label == "unknown"
assert levels.mem_class == "tile-unknown"
assert levels.disk_class == "tile-unknown"
assert levels.mem_pct is None
def test_resource_levels_healthy_snapshot_is_ok() -> None:
levels = app_module._resource_levels(resources=_RESOURCES, disk=_DISK_OK)
assert (levels.cpu_class, levels.cpu_label) == ("tile-ok", "ok")
assert (levels.mem_class, levels.mem_label) == ("tile-ok", "ok")
assert (levels.disk_class, levels.disk_label) == ("tile-ok", "ok")
assert levels.mem_pct == pytest.approx(32.0)
def test_resource_levels_disk_above_elevated_is_warn() -> None:
levels = app_module._resource_levels(resources=_RESOURCES, disk=_DISK_ELEVATED)
assert (levels.disk_class, levels.disk_label) == ("tile-warn", "elevated")
def test_resource_levels_disk_above_critical_is_bad() -> None:
levels = app_module._resource_levels(resources=_RESOURCES, disk=_DISK_CRITICAL)
assert (levels.disk_class, levels.disk_label) == ("tile-bad", "critical")
def test_resource_levels_busy_cpu_and_full_memory_are_bad() -> None:
busy = HostResources(
cpu_pct=95.0, ncpu=16, mem_total_gb=62.5, mem_used_gb=61.0,
uptime_sec=1, load1=0.0, load5=0.0, load15=0.0,
)
levels = app_module._resource_levels(resources=busy, disk=_DISK_OK)
assert (levels.cpu_class, levels.cpu_label) == ("tile-bad", "critical")
assert (levels.mem_class, levels.mem_label) == ("tile-bad", "critical")
# ── the status header is gone ────────────────────────────────────────────
def test_dashboard_has_no_status_header(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
resp = _client(tmp_path, monkeypatch).get("/")
assert resp.status_code == 200
assert 'id="status-header"' not in resp.text
assert "stat-tile" not in resp.text
@pytest.mark.parametrize("route", ["/status-header", "/platform"])
def test_retired_routes_are_gone(
route: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
assert _client(tmp_path, monkeypatch).get(route).status_code == 404
def test_all_four_info_cards_share_the_same_dl_grammar(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Host and Cargo cache were always a <dl>; VMware and Runners used to be
a bordered <table> at a different font-size with uppercase headers --
a visibly different style for cards sitting in the same row. All four
read the same shape now: no other card renders a <table>."""
client = _client(tmp_path, monkeypatch)
for route in ("/host", "/vmware", "/runners", "/cache"):
resp = client.get(route)
assert resp.status_code == 200
assert "<dl>" in resp.text
assert "<table>" not in resp.text
def test_dashboard_renders_the_four_info_cards(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
resp = _client(tmp_path, monkeypatch).get("/")
for card_id in ('id="host-card"', 'id="vmware-card"', 'id="runner-card"', 'id="cache-card"'):
assert card_id in resp.text
# ── /host: the enriched card ─────────────────────────────────────────────
def test_host_card_pairs_identity_onto_two_rows(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Stage G: hostname+kernel share a row, CPU model+core count share a row.
Four one-datum rows made the card twice as tall as its three neighbours
for four facts that never change while the page is open.
"""
resp = _client(tmp_path, monkeypatch).get("/host")
assert resp.status_code == 200
assert "<dt>System</dt>" in resp.text
assert "ci-host" in resp.text
assert "7.0.0-28-generic" in resp.text
assert "<dt>CPU</dt>" in resp.text
assert "16 cores" in resp.text
for retired in ("<dt>Hostname</dt>", "<dt>Kernel</dt>", "<dt>Cores</dt>"):
assert retired not in resp.text
def test_host_card_shortens_the_cpu_model_but_keeps_the_full_one(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A one-quarter-width track cannot hold the raw ``/proc/cpuinfo`` name."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert "Intel Core i9-13900K" in resp.text
assert 'title="Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz"' in resp.text
def test_host_card_holds_five_definition_rows(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""System, CPU, CPU load, Memory, Disk -- the height parity Stage G buys."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert resp.text.count("<dt>") == 5
def test_host_card_shows_cpu_ram_and_disk_with_gauges(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The three readouts that only existed in the retired tiles."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert "42%" in resp.text # cpu_pct
assert "20.0" in resp.text # memory used GB
assert "60 GB free" in resp.text # disk
assert resp.text.count("gauge-fill") == 3
def test_host_card_drops_the_visible_level_strip(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The check-icon + level word row is gone from the card body."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert "resource-level" not in resp.text
def test_host_card_keeps_the_level_word_in_every_gauge_label(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Health is still not color-only: the level rides each gauge's accessible
name, so a screen reader states it even though no strip is drawn."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert 'aria-label="CPU 42 percent used, ok"' in resp.text
assert 'aria-label="Memory 32 percent used, ok"' in resp.text
assert 'aria-label="Disk 40 percent used, ok"' in resp.text
def test_host_card_moves_uptime_into_the_card_header(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Uptime rides the header bar, spelled out -- a bare duration there reads
as nothing in particular, and out of the ``<dl>`` it has no ``<dt>``."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert "head-meta" in resp.text
assert "uptime 26h 03m" in resp.text # 93784s
assert "<dt>Uptime</dt>" not in resp.text
def test_host_card_folds_the_load_average_under_the_cpu_gauge(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Load average belongs to the CPU readout, like "GB free" to the disk."""
resp = _client(tmp_path, monkeypatch).get("/host")
assert "0.50 / 0.75 / 1.00" in resp.text
assert "<dt>Load avg</dt>" not in resp.text
def test_host_card_degrades_when_the_probe_fails(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom() -> Any:
raise RuntimeError("boom")
client = _client(tmp_path, monkeypatch)
monkeypatch.setattr(app_module, "host_resources", _boom)
resp = client.get("/host")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
# ── /vmware, /runners, /cache ────────────────────────────────────────────
def test_vmware_card_lists_both_modules_and_the_service(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
resp = _client(tmp_path, monkeypatch).get("/vmware")
assert resp.status_code == 200
assert "vmmon" in resp.text
assert "vmnet" in resp.text
assert "vmware-host.service" in resp.text
def test_runners_card_lists_every_unit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
resp = _client(tmp_path, monkeypatch).get("/runners")
assert resp.status_code == 200
assert "act-runner.service" in resp.text
assert "act-runner-rust.service" in resp.text
def test_cache_card_shows_size_entries_and_retention(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(app_module, "_fetch_cache_status", lambda _config: _CACHE)
resp = _client(tmp_path, monkeypatch).get("/cache")
assert resp.status_code == 200
assert "/var/lib/ci/cache/cargo" in resp.text
assert "1.0 KB" in resp.text
assert "7" in resp.text # entry count
assert "30 day(s)" in resp.text