Files
dashboards/tests/conftest.py
T
ducoterra 8b048599f3 phase: 02_overview_fleet_and_alerts
**Phase 02 final verification pass — all three tasks already complete; verified criteria, fixed 3 defects found live.**

- Verified: `/` = exactly 2 `<section>`s (Warnings & errors + Fleet), alerts render from `/api/summary` verdicts with each alert's own `href`; `/system`, `/llm`, `/ups` unchanged (charts + data live).
- Fixed (`src/dashboards/alerts.py`): a dead source's message printed the name twice — `gateway: UpstreamError: gateway: http://…`; the poller's `UpstreamError:` wrapper is now stripped (other exception names kept). Tests: 2 new in `tests/test_alerts.py`.
- Fixed (`src/dashboards/api.py`): `/api/summary` dropped a configured host/engine that had never answered post-start, so the fleet said `2/2 hosts` while a third was down. Stub rows (`unreachable: true`, all `n/a`) now match `/api/*/snapshot`; `hosts_total=3 / hosts_live=2`. Test: `test_a_host_that_has_never_answered_is_still_a_row_in_the_fleet`.
- Fixed (`web/static/page-index.js`): fleet row of a non-answering host is now dimmed with the shared `.stale` class (invariant 4 on `/`). Test: `test_overview_dims_the_fleet_row_of_a_host_that_stopped_answering`.
- `bash .agents/validate.sh` exit 0: `uv run pytest --cov --cov-fail-under=94` → **439 passed, 6 skipped, 96.82%** (`alerts.py` 100%, `api.py` 100%); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check web/static` clean; hygiene clean; `uv pip list --outdated` empty; `uv run pytest -m live --live` → **6 passed**.
- Live proof (tunnel kill, then restored — all 6 sources `ok` again): killing the gateway forward produced exactly **one** alert, `fail gateway /system … unreachable (ConnectError)`; screenshots `data/logs/phase02-final-{alert,system,llm,ups,recovered}.png`.
- Completion criteria: two panels ✅ · `[]` when healthy ✅ (hermetic `healthy_client` + a live gateway-only instance returned `[]`; the real fleet can never be `[]` — its UPSes permanently warn on battery age/self-test) · one alert per dead host with `/system` link ✅ · pytest/live/coverage/ruff/pyright ✅ · no change on `/system`,`/llm`,`/ups` ✅ · phase 01 verdict vocabulary intact ✅.
- Docs: README `web/` bullet now states the dimmed `n/a` row; AGENTS.md invariant 4, `alerts.py`/`api.py` bullets, UI-conventions stale-row + window-chip ranges (5m…30d vs 1h…90d), metric example `llm.decode_tps` → `llm.decode_tokens_per_second` (verified against `/api/metrics`), test count 435 → 439. Env table, Containerfile, agent/install-script claims, fleet facts re-checked and consistent.
- Notable: left the fleet's real hostnames/domain in AGENTS.md's "real environment" section (deliberate, prior-phase content) even though rule 6 discourages personal hostnames in tracked files — worth a separate decision; no live config was changed beyond stopping/starting a local dev ssh forward.
- Next pending phase: `.agents/phases/todo/03_system_per_host_panels/`.
2026-10-17 00:59:09 -04:00

125 lines
4.4 KiB
Python

"""Shared fixtures: real captures for shape, fakes for behaviour.
Anything that must match an upstream (an agent snapshot, an engine's /metrics text, the NUT
wire protocol) is asserted against a verbatim capture in tests/fixtures/. Everything that
needs a *failure* — a dead host, a refused command, a counter reset — is a fake here.
The `live` marker is the bridge to real hardware: those tests dial the fleet, the engines,
or upsd, so they SKIP unless you pass `--live` and have set the env vars they read (see
tests/test_live_fleet.py). That keeps `uv run pytest` hermetic and offline while still
letting `uv run pytest -m live --live` catch an upstream that renamed something.
"""
from __future__ import annotations
import copy
import json
import sys
from pathlib import Path
from typing import Any
import pytest
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--live",
action="store_true",
default=False,
help="run tests marked `live` against the real fleet/engines/upsd (they skip otherwise)",
)
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
if config.getoption("--live"):
return
skip = pytest.mark.skip(reason="needs --live and a reachable target (see tests/test_live_fleet.py)")
for item in items:
if "live" in item.keywords:
item.add_marker(skip)
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from dashboards.rates import RateEstimator # noqa: E402
from dashboards.sources.host import HostCollector, parse_host_snapshot # noqa: E402
from dashboards.store import SampleStore # noqa: E402
FIXTURES = Path(__file__).resolve().parent / "fixtures"
@pytest.fixture
def db(tmp_path: Path) -> Any:
store = SampleStore(tmp_path / "t.sqlite3")
try:
yield store
finally:
store.close()
@pytest.fixture
def store(db: Any) -> Any:
"""Alias: some tests read better with `store`, others with `db`. Same object."""
return db
@pytest.fixture
def agent_snapshot() -> dict[str, Any]:
"""A real driveripper snapshot (Threadripper + R9700, 48 logical CPUs)."""
return json.loads((FIXTURES / "agent_driveripper.json").read_text())
@pytest.fixture
def agent_snapshots() -> dict[str, dict[str, Any]]:
"""Every captured host, keyed by name — fleet code paths need >1 host to mean anything."""
out: dict[str, dict[str, Any]] = {}
for path in sorted(FIXTURES.glob("agent_*.json")):
out[path.stem.removeprefix("agent_")] = json.loads(path.read_text())
return out
@pytest.fixture
def host_scrape() -> Any:
"""`host_scrape(snapshot, cpu_busy=10.0, mutate=None)` -> one parsed host payload.
Two scrapes are needed before any rate exists (the agent reports counters; the
dashboard owns the window), and the payload the API serves is the second one. `mutate`
edits the snapshot before both scrapes so a test can put a value on a chosen side of
`severity.THRESHOLDS`; `cpu_busy` sets the CPU percentage over the window directly.
Driven from the real capture so a schema drift breaks here rather than in a browser.
"""
def scrape(snapshot: dict[str, Any], *, name: str = "driveripper", cpu_busy: float = 10.0,
mutate: Any = None) -> dict[str, Any]:
snap = copy.deepcopy(snapshot)
if mutate is not None:
mutate(snap)
collector = HostCollector(RateEstimator(span=60.0))
parse_host_snapshot(name, snap, collector, now=1_000.0)
second = copy.deepcopy(snap)
second["ts"] = 1_010.0
modes = second["cpu"]["modes"]
# busy = (Δtotal - Δidle)/Δtotal, and iowait is left untouched so it rates at 0
modes["total"] += 1_000.0
modes["idle"] += 1_000.0 - (cpu_busy * 10.0)
return parse_host_snapshot(name, second, collector, now=1_010.0).payload
return scrape
@pytest.fixture
def metrics_text() -> Any:
"""metrics_text('gufo') / metrics_text('vllm') -> the captured exposition verbatim."""
def load(engine: str) -> str:
path = FIXTURES / f"metrics_{engine}.txt"
if not path.exists():
pytest.skip(f"missing fixture {path.name} — run scripts/capture-fixtures.sh --with-llm")
return path.read_text()
return load
@pytest.fixture
def rates() -> RateEstimator:
return RateEstimator(span=10.0)